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 | |
| parent | fbb3b7f05737f9571b04b0a8f4f15c0928de8545 (diff) | |
(대표님) 변경사항 20250707 10시 43분 - unstaged 변경사항 추가
Diffstat (limited to 'lib/evaluation-submit')
| -rw-r--r-- | lib/evaluation-submit/evaluation-form.tsx | 592 | ||||
| -rw-r--r-- | lib/evaluation-submit/evaluation-page.tsx | 258 | ||||
| -rw-r--r-- | lib/evaluation-submit/service.ts | 562 | ||||
| -rw-r--r-- | lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx | 556 | ||||
| -rw-r--r-- | lib/evaluation-submit/table/evaluation-submit-dialog.tsx | 353 | ||||
| -rw-r--r-- | lib/evaluation-submit/table/submit-table.tsx | 281 | ||||
| -rw-r--r-- | lib/evaluation-submit/validation.ts | 161 |
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]; + |
