summaryrefslogtreecommitdiff
path: root/lib/evaluation-submit/evaluation-form.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-07 01:44:45 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-07 01:44:45 +0000
commit90f79a7a691943a496f67f01c1e493256070e4de (patch)
tree37275fde3ae08c2bca384fbbc8eb378de7e39230 /lib/evaluation-submit/evaluation-form.tsx
parentfbb3b7f05737f9571b04b0a8f4f15c0928de8545 (diff)
(대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가
Diffstat (limited to 'lib/evaluation-submit/evaluation-form.tsx')
-rw-r--r--lib/evaluation-submit/evaluation-form.tsx592
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