diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-07 01:44:45 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-07 01:44:45 +0000 |
| commit | 90f79a7a691943a496f67f01c1e493256070e4de (patch) | |
| tree | 37275fde3ae08c2bca384fbbc8eb378de7e39230 /lib/evaluation-submit/evaluation-form.tsx | |
| parent | fbb3b7f05737f9571b04b0a8f4f15c0928de8545 (diff) | |
(대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가
Diffstat (limited to 'lib/evaluation-submit/evaluation-form.tsx')
| -rw-r--r-- | lib/evaluation-submit/evaluation-form.tsx | 592 |
1 files changed, 592 insertions, 0 deletions
diff --git a/lib/evaluation-submit/evaluation-form.tsx b/lib/evaluation-submit/evaluation-form.tsx new file mode 100644 index 00000000..65da72b6 --- /dev/null +++ b/lib/evaluation-submit/evaluation-form.tsx @@ -0,0 +1,592 @@ +"use client" + +import * as React from "react" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Textarea } from "@/components/ui/textarea" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { + Building2, + CheckCircle, + Clock, + Save, + Send, + ArrowLeft, + AlertCircle, + FileText +} from "lucide-react" +import { useRouter } from "next/navigation" +import { useToast } from "@/hooks/use-toast" +import { + updateEvaluationResponse, + completeEvaluation +} from "./service" +import { + type EvaluationFormData, + type EvaluationQuestionItem, + EVALUATION_CATEGORIES +} from "./validation" +import { DEPARTMENT_CODE_LABELS, divisionMap, vendortypeMap } from "@/types/evaluation" + +interface EvaluationFormProps { + formData: EvaluationFormData + onSubmit?: () => void +} + +interface QuestionResponse { + detailId: number | null + score: number | null + comment: string +} + +/** + * 평가 폼 메인 컴포넌트 (테이블 레이아웃) + */ +export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { + const router = useRouter() + const { toast } = useToast() + const [isLoading, setIsLoading] = React.useState(false) + const [isSaving, setIsSaving] = React.useState(false) + const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false) + const [showCompleteDialog, setShowCompleteDialog] = React.useState(false) + + const { evaluationInfo, questions } = formData + + // 로컬 상태로 모든 응답 관리 + const [responses, setResponses] = React.useState<Record<number, QuestionResponse>>(() => { + const initial: Record<number, QuestionResponse> = {} + questions.forEach(question => { + const isVariable = question.scoreType === 'variable' + + initial[question.criteriaId] = { + detailId: isVariable ? -1 : question.selectedDetailId, + score: isVariable ? + question.currentScore || null : + (question.selectedDetailId ? + question.availableOptions.find(opt => opt.detailId === question.selectedDetailId)?.score || question.currentScore || null + : question.currentScore || null), + comment: question.currentComment || "", + } + }) + return initial + }) + + // 카테고리별 질문 그룹화 + const questionsByCategory = React.useMemo(() => { + const grouped = questions.reduce((acc, question) => { + const key = question.category + if (!acc[key]) { + acc[key] = [] + } + acc[key].push(question) + return acc + }, {} as Record<string, EvaluationQuestionItem[]>) + + return grouped + }, [questions]) + + const categoryNames = EVALUATION_CATEGORIES + + // 응답 변경 핸들러 + const handleResponseChange = (questionId: number, detailId: number, customScore?: number) => { + const question = questions.find(q => q.criteriaId === questionId) + if (!question) return + + const selectedOption = question.availableOptions.find(opt => opt.detailId === detailId) + + setResponses(prev => ({ + ...prev, + [questionId]: { + ...prev[questionId], + detailId, + score: customScore !== undefined ? customScore : selectedOption?.score || null, + } + })) + setHasUnsavedChanges(true) + } + + // 점수 직접 입력 핸들러 (variable 타입용) + const handleScoreChange = (questionId: number, score: number | null) => { + console.log('Score changed:', questionId, score) + + setResponses(prev => ({ + ...prev, + [questionId]: { + ...prev[questionId], + score, + detailId: prev[questionId].detailId || -1 + } + })) + setHasUnsavedChanges(true) + } + + // 코멘트 변경 핸들러 + const handleCommentChange = (questionId: number, comment: string) => { + setResponses(prev => ({ + ...prev, + [questionId]: { + ...prev[questionId], + comment + } + })) + setHasUnsavedChanges(true) + } + + // 임시저장 + const handleSave = async () => { + try { + setIsSaving(true) + + const promises = Object.entries(responses) + .filter(([questionId, response]) => { + const question = questions.find(q => q.criteriaId === parseInt(questionId)) + const isVariable = question?.scoreType === 'variable' + + if (isVariable) { + return response.score !== null + } else { + return response.detailId !== null && response.detailId > 0 + } + }) + .map(([questionId, response]) => { + const question = questions.find(q => q.criteriaId === parseInt(questionId)) + const isVariable = question?.scoreType === 'variable' + + return updateEvaluationResponse( + evaluationInfo.id, + isVariable ? -1 : response.detailId!, + response.comment, + response.score || undefined + ) + }) + + await Promise.all(promises) + setHasUnsavedChanges(false) + + toast({ + title: "임시저장 완료", + description: "응답이 성공적으로 저장되었습니다.", + }) + } catch (error) { + console.error('Failed to save responses:', error) + toast({ + title: "저장 실패", + description: "응답 저장 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsSaving(false) + } + } + + // 평가 완료 처리 (실제 완료 로직) + const handleCompleteConfirmed = async () => { + try { + setIsLoading(true) + setShowCompleteDialog(false) + + // 먼저 모든 응답 저장 + await handleSave() + + // 평가 완료 처리 + await completeEvaluation(evaluationInfo.id) + + toast({ + title: "평가 완료", + description: "평가가 성공적으로 완료되었습니다.", + }) + + onSubmit?.() + router.push('/evcp/evaluation-input') + } catch (error) { + console.error('Failed to complete evaluation:', error) + toast({ + title: "완료 실패", + description: "평가 완료 처리 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + // 평가 완료 버튼 클릭 (다이얼로그 표시) + const handleCompleteClick = () => { + setShowCompleteDialog(true) + } + + const completedCount = Object.values(responses).filter(r => { + const question = questions.find(q => q.criteriaId === parseInt(Object.keys(responses).find(key => responses[parseInt(key)] === r) || '0')) + const isVariable = question?.scoreType === 'variable' + + if (isVariable) { + return r.score !== null + } else { + return r.detailId !== null && r.detailId > 0 + } + }).length + + const totalCount = questions.length + const allCompleted = completedCount === totalCount + + return ( + <div className="container mx-auto py-6 space-y-6"> + {/* 헤더 */} + <div className="flex items-center justify-between"> + <div className="flex items-center gap-4"> + <Button + variant="ghost" + size="icon" + onClick={() => router.back()} + > + <ArrowLeft className="h-4 w-4" /> + </Button> + <div> + <h1 className="text-2xl font-bold">평가 작성</h1> + <p className="text-muted-foreground">협력업체 평가를 진행해주세요</p> + </div> + </div> + + <div className="flex items-center gap-2"> + {evaluationInfo.isCompleted ? ( + <Badge variant="default" className="flex items-center gap-1"> + <CheckCircle className="h-3 w-3" /> + 완료 + </Badge> + ) : ( + <Badge variant="secondary" className="flex items-center gap-1"> + <Clock className="h-3 w-3" /> + 진행중 + </Badge> + )} + </div> + </div> + + {/* 평가 정보 카드 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Building2 className="h-5 w-5" /> + 평가 정보 + </CardTitle> + </CardHeader> + <CardContent className="pt-4 pb-4"> + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <div className="space-y-1"> + <Label className="text-sm text-muted-foreground">협력업체</Label> + <div className="font-medium text-sm">{evaluationInfo.vendorName} ({evaluationInfo.vendorCode})</div> + </div> + <div className="space-y-1"> + <Label className="text-sm text-muted-foreground">사업부</Label> + <div> + <Badge variant="outline"> + {divisionMap[evaluationInfo.division] || evaluationInfo.division} + </Badge> + </div> + </div> + <div className="space-y-1"> + <Label className="text-sm text-muted-foreground">자재유형</Label> + <div> + <Badge variant="outline"> + {vendortypeMap[evaluationInfo.materialType] || evaluationInfo.materialType} + </Badge> + </div> + </div> + <div className="space-y-1"> + <Label className="text-sm text-muted-foreground">담당부서</Label> + <div className="font-medium text-sm"> + {DEPARTMENT_CODE_LABELS[evaluationInfo.departmentCode] || evaluationInfo.departmentCode} + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 평가 테이블 - 카테고리별 */} + {Object.entries(questionsByCategory).map(([category, categoryQuestions]) => { + const categoryCompletedCount = categoryQuestions.filter(q => { + const response = responses[q.criteriaId] + const isVariable = q.scoreType === 'variable' + + if (isVariable) { + return response.score !== null + } else { + return response.detailId !== null + } + }).length + + const categoryTotalCount = categoryQuestions.length + const categoryProgress = (categoryCompletedCount / categoryTotalCount) * 100 + + return ( + <Card key={category}> + <CardHeader> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <CardTitle className="text-lg">{categoryNames[category] || category}</CardTitle> + <Badge variant="secondary"> + {categoryQuestions.length}개 질문 + </Badge> + </div> + <div className="flex items-center gap-4"> + <div className="text-right"> + <div className="text-sm font-medium"> + {categoryCompletedCount} / {categoryTotalCount} 완료 + </div> + <div className="text-xs text-muted-foreground"> + {Math.round(categoryProgress)}% + </div> + </div> + <div className="w-24 bg-muted rounded-full h-2"> + <div + className="bg-primary h-2 rounded-full transition-all duration-300" + style={{ width: `${categoryProgress}%` }} + /> + </div> + </div> + </div> + </CardHeader> + <CardContent> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[150px]">평가</TableHead> + <TableHead className="w-[200px]">범위</TableHead> + <TableHead className="w-[250px]">비고</TableHead> + <TableHead className="w-[200px]">답변 선택</TableHead> + <TableHead className="w-[80px]">점수</TableHead> + <TableHead className="w-[250px]">추가 의견</TableHead> + <TableHead className="w-[80px]">상태</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {categoryQuestions.map((question) => { + const response = responses[question.criteriaId] + + const isVariable = question.scoreType === 'variable' + const isAnswered = isVariable ? + (response.score !== null) : + (response.detailId !== null && response.detailId > 0) + + return ( + <TableRow key={question.criteriaId} className={isAnswered ? "bg-green-50" : "bg-yellow-50"}> + <TableCell className="font-medium"> + {question.classification} + </TableCell> + + <TableCell className="text-sm"> + {question.range} + </TableCell> + + <TableCell className="text-sm"> + {question.remarks} + </TableCell> + + <TableCell> + {!isVariable && ( + <Select + value={response.detailId?.toString() || ""} + onValueChange={(value) => handleResponseChange(question.criteriaId, parseInt(value))} + disabled={isLoading || isSaving} + > + <SelectTrigger> + <SelectValue placeholder="답변을 선택하세요" /> + </SelectTrigger> + <SelectContent> + {question.availableOptions + .sort((a, b) => b.score - a.score) + .map((option) => ( + <SelectItem key={option.detailId} value={option.detailId.toString()}> + <div className="flex items-center justify-between w-full"> + <span>{option.detail}</span> + {!option.detail.includes('variable') && ( + <Badge variant="outline" className="ml-2"> + {option.score}점 + </Badge> + )} + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + )} + + {isVariable && ( + <Input + type="number" + min="0" + step="1" + value={response.score !== null ? response.score : ""} + onChange={(e) => { + const value = e.target.value + + if (value === '') { + handleScoreChange(question.criteriaId, null) + return + } + + const numericValue = parseInt(value) + + // 0 이상의 정수만 허용 + if (!isNaN(numericValue) && numericValue >= 0) { + handleScoreChange(question.criteriaId, numericValue) + } + }} + onBlur={(e) => { + // 포커스를 잃을 때 추가 검증 + const value = e.target.value + if (value !== '' && (isNaN(parseInt(value)) || parseInt(value) < 0)) { + handleScoreChange(question.criteriaId, null) + } + }} + placeholder="점수 입력 (0 이상)" + className="w-48" + disabled={isLoading || isSaving} + /> + )} + </TableCell> + + <TableCell> + {isAnswered && ( + <Badge variant={response.score! >= 4 ? "default" : response.score! >= 3 ? "secondary" : "destructive"}> + {response.score}점 + </Badge> + )} + </TableCell> + + <TableCell> + <Textarea + placeholder={isAnswered ? "추가 의견을 입력하세요..." : "먼저 답변을 선택하세요"} + value={response.comment} + onChange={(e) => handleCommentChange(question.criteriaId, e.target.value)} + disabled={isLoading || isSaving || !isAnswered} + rows={2} + className="resize-none min-w-[200px]" + /> + </TableCell> + + <TableCell> + {isAnswered ? ( + <Badge variant="default" className="text-xs"> + 완료 + </Badge> + ) : ( + <Badge variant="destructive" className="text-xs"> + 미답변 + </Badge> + )} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </CardContent> + </Card> + ) + })} + + {/* 하단 액션 버튼 */} + <div className="sticky bottom-0 bg-background border-t p-4"> + <div className="flex items-center justify-between max-w-7xl mx-auto"> + {!evaluationInfo.isCompleted && ( + <> + <div className="flex items-center gap-4 text-sm text-muted-foreground"> + + {hasUnsavedChanges && ( + <div className="flex items-center gap-1"> + <AlertCircle className="h-4 w-4 text-amber-500" /> + <span>저장되지 않은 변경사항이 있습니다</span> + </div> + )} + <div className="flex items-center gap-1"> + <FileText className="h-4 w-4" /> + <span>진행률: {completedCount}/{totalCount}</span> + </div> + </div> + + <div className="flex items-center gap-2"> + <Button + variant="outline" + onClick={() => router.back()} + disabled={isLoading || isSaving} + > + 취소 + </Button> + + <Button + variant="secondary" + onClick={handleSave} + disabled={isLoading || isSaving || !hasUnsavedChanges} + className="flex items-center gap-2" + > + <Save className="h-4 w-4" /> + {isSaving ? "저장 중..." : "임시저장"} + </Button> + + + <Button + onClick={handleCompleteClick} + disabled={isLoading || isSaving || !allCompleted} + className="flex items-center gap-2" + > + <Send className="h-4 w-4" /> + 평가 완료 + </Button> + + </div> + </> + )} + </div> + </div> + + {/* 평가 완료 확인 다이얼로그 */} + <AlertDialog open={showCompleteDialog} onOpenChange={setShowCompleteDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <CheckCircle className="h-5 w-5 text-green-600" /> + 평가 완료 확인 + </AlertDialogTitle> + <AlertDialogDescription className="space-y-2"> + <p>평가를 완료하시겠습니까?</p> + <div className="bg-muted p-3 rounded-md text-sm"> + <div className="font-medium text-foreground mb-1">평가 정보</div> + <div>• 협력업체: {evaluationInfo.vendorName}</div> + <div>• 완료된 문항: {completedCount}/{totalCount}개</div> + <div>• 진행률: {Math.round((completedCount / totalCount) * 100)}%</div> + </div> + <p className="text-sm text-muted-foreground"> + 완료 후에는 수정이 제한될 수 있습니다. + </p> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel> + <AlertDialogAction + onClick={handleCompleteConfirmed} + disabled={isLoading} + className="bg-green-600 hover:bg-green-700" + > + {isLoading ? "처리 중..." : "평가 완료"} + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </div> + ) +}
\ No newline at end of file |
