summaryrefslogtreecommitdiff
path: root/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx
diff options
context:
space:
mode:
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.tsx503
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