summaryrefslogtreecommitdiff
path: root/lib/evaluation-submit
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
parentfbb3b7f05737f9571b04b0a8f4f15c0928de8545 (diff)
(대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가
Diffstat (limited to 'lib/evaluation-submit')
-rw-r--r--lib/evaluation-submit/evaluation-form.tsx592
-rw-r--r--lib/evaluation-submit/evaluation-page.tsx258
-rw-r--r--lib/evaluation-submit/service.ts562
-rw-r--r--lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx556
-rw-r--r--lib/evaluation-submit/table/evaluation-submit-dialog.tsx353
-rw-r--r--lib/evaluation-submit/table/submit-table.tsx281
-rw-r--r--lib/evaluation-submit/validation.ts161
7 files changed, 2763 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
diff --git a/lib/evaluation-submit/evaluation-page.tsx b/lib/evaluation-submit/evaluation-page.tsx
new file mode 100644
index 00000000..810ed03e
--- /dev/null
+++ b/lib/evaluation-submit/evaluation-page.tsx
@@ -0,0 +1,258 @@
+"use client"
+
+import * as React from "react"
+import { useParams, useRouter } from "next/navigation"
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Skeleton } from "@/components/ui/skeleton"
+import { AlertCircle, ArrowLeft, RefreshCw } from "lucide-react"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+
+import { getEvaluationFormData, EvaluationFormData } from "./service"
+import { EvaluationForm } from "./evaluation-form"
+
+/**
+ * 로딩 스켈레톤 컴포넌트
+ */
+function EvaluationFormSkeleton() {
+ 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">
+ <Skeleton className="h-10 w-10" />
+ <div className="space-y-2">
+ <Skeleton className="h-8 w-32" />
+ <Skeleton className="h-4 w-48" />
+ </div>
+ </div>
+ <Skeleton className="h-6 w-16" />
+ </div>
+
+ {/* 평가 정보 카드 스켈레톤 */}
+ <Card>
+ <CardHeader>
+ <Skeleton className="h-6 w-24" />
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
+ {[...Array(4)].map((_, i) => (
+ <div key={i} className="space-y-2">
+ <Skeleton className="h-4 w-16" />
+ <Skeleton className="h-5 w-24" />
+ <Skeleton className="h-3 w-20" />
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 진행률 카드 스켈레톤 */}
+ <Card>
+ <CardContent className="pt-6">
+ <div className="flex items-center justify-between">
+ <div className="space-y-1">
+ <Skeleton className="h-4 w-16" />
+ <Skeleton className="h-6 w-24" />
+ </div>
+ <Skeleton className="h-2 w-32" />
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 질문 카드들 스켈레톤 */}
+ {[...Array(3)].map((_, i) => (
+ <Card key={i} className="mb-6">
+ <CardHeader className="pb-4">
+ <div className="flex items-start justify-between">
+ <div className="space-y-2">
+ <div className="flex items-center gap-2">
+ <Skeleton className="h-5 w-16" />
+ <Skeleton className="h-5 w-12" />
+ </div>
+ <Skeleton className="h-6 w-64" />
+ <Skeleton className="h-4 w-48" />
+ </div>
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="space-y-3">
+ <Skeleton className="h-4 w-32" />
+ {[...Array(3)].map((_, j) => (
+ <div key={j} className="flex items-center space-x-3 p-3 border rounded-lg">
+ <Skeleton className="h-4 w-4 rounded-full" />
+ <div className="flex-1 flex items-center justify-between">
+ <Skeleton className="h-4 w-32" />
+ <Skeleton className="h-5 w-12" />
+ </div>
+ </div>
+ ))}
+ </div>
+ <div className="space-y-2">
+ <Skeleton className="h-4 w-24" />
+ <Skeleton className="h-20 w-full" />
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ )
+}
+
+/**
+ * 에러 상태 컴포넌트
+ */
+function EvaluationFormError({
+ error,
+ onRetry
+}: {
+ error: string
+ onRetry: () => void
+}) {
+ const router = useRouter()
+
+ return (
+ <div className="container mx-auto py-6 space-y-6">
+ <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>
+
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ {error}
+ </AlertDescription>
+ </Alert>
+
+ <Card>
+ <CardHeader>
+ <CardTitle>문제 해결</CardTitle>
+ <CardDescription>
+ 다음 방법들을 시도해보세요:
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <ul className="list-disc pl-6 space-y-2 text-sm">
+ <li>페이지를 새로고침해보세요</li>
+ <li>인터넷 연결 상태를 확인해보세요</li>
+ <li>잠시 후 다시 시도해보세요</li>
+ <li>문제가 지속되면 관리자에게 문의하세요</li>
+ </ul>
+
+ <div className="flex items-center gap-2 pt-4">
+ <Button onClick={onRetry} className="flex items-center gap-2">
+ <RefreshCw className="h-4 w-4" />
+ 다시 시도
+ </Button>
+ <Button variant="outline" onClick={() => router.back()}>
+ 목록으로 돌아가기
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )
+}
+
+/**
+ * 평가 작성 페이지 메인 컴포넌트
+ */
+export function EvaluationPage() {
+ const params = useParams()
+ const router = useRouter()
+ const [formData, setFormData] = React.useState<EvaluationFormData | null>(null)
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [error, setError] = React.useState<string | null>(null)
+
+ const reviewerEvaluationId = params.id ? parseInt(params.id as string) : null
+
+ // 평가 데이터 로드
+ const loadEvaluationData = React.useCallback(async () => {
+ if (!reviewerEvaluationId) {
+ setError("잘못된 평가 ID입니다.")
+ setIsLoading(false)
+ return
+ }
+
+ try {
+ setIsLoading(true)
+ setError(null)
+
+ const data = await getEvaluationFormData(reviewerEvaluationId)
+
+ if (!data) {
+ setError("평가 데이터를 찾을 수 없습니다.")
+ return
+ }
+
+ setFormData(data)
+ } catch (err) {
+ console.error('Failed to load evaluation data:', err)
+ setError(
+ err instanceof Error
+ ? err.message
+ : "평가 데이터를 불러오는 중 오류가 발생했습니다."
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }, [reviewerEvaluationId])
+
+ // 초기 데이터 로드
+ React.useEffect(() => {
+ loadEvaluationData()
+ }, [loadEvaluationData])
+
+ // 평가 완료 후 처리
+ const handleSubmitSuccess = React.useCallback(() => {
+ router.push('/evaluations')
+ }, [router])
+
+ // 로딩 상태
+ if (isLoading) {
+ return <EvaluationFormSkeleton />
+ }
+
+ // 에러 상태
+ if (error) {
+ return (
+ <EvaluationFormError
+ error={error}
+ onRetry={loadEvaluationData}
+ />
+ )
+ }
+
+ // 데이터가 없는 경우
+ if (!formData) {
+ return (
+ <EvaluationFormError
+ error="평가 데이터를 불러올 수 없습니다."
+ onRetry={loadEvaluationData}
+ />
+ )
+ }
+
+ // 정상 상태 - 평가 폼 렌더링
+ return (
+ <EvaluationForm
+ formData={formData}
+ onSubmit={handleSubmitSuccess}
+ />
+ )
+}
+
+// 페이지 컴포넌트용 기본 export
+export default function EvaluationPageWrapper() {
+ return <EvaluationPage />
+} \ No newline at end of file
diff --git a/lib/evaluation-submit/service.ts b/lib/evaluation-submit/service.ts
new file mode 100644
index 00000000..84d356e7
--- /dev/null
+++ b/lib/evaluation-submit/service.ts
@@ -0,0 +1,562 @@
+'use server'
+
+import db from "@/db/db";
+import {
+ reviewerEvaluations,
+ reviewerEvaluationsView,
+ reviewerEvaluationDetails,
+ regEvalCriteriaDetails,
+ regEvalCriteriaView,
+ NewReviewerEvaluationDetail,
+ ReviewerEvaluationDetail,
+ evaluationTargetReviewers,
+ evaluationTargets,
+ regEvalCriteria,
+ periodicEvaluations
+} from "@/db/schema";
+import { and, asc, desc, eq, ilike, or, SQL, count , sql, avg, isNotNull} from "drizzle-orm";
+import { filterColumns } from "@/lib/filter-columns";
+import { DEPARTMENT_CATEGORY_MAPPING, EvaluationFormData, EvaluationQuestionItem, GetSHIEvaluationsSubmitSchema, REVIEWER_TYPES, ReviewerType } from "./validation";
+
+
+// ===============================================================================
+// UTILITY FUNCTIONS
+// ===============================================================================
+
+/**
+ * division과 materialType을 기반으로 reviewerType을 계산합니다
+ */
+function calculateReviewerType(division: string, materialType: string): ReviewerType {
+ if (division === 'SHIP') {
+ if (materialType === 'EQUIPMENT' || materialType === 'EQUIPMENT_BULK') {
+ return REVIEWER_TYPES.EQUIPMENT_SHIP;
+ } else if (materialType === 'BULK') {
+ return REVIEWER_TYPES.BULK_SHIP;
+ }
+ return REVIEWER_TYPES.EQUIPMENT_SHIP; // 기본값
+ } else if (division === 'PLANT') {
+ if (materialType === 'EQUIPMENT' || materialType === 'EQUIPMENT_BULK') {
+ return REVIEWER_TYPES.EQUIPMENT_MARINE;
+ } else if (materialType === 'BULK') {
+ return REVIEWER_TYPES.BULK_MARINE;
+ }
+ return REVIEWER_TYPES.EQUIPMENT_MARINE; // 기본값
+ }
+ return REVIEWER_TYPES.EQUIPMENT_SHIP; // 기본값
+}
+
+/**
+ * reviewerType에 따라 해당하는 점수 필드를 가져옵니다
+ */
+function getScoreByReviewerType(
+ detailRecord: any,
+ reviewerType: ReviewerType
+): number | null {
+ let score: string | null = null;
+
+ switch (reviewerType) {
+ case REVIEWER_TYPES.EQUIPMENT_SHIP:
+ score = detailRecord.scoreEquipShip;
+ break;
+ case REVIEWER_TYPES.EQUIPMENT_MARINE:
+ score = detailRecord.scoreEquipMarine;
+ break;
+ case REVIEWER_TYPES.BULK_SHIP:
+ score = detailRecord.scoreBulkShip;
+ break;
+ case REVIEWER_TYPES.BULK_MARINE:
+ score = detailRecord.scoreBulkMarine;
+ break;
+ }
+
+ return score ? parseFloat(score) : null;
+}
+
+
+function getCategoryFilterByDepartment(departmentCode: string): SQL<unknown> {
+ const categoryMapping = DEPARTMENT_CATEGORY_MAPPING as Record<string, string>;
+ const category = categoryMapping[departmentCode] || 'administrator';
+ return eq(regEvalCriteria.category, category);
+}
+
+
+// ===============================================================================
+// MAIN FUNCTIONS
+// ===============================================================================
+
+
+
+/**
+ * 평가 폼 데이터를 조회하고, 응답 레코드가 없으면 생성합니다
+ */
+export async function getEvaluationFormData(reviewerEvaluationId: number): Promise<EvaluationFormData | null> {
+ try {
+ // 1. 리뷰어 평가 정보 조회 (부서 정보 + 평가 대상 정보 포함)
+ const reviewerEvaluationInfo = await db
+ .select({
+ id: reviewerEvaluations.id,
+ periodicEvaluationId: reviewerEvaluations.periodicEvaluationId,
+ evaluationTargetReviewerId: reviewerEvaluations.evaluationTargetReviewerId,
+ isCompleted: reviewerEvaluations.isCompleted,
+ // evaluationTargetReviewers 테이블에서 부서 정보
+ departmentCode: evaluationTargetReviewers.departmentCode,
+ // evaluationTargets 테이블에서 division과 materialType 정보
+ division: evaluationTargets.division,
+ materialType: evaluationTargets.materialType,
+ vendorName: evaluationTargets.vendorName,
+ vendorCode: evaluationTargets.vendorCode,
+ })
+ .from(reviewerEvaluations)
+ .leftJoin(
+ evaluationTargetReviewers,
+ eq(reviewerEvaluations.evaluationTargetReviewerId, evaluationTargetReviewers.id)
+ )
+ .leftJoin(
+ evaluationTargets,
+ eq(evaluationTargetReviewers.evaluationTargetId, evaluationTargets.id)
+ )
+ .where(eq(reviewerEvaluations.id, reviewerEvaluationId))
+ .limit(1);
+
+ if (reviewerEvaluationInfo.length === 0) {
+ throw new Error('Reviewer evaluation not found');
+ }
+
+ const evaluation = reviewerEvaluationInfo[0];
+
+ // 1-1. division과 materialType을 기반으로 reviewerType 계산
+ const reviewerType = calculateReviewerType(evaluation.division, evaluation.materialType);
+
+ // 2. 부서에 따른 카테고리 필터링 로직
+ // const categoryFilter = getCategoryFilterByDepartment("admin");
+ const categoryFilter = getCategoryFilterByDepartment(evaluation.departmentCode);
+
+ // 3. 해당 부서에 맞는 평가 기준들과 답변 옵션들 조회
+ const criteriaWithDetails = await db
+ .select({
+ // 질문 정보 (실제 스키마 기준)
+ criteriaId: regEvalCriteria.id,
+ category: regEvalCriteria.category, // 평가부문
+ category2: regEvalCriteria.category2, // 점수유형
+ item: regEvalCriteria.item, // 항목
+ classification: regEvalCriteria.classification, // 구분 (실제 질문)
+ range: regEvalCriteria.range, // 범위 (실제로 평가명)
+ remarks: regEvalCriteria.remarks,
+ scoreType: regEvalCriteria.scoreType, // ✅ fixed | variable
+ variableScoreMin: regEvalCriteria.variableScoreMin,
+ variableScoreMax: regEvalCriteria.variableScoreMax,
+ variableScoreUnit: regEvalCriteria.variableScoreUnit, // ✅ 오타 있지만 실제 스키마 따름
+
+ // 답변 옵션 정보
+ detailId: regEvalCriteriaDetails.id,
+ detail: regEvalCriteriaDetails.detail,
+ orderIndex: regEvalCriteriaDetails.orderIndex,
+ scoreEquipShip: regEvalCriteriaDetails.scoreEquipShip,
+ scoreEquipMarine: regEvalCriteriaDetails.scoreEquipMarine,
+ scoreBulkShip: regEvalCriteriaDetails.scoreBulkShip,
+ scoreBulkMarine: regEvalCriteriaDetails.scoreBulkMarine,
+ })
+ .from(regEvalCriteria)
+ .leftJoin(
+ regEvalCriteriaDetails,
+ eq(regEvalCriteria.id, regEvalCriteriaDetails.criteriaId)
+ )
+ .where(categoryFilter)
+ .orderBy(
+ regEvalCriteria.id,
+ regEvalCriteriaDetails.orderIndex
+ );
+
+ // 4. 기존 응답 데이터 조회 (실제 답변만)
+ const existingResponses = await db
+ .select({
+ id: reviewerEvaluationDetails.id,
+ reviewerEvaluationId: reviewerEvaluationDetails.reviewerEvaluationId,
+ regEvalCriteriaDetailsId: reviewerEvaluationDetails.regEvalCriteriaDetailsId,
+ score: reviewerEvaluationDetails.score,
+ comment: reviewerEvaluationDetails.comment,
+ createdAt: reviewerEvaluationDetails.createdAt,
+ updatedAt: reviewerEvaluationDetails.updatedAt,
+ })
+ .from(reviewerEvaluationDetails)
+ .where(
+ and(
+ eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId),
+ // ✅ null이 아닌 실제 응답만 조회
+ isNotNull(reviewerEvaluationDetails.regEvalCriteriaDetailsId)
+ )
+ );
+
+ // 5. 질문별로 그룹화하고 답변 옵션들 정리
+ const questionsMap = new Map<number, EvaluationQuestionItem>();
+
+ criteriaWithDetails.forEach(record => {
+ if (!record.detailId) return; // 답변 옵션이 없는 경우 스킵
+
+ const criteriaId = record.criteriaId;
+
+ // 해당 reviewerType에 맞는 점수 가져오기
+ const score = getScoreByReviewerType(record, reviewerType);
+ if (score === null) return; // 해당 리뷰어 타입에 점수가 없으면 스킵
+
+ // 질문이 이미 존재하는지 확인
+ if (!questionsMap.has(criteriaId)) {
+ questionsMap.set(criteriaId, {
+ criteriaId: record.criteriaId,
+ category: record.category,
+ category2: record.category2,
+ item: record.item,
+ classification: record.classification,
+ range: record.range,
+ scoreType: record.scoreType,
+ remarks: record.remarks,
+ availableOptions: [],
+ responseId: null,
+ selectedDetailId: null, // ✅ 초기값은 null (아직 선택하지 않음)
+ currentScore: null,
+ currentComment: null,
+ });
+ }
+
+ // 답변 옵션 추가
+ const question = questionsMap.get(criteriaId)!;
+ question.availableOptions.push({
+ detailId: record.detailId,
+ detail: record.detail,
+ score: score,
+ orderIndex: record.orderIndex,
+ });
+ });
+
+ // 6. ✅ 초기 응답 생성하지 않음 - 사용자가 실제로 답변할 때만 생성
+
+ // 7. 기존 응답 데이터를 질문에 매핑
+ const existingResponsesMap = new Map(
+ existingResponses.map(r => [r.regEvalCriteriaDetailsId, r])
+ );
+
+ // 8. 각 질문에 현재 응답 정보 매핑
+ const questions: EvaluationQuestionItem[] = [];
+ questionsMap.forEach(question => {
+ // 현재 선택된 답변 찾기 (실제 응답이 있는 경우에만)
+ let selectedResponse = null;
+ for (const option of question.availableOptions) {
+ const response = existingResponsesMap.get(option.detailId);
+ if (response) {
+ selectedResponse = response;
+ question.selectedDetailId = option.detailId;
+ break;
+ }
+ }
+
+ if (selectedResponse) {
+ question.responseId = selectedResponse.id;
+ question.currentScore = selectedResponse.score;
+ question.currentComment = selectedResponse.comment;
+ }
+ // ✅ else 케이스: 아직 답변하지 않은 상태 (모든 값이 null)
+
+ questions.push(question);
+ });
+
+ return {
+ evaluationInfo: {
+ ...evaluation,
+ reviewerType
+ },
+ questions,
+ };
+
+ } catch (err) {
+ console.error('Error in getEvaluationFormData:', err);
+ return null;
+ }
+}
+
+
+
+/**
+ * 평가 제출 목록을 조회합니다
+ */
+export async function getSHIEvaluationSubmissions(input: GetSHIEvaluationsSubmitSchema, userId: number) {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 고급 필터링
+ const advancedWhere = filterColumns({
+ table: reviewerEvaluationsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 전역 검색
+ let globalWhere: SQL<unknown> | undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(reviewerEvaluationsView.isCompleted, s),
+ );
+ }
+
+ const existingReviewer = await db.query.evaluationTargetReviewers.findFirst({
+ where: eq(evaluationTargetReviewers.reviewerUserId, userId),
+ });
+
+
+
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere,
+ eq(reviewerEvaluationsView.evaluationTargetReviewerId, existingReviewer?.id),
+ );
+
+ // 정렬
+ const orderBy = input.sort.length > 0
+ ? input.sort.map((item) => {
+ return item.desc
+ ? desc(reviewerEvaluationsView[item.id])
+ : asc(reviewerEvaluationsView[item.id]);
+ })
+ : [desc(reviewerEvaluationsView.reviewerEvaluationCreatedAt)];
+
+ // 데이터 조회
+ const { data, total } = await db.transaction(async (tx) => {
+ // 메인 데이터 조회
+ const data = await tx
+ .select()
+ .from(reviewerEvaluationsView)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset);
+
+ // 총 개수 조회
+ const totalResult = await tx
+ .select({ count: count() })
+ .from(reviewerEvaluationsView)
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+ return { data, pageCount };
+ } catch (err) {
+ console.log('Error in getEvaluationSubmissions:', err);
+ return { data: [], pageCount: 0 };
+ }
+}
+
+/**
+ * 특정 평가 제출의 상세 정보를 조회합니다
+ */
+export async function getSHIEvaluationSubmissionById(id: number) {
+ try {
+ const result = await db
+ .select()
+ .from(reviewerEvaluationsView)
+ .where(
+ and(
+ eq(reviewerEvaluationsView.evaluationTargetReviewerId, id),
+ )
+ )
+ .limit(1);
+
+ if (result.length === 0) {
+ return null;
+ }
+
+ const submission = result[0];
+
+ // 응답 데이터도 함께 조회
+ const [generalResponses] = await Promise.all([
+ db
+ .select()
+ .from(reviewerEvaluationDetails)
+ .where(
+ and(
+ eq(reviewerEvaluationDetails.reviewerEvaluationId, id),
+ )
+ ),
+ ]);
+
+ return {
+ ...submission,
+ generalResponses,
+ };
+ } catch (err) {
+ console.error('Error in getEvaluationSubmissionById:', err);
+ return null;
+ }
+}
+
+/**
+ * 평가 응답을 업데이트합니다
+ */
+export async function updateEvaluationResponse(
+ reviewerEvaluationId: number,
+ selectedDetailId: number,
+ comment?: string
+) {
+ try {
+ await db.transaction(async (tx) => {
+ // 1. 선택된 답변 옵션의 정보 조회
+ const selectedDetail = await tx
+ .select()
+ .from(regEvalCriteriaDetails)
+ .where(eq(regEvalCriteriaDetails.id, selectedDetailId))
+ .limit(1);
+
+ if (selectedDetail.length === 0) {
+ throw new Error('Selected detail not found');
+ }
+
+ // 2. reviewerEvaluation 정보 조회 (periodicEvaluationId 포함)
+ const reviewerEvaluationInfo = await tx
+ .select({
+ periodicEvaluationId: reviewerEvaluations.periodicEvaluationId,
+ })
+ .from(reviewerEvaluations)
+ .where(eq(reviewerEvaluations.id, reviewerEvaluationId))
+ .limit(1);
+
+ if (reviewerEvaluationInfo.length === 0) {
+ throw new Error('Reviewer evaluation not found');
+ }
+
+ const { periodicEvaluationId } = reviewerEvaluationInfo[0];
+
+ // 3. periodicEvaluation의 현재 상태 확인 및 업데이트
+ const currentStatus = await tx
+ .select({
+ status: periodicEvaluations.status,
+ })
+ .from(periodicEvaluations)
+ .where(eq(periodicEvaluations.id, periodicEvaluationId))
+ .limit(1);
+
+ if (currentStatus.length > 0 && currentStatus[0].status !== "IN_REVIEW") {
+ await tx
+ .update(periodicEvaluations)
+ .set({
+ status: "IN_REVIEW",
+ updatedAt: new Date(),
+ })
+ .where(eq(periodicEvaluations.id, periodicEvaluationId));
+ }
+
+ // 4. 리뷰어 타입 정보 조회
+ const evaluationInfo = await getEvaluationFormData(reviewerEvaluationId);
+ if (!evaluationInfo) {
+ throw new Error('Evaluation not found');
+ }
+
+ // 5. 해당 리뷰어 타입에 맞는 점수 가져오기
+ const score = getScoreByReviewerType(selectedDetail[0], evaluationInfo.evaluationInfo.reviewerType);
+ if (score === null) {
+ throw new Error('Score not found for this reviewer type');
+ }
+
+ // 6. 같은 질문에 대한 기존 응답들 삭제
+ const criteriaId = selectedDetail[0].criteriaId;
+ await tx
+ .delete(reviewerEvaluationDetails)
+ .where(
+ and(
+ eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId),
+ sql`${reviewerEvaluationDetails.regEvalCriteriaDetailsId} IN (
+ SELECT id FROM reg_eval_criteria_details WHERE criteria_id = ${criteriaId}
+ )`
+ )
+ );
+
+ // 7. 새로운 응답 생성
+ await tx
+ .insert(reviewerEvaluationDetails)
+ .values({
+ reviewerEvaluationId,
+ regEvalCriteriaDetailsId: selectedDetailId,
+ score: score.toString(),
+ comment,
+ });
+
+ // 8. 카테고리별 점수 계산 및 총점 업데이트
+ await recalculateEvaluationScores(tx, reviewerEvaluationId);
+ });
+
+ return { success: true };
+ } catch (err) {
+ console.error('Error in updateEvaluationResponse:', err);
+ throw err;
+ }
+}
+
+
+/**
+ * 평가 점수 재계산
+ */
+async function recalculateEvaluationScores(tx: any, reviewerEvaluationId: number) {
+ await tx
+ .update(reviewerEvaluations)
+ .set({
+ updatedAt: new Date(),
+ })
+ .where(eq(reviewerEvaluations.id, reviewerEvaluationId));
+}
+
+
+export async function completeEvaluation(
+ reviewerEvaluationId: number,
+ reviewerComment?: string
+) {
+ try {
+ await db.transaction(async (tx) => {
+ // 1. 먼저 해당 리뷰어 평가를 완료로 표시
+ const updatedEvaluation = await tx
+ .update(reviewerEvaluations)
+ .set({
+ isCompleted: true,
+ completedAt: new Date(),
+ reviewerComment,
+ updatedAt: new Date(),
+ })
+ .where(eq(reviewerEvaluations.id, reviewerEvaluationId))
+ .returning({ periodicEvaluationId: reviewerEvaluations.periodicEvaluationId });
+
+ if (updatedEvaluation.length === 0) {
+ throw new Error('Reviewer evaluation not found');
+ }
+
+ const { periodicEvaluationId } = updatedEvaluation[0];
+
+ // 2. 같은 periodicEvaluationId를 가진 모든 리뷰어 평가가 완료되었는지 확인
+ const allEvaluations = await tx
+ .select({
+ isCompleted: reviewerEvaluations.isCompleted,
+ })
+ .from(reviewerEvaluations)
+ .where(eq(reviewerEvaluations.periodicEvaluationId, periodicEvaluationId));
+
+ // 3. 모든 평가가 완료되었는지 확인
+ const allCompleted = allEvaluations.every(evaluation => evaluation.isCompleted);
+
+ // 4. 모든 평가가 완료되었다면 periodicEvaluations의 status 업데이트
+ if (allCompleted) {
+ await tx
+ .update(periodicEvaluations)
+ .set({
+ status: "REVIEW_COMPLETED",
+ updatedAt: new Date(),
+ })
+ .where(eq(periodicEvaluations.id, periodicEvaluationId));
+ }
+ });
+
+ return { success: true };
+ } catch (err) {
+ console.error('Error in completeEvaluation:', err);
+ throw err;
+ }
+} \ No newline at end of file
diff --git a/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx b/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx
new file mode 100644
index 00000000..1ec0284f
--- /dev/null
+++ b/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx
@@ -0,0 +1,556 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import {
+ Ellipsis,
+ InfoIcon,
+ PenToolIcon,
+ FileTextIcon,
+ ClipboardListIcon,
+ CheckIcon,
+ XIcon,
+ ClockIcon,
+ Send,
+ User,
+ Calendar
+} from "lucide-react"
+
+import { formatDate, formatCurrency } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Badge } from "@/components/ui/badge"
+import { useRouter } from "next/navigation"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { ReviewerEvaluationView } from "@/db/schema"
+
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ReviewerEvaluationView> | null>>
+ router: NextRouter;
+
+}
+
+/**
+ * 평가 진행 상태에 따른 배지 스타일
+ */
+const getProgressBadge = (isCompleted: boolean, completedAt: Date | null) => {
+ if (isCompleted && completedAt) {
+ return {
+ variant: "default" as const,
+ icon: <CheckIcon className="h-3 w-3" />,
+ label: "완료",
+ className: "bg-green-100 text-green-800 border-green-200"
+ }
+ } else {
+ return {
+ variant: "secondary" as const,
+ icon: <ClockIcon className="h-3 w-3" />,
+ label: "미완료"
+ }
+ }
+}
+
+/**
+ * 정기평가 상태에 따른 배지 스타일
+ */
+const getPeriodicStatusBadge = (status: string) => {
+ switch (status) {
+ case 'PENDING':
+ return {
+ variant: "secondary" as const,
+ icon: <ClockIcon className="h-3 w-3" />,
+ label: "대기중"
+ }
+
+ case 'PENDING_SUBMISSION':
+ return {
+ variant: "secondary" as const,
+ icon: <ClockIcon className="h-3 w-3" />,
+ label: "업체 제출 대기중"
+ }
+ case 'IN_PROGRESS':
+ return {
+ variant: "default" as const,
+ icon: <PenToolIcon className="h-3 w-3" />,
+ label: "진행중"
+ }
+ case 'REVIEW':
+ return {
+ variant: "outline" as const,
+ icon: <ClipboardListIcon className="h-3 w-3" />,
+ label: "검토중"
+ }
+ case 'COMPLETED':
+ return {
+ variant: "default" as const,
+ icon: <CheckIcon className="h-3 w-3" />,
+ label: "완료",
+ className: "bg-green-100 text-green-800 border-green-200"
+ }
+ default:
+ return {
+ variant: "secondary" as const,
+ icon: null,
+ label: status
+ }
+ }
+}
+
+/**
+ * 평가 제출 테이블 컬럼 정의
+ */
+export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<ReviewerEvaluationView>[] {
+
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<ReviewerEvaluationView> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ size: 40,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 기본 정보 컬럼들
+ // ----------------------------------------------------------------
+ const basicColumns: ColumnDef<ReviewerEvaluationView>[] = [
+ {
+ accessorKey: "evaluationYear",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="평가연도" />
+ ),
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {row.getValue("evaluationYear")}년
+ </Badge>
+ ),
+ size: 80,
+ },
+
+ {
+ id: "vendorInfo",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체" />
+ ),
+ cell: ({ row }) => {
+ const vendorName = row.original.vendorName;
+ const vendorCode = row.original.vendorCode;
+ const domesticForeign = row.original.domesticForeign;
+
+ return (
+ <div className="space-y-1">
+ <div className="font-medium">{vendorName}</div>
+ <div className="text-sm text-muted-foreground">
+ {vendorCode} • {domesticForeign === 'DOMESTIC' ? 'D' : 'F'}
+ </div>
+ </div>
+ );
+ },
+ enableSorting: false,
+ size: 200,
+ },
+
+
+ {
+ id: "materialType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재구분" />
+ ),
+ cell: ({ row }) => {
+ const materialType = row.original.materialType;
+ const material = materialType ==="BULK" ? "벌크": materialType ==="EQUIPMENT" ? "기자재" :"기자재/벌크"
+
+ return (
+ <div className="space-y-1">
+ <div className="font-medium">{material}</div>
+
+ </div>
+ );
+ },
+ enableSorting: false,
+ },
+
+ {
+ id: "division",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="division" />
+ ),
+ cell: ({ row }) => {
+ const division = row.original.division;
+ const divisionKR = division === "PLANT"?"해양":"조선";
+
+ return (
+ <div className="space-y-1">
+ <div className="font-medium">{divisionKR}</div>
+
+ </div>
+ );
+ },
+ enableSorting: false,
+ },
+ ]
+
+ // ----------------------------------------------------------------
+ // 3) 상태 정보 컬럼들
+ // ----------------------------------------------------------------
+ const statusColumns: ColumnDef<ReviewerEvaluationView>[] = [
+ {
+ id: "evaluationProgress",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="평가 진행상태" />
+ ),
+ cell: ({ row }) => {
+ const isCompleted = row.original.isCompleted;
+ const completedAt = row.original.completedAt;
+ const badgeInfo = getProgressBadge(isCompleted, completedAt);
+
+ return (
+ <div className="space-y-1">
+ <Badge
+ variant={badgeInfo.variant}
+ className={`flex items-center gap-1 ${badgeInfo.className || ''}`}
+ >
+ {badgeInfo.icon}
+ {badgeInfo.label}
+ </Badge>
+ {completedAt && (
+ <div className="text-xs text-muted-foreground">
+ {formatDate(completedAt,"KR")}
+ </div>
+ )}
+ </div>
+ );
+ },
+ size: 130,
+ },
+
+ {
+ id: "periodicStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="정기평가 상태" />
+ ),
+ cell: ({ row }) => {
+ const status = row.original.periodicStatus;
+ const badgeInfo = getPeriodicStatusBadge(status);
+
+ return (
+ <Badge
+ variant={badgeInfo.variant}
+ className={`flex items-center gap-1 ${badgeInfo.className || ''}`}
+ >
+ {badgeInfo.icon}
+ {badgeInfo.label}
+ </Badge>
+ );
+ },
+ size: 120,
+ },
+
+ // {
+ // id: "submissionInfo",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="제출정보" />
+ // ),
+ // cell: ({ row }) => {
+ // // const submissionDate = row.original.submittedAt;
+ // const completedAt = row.original.completedAt;
+
+ // return (
+ // <div className="space-y-1">
+ // <div className="flex items-center gap-1">
+ // <Badge variant={submissionDate ? "default" : "secondary"}>
+ // {submissionDate ? "제출완료" : "미제출"}
+ // </Badge>
+ // </div>
+
+ // {completedAt && (
+ // <div className="text-xs text-muted-foreground">
+ // 평가완료: {formatDate(completedAt, "KR")}
+ // </div>
+ // )}
+ // {/* {submissionDate && (
+ // <div className="text-xs text-muted-foreground">
+ // 제출: {formatDate(submissionDate, "KR")}
+ // </div>
+ // )} */}
+
+ // </div>
+ // );
+ // },
+ // enableSorting: false,
+ // size: 140,
+ // },
+ ]
+
+ // ----------------------------------------------------------------
+ // 4) 점수 및 평가 정보 컬럼들
+ // ----------------------------------------------------------------
+ const scoreColumns: ColumnDef<ReviewerEvaluationView>[] = [
+ {
+ id: "periodicScores",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="정기평가 점수" />
+ ),
+ cell: ({ row }) => {
+ const finalScore = row.original.periodicFinalScore;
+ const finalGrade = row.original.periodicFinalGrade;
+ const evaluationScore = row.original.periodicEvaluationScore;
+ const evaluationGrade = row.original.periodicEvaluationGrade;
+
+ return (
+ <div className="text-center space-y-1">
+ {finalScore && finalGrade ? (
+ <div className="space-y-1">
+ <div className="font-medium text-blue-600">
+ 최종: {parseFloat(finalScore.toString()).toFixed(1)}점
+ </div>
+ <Badge variant="outline">{finalGrade}</Badge>
+ </div>
+ ) : evaluationScore && evaluationGrade ? (
+ <div className="space-y-1">
+ <div className="font-medium">
+ {parseFloat(evaluationScore.toString()).toFixed(1)}점
+ </div>
+ <Badge variant="outline">{evaluationGrade}</Badge>
+ </div>
+ ) : (
+ <span className="text-muted-foreground">미산정</span>
+ )}
+ </div>
+ );
+ },
+ enableSorting: false,
+ size: 120,
+ },
+
+ ]
+
+
+
+ // ----------------------------------------------------------------
+ // 6) 메타데이터 컬럼들
+ // ----------------------------------------------------------------
+ const metaColumns: ColumnDef<ReviewerEvaluationView>[] = [
+ {
+ accessorKey: "reviewerEvaluationCreatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("reviewerEvaluationCreatedAt") as Date;
+ return formatDate(date);
+ },
+ size: 140,
+ },
+ {
+ accessorKey: "reviewerEvaluationUpdatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ row }) => {
+ const date = row.getValue("reviewerEvaluationUpdatedAt") as Date;
+ return formatDate(date);
+ },
+ size: 140,
+ },
+ ]
+
+ // ----------------------------------------------------------------
+ // 7) actions 컬럼 (드롭다운 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<ReviewerEvaluationView> = {
+ id: "actions",
+ header: "작업",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const isCompleted = row.original.isCompleted;
+ const reviewerEvaluationId = row.original.reviewerEvaluationId;
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon">
+ <Ellipsis className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => router.push(`/evcp/evaluation-input/${reviewerEvaluationId}`)}
+ >
+ {isCompleted ? "완료된 평가보기":"평가 작성하기"}
+ </DropdownMenuItem>
+
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 80,
+ }
+
+ // ----------------------------------------------------------------
+ // 8) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...basicColumns,
+ {
+ id: "statusInfo",
+ header: "상태 정보",
+ columns: statusColumns,
+ },
+ {
+ id: "scoreInfo",
+ header: "점수 및 평가",
+ columns: scoreColumns,
+ },
+
+ {
+ id: "metadata",
+ header: "메타데이터",
+ columns: metaColumns,
+ },
+ actionsColumn,
+ ]
+}
+
+// ----------------------------------------------------------------
+// 9) 컬럼 설정 (필터링용)
+// ----------------------------------------------------------------
+export const evaluationSubmissionsColumnsConfig = [
+ {
+ id: "reviewerEvaluationId",
+ label: "평가 ID",
+ group: "기본 정보",
+ type: "text",
+ excelHeader: "Evaluation ID",
+ },
+ {
+ id: "vendorName",
+ label: "협력업체명",
+ group: "기본 정보",
+ type: "text",
+ excelHeader: "Vendor Name",
+ },
+ {
+ id: "vendorCode",
+ label: "협력업체 코드",
+ group: "기본 정보",
+ type: "text",
+ excelHeader: "Vendor Code",
+ },
+ {
+ id: "evaluationYear",
+ label: "평가연도",
+ group: "기본 정보",
+ type: "number",
+ excelHeader: "Evaluation Year",
+ },
+ {
+ id: "departmentCode",
+ label: "부서코드",
+ group: "기본 정보",
+ type: "text",
+ excelHeader: "Department Code",
+ },
+ {
+ id: "isCompleted",
+ label: "완료 여부",
+ group: "상태 정보",
+ type: "select",
+ options: [
+ { label: "완료", value: "true" },
+ { label: "미완료", value: "false" },
+ ],
+ excelHeader: "Is Completed",
+ },
+ {
+ id: "periodicStatus",
+ label: "정기평가 상태",
+ group: "상태 정보",
+ type: "select",
+ options: [
+ { label: "대기중", value: "PENDING" },
+ { label: "진행중", value: "IN_PROGRESS" },
+ { label: "검토중", value: "REVIEW" },
+ { label: "완료", value: "COMPLETED" },
+ ],
+ excelHeader: "Periodic Status",
+ },
+ {
+ id: "documentsSubmitted",
+ label: "문서 제출여부",
+ group: "상태 정보",
+ type: "select",
+ options: [
+ { label: "제출완료", value: "true" },
+ { label: "미제출", value: "false" },
+ ],
+ excelHeader: "Documents Submitted",
+ },
+ {
+ id: "periodicFinalScore",
+ label: "최종점수",
+ group: "점수 정보",
+ type: "number",
+ excelHeader: "Final Score",
+ },
+ {
+ id: "periodicFinalGrade",
+ label: "최종등급",
+ group: "점수 정보",
+ type: "text",
+ excelHeader: "Final Grade",
+ },
+ {
+ id: "reviewerEvaluationCreatedAt",
+ label: "생성일",
+ group: "메타데이터",
+ type: "date",
+ excelHeader: "Created At",
+ },
+ {
+ id: "reviewerEvaluationUpdatedAt",
+ label: "수정일",
+ group: "메타데이터",
+ type: "date",
+ excelHeader: "Updated At",
+ },
+] as const; \ No newline at end of file
diff --git a/lib/evaluation-submit/table/evaluation-submit-dialog.tsx b/lib/evaluation-submit/table/evaluation-submit-dialog.tsx
new file mode 100644
index 00000000..20ed5f30
--- /dev/null
+++ b/lib/evaluation-submit/table/evaluation-submit-dialog.tsx
@@ -0,0 +1,353 @@
+"use client"
+
+import * as React from "react"
+import {
+ AlertTriangleIcon,
+ CheckCircleIcon,
+ SendIcon,
+ XCircleIcon,
+ FileTextIcon,
+ ClipboardListIcon,
+ LoaderIcon
+} from "lucide-react"
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Alert,
+ AlertDescription,
+ AlertTitle,
+} from "@/components/ui/alert"
+import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { toast } from "sonner"
+
+// Progress 컴포넌트 (간단한 구현)
+function Progress({ value, className }: { value: number; className?: string }) {
+ return (
+ <div className={`w-full bg-gray-200 rounded-full overflow-hidden ${className}`}>
+ <div
+ className={`h-full bg-blue-600 transition-all duration-300 ${
+ value === 100 ? 'bg-green-500' : value >= 50 ? 'bg-blue-500' : 'bg-yellow-500'
+ }`}
+ style={{ width: `${Math.min(100, Math.max(0, value))}%` }}
+ />
+ </div>
+ )
+}
+
+import {
+ getEvaluationSubmissionCompleteness,
+ updateEvaluationSubmissionStatus
+} from "../service"
+import type { EvaluationSubmissionWithVendor } from "../service"
+
+interface EvaluationSubmissionDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ submission: EvaluationSubmissionWithVendor | null
+ onSuccess: () => void
+}
+
+type CompletenessData = {
+ general: {
+ total: number
+ completed: number
+ percentage: number
+ isComplete: boolean
+ }
+ esg: {
+ total: number
+ completed: number
+ percentage: number
+ averageScore: number
+ isComplete: boolean
+ }
+ overall: {
+ isComplete: boolean
+ totalItems: number
+ completedItems: number
+ }
+}
+
+export function EvaluationSubmissionDialog({
+ open,
+ onOpenChange,
+ submission,
+ onSuccess,
+}: EvaluationSubmissionDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [completeness, setCompleteness] = React.useState<CompletenessData | null>(null)
+
+ // 완성도 데이터 로딩
+ React.useEffect(() => {
+ if (open && submission?.id) {
+ loadCompleteness()
+ }
+ }, [open, submission?.id])
+
+ const loadCompleteness = async () => {
+ if (!submission?.id) return
+
+ setIsLoading(true)
+ try {
+ const data = await getEvaluationSubmissionCompleteness(submission.id)
+ setCompleteness(data)
+ } catch (error) {
+ console.error('Error loading completeness:', error)
+ toast.error('완성도 정보를 불러오는데 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 제출하기
+ const handleSubmit = async () => {
+ if (!submission?.id || !completeness) return
+
+ if (!completeness.overall.isComplete) {
+ toast.error('모든 평가 항목을 완료해야 제출할 수 있습니다.')
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ await updateEvaluationSubmissionStatus(submission.id, 'submitted')
+ toast.success('평가가 성공적으로 제출되었습니다.')
+ onSuccess()
+ } catch (error: any) {
+ console.error('Error submitting evaluation:', error)
+ toast.error(error.message || '제출에 실패했습니다.')
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ const isKorean = submission?.vendor.countryCode === 'KR'
+
+ if (isLoading) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center space-y-4">
+ <LoaderIcon className="h-8 w-8 animate-spin mx-auto" />
+ <p>완성도를 확인하는 중...</p>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-[600px]">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <SendIcon className="h-5 w-5" />
+ 평가 제출하기
+ </DialogTitle>
+ <DialogDescription>
+ {submission?.vendor.vendorName}의 {submission?.evaluationYear}년 평가를 제출합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {completeness && (
+ <div className="space-y-6">
+ {/* 전체 완성도 카드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-base flex items-center justify-between">
+ <span>전체 완성도</span>
+ <Badge
+ variant={completeness.overall.isComplete ? "default" : "secondary"}
+ className={
+ completeness.overall.isComplete
+ ? "bg-green-100 text-green-800 border-green-200"
+ : ""
+ }
+ >
+ {completeness.overall.isComplete ? "완료" : "미완료"}
+ </Badge>
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="space-y-2">
+ <div className="flex items-center justify-between text-sm">
+ <span>전체 진행률</span>
+ <span className="font-medium">
+ {completeness.overall.completedItems}/{completeness.overall.totalItems}개 완료
+ </span>
+ </div>
+ <Progress
+ value={
+ completeness.overall.totalItems > 0
+ ? (completeness.overall.completedItems / completeness.overall.totalItems) * 100
+ : 0
+ }
+ className="h-2"
+ />
+ <p className="text-xs text-muted-foreground">
+ {completeness.overall.totalItems > 0
+ ? Math.round((completeness.overall.completedItems / completeness.overall.totalItems) * 100)
+ : 0}% 완료
+ </p>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 세부 완성도 */}
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 일반평가 */}
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm flex items-center gap-2">
+ <FileTextIcon className="h-4 w-4" />
+ 일반평가
+ {completeness.general.isComplete ? (
+ <CheckCircleIcon className="h-4 w-4 text-green-600" />
+ ) : (
+ <XCircleIcon className="h-4 w-4 text-red-600" />
+ )}
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="space-y-1">
+ <div className="flex items-center justify-between text-xs">
+ <span>응답 완료</span>
+ <span className="font-medium">
+ {completeness.general.completed}/{completeness.general.total}개
+ </span>
+ </div>
+ <Progress value={completeness.general.percentage} className="h-1" />
+ <p className="text-xs text-muted-foreground">
+ {completeness.general.percentage.toFixed(0)}% 완료
+ </p>
+ </div>
+
+ {!completeness.general.isComplete && (
+ <p className="text-xs text-red-600">
+ {completeness.general.total - completeness.general.completed}개 항목이 미완료입니다.
+ </p>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* ESG평가 */}
+ {isKorean ? (
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm flex items-center gap-2">
+ <ClipboardListIcon className="h-4 w-4" />
+ ESG평가
+ {completeness.esg.isComplete ? (
+ <CheckCircleIcon className="h-4 w-4 text-green-600" />
+ ) : (
+ <XCircleIcon className="h-4 w-4 text-red-600" />
+ )}
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="space-y-1">
+ <div className="flex items-center justify-between text-xs">
+ <span>응답 완료</span>
+ <span className="font-medium">
+ {completeness.esg.completed}/{completeness.esg.total}개
+ </span>
+ </div>
+ <Progress value={completeness.esg.percentage} className="h-1" />
+ <p className="text-xs text-muted-foreground">
+ {completeness.esg.percentage.toFixed(0)}% 완료
+ </p>
+ </div>
+
+ {completeness.esg.completed > 0 && (
+ <div className="text-xs">
+ <span className="text-muted-foreground">평균 점수: </span>
+ <span className="font-medium text-blue-600">
+ {completeness.esg.averageScore.toFixed(1)}점
+ </span>
+ </div>
+ )}
+
+ {!completeness.esg.isComplete && (
+ <p className="text-xs text-red-600">
+ {completeness.esg.total - completeness.esg.completed}개 항목이 미완료입니다.
+ </p>
+ )}
+ </CardContent>
+ </Card>
+ ) : (
+ <Card>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-sm flex items-center gap-2">
+ <ClipboardListIcon className="h-4 w-4" />
+ ESG평가
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="text-center text-muted-foreground">
+ <Badge variant="outline">해당없음</Badge>
+ <p className="text-xs mt-2">한국 업체가 아니므로 ESG 평가가 제외됩니다.</p>
+ </div>
+ </CardContent>
+ </Card>
+ )}
+ </div>
+
+ {/* 제출 상태 알림 */}
+ {completeness.overall.isComplete ? (
+ <Alert>
+ <CheckCircleIcon className="h-4 w-4" />
+ <AlertTitle>제출 준비 완료</AlertTitle>
+ <AlertDescription>
+ 모든 평가 항목이 완료되었습니다. 제출하시겠습니까?
+ </AlertDescription>
+ </Alert>
+ ) : (
+ <Alert variant="destructive">
+ <AlertTriangleIcon className="h-4 w-4" />
+ <AlertTitle>제출 불가</AlertTitle>
+ <AlertDescription>
+ 아직 완료되지 않은 평가 항목이 있습니다. 모든 항목을 완료한 후 제출해 주세요.
+ </AlertDescription>
+ </Alert>
+ )}
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={!completeness?.overall.isComplete || isSubmitting}
+ className="min-w-[100px]"
+ >
+ {isSubmitting ? (
+ <>
+ <LoaderIcon className="mr-2 h-4 w-4 animate-spin" />
+ 제출 중...
+ </>
+ ) : (
+ <>
+ <SendIcon className="mr-2 h-4 w-4" />
+ 제출하기
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/evaluation-submit/table/submit-table.tsx b/lib/evaluation-submit/table/submit-table.tsx
new file mode 100644
index 00000000..9000c48b
--- /dev/null
+++ b/lib/evaluation-submit/table/submit-table.tsx
@@ -0,0 +1,281 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+
+import { getSHIEvaluationSubmissions } from "../service"
+import { getColumns } from "./evaluation-submissions-table-columns"
+import { useRouter } from "next/navigation"
+import { ReviewerEvaluationView } from "@/db/schema"
+
+interface EvaluationSubmissionsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getSHIEvaluationSubmissions>>,
+ ]
+ >
+}
+
+export function SHIEvaluationSubmissionsTable({ promises }: EvaluationSubmissionsTableProps) {
+ // 1. 데이터 로딩 상태 관리
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [tableData, setTableData] = React.useState<{
+ data: ReviewerEvaluationView[]
+ pageCount: number
+ }>({ data: [], pageCount: 0 })
+ const router = useRouter()
+
+ console.log(tableData)
+
+
+ // 2. 행 액션 상태 관리
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<ReviewerEvaluationView> | null>(null)
+
+ // 3. Promise 해결을 useEffect로 처리
+ React.useEffect(() => {
+ promises
+ .then(([result]) => {
+ setTableData(result)
+ setIsLoading(false)
+ })
+ // .catch((error) => {
+ // console.error('Failed to load evaluation submissions:', error)
+ // setIsLoading(false)
+ // })
+ }, [promises])
+
+ // 4. 컬럼 정의
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction , router}),
+ [setRowAction, router]
+ )
+
+ // 5. 필터 필드 정의
+ const filterFields: DataTableFilterField<ReviewerEvaluationView>[] = [
+ {
+ id: "isCompleted",
+ label: "완료상태",
+ placeholder: "완료상태 선택...",
+ },
+ {
+ id: "periodicStatus",
+ label: "정기평가 상태",
+ placeholder: "상태 선택...",
+ },
+ {
+ id: "evaluationYear",
+ label: "평가연도",
+ placeholder: "연도 선택...",
+ },
+ {
+ id: "departmentCode",
+ label: "담당부서",
+ placeholder: "부서 선택...",
+ },
+ ]
+
+ const advancedFilterFields: DataTableAdvancedFilterField<ReviewerEvaluationView>[] = [
+ {
+ id: "reviewerEvaluationId",
+ label: "평가 ID",
+ type: "text",
+ },
+ {
+ id: "vendorName",
+ label: "협력업체명",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "협력업체 코드",
+ type: "text",
+ },
+ {
+ id: "evaluationYear",
+ label: "평가연도",
+ type: "number",
+ },
+ {
+ id: "departmentCode",
+ label: "부서코드",
+ type: "select",
+ options: [
+ { label: "구매평가", value: "ORDER_EVAL" },
+ { label: "조달평가", value: "PROCUREMENT_EVAL" },
+ { label: "품질평가", value: "QUALITY_EVAL" },
+ { label: "CS평가", value: "CS_EVAL" },
+ { label: "관리자", value: "ADMIN_EVAL" },
+ ],
+ },
+ {
+ id: "division",
+ label: "사업부",
+ type: "select",
+ options: [
+ { label: "조선", value: "SHIP" },
+ { label: "플랜트", value: "PLANT" },
+ ],
+ },
+ {
+ id: "materialType",
+ label: "자재유형",
+ type: "select",
+ options: [
+ { label: "장비", value: "EQUIPMENT" },
+ { label: "벌크", value: "BULK" },
+ { label: "장비+벌크", value: "EQUIPMENT_BULK" },
+ ],
+ },
+ {
+ id: "domesticForeign",
+ label: "국내/해외",
+ type: "select",
+ options: [
+ { label: "국내", value: "DOMESTIC" },
+ { label: "해외", value: "FOREIGN" },
+ ],
+ },
+ {
+ id: "isCompleted",
+ label: "평가완료 여부",
+ type: "select",
+ options: [
+ { label: "완료", value: "true" },
+ { label: "미완료", value: "false" },
+ ],
+ },
+ {
+ id: "periodicStatus",
+ label: "정기평가 상태",
+ type: "select",
+ options: [
+ { label: "대기중", value: "PENDING" },
+ { label: "진행중", value: "IN_PROGRESS" },
+ { label: "검토중", value: "REVIEW" },
+ { label: "완료", value: "COMPLETED" },
+ ],
+ },
+ {
+ id: "documentsSubmitted",
+ label: "문서 제출여부",
+ type: "select",
+ options: [
+ { label: "제출완료", value: "true" },
+ { label: "미제출", value: "false" },
+ ],
+ },
+ {
+ id: "periodicFinalScore",
+ label: "최종점수",
+ type: "number",
+ },
+ {
+ id: "periodicFinalGrade",
+ label: "최종등급",
+ type: "text",
+ },
+ {
+ id: "ldClaimCount",
+ label: "LD 클레임 건수",
+ type: "number",
+ },
+ {
+ id: "submissionDate",
+ label: "제출일",
+ type: "date",
+ },
+ {
+ id: "submissionDeadline",
+ label: "제출마감일",
+ type: "date",
+ },
+ {
+ id: "completedAt",
+ label: "완료일시",
+ type: "date",
+ },
+ {
+ id: "reviewerEvaluationCreatedAt",
+ label: "생성일",
+ type: "date",
+ },
+ {
+ id: "reviewerEvaluationUpdatedAt",
+ label: "수정일",
+ type: "date",
+ },
+ ]
+
+ // 6. 데이터 테이블 설정
+ const { table } = useDataTable({
+ data: tableData.data,
+ columns,
+ pageCount: tableData.pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "reviewerEvaluationUpdatedAt", desc: true }],
+ columnPinning: { left: ["select"], right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.reviewerEvaluationId),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ // 7. 데이터 새로고침 함수
+ const handleRefresh = React.useCallback(() => {
+ setIsLoading(true)
+ router.refresh()
+ }, [router])
+
+ // 8. 각종 성공 핸들러
+ const handleActionSuccess = React.useCallback(() => {
+ setRowAction(null)
+ table.resetRowSelection()
+ handleRefresh()
+ }, [handleRefresh, table])
+
+ // 9. 로딩 상태 표시
+ if (isLoading) {
+ return (
+ <div className="flex items-center justify-center h-32">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
+ <span className="ml-2">평가 제출 목록을 불러오는 중...</span>
+ </div>
+ )
+ }
+
+ return (
+ <>
+ {/* 메인 테이블 */}
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ {/* 추가 툴바 버튼들이 필요하면 여기에 */}
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 행 액션 모달들 - 필요에 따라 구현 */}
+ {/* {rowAction?.type === "view_detail" && (
+ <EvaluationDetailDialog
+ row={rowAction.row}
+ onClose={() => setRowAction(null)}
+ onSuccess={handleActionSuccess}
+ />
+ )} */}
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/evaluation-submit/validation.ts b/lib/evaluation-submit/validation.ts
new file mode 100644
index 00000000..dc6f3f0f
--- /dev/null
+++ b/lib/evaluation-submit/validation.ts
@@ -0,0 +1,161 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { ReviewerEvaluationView } from "@/db/schema";
+
+
+export const getSHIEvaluationsSubmitSchema = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<ReviewerEvaluationView>().withDefault([
+ { id: "reviewerEvaluationCreatedAt", desc: true },
+ ]),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+});
+
+export type GetSHIEvaluationsSubmitSchema = Awaited<ReturnType<typeof getSHIEvaluationsSubmitSchema.parse>>
+
+// 리뷰어 타입 상수 정의
+export const REVIEWER_TYPES = {
+ EQUIPMENT_SHIP: 'equipment_ship',
+ EQUIPMENT_MARINE: 'equipment_marine',
+ BULK_SHIP: 'bulk_ship',
+ BULK_MARINE: 'bulk_marine',
+} as const;
+
+// 리뷰어 타입 union type
+export type ReviewerType = typeof REVIEWER_TYPES[keyof typeof REVIEWER_TYPES];
+
+// 답변 옵션 타입 (각 질문에 대한 선택 가능한 답변들)
+export type EvaluationAnswerOption = {
+ detailId: number;
+ detail: string;
+ score: number;
+ orderIndex: number;
+ isNotApplicable?: boolean; // "해당없음" 옵션 여부
+ isCustomScore?: boolean; // 사용자 직접 입력 옵션 여부
+};
+
+// 평가 질문 항목 타입
+// 확장된 평가 질문 항목 타입
+export type EvaluationQuestionItem = {
+ // 질문 정보 (regEvalCriteria)
+ criteriaId: number;
+ category: string;
+ category2: string;
+ item: string;
+ classification: string;
+ range: string | null;
+ remarks: string | null;
+
+ // 평가 및 점수 유형
+ scoreType: ScoreType; // fixed | variable
+
+ // 가변 점수용 설정
+ variableScoreMin?: number; // 최소 점수 (예: -3)
+ variableScoreMax?: number; // 최대 점수 (예: +5)
+ variableScoreUnit?: string; // 단위 설명 (예: "1건당 1점", "1일당 0.5점")
+
+ // 답변 옵션들
+ availableOptions: EvaluationAnswerOption[];
+
+ // 현재 응답 정보
+ responseId: number | null;
+ selectedDetailId: number | null;
+ currentScore: string | null;
+ currentComment: string | null;
+
+ // 가변 점수용 추가 필드
+ customScore?: number; // 사용자가 입력한 점수
+ customScoreReason?: string; // 점수 입력 근거
+};
+
+
+// 평가 정보 타입
+export type EvaluationInfo = {
+ id: number;
+ periodicEvaluationId: number;
+ evaluationTargetReviewerId: number;
+ isCompleted: boolean;
+
+ // 부서 및 벤더 정보
+ departmentCode: string;
+ division: 'SHIP' | 'PLANT';
+ materialType: 'EQUIPMENT' | 'BULK' | 'EQUIPMENT_BULK';
+ vendorName: string;
+ vendorCode: string;
+
+ // 계산된 리뷰어 타입
+ reviewerType: ReviewerType;
+};
+
+// 전체 평가 폼 데이터 타입
+export type EvaluationFormData = {
+ evaluationInfo: EvaluationInfo;
+ questions: EvaluationQuestionItem[];
+};
+
+// 평가 응답 업데이트용 타입
+export type EvaluationResponseUpdate = {
+ regEvalCriteriaDetailsId: number; // 선택된 답변 옵션의 ID
+ score: string; // 해당 답변 옵션의 점수
+ comment?: string;
+};
+
+// 평가 제출 목록 조회용 뷰 타입 (기존 타입에서 확장)
+export type EvaluationSubmissionWithVendor = {
+ vendor: {
+ id: number;
+ vendorCode: string;
+ vendorName: string;
+ countryCode: string;
+ contactEmail: string;
+ };
+ _count: {
+ generalResponses: number;
+ esgResponses: number;
+ attachments: number;
+ };
+};
+
+// 평가 카테고리 매핑
+export const EVALUATION_CATEGORIES = {
+ 'customer-service': 'CS 평가',
+ 'administrator': '관리자 평가',
+ 'procurement': '구매 평가',
+ 'design': '설계 평가',
+ 'sourcing': '조달 평가',
+ 'quality': '품질 평가',
+} as const;
+
+// 부서 코드별 카테고리 매핑
+export const DEPARTMENT_CATEGORY_MAPPING = {
+ 'ORDER_EVAL': 'procurement',
+ 'PROCUREMENT_EVAL': 'sourcing',
+ 'QUALITY_EVAL': 'quality',
+ 'CS_EVAL': 'customer-service',
+ 'DESIGN_EVAL': 'design'
+} as const;
+
+// 점수 유형 정의
+export const SCORE_TYPES = {
+ FIXED: 'fixed', // 미리 정해진 점수 (기존 방식)
+ VARIABLE: 'variable', // 사용자 직접 입력 점수
+} as const;
+
+export type ScoreType = typeof SCORE_TYPES[keyof typeof SCORE_TYPES];
+