diff options
Diffstat (limited to 'lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx')
| -rw-r--r-- | lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx | 305 |
1 files changed, 305 insertions, 0 deletions
diff --git a/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx new file mode 100644 index 00000000..7d6ca45d --- /dev/null +++ b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx @@ -0,0 +1,305 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm, useFieldArray } from "react-hook-form" +import * as z from "zod" +import { toast } from "sonner" +import { CheckCircle2, AlertCircle, Building2 } from "lucide-react" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { PeriodicEvaluationView } from "@/db/schema" +import { finalizeEvaluations } from "../service" + +// 등급 옵션 +const GRADE_OPTIONS = [ + { value: "S", label: "S등급 (90점 이상)" }, + { value: "A", label: "A등급 (80-89점)" }, + { value: "B", label: "B등급 (70-79점)" }, + { value: "C", label: "C등급 (60-69점)" }, + { value: "D", label: "D등급 (60점 미만)" }, +] as const + +// 점수에 따른 등급 계산 +const calculateGrade = (score: number): string => { + if (score >= 90) return "S" + if (score >= 80) return "A" + if (score >= 70) return "B" + if (score >= 60) return "C" + return "D" +} + +// 개별 평가 스키마 +const evaluationItemSchema = z.object({ + id: z.number(), + vendorName: z.string(), + vendorCode: z.string(), + evaluationScore: z.number().nullable(), + finalScore: z.number() + .min(0, "점수는 0 이상이어야 합니다"), + // .max(100, "점수는 100 이하여야 합니다"), + finalGrade: z.enum(["S", "A", "B", "C", "D"]), +}) + +// 전체 폼 스키마 +const finalizeEvaluationSchema = z.object({ + evaluations: z.array(evaluationItemSchema).min(1, "확정할 평가가 없습니다"), +}) + +type FinalizeEvaluationFormData = z.infer<typeof finalizeEvaluationSchema> + +interface FinalizeEvaluationDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + evaluations: PeriodicEvaluationView[] + onSuccess?: () => void +} + +export function FinalizeEvaluationDialog({ + open, + onOpenChange, + evaluations, + onSuccess, +}: FinalizeEvaluationDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + + const form = useForm<FinalizeEvaluationFormData>({ + resolver: zodResolver(finalizeEvaluationSchema), + defaultValues: { + evaluations: [], + }, + }) + + const { fields, update } = useFieldArray({ + control: form.control, + name: "evaluations", + }) + + // evaluations가 변경될 때 폼 초기화 + React.useEffect(() => { + if (evaluations.length > 0) { + const formData = evaluations.map(evaluation => ({ + id: evaluation.id, + vendorName: evaluation.vendorName || "", + vendorCode: evaluation.vendorCode || "", + evaluationScore: evaluation.evaluationScore || null, + finalScore: Number(evaluation.evaluationScore || 0), + finalGrade: calculateGrade(Number(evaluation.evaluationScore || 0)), + })) + + form.reset({ evaluations: formData }) + } + }, [evaluations, form]) + + // 점수 변경 시 등급 자동 계산 + const handleScoreChange = (index: number, score: number) => { + const currentEvaluation = form.getValues(`evaluations.${index}`) + const newGrade = calculateGrade(score) + + update(index, { + ...currentEvaluation, + finalScore: score, + finalGrade: newGrade, + }) + } + + // 폼 제출 + const onSubmit = async (data: FinalizeEvaluationFormData) => { + try { + setIsLoading(true) + + const finalizeData = data.evaluations.map(evaluation => ({ + id: evaluation.id, + finalScore: evaluation.finalScore, + finalGrade: evaluation.finalGrade, + })) + + await finalizeEvaluations(finalizeData) + + toast.success("평가가 확정되었습니다", { + description: `${data.evaluations.length}건의 평가가 최종 확정되었습니다.`, + }) + + onSuccess?.() + onOpenChange(false) + } catch (error) { + console.error("Failed to finalize evaluations:", error) + toast.error("평가 확정 실패", { + description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + }) + } finally { + setIsLoading(false) + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <CheckCircle2 className="h-5 w-5 text-purple-600" /> + 평가 확정 + </DialogTitle> + <DialogDescription> + 검토가 완료된 평가의 최종 점수와 등급을 확정합니다. + 확정 후에는 수정이 제한됩니다. + </DialogDescription> + </DialogHeader> + + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + 확정할 평가: <strong>{evaluations.length}건</strong> + <br /> + 평가 점수는 리뷰어들의 평가를 바탕으로 계산된 값을 기본으로 하며, 필요시 조정 가능합니다. + </AlertDescription> + </Alert> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + <div className="rounded-md border"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[200px]">협력업체</TableHead> + <TableHead className="w-[100px]">평가점수</TableHead> + <TableHead className="w-[120px]">최종점수</TableHead> + <TableHead className="w-[120px]">최종등급</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {fields.map((field, index) => ( + <TableRow key={field.id}> + <TableCell> + <div className="space-y-1"> + <div className="font-medium"> + {form.watch(`evaluations.${index}.vendorName`)} + </div> + <div className="text-sm text-muted-foreground"> + {form.watch(`evaluations.${index}.vendorCode`)} + </div> + </div> + </TableCell> + + <TableCell> + <div className="text-center"> + {form.watch(`evaluations.${index}.evaluationScore`) !== null ? ( + <Badge variant="outline" className="font-mono"> + {Number(form.watch(`evaluations.${index}.evaluationScore`)).toFixed(1)}점 + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </div> + </TableCell> + + <TableCell> + <FormField + control={form.control} + name={`evaluations.${index}.finalScore`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + type="number" + min="0" + max="100" + step="0.1" + {...field} + onChange={(e) => { + const value = parseFloat(e.target.value) + field.onChange(value) + if (!isNaN(value)) { + handleScoreChange(index, value) + } + }} + className="text-center font-mono" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + + <TableCell> + <FormField + control={form.control} + name={`evaluations.${index}.finalGrade`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Select value={field.value} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {GRADE_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading} + className="bg-purple-600 hover:bg-purple-700" + > + {isLoading ? "확정 중..." : `평가 확정 (${fields.length}건)`} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
