diff options
Diffstat (limited to 'lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx')
| -rw-r--r-- | lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx | 503 |
1 files changed, 503 insertions, 0 deletions
diff --git a/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx b/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx new file mode 100644 index 00000000..53d25382 --- /dev/null +++ b/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx @@ -0,0 +1,503 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { SaveIcon, CheckIcon, XIcon, BarChart3Icon, TrendingUpIcon } from "lucide-react" + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { toast } from "sonner" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" + +import { + getEsgEvaluationFormData, + saveEsgEvaluationResponse, + recalculateEvaluationProgress, + EsgEvaluationFormData +} from "../service" +import { EvaluationSubmissionWithVendor } from "../service" + +interface EsgEvaluationFormSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: EvaluationSubmissionWithVendor | null + onSuccess: () => void +} + +// 폼 스키마 정의 +const formSchema = z.object({ + responses: z.array(z.object({ + itemId: z.number(), + selectedOptionId: z.number().optional(), + selectedScore: z.number().default(0), + additionalComments: z.string().optional(), + })) +}) + +type FormData = z.infer<typeof formSchema> + +export function EsgEvaluationFormSheet({ + open, + onOpenChange, + submission, + onSuccess, +}: EsgEvaluationFormSheetProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [isSaving, setIsSaving] = React.useState(false) + const [formData, setFormData] = React.useState<EsgEvaluationFormData | null>(null) + const [currentScores, setCurrentScores] = React.useState<Record<number, number>>({}) + + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + defaultValues: { + responses: [] + } + }) + + // 데이터 로딩 + React.useEffect(() => { + if (open && submission?.id) { + loadFormData() + } + }, [open, submission?.id]) + + const loadFormData = async () => { + if (!submission?.id) return + + setIsLoading(true) + try { + const data = await getEsgEvaluationFormData(submission.id) + setFormData(data) + + // 폼 초기값 설정 + const responses: any[] = [] + const scores: Record<number, number> = {} + + data.evaluations.forEach(evaluation => { + evaluation.items.forEach(item => { + responses.push({ + itemId: item.item.id, + selectedOptionId: item.response?.esgAnswerOptionId, + selectedScore: item.response?.selectedScore || 0, + additionalComments: item.response?.additionalComments || '', + }) + + if (item.response?.selectedScore) { + scores[item.item.id] = item.response.selectedScore + } + }) + }) + + setCurrentScores(scores) + form.reset({ responses }) + } catch (error) { + console.error('Error loading ESG form data:', error) + toast.error('ESG 평가 데이터를 불러오는데 실패했습니다.') + } finally { + setIsLoading(false) + } + } + + // 개별 응답 저장 + const handleSaveResponse = async (itemId: number, optionId: number, score: number) => { + if (!submission?.id) return + + try { + const formResponse = form.getValues('responses').find(r => r.itemId === itemId) + + await saveEsgEvaluationResponse({ + submissionId: submission.id, + esgEvaluationItemId: itemId, + esgAnswerOptionId: optionId, + selectedScore: score, + additionalComments: formResponse?.additionalComments || '', + }) + + // 현재 점수 업데이트 + setCurrentScores(prev => ({ + ...prev, + [itemId]: score + })) + + // 평균 점수 재계산 + await recalculateEvaluationProgress(submission.id) + + toast.success('응답이 저장되었습니다.') + } catch (error) { + console.error('Error saving ESG response:', error) + toast.error('응답 저장에 실패했습니다.') + } + } + + // 선택 변경 핸들러 + const handleOptionChange = (itemId: number, optionId: string, score: number) => { + const responseIndex = form.getValues('responses').findIndex(r => r.itemId === itemId) + if (responseIndex >= 0) { + form.setValue(`responses.${responseIndex}.selectedOptionId`, parseInt(optionId)) + form.setValue(`responses.${responseIndex}.selectedScore`, score) + } + + // 자동 저장 + handleSaveResponse(itemId, parseInt(optionId), score) + } + + // 전체 저장 + const onSubmit = async (data: FormData) => { + if (!submission?.id || !formData) return + + setIsSaving(true) + try { + // 모든 응답을 순차적으로 저장 + for (const response of data.responses) { + if (response.selectedOptionId && response.selectedScore > 0) { + await saveEsgEvaluationResponse({ + submissionId: submission.id, + esgEvaluationItemId: response.itemId, + esgAnswerOptionId: response.selectedOptionId, + selectedScore: response.selectedScore, + additionalComments: response.additionalComments || '', + }) + } + } + + // 평균 점수 재계산 + await recalculateEvaluationProgress(submission.id) + + toast.success('모든 ESG 평가가 저장되었습니다.') + onSuccess() + } catch (error) { + console.error('Error saving all ESG responses:', error) + toast.error('ESG 평가 저장에 실패했습니다.') + } finally { + setIsSaving(false) + } + } + + // 진행률 및 점수 계산 + const getProgress = () => { + if (!formData) return { + completed: 0, + total: 0, + percentage: 0, + averageScore: 0, + maxAverageScore: 0 + } + + let total = 0 + let completed = 0 + let totalScore = 0 + let maxTotalScore = 0 + + formData.evaluations.forEach(evaluation => { + evaluation.items.forEach(item => { + total++ + if (currentScores[item.item.id] > 0) { + completed++ + totalScore += currentScores[item.item.id] + } + + // 최대 점수 계산 + const maxOptionScore = Math.max(...item.answerOptions.map(opt => parseFloat(opt.score.toString()))) + maxTotalScore += maxOptionScore + }) + }) + + const percentage = total > 0 ? Math.round((completed / total) * 100) : 0 + const averageScore = completed > 0 ? totalScore / completed : 0 + const maxAverageScore = total > 0 ? maxTotalScore / total : 0 + + return { completed, total, percentage, averageScore, maxAverageScore } + } + + const progress = getProgress() + + if (isLoading) { + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[900px] sm:max-w-[900px]"> + <div className="flex items-center justify-center h-full"> + <div className="text-center space-y-4"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto"></div> + <p>ESG 평가 데이터를 불러오는 중...</p> + </div> + </div> + </SheetContent> + </Sheet> + ) + } + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[900px] sm:max-w-[900px] flex flex-col" style={{width:900, maxWidth:900}}> + <SheetHeader> + <SheetTitle>ESG 평가 작성</SheetTitle> + <SheetDescription> + {formData?.submission.vendorName}의 ESG 평가를 작성해주세요. + </SheetDescription> + </SheetHeader> + + {formData && ( + <> + {/* 진행률 및 점수 표시 */} + <div className="mt-6 grid grid-cols-2 gap-4"> + <Card> + <CardContent className="p-4"> + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center gap-2"> + <CheckIcon className="h-4 w-4" /> + <span className="text-sm font-medium">진행률</span> + </div> + <span className="text-sm text-muted-foreground"> + {progress.completed}/{progress.total} + </span> + </div> + <div className="w-full bg-gray-200 rounded-full h-2 mb-2"> + <div + className="bg-green-600 h-2 rounded-full transition-all duration-300" + style={{ width: `${progress.percentage}%` }} + /> + </div> + <p className="text-xs text-muted-foreground"> + {progress.percentage}% 완료 + </p> + </CardContent> + </Card> + + <Card> + <CardContent className="p-4"> + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center gap-2"> + <TrendingUpIcon className="h-4 w-4" /> + <span className="text-sm font-medium">평균 점수</span> + </div> + <Badge variant="outline"> + {progress.averageScore.toFixed(1)} / {progress.maxAverageScore.toFixed(1)} + </Badge> + </div> + <div className="w-full bg-gray-200 rounded-full h-2 mb-2"> + <div + className="bg-blue-600 h-2 rounded-full transition-all duration-300" + style={{ + width: `${progress.maxAverageScore > 0 ? (progress.averageScore / progress.maxAverageScore) * 100 : 0}%` + }} + /> + </div> + <p className="text-xs text-muted-foreground"> + {progress.completed > 0 ? + `${progress.completed}개 항목 평균` : '응답 없음'} + </p> + </CardContent> + </Card> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + <div className="flex-1 overflow-y-auto min-h-0"> + + <ScrollArea className="h-full pr-4"> + <div className="space-y-4 pr-4"> + <Accordion type="multiple" defaultValue={formData.evaluations.map((_, i) => `evaluation-${i}`)}> + {formData.evaluations.map((evaluation, evalIndex) => ( + <AccordionItem + key={evaluation.evaluation.id} + value={`evaluation-${evalIndex}`} + > + <AccordionTrigger className="hover:no-underline"> + <div className="flex items-center justify-between w-full mr-4"> + <div className="flex items-center gap-3"> + <Badge variant="outline"> + {evaluation.evaluation.serialNumber} + </Badge> + <div className="text-left"> + <div className="font-medium"> + {evaluation.evaluation.category} + </div> + <div className="text-sm text-muted-foreground"> + {evaluation.evaluation.inspectionItem} + </div> + </div> + </div> + <div className="flex items-center gap-2"> + <BarChart3Icon className="h-4 w-4" /> + <span className="text-sm"> + {evaluation.items.filter(item => + currentScores[item.item.id] > 0 + ).length}/{evaluation.items.length} + </span> + </div> + </div> + </AccordionTrigger> + <AccordionContent> + <div className="space-y-6 pt-4"> + {evaluation.items.map((item, itemIndex) => { + const responseIndex = form.getValues('responses').findIndex( + r => r.itemId === item.item.id + ) + + return ( + <Card key={item.item.id} className="bg-gray-50"> + <CardHeader className="pb-3"> + <CardTitle className="text-sm flex items-center justify-between"> + <span>{item.item.evaluationItem}</span> + {currentScores[item.item.id] > 0 && ( + <Badge variant="default" className="bg-green-100 text-green-800"> + {currentScores[item.item.id]}점 + </Badge> + )} + </CardTitle> + {item.item.evaluationItemDescription && ( + <p className="text-xs text-muted-foreground"> + {item.item.evaluationItemDescription} + </p> + )} + </CardHeader> + <CardContent className="space-y-4"> + {/* 답변 옵션들 */} + <RadioGroup + value={item.response?.esgAnswerOptionId?.toString() || ''} + onValueChange={(value) => { + const option = item.answerOptions.find( + opt => opt.id === parseInt(value) + ) + if (option) { + handleOptionChange( + item.item.id, + value, + parseFloat(option.score.toString()) + ) + } + }} + > + <div className="space-y-2"> + {item.answerOptions.map((option) => ( + <div + key={option.id} + className="flex items-center space-x-3 p-3 rounded-md border hover:bg-white transition-colors" + > + <RadioGroupItem + value={option.id.toString()} + id={`option-${option.id}`} + /> + <label + htmlFor={`option-${option.id}`} + className="flex-1 cursor-pointer" + > + <div className="flex items-center justify-between"> + <span className="text-sm"> + {option.answerText} + </span> + <Badge + variant="secondary" + className="ml-2" + > + {option.score}점 + </Badge> + </div> + </label> + </div> + ))} + </div> + </RadioGroup> + + {/* 추가 의견 */} + {responseIndex >= 0 && ( + <FormField + control={form.control} + name={`responses.${responseIndex}.additionalComments`} + render={({ field }) => ( + <FormItem> + <FormLabel className="text-xs"> + 추가 의견 (선택사항) + </FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="추가적인 설명이나 의견을 입력하세요..." + className="min-h-[60px] text-sm" + /> + </FormControl> + </FormItem> + )} + /> + )} + </CardContent> + </Card> + ) + })} + </div> + </AccordionContent> + </AccordionItem> + ))} + </Accordion> + </div> + </ScrollArea> + </div> + + <Separator /> + + {/* 하단 버튼 영역 */} + <div className="flex-shrink-0 flex items-center justify-between pt-4"> + <div className="text-sm text-muted-foreground"> + {progress.percentage === 100 ? ( + <div className="flex items-center gap-2 text-green-600"> + <CheckIcon className="h-4 w-4" /> + 모든 ESG 평가가 완료되었습니다 + </div> + ) : ( + <div className="flex items-center gap-2"> + <XIcon className="h-4 w-4" /> + {progress.total - progress.completed}개 항목이 미완료입니다 + </div> + )} + </div> + <div className="flex items-center gap-2"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + > + 닫기 + </Button> + <Button + type="submit" + disabled={isSaving || progress.completed === 0} + > + {isSaving ? "저장 중..." : "최종 저장"} + </Button> + </div> + </div> + </form> + </Form> + </> + )} + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file |
