429 lines
14 KiB
TypeScript
429 lines
14 KiB
TypeScript
"use client"
|
||
|
||
import { useState, useEffect } from "react"
|
||
import { Sidebar } from "@/components/sidebar"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Input } from "@/components/ui/input"
|
||
import { Label } from "@/components/ui/label"
|
||
import { Textarea } from "@/components/ui/textarea"
|
||
import { Slider } from "@/components/ui/slider"
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Plus, Trash2, Save, RotateCcw, FileText, Loader2 } from "lucide-react"
|
||
import { useToast } from "@/hooks/use-toast"
|
||
|
||
interface CriteriaItem {
|
||
id: number | string
|
||
name: string
|
||
description: string
|
||
weight: number
|
||
maxScore: number
|
||
}
|
||
|
||
interface CriteriaTemplate {
|
||
id: number
|
||
name: string
|
||
description: string
|
||
total_weight: number
|
||
items: CriteriaItem[]
|
||
}
|
||
|
||
export default function CriteriaPage() {
|
||
const [criteria, setCriteria] = useState<CriteriaItem[]>([])
|
||
const [templateName, setTemplateName] = useState("")
|
||
const [templateDescription, setTemplateDescription] = useState("")
|
||
const [isLoading, setIsLoading] = useState(true)
|
||
const [isSaving, setIsSaving] = useState(false)
|
||
const [currentTemplate, setCurrentTemplate] = useState<CriteriaTemplate | null>(null)
|
||
const { toast } = useToast()
|
||
|
||
// 載入評分標準
|
||
useEffect(() => {
|
||
loadTemplate()
|
||
}, [])
|
||
|
||
const loadTemplate = async () => {
|
||
try {
|
||
setIsLoading(true)
|
||
const response = await fetch('/api/criteria-templates')
|
||
const result = await response.json()
|
||
|
||
if (result.success && result.data && result.data.length > 0) {
|
||
// 載入第一個模板(單一模板模式)
|
||
const template = result.data[0]
|
||
setCurrentTemplate(template)
|
||
setTemplateName(template.name)
|
||
setTemplateDescription(template.description || '')
|
||
setCriteria(template.items || [])
|
||
} else {
|
||
// 如果沒有模板,載入預設模板
|
||
await loadDefaultTemplate()
|
||
}
|
||
} catch (error) {
|
||
console.error('載入評分標準失敗:', error)
|
||
// 如果載入失敗,嘗試載入預設模板
|
||
await loadDefaultTemplate()
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
const loadDefaultTemplate = async () => {
|
||
try {
|
||
const response = await fetch('/api/criteria-templates/default')
|
||
const result = await response.json()
|
||
|
||
if (result.success && result.data) {
|
||
const template = result.data
|
||
setCurrentTemplate(template)
|
||
setTemplateName(template.name)
|
||
setTemplateDescription(template.description || '')
|
||
setCriteria(template.items || [])
|
||
} else {
|
||
// 如果連預設模板都沒有,使用空模板
|
||
setCurrentTemplate(null)
|
||
setTemplateName('')
|
||
setTemplateDescription('')
|
||
setCriteria([])
|
||
}
|
||
} catch (error) {
|
||
console.error('載入預設評分標準失敗:', error)
|
||
// 使用空模板
|
||
setCurrentTemplate(null)
|
||
setTemplateName('')
|
||
setTemplateDescription('')
|
||
setCriteria([])
|
||
}
|
||
}
|
||
|
||
const addCriteria = () => {
|
||
const newCriteria: CriteriaItem = {
|
||
id: Date.now().toString(),
|
||
name: "",
|
||
description: "",
|
||
weight: 10,
|
||
maxScore: 10,
|
||
}
|
||
setCriteria([...criteria, newCriteria])
|
||
}
|
||
|
||
const removeCriteria = (id: string) => {
|
||
setCriteria(criteria.filter((item) => item.id !== id))
|
||
}
|
||
|
||
const updateCriteria = (id: string, field: keyof CriteriaItem, value: string | number) => {
|
||
setCriteria(criteria.map((item) => (item.id === id ? { ...item, [field]: value } : item)))
|
||
}
|
||
|
||
const updateWeight = (id: string, weight: number[]) => {
|
||
updateCriteria(id, "weight", Number(weight[0]) || 0)
|
||
}
|
||
|
||
const totalWeight = criteria.reduce((sum, item) => sum + (Number(item.weight) || 0), 0)
|
||
|
||
const saveCriteria = async () => {
|
||
if (totalWeight !== 100) {
|
||
toast({
|
||
title: "權重設定錯誤",
|
||
description: "所有評分項目的權重總和必須等於 100%",
|
||
variant: "destructive",
|
||
})
|
||
return
|
||
}
|
||
|
||
if (criteria.some((item) => !item.name.trim())) {
|
||
toast({
|
||
title: "設定不完整",
|
||
description: "請填寫所有評分項目的名稱",
|
||
variant: "destructive",
|
||
})
|
||
return
|
||
}
|
||
|
||
if (!templateName.trim()) {
|
||
toast({
|
||
title: "設定不完整",
|
||
description: "請填寫模板名稱",
|
||
variant: "destructive",
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
setIsSaving(true)
|
||
|
||
const templateData = {
|
||
name: templateName.trim(),
|
||
description: templateDescription.trim(),
|
||
items: criteria.map(item => ({
|
||
name: item.name.trim(),
|
||
description: item.description.trim(),
|
||
weight: item.weight,
|
||
maxScore: item.maxScore
|
||
}))
|
||
}
|
||
|
||
// 單一模板模式,總是使用 POST 進行覆蓋
|
||
const url = '/api/criteria-templates'
|
||
const method = 'POST'
|
||
|
||
const response = await fetch(url, {
|
||
method,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(templateData),
|
||
})
|
||
|
||
const result = await response.json()
|
||
|
||
if (result.success) {
|
||
toast({
|
||
title: "儲存成功",
|
||
description: "評分標準已成功儲存",
|
||
})
|
||
|
||
// 重新載入資料
|
||
await loadTemplate()
|
||
} else {
|
||
throw new Error(result.error || '儲存失敗')
|
||
}
|
||
} catch (error) {
|
||
console.error('儲存評分標準失敗:', error)
|
||
toast({
|
||
title: "儲存失敗",
|
||
description: error instanceof Error ? error.message : "無法儲存評分標準",
|
||
variant: "destructive",
|
||
})
|
||
} finally {
|
||
setIsSaving(false)
|
||
}
|
||
}
|
||
|
||
const resetToDefault = async () => {
|
||
try {
|
||
await loadDefaultTemplate()
|
||
toast({
|
||
title: "已重置",
|
||
description: "評分標準已重置為預設值",
|
||
})
|
||
} catch (error) {
|
||
toast({
|
||
title: "重置失敗",
|
||
description: "無法重置評分標準",
|
||
variant: "destructive",
|
||
})
|
||
}
|
||
}
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="min-h-screen bg-background">
|
||
<Sidebar />
|
||
<main className="md:ml-64 p-6">
|
||
<div className="max-w-4xl mx-auto">
|
||
<div className="flex items-center justify-center py-12">
|
||
<div className="text-center">
|
||
<Loader2 className="h-8 w-8 animate-spin mx-auto mb-4" />
|
||
<p className="text-muted-foreground">載入評分標準中...</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-background">
|
||
<Sidebar />
|
||
|
||
<main className="md:ml-64 p-6">
|
||
<div className="max-w-4xl mx-auto">
|
||
{/* Header */}
|
||
<div className="mb-8 pt-8 md:pt-0">
|
||
<h1 className="text-3xl font-bold text-foreground mb-2 font-[var(--font-playfair)]">評分標準設定</h1>
|
||
<p className="text-muted-foreground">自定義評分項目和權重,建立符合您需求的評審標準</p>
|
||
</div>
|
||
|
||
{/* Template Name */}
|
||
<Card className="mb-6">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<FileText className="h-5 w-5" />
|
||
標準模板資訊
|
||
</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div>
|
||
<Label htmlFor="template-name" className="mb-2 block">模板名稱 *</Label>
|
||
<Input
|
||
id="template-name"
|
||
value={templateName}
|
||
onChange={(e) => setTemplateName(e.target.value)}
|
||
placeholder="輸入評分標準名稱"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor="template-description" className="mb-2 block">模板描述</Label>
|
||
<Textarea
|
||
id="template-description"
|
||
value={templateDescription}
|
||
onChange={(e) => setTemplateDescription(e.target.value)}
|
||
placeholder="輸入模板描述(選填)"
|
||
rows={2}
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Weight Summary */}
|
||
<Card className="mb-6">
|
||
<CardContent className="pt-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<span className="text-sm font-medium">權重總計</span>
|
||
<Badge variant={totalWeight === 100 ? "default" : "destructive"}>
|
||
{Number(totalWeight).toFixed(1)}%
|
||
</Badge>
|
||
</div>
|
||
<div className="w-full bg-muted rounded-full h-2">
|
||
<div
|
||
className={`h-2 rounded-full transition-all ${totalWeight === 100 ? "bg-primary" : "bg-destructive"}`}
|
||
style={{ width: `${Math.min(totalWeight, 100)}%` }}
|
||
/>
|
||
</div>
|
||
{totalWeight !== 100 && <p className="text-sm text-destructive mt-2">權重總和必須等於 100%</p>}
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Criteria List */}
|
||
<div className="space-y-4 mb-6">
|
||
{criteria.map((item, index) => (
|
||
<Card key={item.id}>
|
||
<CardHeader>
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-lg">評分項目 {index + 1}</CardTitle>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => removeCriteria(item.id)}
|
||
className="text-destructive hover:text-destructive"
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<div>
|
||
<Label htmlFor={`name-${item.id}`} className="mb-2 block">項目名稱</Label>
|
||
<Input
|
||
id={`name-${item.id}`}
|
||
value={item.name}
|
||
onChange={(e) => updateCriteria(item.id, "name", e.target.value)}
|
||
placeholder="例如:內容品質"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label htmlFor={`maxScore-${item.id}`} className="mb-2 block">滿分</Label>
|
||
<Input
|
||
id={`maxScore-${item.id}`}
|
||
type="number"
|
||
value={item.maxScore}
|
||
onChange={(e) => updateCriteria(item.id, "maxScore", Number.parseInt(e.target.value) || 10)}
|
||
min="1"
|
||
max="100"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor={`description-${item.id}`} className="mb-2 block">評分說明</Label>
|
||
<Textarea
|
||
id={`description-${item.id}`}
|
||
value={item.description}
|
||
onChange={(e) => updateCriteria(item.id, "description", e.target.value)}
|
||
placeholder="描述此項目的評分標準和要求"
|
||
rows={2}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<div className="flex items-center justify-between mb-2">
|
||
<Label>權重比例</Label>
|
||
<Badge variant="outline">{Number(item.weight).toFixed(1)}%</Badge>
|
||
</div>
|
||
<Slider
|
||
value={[Number(item.weight) || 0]}
|
||
onValueChange={(value) => updateWeight(item.id, value)}
|
||
max={100}
|
||
min={0}
|
||
step={5}
|
||
className="w-full"
|
||
/>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
|
||
{/* Add Criteria Button */}
|
||
<Card className="mb-6">
|
||
<CardContent className="pt-6">
|
||
<Button onClick={addCriteria} variant="outline" className="w-full bg-transparent">
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
新增評分項目
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Action Buttons */}
|
||
<div className="flex flex-col sm:flex-row gap-4">
|
||
<Button onClick={saveCriteria} disabled={isSaving || isLoading} className="flex-1">
|
||
{isSaving ? (
|
||
<>
|
||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||
儲存中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Save className="h-4 w-4 mr-2" />
|
||
儲存評分標準
|
||
</>
|
||
)}
|
||
</Button>
|
||
<Button onClick={resetToDefault} variant="outline" disabled={isLoading || isSaving}>
|
||
<RotateCcw className="h-4 w-4 mr-2" />
|
||
重置為預設
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Preview */}
|
||
<Card className="mt-8">
|
||
<CardHeader>
|
||
<CardTitle>預覽</CardTitle>
|
||
<CardDescription>以下是您設定的評分標準預覽</CardDescription>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="space-y-3">
|
||
{criteria.map((item, index) => (
|
||
<div key={item.id} className="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||
<div className="flex-1">
|
||
<div className="font-medium">{item.name || `評分項目 ${index + 1}`}</div>
|
||
{item.description && <div className="text-sm text-muted-foreground mt-1">{item.description}</div>}
|
||
</div>
|
||
<div className="text-right">
|
||
<div className="font-medium">權重: {Number(item.weight).toFixed(1)}%</div>
|
||
<div className="text-sm text-muted-foreground">滿分: {item.maxScore}</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|