diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/evaluation-submit/evaluation-form.tsx | 453 | ||||
| -rw-r--r-- | lib/evaluation-submit/service.ts | 385 | ||||
| -rw-r--r-- | lib/evaluation-target-list/service.ts | 195 | ||||
| -rw-r--r-- | lib/evaluation-target-list/table/evaluation-target-table.tsx | 2 | ||||
| -rw-r--r-- | lib/evaluation-target-list/table/evaluation-targets-columns.tsx | 66 | ||||
| -rw-r--r-- | lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx | 181 | ||||
| -rw-r--r-- | lib/evaluation-target-list/table/update-evaluation-target.tsx | 10 | ||||
| -rw-r--r-- | lib/evaluation/service.ts | 124 | ||||
| -rw-r--r-- | lib/evaluation/table/evaluation-columns.tsx | 71 | ||||
| -rw-r--r-- | lib/evaluation/table/evaluation-details-dialog.tsx | 591 | ||||
| -rw-r--r-- | lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx | 16 | ||||
| -rw-r--r-- | lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx | 2 | ||||
| -rw-r--r-- | lib/items/service.ts | 3 | ||||
| -rw-r--r-- | lib/items/table/items-table.tsx | 50 | ||||
| -rw-r--r-- | lib/sedp/sync-form.ts | 44 | ||||
| -rw-r--r-- | lib/sedp/sync-package.ts | 30 | ||||
| -rw-r--r-- | lib/vendor-data/services copy.ts | 99 | ||||
| -rw-r--r-- | lib/vendor-data/services.ts | 35 |
18 files changed, 1780 insertions, 577 deletions
diff --git a/lib/evaluation-submit/evaluation-form.tsx b/lib/evaluation-submit/evaluation-form.tsx index 65da72b6..fbdcee69 100644 --- a/lib/evaluation-submit/evaluation-form.tsx +++ b/lib/evaluation-submit/evaluation-form.tsx @@ -27,20 +27,28 @@ import { Send, ArrowLeft, AlertCircle, - FileText + FileText, + Upload, + File, + X, + Download, + Paperclip } from "lucide-react" import { useRouter } from "next/navigation" import { useToast } from "@/hooks/use-toast" import { updateEvaluationResponse, + updateVariableEvaluationResponse, completeEvaluation } from "./service" import { - type EvaluationFormData, type EvaluationQuestionItem, EVALUATION_CATEGORIES } from "./validation" import { DEPARTMENT_CODE_LABELS, divisionMap, vendortypeMap } from "@/types/evaluation" +import { EvaluationFormData } from "@/types/evaluation-form" +// 파일 다운로드 유틸리티 import +import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download" interface EvaluationFormProps { formData: EvaluationFormData @@ -53,6 +61,15 @@ interface QuestionResponse { comment: string } +interface AttachmentInfo { + id: number + originalFileName: string + publicPath: string + fileSize: number + description?: string + createdAt: Date +} + /** * 평가 폼 메인 컴포넌트 (테이블 레이아웃) */ @@ -63,6 +80,7 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { const [isSaving, setIsSaving] = React.useState(false) const [hasUnsavedChanges, setHasUnsavedChanges] = React.useState(false) const [showCompleteDialog, setShowCompleteDialog] = React.useState(false) + const [uploadingFiles, setUploadingFiles] = React.useState<Set<number>>(new Set()) const { evaluationInfo, questions } = formData @@ -85,6 +103,44 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { return initial }) + // 첨부파일 상태 관리 (서버에서 받은 데이터로 초기화) + const [attachments, setAttachments] = React.useState<Record<number, AttachmentInfo[]>>(() => { + console.log('Initializing attachments from server data...') + const initial: Record<number, AttachmentInfo[]> = {} + questions.forEach(question => { + const questionAttachments = question.attachments || [] + initial[question.criteriaId] = questionAttachments + if (questionAttachments.length > 0) { + console.log(`Question ${question.criteriaId} has ${questionAttachments.length} attachments:`, questionAttachments) + } + }) + console.log('Initial attachments state:', initial) + return initial + }) + + // 첨부파일 다운로드 핸들러 - downloadFile 사용 + const handleDownloadAttachment = async (attachment: AttachmentInfo) => { + try { + await downloadFile( + attachment.publicPath, + attachment.originalFileName, + { + action: 'download', + showToast: true, + showSuccessToast: true, + onError: (error) => { + console.error("파일 다운로드 실패:", error) + }, + onSuccess: (fileName, fileSize) => { + console.log("파일 다운로드 성공:", fileName, fileSize ? formatFileSize(fileSize) : '') + } + } + ) + } catch (error) { + console.error("다운로드 처리 중 오류:", error) + } + } + // 카테고리별 질문 그룹화 const questionsByCategory = React.useMemo(() => { const grouped = questions.reduce((acc, question) => { @@ -146,6 +202,130 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { setHasUnsavedChanges(true) } + // 파일 업로드 핸들러 + const handleFileUpload = async (questionId: number, file: File, description?: string) => { + try { + console.log('Starting file upload for question:', questionId, 'file:', file.name) + setUploadingFiles(prev => new Set(prev).add(questionId)) + + // 질문 정보 확인 + const question = questions.find(q => q.criteriaId === questionId) + if (!question) { + throw new Error('질문을 찾을 수 없습니다.') + } + + // 답변이 있는지 확인 + const response = responses[questionId] + const isVariable = question.scoreType === 'variable' + const isAnswered = isVariable ? + (response.score !== null) : + (response.detailId !== null && response.detailId > 0) + + if (!isAnswered) { + throw new Error('먼저 답변을 선택해주세요.') + } + + // FormData 생성 + const formData = new FormData() + formData.append('file', file) + formData.append('questionId', questionId.toString()) + formData.append('evaluationId', evaluationInfo.id.toString()) + + if (description) { + formData.append('description', description) + } + + if (isVariable) { + formData.append('isVariable', 'true') + } + + console.log('Sending upload request...') + + // 파일 업로드 API 호출 + const response_api = await fetch('/api/evaluation/attachments', { + method: 'POST', + body: formData, + }) + + console.log('Upload response status:', response_api.status) + + if (!response_api.ok) { + const errorText = await response_api.text() + console.error('Upload failed with status:', response_api.status, 'error:', errorText) + throw new Error(`파일 업로드에 실패했습니다. (${response_api.status})`) + } + + const result = await response_api.json() + console.log('Upload result:', result) + + if (result.success && result.attachment) { + // 첨부파일 목록 업데이트 + setAttachments(prev => ({ + ...prev, + [questionId]: [...(prev[questionId] || []), { + id: result.attachment.id, + originalFileName: result.attachment.originalFileName, + publicPath: result.attachment.publicPath, + fileSize: result.attachment.fileSize, + description: result.attachment.description, + createdAt: new Date(result.attachment.createdAt), + }] + })) + + toast({ + title: "파일 업로드 완료", + description: `${file.name}이 성공적으로 업로드되었습니다.`, + }) + } else { + throw new Error(result.error || '파일 업로드에 실패했습니다.') + } + } catch (error) { + console.error('File upload failed:', error) + toast({ + title: "업로드 실패", + description: error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setUploadingFiles(prev => { + const newSet = new Set(prev) + newSet.delete(questionId) + return newSet + }) + } + } + + // 첨부파일 삭제 핸들러 + const handleDeleteAttachment = async (questionId: number, attachmentId: number) => { + try { + const response = await fetch(`/api/evaluation/attachments/${attachmentId}`, { + method: 'DELETE', + }) + + if (!response.ok) { + throw new Error('파일 삭제에 실패했습니다.') + } + + // 첨부파일 목록에서 제거 + setAttachments(prev => ({ + ...prev, + [questionId]: prev[questionId]?.filter(att => att.id !== attachmentId) || [] + })) + + toast({ + title: "파일 삭제 완료", + description: "첨부파일이 삭제되었습니다.", + }) + } catch (error) { + console.error('File deletion failed:', error) + toast({ + title: "삭제 실패", + description: "파일 삭제 중 오류가 발생했습니다.", + variant: "destructive", + }) + } + } + // 임시저장 const handleSave = async () => { try { @@ -166,12 +346,23 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { 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 - ) + if (isVariable) { + // Variable 타입은 별도 함수 사용 + return updateVariableEvaluationResponse( + evaluationInfo.id, + parseInt(questionId), // criteriaId + response.score!, + response.comment || undefined + ) + } else { + // 일반 타입 + return updateEvaluationResponse( + evaluationInfo.id, + response.detailId!, + response.comment || undefined, + response.score || undefined + ) + } }) await Promise.all(promises) @@ -285,35 +476,58 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { </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> + <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-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> + + {/* 📎 첨부파일 통계 정보 */} + {formData.attachmentStats && formData.attachmentStats.totalFiles > 0 && ( + <div className="border-t pt-4"> + <div className="flex items-center gap-4 text-sm"> + <div className="flex items-center gap-2"> + <Paperclip className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium">첨부파일 현황</span> + </div> + <div className="flex items-center gap-4"> + <div className="text-muted-foreground"> + 총 <span className="font-medium text-foreground">{formData.attachmentStats.totalFiles}</span>개 파일 + </div> + <div className="text-muted-foreground"> + 크기: <span className="font-medium text-foreground">{formatFileSize(formData.attachmentStats.totalSize)}</span> + </div> + <div className="text-muted-foreground"> + 첨부 질문: <span className="font-medium text-foreground">{formData.attachmentStats.questionsWithAttachments}</span>개 + </div> + </div> + </div> + </div> + )} + </CardContent> </Card> {/* 평가 테이블 - 카테고리별 */} @@ -370,12 +584,14 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { <TableHead className="w-[200px]">답변 선택</TableHead> <TableHead className="w-[80px]">점수</TableHead> <TableHead className="w-[250px]">추가 의견</TableHead> + <TableHead className="w-[200px]">첨부파일</TableHead> <TableHead className="w-[80px]">상태</TableHead> </TableRow> </TableHeader> <TableBody> {categoryQuestions.map((question) => { const response = responses[question.criteriaId] + const questionAttachments = attachments[question.criteriaId] || [] const isVariable = question.scoreType === 'variable' const isAnswered = isVariable ? @@ -480,6 +696,80 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { </TableCell> <TableCell> + <div className="space-y-2"> + {/* 파일 업로드 버튼 */} + <div className="flex items-center gap-2"> + <input + type="file" + id={`file-${question.criteriaId}`} + + className="hidden" + accept=".pdf,.doc,.docx,.hwp,.xls,.xlsx,.jpg,.jpeg,.png,.gif" + onChange={(e) => { + const file = e.target.files?.[0] + if (file && isAnswered) handleFileUpload(question.criteriaId, file) + }} + disabled={!isAnswered || isLoading || isSaving || uploadingFiles.has(question.criteriaId)} + /> + <Button + asChild /* shadcn/ui -> 내부에 <button> 대신 원하는 태그로 감싸 줌 */ + variant="outline" + size="sm" + disabled={!isAnswered || isLoading || isSaving || uploadingFiles.has(question.criteriaId)} + className="flex items-center gap-1" + > + <label htmlFor={`file-${question.criteriaId}`} className="cursor-pointer"> + <Upload className="h-3 w-3" /> + {uploadingFiles.has(question.criteriaId) ? "업로드 중..." : "파일 첨부"} + </label> + </Button> + </div> + + {/* 첨부된 파일 목록 - 개선된 버전 */} + {questionAttachments.length > 0 && ( + <div className="space-y-1"> + {questionAttachments.map((attachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + return ( + <div key={attachment.id} className="flex items-center justify-between p-2 bg-muted rounded-md"> + <div className="flex items-center gap-2 flex-1"> + <span className="text-sm">{fileInfo.icon}</span> + <div className="flex-1 min-w-0"> + <div className="text-xs font-medium truncate"> + {attachment.originalFileName} + </div> + <div className="text-xs text-muted-foreground"> + {formatFileSize(attachment.fileSize)} + </div> + </div> + </div> + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="sm" + onClick={() => handleDownloadAttachment(attachment)} + className="h-6 w-6 p-0" + > + <Download className="h-3 w-3" /> + </Button> + <Button + variant="ghost" + size="sm" + onClick={() => handleDeleteAttachment(question.criteriaId, attachment.id)} + className="h-6 w-6 p-0 text-destructive hover:text-destructive" + > + <X className="h-3 w-3" /> + </Button> + </div> + </div> + ) + })} + </div> + )} + </div> + </TableCell> + + <TableCell> {isAnswered ? ( <Badge variant="default" className="text-xs"> 완료 @@ -503,54 +793,51 @@ export function EvaluationForm({ formData, onSubmit }: EvaluationFormProps) { {/* 하단 액션 버튼 */} <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> + {!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-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 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> diff --git a/lib/evaluation-submit/service.ts b/lib/evaluation-submit/service.ts index 84d356e7..99c5cb5e 100644 --- a/lib/evaluation-submit/service.ts +++ b/lib/evaluation-submit/service.ts @@ -12,11 +12,14 @@ import { evaluationTargetReviewers, evaluationTargets, regEvalCriteria, - periodicEvaluations + periodicEvaluations, + reviewerEvaluationAttachments, + users } 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"; +import { DEPARTMENT_CATEGORY_MAPPING, EvaluationFormData, GetSHIEvaluationsSubmitSchema, REVIEWER_TYPES, ReviewerType } from "./validation"; +import { AttachmentInfo, EvaluationQuestionItem } from "@/types/evaluation-form"; // =============================================================================== @@ -128,7 +131,6 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi const reviewerType = calculateReviewerType(evaluation.division, evaluation.materialType); // 2. 부서에 따른 카테고리 필터링 로직 - // const categoryFilter = getCategoryFilterByDepartment("admin"); const categoryFilter = getCategoryFilterByDepartment(evaluation.departmentCode); // 3. 해당 부서에 맞는 평가 기준들과 답변 옵션들 조회 @@ -179,15 +181,90 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi updatedAt: reviewerEvaluationDetails.updatedAt, }) .from(reviewerEvaluationDetails) - .where( - and( - eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId), - // ✅ null이 아닌 실제 응답만 조회 - isNotNull(reviewerEvaluationDetails.regEvalCriteriaDetailsId) - ) - ); + .where(eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId)); + + // 📎 5. 첨부파일 정보 조회 + const attachmentsData = await db + .select({ + // 첨부파일 정보 + attachmentId: reviewerEvaluationAttachments.id, + originalFileName: reviewerEvaluationAttachments.originalFileName, + storedFileName: reviewerEvaluationAttachments.storedFileName, + publicPath: reviewerEvaluationAttachments.publicPath, + fileSize: reviewerEvaluationAttachments.fileSize, + mimeType: reviewerEvaluationAttachments.mimeType, + fileExtension: reviewerEvaluationAttachments.fileExtension, + description: reviewerEvaluationAttachments.description, + uploadedBy: reviewerEvaluationAttachments.uploadedBy, + attachmentCreatedAt: reviewerEvaluationAttachments.createdAt, + attachmentUpdatedAt: reviewerEvaluationAttachments.updatedAt, + + // 업로드한 사용자 정보 + uploadedByName: users.name, + + // 평가 세부사항 정보 (어떤 질문에 대한 첨부파일인지 확인) + evaluationDetailId: reviewerEvaluationDetails.id, + regEvalCriteriaDetailsId: reviewerEvaluationDetails.regEvalCriteriaDetailsId, + + // 평가 기준 정보 (질문 식별용) + criteriaId: regEvalCriteriaDetails.criteriaId, + category: regEvalCriteria.category, + }) + .from(reviewerEvaluationAttachments) + .innerJoin( + reviewerEvaluationDetails, + eq(reviewerEvaluationAttachments.reviewerEvaluationDetailId, reviewerEvaluationDetails.id) + ) + .leftJoin( + regEvalCriteriaDetails, + eq(reviewerEvaluationDetails.regEvalCriteriaDetailsId, regEvalCriteriaDetails.id) + ) + .leftJoin( + regEvalCriteria, + eq(regEvalCriteriaDetails.criteriaId, regEvalCriteria.id) + ) + .leftJoin( + users, + eq(reviewerEvaluationAttachments.uploadedBy, users.id) + ) + .where(eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluationId)) + .orderBy(desc(reviewerEvaluationAttachments.createdAt)); + + // 📎 6. 첨부파일을 질문별로 그룹화 + const attachmentsByQuestion = new Map<number, AttachmentInfo[]>(); + const attachmentsByCategory = new Map<string, number>(); + + attachmentsData.forEach(attachment => { + // Variable 타입 질문의 경우 criteriaId가 null일 수 있으므로 처리 + const questionKey = attachment.criteriaId || attachment.evaluationDetailId; + + if (!attachmentsByQuestion.has(questionKey)) { + attachmentsByQuestion.set(questionKey, []); + } + + const attachmentInfo: AttachmentInfo = { + id: attachment.attachmentId, + originalFileName: attachment.originalFileName, + storedFileName: attachment.storedFileName, + publicPath: attachment.publicPath, + fileSize: attachment.fileSize, + mimeType: attachment.mimeType || undefined, + fileExtension: attachment.fileExtension || undefined, + description: attachment.description || undefined, + uploadedBy: attachment.uploadedBy, + uploadedByName: attachment.uploadedByName || undefined, + createdAt: new Date(attachment.attachmentCreatedAt), + updatedAt: new Date(attachment.attachmentUpdatedAt), + }; + + attachmentsByQuestion.get(questionKey)!.push(attachmentInfo); + + // 카테고리별 파일 수 집계 + const category = attachment.category || '기타'; + attachmentsByCategory.set(category, (attachmentsByCategory.get(category) || 0) + 1); + }); - // 5. 질문별로 그룹화하고 답변 옵션들 정리 + // 7. 질문별로 그룹화하고 답변 옵션들 정리 const questionsMap = new Map<number, EvaluationQuestionItem>(); criteriaWithDetails.forEach(record => { @@ -201,6 +278,8 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi // 질문이 이미 존재하는지 확인 if (!questionsMap.has(criteriaId)) { + const questionAttachments = attachmentsByQuestion.get(criteriaId) || []; + questionsMap.set(criteriaId, { criteriaId: record.criteriaId, category: record.category, @@ -215,6 +294,11 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi selectedDetailId: null, // ✅ 초기값은 null (아직 선택하지 않음) currentScore: null, currentComment: null, + + // 📎 첨부파일 정보 추가 + attachments: questionAttachments, + attachmentCount: questionAttachments.length, + attachmentTotalSize: questionAttachments.reduce((sum, att) => sum + att.fileSize, 0), }); } @@ -228,14 +312,45 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi }); }); - // 6. ✅ 초기 응답 생성하지 않음 - 사용자가 실제로 답변할 때만 생성 + // 8. ✅ Variable 타입 질문 처리 (첨부파일은 있지만 criteriaId가 null인 경우) + const variableTypeAttachments = new Map<number, AttachmentInfo[]>(); + attachmentsData.forEach(attachment => { + if (!attachment.criteriaId && attachment.regEvalCriteriaDetailsId === null) { + // Variable 타입 질문의 첨부파일 + if (!variableTypeAttachments.has(attachment.evaluationDetailId)) { + variableTypeAttachments.set(attachment.evaluationDetailId, []); + } + variableTypeAttachments.get(attachment.evaluationDetailId)!.push({ + id: attachment.attachmentId, + originalFileName: attachment.originalFileName, + storedFileName: attachment.storedFileName, + publicPath: attachment.publicPath, + fileSize: attachment.fileSize, + mimeType: attachment.mimeType || undefined, + fileExtension: attachment.fileExtension || undefined, + description: attachment.description || undefined, + uploadedBy: attachment.uploadedBy, + uploadedByName: attachment.uploadedByName || undefined, + createdAt: new Date(attachment.attachmentCreatedAt), + updatedAt: new Date(attachment.attachmentUpdatedAt), + }); + } + }); - // 7. 기존 응답 데이터를 질문에 매핑 - const existingResponsesMap = new Map( - existingResponses.map(r => [r.regEvalCriteriaDetailsId, r]) - ); + // 9. 기존 응답 데이터를 질문에 매핑 + const existingResponsesMap = new Map<number | null, typeof existingResponses[0]>(); + const responseDetailMap = new Map<number, typeof existingResponses[0]>(); - // 8. 각 질문에 현재 응답 정보 매핑 + existingResponses.forEach(response => { + if (response.regEvalCriteriaDetailsId) { + existingResponsesMap.set(response.regEvalCriteriaDetailsId, response); + } else { + // Variable 타입 응답 (regEvalCriteriaDetailsId가 null) + responseDetailMap.set(response.id, response); + } + }); + + // 10. 각 질문에 현재 응답 정보 매핑 const questions: EvaluationQuestionItem[] = []; questionsMap.forEach(question => { // 현재 선택된 답변 찾기 (실제 응답이 있는 경우에만) @@ -251,7 +366,7 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi if (selectedResponse) { question.responseId = selectedResponse.id; - question.currentScore = selectedResponse.score; + question.currentScore = selectedResponse.score ? Number(selectedResponse.score) : null; question.currentComment = selectedResponse.comment; } // ✅ else 케이스: 아직 답변하지 않은 상태 (모든 값이 null) @@ -259,12 +374,21 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi questions.push(question); }); + // 📎 11. 전체 첨부파일 통계 계산 + const attachmentStats = { + totalFiles: attachmentsData.length, + totalSize: attachmentsData.reduce((sum, att) => sum + att.fileSize, 0), + questionsWithAttachments: attachmentsByQuestion.size + variableTypeAttachments.size, + filesByCategory: Object.fromEntries(attachmentsByCategory), + }; + return { evaluationInfo: { ...evaluation, reviewerType }, questions, + attachmentStats, }; } catch (err) { @@ -274,7 +398,6 @@ export async function getEvaluationFormData(reviewerEvaluationId: number): Promi } - /** * 평가 제출 목록을 조회합니다 */ @@ -395,22 +518,33 @@ export async function getSHIEvaluationSubmissionById(id: number) { /** * 평가 응답을 업데이트합니다 */ +// 기존 updateEvaluationResponse 함수를 확장하여 첨부파일 처리 지원 + export async function updateEvaluationResponse( reviewerEvaluationId: number, selectedDetailId: number, - comment?: string + comment?: string, + customScore?: number ) { try { - await db.transaction(async (tx) => { - // 1. 선택된 답변 옵션의 정보 조회 - const selectedDetail = await tx - .select() - .from(regEvalCriteriaDetails) - .where(eq(regEvalCriteriaDetails.id, selectedDetailId)) - .limit(1); + let reviewerEvaluationDetailId: number | null = null; - if (selectedDetail.length === 0) { - throw new Error('Selected detail not found'); + await db.transaction(async (tx) => { + // 1. 선택된 답변 옵션의 정보 조회 (variable 타입이 아닌 경우) + let selectedDetail = null; + let score: number; + + if (selectedDetailId !== -1) { + const detailResult = await tx + .select() + .from(regEvalCriteriaDetails) + .where(eq(regEvalCriteriaDetails.id, selectedDetailId)) + .limit(1); + + if (detailResult.length === 0) { + throw new Error('Selected detail not found'); + } + selectedDetail = detailResult[0]; } // 2. reviewerEvaluation 정보 조회 (periodicEvaluationId 포함) @@ -447,20 +581,37 @@ export async function updateEvaluationResponse( .where(eq(periodicEvaluations.id, periodicEvaluationId)); } - // 4. 리뷰어 타입 정보 조회 - const evaluationInfo = await getEvaluationFormData(reviewerEvaluationId); - if (!evaluationInfo) { - throw new Error('Evaluation not found'); + // 4. 점수 결정 + if (selectedDetailId === -1) { + // variable 타입인 경우 customScore 사용 + if (customScore === undefined) { + throw new Error('Custom score is required for variable type'); + } + score = customScore; + } else { + // 일반 타입인 경우 리뷰어 타입에 맞는 점수 가져오기 + const evaluationInfo = await getEvaluationFormData(reviewerEvaluationId); + if (!evaluationInfo) { + throw new Error('Evaluation not found'); + } + + const calculatedScore = getScoreByReviewerType(selectedDetail!, evaluationInfo.evaluationInfo.reviewerType); + if (calculatedScore === null) { + throw new Error('Score not found for this reviewer type'); + } + score = calculatedScore; } - // 5. 해당 리뷰어 타입에 맞는 점수 가져오기 - const score = getScoreByReviewerType(selectedDetail[0], evaluationInfo.evaluationInfo.reviewerType); - if (score === null) { - throw new Error('Score not found for this reviewer type'); + // 5. 해당 평가 기준에 대한 기존 응답들 삭제 + let criteriaId: number; + if (selectedDetailId === -1) { + // variable 타입인 경우, criteriaId를 별도로 조회해야 함 + // 이 부분은 실제 데이터 구조에 따라 조정 필요 + throw new Error('Variable type criteria ID lookup not implemented'); + } else { + criteriaId = selectedDetail!.criteriaId; } - // 6. 같은 질문에 대한 기존 응답들 삭제 - const criteriaId = selectedDetail[0].criteriaId; await tx .delete(reviewerEvaluationDetails) .where( @@ -472,27 +623,173 @@ export async function updateEvaluationResponse( ) ); - // 7. 새로운 응답 생성 - await tx + // 6. 새로운 응답 생성 + const [newDetail] = await tx .insert(reviewerEvaluationDetails) .values({ reviewerEvaluationId, - regEvalCriteriaDetailsId: selectedDetailId, + regEvalCriteriaDetailsId: selectedDetailId === -1 ? null : selectedDetailId, score: score.toString(), comment, + }) + .returning({ + id: reviewerEvaluationDetails.id, }); - // 8. 카테고리별 점수 계산 및 총점 업데이트 + reviewerEvaluationDetailId = newDetail.id; + + // 7. 카테고리별 점수 계산 및 총점 업데이트 await recalculateEvaluationScores(tx, reviewerEvaluationId); }); - return { success: true }; + return { + success: true, + reviewerEvaluationDetailId + }; } catch (err) { console.error('Error in updateEvaluationResponse:', err); throw err; } } +// variable 타입을 위한 별도 함수 +export async function updateVariableEvaluationResponse( + reviewerEvaluationId: number, + criteriaId: number, + score: number, + comment?: string +) { + try { + let reviewerEvaluationDetailId: number | null = null; + + await db.transaction(async (tx) => { + // 1. reviewerEvaluation 정보 조회 + 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]; + + // 2. 상태 업데이트 + 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)); + } + + // 3. 해당 평가 기준에 대한 기존 응답들 삭제 + 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} + )` + ) + ); + + // 4. 새로운 응답 생성 (variable 타입은 regEvalCriteriaDetailsId가 null) + const [newDetail] = await tx + .insert(reviewerEvaluationDetails) + .values({ + reviewerEvaluationId, + regEvalCriteriaDetailsId: null, // variable 타입은 null + score: score.toString(), + comment, + }) + .returning({ + id: reviewerEvaluationDetails.id, + }); + + reviewerEvaluationDetailId = newDetail.id; + + // 5. 점수 재계산 + await recalculateEvaluationScores(tx, reviewerEvaluationId); + }); + + return { + success: true, + reviewerEvaluationDetailId + }; + } catch (err) { + console.error('Error in updateVariableEvaluationResponse:', err); + throw err; + } +} + +// 첨부파일과 함께 평가 응답 업데이트 +// export async function updateEvaluationResponseWithAttachment( +// reviewerEvaluationId: number, +// selectedDetailId: number, +// comment?: string, +// customScore?: number, +// attachmentFile?: File, +// attachmentDescription?: string +// ) { +// try { +// // 1. 먼저 평가 응답 업데이트 +// const updateResult = selectedDetailId === -1 && customScore !== undefined ? +// await updateVariableEvaluationResponse(reviewerEvaluationId, /* criteriaId 필요 */, customScore, comment) : +// await updateEvaluationResponse(reviewerEvaluationId, selectedDetailId, comment, customScore); + +// if (!updateResult.success || !updateResult.reviewerEvaluationDetailId) { +// throw new Error('Failed to update evaluation response'); +// } + +// // 2. 첨부파일이 있으면 저장 +// if (attachmentFile) { +// const fileResult = await saveFile({ +// file: attachmentFile, +// directory: "evaluation-attachments", +// originalName: attachmentFile.name, +// }); + +// if (!fileResult.success) { +// throw new Error(fileResult.error || "파일 저장에 실패했습니다."); +// } + +// // 3. DB에 첨부파일 정보 저장 +// await db.insert(reviewerEvaluationAttachments).values({ +// reviewerEvaluationDetailId: updateResult.reviewerEvaluationDetailId, +// originalFileName: attachmentFile.name, +// storedFileName: fileResult.fileName!, +// filePath: fileResult.filePath!, +// publicPath: fileResult.publicPath!, +// fileSize: attachmentFile.size, +// mimeType: attachmentFile.type, +// fileExtension: attachmentFile.name.split('.').pop()?.toLowerCase() || '', +// description: attachmentDescription || null, +// uploadedBy: /* session.user.id 필요 */, +// }); +// } + +// return { success: true }; +// } catch (err) { +// console.error('Error in updateEvaluationResponseWithAttachment:', err); +// throw err; +// } +// } /** * 평가 점수 재계산 diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts index 9e21dc51..6de00329 100644 --- a/lib/evaluation-target-list/service.ts +++ b/lib/evaluation-target-list/service.ts @@ -1,6 +1,6 @@ 'use server' -import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray } from "drizzle-orm"; +import { and, or, desc, asc, ilike, eq, isNull, sql, count, inArray, gte, lte } from "drizzle-orm"; import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"; import { filterColumns } from "@/lib/filter-columns"; @@ -22,7 +22,9 @@ import { reviewerEvaluations, evaluationSubmissions, generalEvaluations, - esgEvaluationItems + esgEvaluationItems, + contracts, + projects } from "@/db/schema"; @@ -33,6 +35,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { sendEmail } from "../mail/sendEmail"; import type { SQL } from "drizzle-orm" import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation"; +import { revalidatePath } from "next/cache"; export async function selectEvaluationTargetsFromView( tx: PgTransaction<any, any, any>, @@ -214,8 +217,8 @@ export async function getEvaluationTargetsStats(evaluationYear: number) { consensusTrue: sql<number>`sum(case when consensus_status = true then 1 else 0 end)`, consensusFalse: sql<number>`sum(case when consensus_status = false then 1 else 0 end)`, consensusNull: sql<number>`sum(case when consensus_status is null then 1 else 0 end)`, - oceanDivision: sql<number>`sum(case when division = 'OCEAN' then 1 else 0 end)`, - shipyardDivision: sql<number>`sum(case when division = 'SHIPYARD' then 1 else 0 end)`, + oceanDivision: sql<number>`sum(case when division = 'PLANT' then 1 else 0 end)`, + shipyardDivision: sql<number>`sum(case when division = 'SHIP' then 1 else 0 end)`, }) .from(evaluationTargetsWithDepartments) .where(eq(evaluationTargetsWithDepartments.evaluationYear, evaluationYear)); @@ -1165,4 +1168,188 @@ export async function requestEvaluationReview(targetIds: number[], message?: str error: error instanceof Error ? error.message : "의견 요청 중 오류가 발생했습니다." } } +} + + + +interface AutoGenerateResult { + success: boolean + message: string + error?: string + generatedCount?: number + skippedCount?: number + details?: { + shipTargets: number + plantTargets: number + duplicateSkipped: number + } +} + +/** + * 자동으로 평가 대상을 생성하는 서버 액션 + * 전년도 10월부터 현재년도 9월까지의 계약을 기준으로 평가 대상을 생성 + */ +export async function autoGenerateEvaluationTargets( + evaluationYear: number, + adminUserId: number +): Promise<AutoGenerateResult> { + try { + // 평가 기간 계산 (전년도 10월 ~ 현재년도 9월) + const startDate = `${evaluationYear - 1}-10-01` + const endDate = `${evaluationYear}-09-30` + + console.log(`Generating evaluation targets for period: ${startDate} to ${endDate}`) + + // 1. 해당 기간의 계약들과 관련 정보를 조회 + const contractsWithDetails = await db + .select({ + contractId: contracts.id, + vendorId: contracts.vendorId, + projectId: contracts.projectId, + startDate: contracts.startDate, + // vendor 정보 + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + vendorType: vendors.country ==="KR"? "DOMESTIC":"FOREIGN", // DOMESTIC | FOREIGN + // project 정보 + projectType: projects.type, // ship | plant + }) + .from(contracts) + .innerJoin(vendors, eq(contracts.vendorId, vendors.id)) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .where( + and( + gte(contracts.startDate, startDate), + lte(contracts.startDate, endDate) + ) + ) + + if (contractsWithDetails.length === 0) { + return { + success: true, + message: "해당 기간에 생성할 평가 대상이 없습니다.", + generatedCount: 0, + skippedCount: 0 + } + } + + console.log(`Found ${contractsWithDetails.length} contracts in the period`) + + // 2. 벤더별, 구분별로 그룹화하여 중복 제거 + const targetGroups = new Map<string, { + vendorId: number + vendorCode: string + vendorName: string + domesticForeign: "DOMESTIC" | "FOREIGN" + division: "SHIP" | "PLANT" + materialType: "EQUIPMENT" | "BULK" | "EQUIPMENT_BULK" + }>() + + contractsWithDetails.forEach(contract => { + const division = contract.projectType === "ship" ? "SHIP" : "PLANT" + const key = `${contract.vendorId}-${division}` + + if (!targetGroups.has(key)) { + targetGroups.set(key, { + vendorId: contract.vendorId, + vendorCode: contract.vendorCode, + vendorName: contract.vendorName, + domesticForeign: contract.vendorType === "DOMESTIC" ? "DOMESTIC" : "FOREIGN", + division: division as "SHIP" | "PLANT", + // 기본값으로 EQUIPMENT 설정 (추후 더 정교한 로직 필요시 수정) + materialType: "EQUIPMENT" as const + }) + } + }) + + console.log(`Created ${targetGroups.size} unique vendor-division combinations`) + + // 3. 이미 존재하는 평가 대상 확인 + const existingTargetsKeys = new Set<string>() + if (targetGroups.size > 0) { + const vendorIds = Array.from(targetGroups.values()).map(t => t.vendorId) + const existingTargets = await db + .select({ + vendorId: evaluationTargets.vendorId, + division: evaluationTargets.division + }) + .from(evaluationTargets) + .where( + and( + eq(evaluationTargets.evaluationYear, evaluationYear), + inArray(evaluationTargets.vendorId, vendorIds) + ) + ) + + existingTargets.forEach(target => { + existingTargetsKeys.add(`${target.vendorId}-${target.division}`) + }) + } + + console.log(`Found ${existingTargetsKeys.size} existing targets`) + + // 4. 새로운 평가 대상만 필터링 + const newTargets = Array.from(targetGroups.entries()) + .filter(([key]) => !existingTargetsKeys.has(key)) + .map(([_, target]) => target) + + if (newTargets.length === 0) { + return { + success: true, + message: "이미 모든 평가 대상이 생성되어 있습니다.", + generatedCount: 0, + skippedCount: targetGroups.size + } + } + + // 5. 평가 대상 생성 + const evaluationTargetsToInsert = newTargets.map(target => ({ + evaluationYear, + division: target.division, + vendorId: target.vendorId, + vendorCode: target.vendorCode, + vendorName: target.vendorName, + domesticForeign: target.domesticForeign, + materialType: target.materialType, + status: "PENDING" as const, + adminUserId, + ldClaimCount: 0, + ldClaimAmount: "0", + ldClaimCurrency: "KRW" as const + })) + + // 배치로 삽입 + await db.insert(evaluationTargets).values(evaluationTargetsToInsert) + + // 통계 계산 + const shipTargets = newTargets.filter(t => t.division === "SHIP").length + const plantTargets = newTargets.filter(t => t.division === "PLANT").length + const duplicateSkipped = existingTargetsKeys.size + + console.log(`Successfully created ${newTargets.length} evaluation targets`) + + // 캐시 무효화 + revalidatePath("/evcp/evaluation-target-list") + revalidatePath("/procurement/evaluation-target-list") + + return { + success: true, + message: `${newTargets.length}개의 평가 대상이 성공적으로 생성되었습니다.`, + generatedCount: newTargets.length, + skippedCount: duplicateSkipped, + details: { + shipTargets, + plantTargets, + duplicateSkipped + } + } + + } catch (error) { + console.error("Error auto generating evaluation targets:", error) + return { + success: false, + error: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.", + message: "평가 대상 자동 생성에 실패했습니다." + } + } }
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx index b140df0e..5560d3ff 100644 --- a/lib/evaluation-target-list/table/evaluation-target-table.tsx +++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx @@ -271,6 +271,8 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }: const [promiseData] = React.use(promises); const tableData = promiseData; + console.log(tableData) + /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */ const searchString = React.useMemo( () => searchParams.toString(), // query가 바뀔 때만 새로 계산 diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx index 60f1af39..c3aa9d71 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx @@ -16,7 +16,7 @@ interface GetColumnsProps { } // ✅ 모든 헬퍼 함수들을 컴포넌트 외부로 이동 -const getStatusBadgeVariant = (status: string) => { +export const getStatusBadgeVariant = (status: string) => { switch (status) { case "PENDING": return "secondary"; case "CONFIRMED": return "default"; @@ -43,14 +43,14 @@ const getDivisionBadge = (division: string) => { ); }; -const getMaterialTypeBadge = (materialType: string) => { +export const getMaterialTypeBadge = (materialType: string) => { return <Badge variant="outline">{vendortypeMap[materialType] || materialType}</Badge>; }; const getDomesticForeignBadge = (domesticForeign: string) => { return ( <Badge variant={domesticForeign === "DOMESTIC" ? "default" : "secondary"}> - {domesticForeign === "DOMESTIC" ? "내자" : "외자"} + {domesticForeign === "DOMESTIC" ? "D" : "F"} </Badge> ); }; @@ -72,6 +72,16 @@ const getEvaluationTargetBadge = (isTarget: boolean | null) => { ); }; +const getStatusLabel = (status: string) => { + const statusMap = { + PENDING: "미확정", + EXCLUDED: "제외", + CONFIRMED: "확정" + }; + return statusMap[status] || status; +}; + + // ✅ 모든 cell 렌더러 함수들을 미리 정의 (매번 새로 생성 방지) const renderEvaluationYear = ({ row }: any) => ( <span className="font-medium">{row.getValue("evaluationYear")}</span> @@ -83,7 +93,7 @@ const renderStatus = ({ row }: any) => { const status = row.getValue<string>("status"); return ( <Badge variant={getStatusBadgeVariant(status)}> - {status} + {getStatusLabel(status)} </Badge> ); }; @@ -213,12 +223,7 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col cell: renderStatus, size: 100, }, - { - accessorKey: "consensusStatus", - header: createHeaderRenderer("의견 일치"), - cell: renderConsensusStatus, - size: 100, - }, + // 벤더 정보 { @@ -252,6 +257,47 @@ function createStaticColumns(setRowAction: GetColumnsProps['setRowAction']): Col ] }, + { + accessorKey: "consensusStatus", + header: createHeaderRenderer("의견 일치"), + cell: renderConsensusStatus, + size: 100, + }, + + { + id: "claim", + header: "L/D, Claim", + columns:[ + { + accessorKey: "ldClaimCount", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="건수" />, + cell: ({ row }) => ( + <span className="text-sm">{row.original.ldClaimCount}</span> + ), + size: 80, + }, + + { + accessorKey: "ldClaimAmount", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="금액" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{(Number(row.original.ldClaimAmount).toLocaleString())}</span> + ), + size: 80, + }, + { + accessorKey: "ldClaimCurrency", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="단위" />, + + cell: ({ row }) => ( + <span className="text-sm">{row.original.ldClaimCurrency}</span> + ), + size: 80, + }, + ] + + }, + // 발주 담당자 { id: "orderReviewer", diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx index 8bc5254c..d1c7e500 100644 --- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx +++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx @@ -14,6 +14,7 @@ import { } from "lucide-react" import { toast } from "sonner" import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" import { Button } from "@/components/ui/button" import { @@ -31,6 +32,8 @@ import { } from "./evaluation-target-action-dialogs" import { EvaluationTargetWithDepartments } from "@/db/schema" import { exportTableToExcel } from "@/lib/export" +import { autoGenerateEvaluationTargets } from "../service" // 서버 액션 import +import { useAuthRole } from "@/hooks/use-auth-role" interface EvaluationTargetsTableToolbarActionsProps { table: Table<EvaluationTargetWithDepartments> @@ -47,6 +50,16 @@ export function EvaluationTargetsTableToolbarActions({ const [excludeDialogOpen, setExcludeDialogOpen] = React.useState(false) const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false) const router = useRouter() + const { data: session } = useSession() + + // 권한 체크 + const { hasRole, isLoading: roleLoading } = useAuthRole() + const canManageEvaluations = hasRole('정기평가') || hasRole('admin') + + // 사용자 ID 가져오기 + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : 1; + }, [session]); // 선택된 행들 const selectedRows = table.getFilteredSelectedRowModel().rows @@ -141,16 +154,36 @@ export function EvaluationTargetsTableToolbarActions({ const handleAutoGenerate = React.useCallback(async () => { setIsLoading(true) try { - // TODO: 발주실적에서 자동 추출 API 호출 - toast.success("평가 대상이 자동으로 생성되었습니다.") - router.refresh() + // 현재 년도를 기준으로 평가 대상 자동 생성 + const currentYear = new Date().getFullYear() + const result = await autoGenerateEvaluationTargets(currentYear, userId) + + if (result.success) { + if (result.generatedCount === 0) { + toast.info(result.message, { + description: result.skippedCount + ? `이미 존재하는 평가 대상: ${result.skippedCount}개` + : undefined + }) + } else { + toast.success(result.message, { + description: result.details + ? `해양: ${result.details.shipTargets}개, 조선: ${result.details.plantTargets}개 생성${result.details.duplicateSkipped > 0 ? `, 중복 건너뜀: ${result.details.duplicateSkipped}개` : ''}` + : undefined + }) + } + onRefresh?.() + router.refresh() + } else { + toast.error(result.error || "자동 생성 중 오류가 발생했습니다.") + } } catch (error) { console.error('Error auto generating targets:', error) toast.error("자동 생성 중 오류가 발생했습니다.") } finally { setIsLoading(false) } - }, [router]) + }, [router, onRefresh, userId]) // ---------------------------------------------------------------- // 신규 평가 대상 생성 (수동) @@ -178,33 +211,54 @@ export function EvaluationTargetsTableToolbarActions({ }) }, [table]) + // 권한이 없거나 로딩 중인 경우 내보내기 버튼만 표시 + if (roleLoading) { + return ( + <div className="flex items-center gap-2"> + <div className="flex items-center gap-1 border-l pl-2 ml-2"> + <Button + variant="outline" + size="sm" + disabled + className="gap-2" + > + <Download className="size-4 animate-spin" aria-hidden="true" /> + <span className="hidden sm:inline">로딩중...</span> + </Button> + </div> + </div> + ) + } + return ( <> <div className="flex items-center gap-2"> - {/* 신규 생성 드롭다운 */} - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - variant="default" - size="sm" - className="gap-2" - disabled={isLoading} - > - <Plus className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">신규 생성</span> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="start"> - <DropdownMenuItem onClick={handleAutoGenerate} disabled={isLoading}> - <RefreshCw className="size-4 mr-2" /> - 자동 생성 (발주실적 기반) - </DropdownMenuItem> - <DropdownMenuItem onClick={handleManualCreate}> - <Plus className="size-4 mr-2" /> - 수동 생성 - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> + {/* 신규 생성 드롭다운 - 정기평가 권한이 있는 경우만 표시 */} + {canManageEvaluations && ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="default" + size="sm" + className="gap-2" + disabled={isLoading} + > + <Plus className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">신규 생성</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="start"> + <DropdownMenuItem onClick={handleAutoGenerate} disabled={isLoading}> + <RefreshCw className={`size-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} /> + 자동 생성 (발주실적 기반) + </DropdownMenuItem> + <DropdownMenuItem onClick={handleManualCreate}> + <Plus className="size-4 mr-2" /> + 수동 생성 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + )} {/* 유틸리티 버튼들 */} <div className="flex items-center gap-1 border-l pl-2 ml-2"> @@ -219,8 +273,8 @@ export function EvaluationTargetsTableToolbarActions({ </Button> </div> - {/* 선택된 항목 액션 버튼들 */} - {hasSelection && ( + {/* 선택된 항목 액션 버튼들 - 정기평가 권한이 있는 경우만 표시 */} + {canManageEvaluations && hasSelection && ( <div className="flex items-center gap-1 border-l pl-2 ml-2"> {/* 확정 버튼 */} {selectedStats.canConfirm && ( @@ -271,37 +325,52 @@ export function EvaluationTargetsTableToolbarActions({ )} </div> )} + + {/* 권한이 없는 경우 안내 메시지 (선택사항) */} + {!canManageEvaluations && hasSelection && ( + <div className="flex items-center gap-1 border-l pl-2 ml-2"> + <div className="text-xs text-muted-foreground px-2 py-1"> + 평가 관리 권한이 필요합니다 + </div> + </div> + )} </div> - {/* 수동 생성 다이얼로그 */} - <ManualCreateEvaluationTargetDialog - open={manualCreateDialogOpen} - onOpenChange={setManualCreateDialogOpen} - /> + {/* 다이얼로그들 - 권한이 있는 경우만 렌더링 */} + {canManageEvaluations && ( + <> + {/* 수동 생성 다이얼로그 */} + <ManualCreateEvaluationTargetDialog + open={manualCreateDialogOpen} + onOpenChange={setManualCreateDialogOpen} + onSuccess={handleActionSuccess} + /> - {/* 확정 컨펌 다이얼로그 */} - <ConfirmTargetsDialog - open={confirmDialogOpen} - onOpenChange={setConfirmDialogOpen} - targets={selectedTargets} - onSuccess={handleActionSuccess} - /> + {/* 확정 컨펌 다이얼로그 */} + <ConfirmTargetsDialog + open={confirmDialogOpen} + onOpenChange={setConfirmDialogOpen} + targets={selectedTargets} + onSuccess={handleActionSuccess} + /> - {/* 제외 컨펌 다이얼로그 */} - <ExcludeTargetsDialog - open={excludeDialogOpen} - onOpenChange={setExcludeDialogOpen} - targets={selectedTargets} - onSuccess={handleActionSuccess} - /> + {/* 제외 컨펌 다이얼로그 */} + <ExcludeTargetsDialog + open={excludeDialogOpen} + onOpenChange={setExcludeDialogOpen} + targets={selectedTargets} + onSuccess={handleActionSuccess} + /> - {/* 의견 요청 다이얼로그 */} - <RequestReviewDialog - open={reviewDialogOpen} - onOpenChange={setReviewDialogOpen} - targets={selectedTargets} - onSuccess={handleActionSuccess} - /> + {/* 의견 요청 다이얼로그 */} + <RequestReviewDialog + open={reviewDialogOpen} + onOpenChange={setReviewDialogOpen} + targets={selectedTargets} + onSuccess={handleActionSuccess} + /> + </> + )} </> ) }
\ No newline at end of file diff --git a/lib/evaluation-target-list/table/update-evaluation-target.tsx b/lib/evaluation-target-list/table/update-evaluation-target.tsx index 9f9b7af4..8ea63a1a 100644 --- a/lib/evaluation-target-list/table/update-evaluation-target.tsx +++ b/lib/evaluation-target-list/table/update-evaluation-target.tsx @@ -58,6 +58,8 @@ import { type UpdateEvaluationTargetInput, } from "../service" import { EvaluationTargetWithDepartments } from "@/db/schema" +import { getMaterialTypeBadge } from "./evaluation-targets-columns" +import { getStatusLabel } from "../validation" // 편집 가능한 필드들에 대한 스키마 const editEvaluationTargetSchema = z.object({ @@ -123,10 +125,10 @@ export function EditEvaluationTargetSheet({ } const userEmail = session.user.email - const userRole = session.user.role + const userRole = session.user?.roles // 평가관리자는 모든 권한 - if (userRole === "평가관리자") { + if (userRole?.some(role => role.includes('정기평가'))|| userRole?.some(role => role.toLocaleLowerCase().includes('admin'))) { return { level: "admin", editableApprovals: [ @@ -372,10 +374,10 @@ export function EditEvaluationTargetSheet({ <span className="font-medium">벤더명:</span> {evaluationTarget.vendorName} </div> <div> - <span className="font-medium">자재구분:</span> {evaluationTarget.materialType} + <span className="font-medium">자재구분:</span> {getMaterialTypeBadge(evaluationTarget.materialType)} </div> <div> - <span className="font-medium">상태:</span> {evaluationTarget.status} + <span className="font-medium">상태:</span> {getStatusLabel(evaluationTarget.status)} </div> </div> </CardContent> diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts index 76811753..8e394f88 100644 --- a/lib/evaluation/service.ts +++ b/lib/evaluation/service.ts @@ -9,6 +9,7 @@ import { periodicEvaluationsView, regEvalCriteria, regEvalCriteriaDetails, + reviewerEvaluationAttachments, reviewerEvaluationDetails, reviewerEvaluations, roles, @@ -32,6 +33,7 @@ import { revalidatePath } from "next/cache" import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation" import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { AttachmentDetail, EvaluationDetailResponse } from "@/types/evaluation-form" export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) { try { @@ -989,17 +991,7 @@ export interface EvaluationDetailData { /** * 특정 정기평가의 상세 정보를 조회합니다 */ -export async function getEvaluationDetails(periodicEvaluationId: number): Promise<{ - evaluationInfo: { - id: number - vendorName: string - vendorCode: string - evaluationYear: number - division: string - status: string - } - reviewerDetails: EvaluationDetailData[] -}> { +export async function getEvaluationDetails(periodicEvaluationId: number): Promise<EvaluationDetailResponse> { try { // 1. 평가 기본 정보 조회 const evaluationInfo = await db @@ -1060,11 +1052,90 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis .where(eq(reviewerEvaluations.periodicEvaluationId, periodicEvaluationId)) .orderBy(evaluationTargetReviewers.departmentCode, regEvalCriteria.category, regEvalCriteria.classification) - // 3. 리뷰어별로 그룹화 + // 📎 3. 첨부파일 정보 조회 + const attachmentsData = await db + .select({ + // 첨부파일 정보 + attachmentId: reviewerEvaluationAttachments.id, + originalFileName: reviewerEvaluationAttachments.originalFileName, + storedFileName: reviewerEvaluationAttachments.storedFileName, + publicPath: reviewerEvaluationAttachments.publicPath, + fileSize: reviewerEvaluationAttachments.fileSize, + mimeType: reviewerEvaluationAttachments.mimeType, + fileExtension: reviewerEvaluationAttachments.fileExtension, + description: reviewerEvaluationAttachments.description, + uploadedBy: reviewerEvaluationAttachments.uploadedBy, + attachmentCreatedAt: reviewerEvaluationAttachments.createdAt, + + // 업로드한 사용자 정보 + uploadedByName: users.name, + + // 평가 세부사항 정보 + evaluationDetailId: reviewerEvaluationDetails.id, + reviewerEvaluationId: reviewerEvaluationDetails.reviewerEvaluationId, + + // 평가 기준 정보 (질문 식별용) + criteriaId: regEvalCriteriaDetails.criteriaId, + }) + .from(reviewerEvaluationAttachments) + .innerJoin( + reviewerEvaluationDetails, + eq(reviewerEvaluationAttachments.reviewerEvaluationDetailId, reviewerEvaluationDetails.id) + ) + .innerJoin( + reviewerEvaluations, + eq(reviewerEvaluationDetails.reviewerEvaluationId, reviewerEvaluations.id) + ) + .leftJoin( + regEvalCriteriaDetails, + eq(reviewerEvaluationDetails.regEvalCriteriaDetailsId, regEvalCriteriaDetails.id) + ) + .leftJoin( + users, + eq(reviewerEvaluationAttachments.uploadedBy, users.id) + ) + .where(eq(reviewerEvaluations.periodicEvaluationId, periodicEvaluationId)) + .orderBy(desc(reviewerEvaluationAttachments.createdAt)) + + // 📎 4. 첨부파일을 평가 세부사항별로 그룹화 + const attachmentsByDetailId = new Map<number, AttachmentDetail[]>() + const attachmentsByReviewerId = new Map<number, AttachmentDetail[]>() + + attachmentsData.forEach(attachment => { + const attachmentInfo: AttachmentDetail = { + id: attachment.attachmentId, + originalFileName: attachment.originalFileName, + storedFileName: attachment.storedFileName, + publicPath: attachment.publicPath, + fileSize: attachment.fileSize, + mimeType: attachment.mimeType || undefined, + fileExtension: attachment.fileExtension || undefined, + description: attachment.description || undefined, + uploadedBy: attachment.uploadedBy, + uploadedByName: attachment.uploadedByName || undefined, + createdAt: new Date(attachment.attachmentCreatedAt), + } + + // 평가 세부사항별 그룹화 + if (!attachmentsByDetailId.has(attachment.evaluationDetailId)) { + attachmentsByDetailId.set(attachment.evaluationDetailId, []) + } + attachmentsByDetailId.get(attachment.evaluationDetailId)!.push(attachmentInfo) + + // 리뷰어별 그룹화 + if (!attachmentsByReviewerId.has(attachment.reviewerEvaluationId)) { + attachmentsByReviewerId.set(attachment.reviewerEvaluationId, []) + } + attachmentsByReviewerId.get(attachment.reviewerEvaluationId)!.push(attachmentInfo) + }) + + // 5. 리뷰어별로 그룹화하고 첨부파일 정보 포함 const reviewerDetailsMap = new Map<number, EvaluationDetailData>() reviewerDetailsRaw.forEach(row => { if (!reviewerDetailsMap.has(row.reviewerEvaluationId)) { + const reviewerAttachments = attachmentsByReviewerId.get(row.reviewerEvaluationId) || [] + reviewerDetailsMap.set(row.reviewerEvaluationId, { reviewerEvaluationId: row.reviewerEvaluationId, reviewerName: row.reviewerName || "", @@ -1074,13 +1145,22 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis isCompleted: row.isCompleted || false, completedAt: row.completedAt, reviewerComment: row.reviewerComment, - evaluationItems: [] + evaluationItems: [], + + // 📎 리뷰어별 첨부파일 통계 + totalAttachments: reviewerAttachments.length, + totalAttachmentSize: reviewerAttachments.reduce((sum, att) => sum + att.fileSize, 0), + questionsWithAttachments: new Set(reviewerAttachments.map(att => + attachmentsData.find(a => a.attachmentId === att.id)?.criteriaId + ).filter(Boolean)).size, }) } // 평가 항목이 있는 경우에만 추가 if (row.criteriaId && row.detailId) { const reviewer = reviewerDetailsMap.get(row.reviewerEvaluationId)! + const itemAttachments = attachmentsByDetailId.get(row.detailId) || [] + reviewer.evaluationItems.push({ criteriaId: row.criteriaId, category: row.category || "", @@ -1093,14 +1173,28 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis selectedDetailId: row.selectedDetailId, selectedDetail: row.selectedDetail, score: row.score ? Number(row.score) : null, - comment: row.comment + comment: row.comment, + + // 📎 항목별 첨부파일 정보 + attachments: itemAttachments, + attachmentCount: itemAttachments.length, + attachmentTotalSize: itemAttachments.reduce((sum, att) => sum + att.fileSize, 0), }) } }) + // 📎 6. 전체 첨부파일 통계 계산 + const attachmentStats = { + totalFiles: attachmentsData.length, + totalSize: attachmentsData.reduce((sum, att) => sum + att.fileSize, 0), + reviewersWithAttachments: attachmentsByReviewerId.size, + questionsWithAttachments: new Set(attachmentsData.map(att => att.criteriaId).filter(Boolean)).size, + } + return { evaluationInfo: evaluationInfo[0], - reviewerDetails: Array.from(reviewerDetailsMap.values()) + reviewerDetails: Array.from(reviewerDetailsMap.values()), + attachmentStats, } } catch (error) { diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx index 4b7d9a80..315ec66b 100644 --- a/lib/evaluation/table/evaluation-columns.tsx +++ b/lib/evaluation/table/evaluation-columns.tsx @@ -8,7 +8,7 @@ import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle } from "lucide-react"; +import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText, Circle, Ellipsis } from "lucide-react"; import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; import { PeriodicEvaluationView } from "@/db/schema"; import { DataTableRowAction } from "@/types/table"; @@ -40,11 +40,11 @@ const getStatusBadgeVariant = (status: string) => { const getStatusLabel = (status: string) => { const statusMap = { - PENDING_SUBMISSION: "제출대기", + PENDING_SUBMISSION: "자료접수중", SUBMITTED: "제출완료", - IN_REVIEW: "검토중", - REVIEW_COMPLETED: "검토완료", - FINALIZED: "최종확정" + IN_REVIEW: "평가중", + REVIEW_COMPLETED: "평가완료", + FINALIZED: "결과확정" }; return statusMap[status] || status; }; @@ -215,6 +215,14 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): size: 80, }, + { + accessorKey: "status", + header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Status" />, + cell: ({ row }) => getStatusLabel(row.original.status || ""), + size: 80, + }, + + // ═══════════════════════════════════════════════════════════════ // 협력업체 정보 // ═══════════════════════════════════════════════════════════════ @@ -329,6 +337,32 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): }, ] }, + + { + // id: "평가상세", + // accessorKey: "평가상세", + header: "평가상세", + enableHiding: true, + size: 80, + minSize: 80, + cell: ({ row }) => { + return ( + <div className="flex items-center gap-1"> + <Button + variant="ghost" + size="icon" + className="size-8" + onClick={() => setRowAction({ row, type: "view" })} + aria-label="상세보기" + title="상세보기" + > + <Ellipsis className="size-4" /> + </Button> + + </div> + ); + }, + }, // ═══════════════════════════════════════════════════════════════ // 제출 현황 // ═══════════════════════════════════════════════════════════════ @@ -549,32 +583,5 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ] }, - - - - // ░░░ Actions ░░░ - { - id: "actions", - enableHiding: false, - size: 40, - minSize: 40, - cell: ({ row }) => { - return ( - <div className="flex items-center gap-1"> - <Button - variant="ghost" - size="icon" - className="size-8" - onClick={() => setRowAction({ row, type: "view" })} - aria-label="상세보기" - title="상세보기" - > - <Eye className="size-4" /> - </Button> - - </div> - ); - }, - }, ]; }
\ No newline at end of file diff --git a/lib/evaluation/table/evaluation-details-dialog.tsx b/lib/evaluation/table/evaluation-details-dialog.tsx index df4ef016..2f682402 100644 --- a/lib/evaluation/table/evaluation-details-dialog.tsx +++ b/lib/evaluation/table/evaluation-details-dialog.tsx @@ -10,7 +10,11 @@ import { Clock, MessageSquare, Award, - FileText + FileText, + Paperclip, + Download, + File, + BarChart3 } from "lucide-react" import { @@ -19,6 +23,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -26,7 +36,10 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" import { PeriodicEvaluationView } from "@/db/schema" -import { getEvaluationDetails, type EvaluationDetailData } from "../service" +import { getEvaluationDetails} from "../service" +import { AttachmentDetail, EvaluationDetailResponse } from "@/types/evaluation-form" +// 파일 다운로드 유틸리티 import +import { downloadFile, formatFileSize, getFileInfo } from "@/lib/file-download" interface EvaluationDetailsDialogProps { open: boolean @@ -75,10 +88,30 @@ export function EvaluationDetailsDialog({ evaluation, }: EvaluationDetailsDialogProps) { const [isLoading, setIsLoading] = React.useState(false) - const [evaluationDetails, setEvaluationDetails] = React.useState<{ - evaluationInfo: any - reviewerDetails: EvaluationDetailData[] - } | null>(null) + const [evaluationDetails, setEvaluationDetails] = React.useState<EvaluationDetailResponse | null>(null) + + // 첨부파일 다운로드 핸들러 - downloadFile 사용 + const handleDownloadAttachment = async (attachment: AttachmentDetail) => { + try { + await downloadFile( + attachment.publicPath, + attachment.originalFileName, + { + action: 'download', + showToast: true, + showSuccessToast: true, + onError: (error) => { + console.error("파일 다운로드 실패:", error) + }, + onSuccess: (fileName, fileSize) => { + console.log("파일 다운로드 성공:", fileName, fileSize ? formatFileSize(fileSize) : '') + } + } + ) + } catch (error) { + console.error("다운로드 처리 중 오류:", error) + } + } // 평가 상세 정보 로드 React.useEffect(() => { @@ -109,258 +142,346 @@ export function EvaluationDetailsDialog({ if (!evaluation) return null return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-7xl max-h-[90vh] overflow-y-auto"> - <DialogHeader className="space-y-4"> - <DialogTitle className="flex items-center gap-2"> - <Eye className="h-5 w-5 text-blue-600" /> - 평가 상세 - </DialogTitle> - - {/* 평가 기본 정보 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2 text-lg"> - <Building2 className="h-5 w-5" /> - 평가 정보 - </CardTitle> - </CardHeader> - <CardContent> - <div className="flex flex-wrap items-center gap-6 text-sm"> - {/* 협력업체 */} - <div className="flex items-center gap-2"> - <span className="text-muted-foreground">협력업체:</span> - <span className="font-medium">{evaluation.vendorName}</span> - <span className="text-muted-foreground">({evaluation.vendorCode})</span> - </div> + <TooltipProvider> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-7xl h-[90vh] flex flex-col"> + {/* 고정 헤더 */} + <DialogHeader className="space-y-4 flex-shrink-0"> + <DialogTitle className="flex items-center gap-2"> + <Eye className="h-5 w-5 text-blue-600" /> + 평가 상세 + </DialogTitle> + + {/* 평가 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2 text-lg"> + <Building2 className="h-5 w-5" /> + 평가 정보 + </CardTitle> + </CardHeader> + <CardContent> + <div className="flex flex-wrap items-center gap-6 text-sm mb-4"> + {/* 협력업체 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">협력업체:</span> + <span className="font-medium">{evaluation.vendorName}</span> + <span className="text-muted-foreground">({evaluation.vendorCode})</span> + </div> - {/* 평가년도 */} - <div className="flex items-center gap-2"> - <span className="text-muted-foreground">년도:</span> - <span className="font-medium">{evaluation.evaluationYear}년</span> - </div> + {/* 평가년도 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">년도:</span> + <span className="font-medium">{evaluation.evaluationYear}년</span> + </div> - {/* 구분 */} - <div className="flex items-center gap-2"> - <span className="text-muted-foreground">구분:</span> - <Badge variant="outline" className="text-xs"> - {evaluation.division === "PLANT" ? "해양" : "조선"} - </Badge> - </div> + {/* 구분 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">구분:</span> + <Badge variant="outline" className="text-xs"> + {evaluation.division === "PLANT" ? "해양" : "조선"} + </Badge> + </div> - {/* 진행상태 */} - <div className="flex items-center gap-2"> - <span className="text-muted-foreground">상태:</span> - <Badge variant="secondary" className="text-xs">{evaluation.status}</Badge> - </div> + {/* 진행상태 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">상태:</span> + <Badge variant="secondary" className="text-xs">{evaluation.status}</Badge> + </div> - {/* 평가점수/등급 */} - <div className="flex items-center gap-2"> - <span className="text-muted-foreground">평가점수/등급:</span> - {evaluation.evaluationScore ? ( - <div className="flex items-center gap-1"> - <span className="font-bold text-blue-600"> - {Number(evaluation.evaluationScore).toFixed(1)}점 - </span> - {evaluation.evaluationGrade && ( - <Badge variant="default" className="text-xs h-5"> - {evaluation.evaluationGrade} - </Badge> - )} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - )} - </div> + {/* 평가점수/등급 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">평가점수/등급:</span> + {evaluation.evaluationScore ? ( + <div className="flex items-center gap-1"> + <span className="font-bold text-blue-600"> + {Number(evaluation.evaluationScore).toFixed(1)}점 + </span> + {evaluation.evaluationGrade && ( + <Badge variant="default" className="text-xs h-5"> + {evaluation.evaluationGrade} + </Badge> + )} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </div> - {/* 확정점수/등급 */} - <div className="flex items-center gap-2"> - <span className="text-muted-foreground">확정점수/등급:</span> - {evaluation.finalScore ? ( - <div className="flex items-center gap-1"> - <span className="font-bold text-green-600"> - {Number(evaluation.finalScore).toFixed(1)}점 - </span> - {evaluation.finalGrade && ( - <Badge variant="default" className="bg-green-600 text-xs h-5"> - {evaluation.finalGrade} - </Badge> - )} - </div> - ) : ( - <span className="text-muted-foreground">미확정</span> - )} + {/* 확정점수/등급 */} + <div className="flex items-center gap-2"> + <span className="text-muted-foreground">확정점수/등급:</span> + {evaluation.finalScore ? ( + <div className="flex items-center gap-1"> + <span className="font-bold text-green-600"> + {Number(evaluation.finalScore).toFixed(1)}점 + </span> + {evaluation.finalGrade && ( + <Badge variant="default" className="bg-green-600 text-xs h-5"> + {evaluation.finalGrade} + </Badge> + )} + </div> + ) : ( + <span className="text-muted-foreground">미확정</span> + )} + </div> </div> - </div> - </CardContent> - </Card> - </DialogHeader> - {isLoading ? ( - <div className="space-y-4"> - <Card> - <CardHeader> - <Skeleton className="h-6 w-48" /> - </CardHeader> - <CardContent> - <Skeleton className="h-64 w-full" /> - </CardContent> + </CardContent> </Card> - </div> - ) : evaluationDetails ? ( - <div className="space-y-6"> - {/* 통합 평가 테이블 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <FileText className="h-5 w-5" /> - 평가 상세 내역 - </CardTitle> - </CardHeader> - <CardContent> - {evaluationDetails.reviewerDetails.some(r => r.evaluationItems.length > 0) ? ( - <Table> - <TableHeader> - <TableRow> - <TableHead className="w-[120px]">담당자</TableHead> - {/* <TableHead className="w-[80px]">상태</TableHead> */} - <TableHead className="w-[100px]">평가부문</TableHead> - <TableHead className="w-[100px]">항목</TableHead> - <TableHead className="w-[150px]">구분</TableHead> - <TableHead className="w-[200px]">범위</TableHead> - <TableHead className="w-[200px]">선택옵션</TableHead> - <TableHead className="w-[80px]">점수</TableHead> - <TableHead className="min-w-[200px]">의견</TableHead> - </TableRow> - </TableHeader> - <TableBody> - {evaluationDetails.reviewerDetails.map((reviewer) => - reviewer.evaluationItems.map((item, index) => ( - <TableRow key={`${reviewer.reviewerEvaluationId}-${item.criteriaId}-${index}`}> - <TableCell> - <div className="space-y-1"> - <div className="font-medium text-sm">{reviewer.departmentName}</div> - <div className="text-xs text-muted-foreground"> - {reviewer.reviewerName} - </div> - </div> - </TableCell> - {/* <TableCell> - {reviewer.isCompleted ? ( - <Badge variant="default" className="flex items-center gap-1"> - <CheckCircle2 className="h-3 w-3" /> - 완료 - </Badge> - ) : ( - <Badge variant="secondary" className="flex items-center gap-1"> - <Clock className="h-3 w-3" /> - 진행중 - </Badge> - )} - </TableCell> */} - <TableCell> - <Badge variant={getCategoryBadgeVariant(item.category)}> - {CATEGORY_LABELS[item.category as keyof typeof CATEGORY_LABELS] || item.category} - </Badge> - </TableCell> - <TableCell> - {CATEGORY_LABELS2[item.item as keyof typeof CATEGORY_LABELS2] || item.item} - </TableCell> - <TableCell className="font-medium"> - {item.classification} - </TableCell> - <TableCell className="text-sm"> - {item.range || "-"} - </TableCell> - <TableCell className="text-sm"> - {item.scoreType === "variable" ? ( - <Badge variant="outline">직접 입력</Badge> - ) : ( - item.selectedDetail || "-" - )} - </TableCell> - <TableCell> - {item.score !== null ? ( - <Badge variant="default" className="font-mono"> - {item.score.toFixed(1)} + </DialogHeader> + + {/* 스크롤 가능한 컨텐츠 영역 */} + <div className="flex-1 overflow-y-auto min-h-0"> + {isLoading ? ( + <div className="space-y-4 p-1"> + <Card> + <CardHeader> + <Skeleton className="h-6 w-48" /> + </CardHeader> + <CardContent> + <Skeleton className="h-64 w-full" /> + </CardContent> + </Card> + </div> + ) : evaluationDetails ? ( + <div className="space-y-6 p-1"> + {/* 통합 평가 테이블 */} + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + 평가 상세 내역 + </CardTitle> + </CardHeader> + <CardContent> + {evaluationDetails.reviewerDetails.some(r => r.evaluationItems.length > 0) ? ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">담당자</TableHead> + <TableHead className="w-[100px]">평가부문</TableHead> + <TableHead className="w-[100px]">항목</TableHead> + <TableHead className="w-[150px]">구분</TableHead> + <TableHead className="w-[200px]">범위</TableHead> + <TableHead className="w-[200px]">선택옵션</TableHead> + <TableHead className="w-[80px]">점수</TableHead> + <TableHead className="w-[150px]">첨부파일</TableHead> + <TableHead className="min-w-[200px]">의견</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {evaluationDetails.reviewerDetails.map((reviewer) => + reviewer.evaluationItems.map((item, index) => ( + <TableRow key={`${reviewer.reviewerEvaluationId}-${item.criteriaId}-${index}`}> + <TableCell> + <div className="space-y-1"> + <div className="font-medium text-sm">{reviewer.departmentName}</div> + <div className="text-xs text-muted-foreground"> + {reviewer.reviewerName} + </div> + </div> + </TableCell> + <TableCell> + <Badge variant={getCategoryBadgeVariant(item.category)}> + {CATEGORY_LABELS[item.category as keyof typeof CATEGORY_LABELS] || item.category} + </Badge> + </TableCell> + <TableCell> + {CATEGORY_LABELS2[item.item as keyof typeof CATEGORY_LABELS2] || item.item} + </TableCell> + <TableCell className="font-medium"> + {item.classification} + </TableCell> + <TableCell className="text-sm"> + {item.range || "-"} + </TableCell> + <TableCell className="text-sm"> + {item.scoreType === "variable" ? ( + <Badge variant="outline">직접 입력</Badge> + ) : ( + item.selectedDetail || "-" + )} + </TableCell> + <TableCell> + {item.score !== null ? ( + <Badge variant="default" className="font-mono"> + {item.score.toFixed(1)} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + )} + </TableCell> + + {/* 📎 첨부파일 컬럼 - 개선된 버전 */} + <TableCell> + {item.attachments.length > 0 ? ( + <div className="space-y-1"> + {item.attachments.map((attachment) => { + const fileInfo = getFileInfo(attachment.originalFileName) + return ( + <div key={attachment.id} className="flex items-center gap-1 p-1 bg-muted rounded"> + <span className="text-sm">{fileInfo.icon}</span> + <Tooltip> + <TooltipTrigger asChild> + <span className="text-xs truncate max-w-[80px] cursor-help"> + {attachment.originalFileName} + </span> + </TooltipTrigger> + <TooltipContent> + <div className="text-xs space-y-1"> + <div className="font-medium">{attachment.originalFileName}</div> + <div>크기: {formatFileSize(attachment.fileSize)}</div> + <div>타입: {fileInfo.type}</div> + {attachment.description && ( + <div>설명: {attachment.description}</div> + )} + <div>업로드: {attachment.uploadedByName}</div> + <div className="text-muted-foreground"> + {fileInfo.canPreview ? "미리보기 가능" : "다운로드만 가능"} + </div> + </div> + </TooltipContent> + </Tooltip> + <Button + variant="ghost" + size="sm" + className="h-4 w-4 p-0" + onClick={() => handleDownloadAttachment(attachment)} + > + <Download className="h-3 w-3" /> + </Button> + </div> + ) + })} + {item.attachments.length > 1 && ( + <div className="text-xs text-muted-foreground"> + 총 {formatFileSize(item.attachmentTotalSize)} + </div> + )} + </div> + ) : ( + <div className="text-xs text-muted-foreground"> + 첨부파일 없음 + </div> + )} + </TableCell> + + <TableCell className="text-sm"> + {item.comment || ( + <span className="text-muted-foreground">의견 없음</span> + )} + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + ) : ( + <div className="text-center text-muted-foreground py-8"> + <FileText className="h-8 w-8 mx-auto mb-2" /> + <div>평가 항목이 없습니다</div> + </div> + )} + </CardContent> + </Card> + + {/* 리뷰어별 종합 의견 (있는 경우만) */} + {evaluationDetails.reviewerDetails.some(r => r.reviewerComment) && ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <MessageSquare className="h-5 w-5" /> + 종합 의견 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {evaluationDetails.reviewerDetails + .filter(reviewer => reviewer.reviewerComment) + .map((reviewer) => ( + <div key={reviewer.reviewerEvaluationId} className="space-y-2"> + <div className="flex items-center gap-2"> + <Badge variant="outline">{reviewer.departmentName}</Badge> + <span className="text-sm font-medium">{reviewer.reviewerName}</span> + {reviewer.totalAttachments > 0 && ( + <Badge variant="secondary" className="text-xs"> + <Paperclip className="h-3 w-3 mr-1" /> + {reviewer.totalAttachments}개 파일 </Badge> - ) : ( - <span className="text-muted-foreground">-</span> )} - </TableCell> - <TableCell className="text-sm"> - {item.comment || ( - <span className="text-muted-foreground">의견 없음</span> - )} - </TableCell> - </TableRow> - )) - )} - </TableBody> - </Table> - ) : ( - <div className="text-center text-muted-foreground py-8"> - <FileText className="h-8 w-8 mx-auto mb-2" /> - <div>평가 항목이 없습니다</div> - </div> + </div> + <div className="bg-muted p-3 rounded-md text-sm"> + {reviewer.reviewerComment} + </div> + </div> + ))} + </CardContent> + </Card> )} - </CardContent> - </Card> - {/* 리뷰어별 종합 의견 (있는 경우만) */} - {evaluationDetails.reviewerDetails.some(r => r.reviewerComment) && ( - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - <MessageSquare className="h-5 w-5" /> - 종합 의견 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {evaluationDetails.reviewerDetails - .filter(reviewer => reviewer.reviewerComment) - .map((reviewer) => ( - <div key={reviewer.reviewerEvaluationId} className="space-y-2"> - <div className="flex items-center gap-2"> - <Badge variant="outline">{reviewer.departmentName}</Badge> - <span className="text-sm font-medium">{reviewer.reviewerName}</span> + {/* 📎 첨부파일 요약 (파일이 많은 경우) */} + {evaluationDetails.attachmentStats.totalFiles > 5 && ( + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <BarChart3 className="h-5 w-5" /> + 첨부파일 요약 + </CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> + <div className="space-y-1"> + <div className="text-muted-foreground">전체 파일 수</div> + <div className="font-bold text-lg">{evaluationDetails.attachmentStats.totalFiles}개</div> </div> - <div className="bg-muted p-3 rounded-md text-sm"> - {reviewer.reviewerComment} + <div className="space-y-1"> + <div className="text-muted-foreground">전체 파일 크기</div> + <div className="font-bold text-lg">{formatFileSize(evaluationDetails.attachmentStats.totalSize)}</div> + </div> + <div className="space-y-1"> + <div className="text-muted-foreground">첨부 질문 수</div> + <div className="font-bold text-lg">{evaluationDetails.attachmentStats.questionsWithAttachments}개</div> + </div> + <div className="space-y-1"> + <div className="text-muted-foreground">첨부 담당자 수</div> + <div className="font-bold text-lg">{evaluationDetails.attachmentStats.reviewersWithAttachments}명</div> </div> </div> - ))} - </CardContent> - </Card> - )} + </CardContent> + </Card> + )} - {evaluationDetails.reviewerDetails.length === 0 && ( - <Card> + {evaluationDetails.reviewerDetails.length === 0 && ( + <Card> + <CardContent className="py-8"> + <div className="text-center text-muted-foreground"> + <User className="h-8 w-8 mx-auto mb-2" /> + <div>배정된 리뷰어가 없습니다</div> + </div> + </CardContent> + </Card> + )} + </div> + ) : ( + <Card className="m-1"> <CardContent className="py-8"> <div className="text-center text-muted-foreground"> - <User className="h-8 w-8 mx-auto mb-2" /> - <div>배정된 리뷰어가 없습니다</div> + 평가 상세 정보를 불러올 수 없습니다 </div> </CardContent> </Card> )} </div> - ) : ( - <Card> - <CardContent className="py-8"> - <div className="text-center text-muted-foreground"> - 평가 상세 정보를 불러올 수 없습니다 - </div> - </CardContent> - </Card> - )} - <div className="flex justify-end pt-4"> - <Button variant="outline" onClick={() => onOpenChange(false)}> - 닫기 - </Button> - </div> - </DialogContent> - </Dialog> + {/* 고정 푸터 */} + <div className="flex justify-end pt-4 border-t flex-shrink-0"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + </div> + </DialogContent> + </Dialog> + </TooltipProvider> ) }
\ No newline at end of file diff --git a/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx index d6784754..84651350 100644 --- a/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx +++ b/lib/evaluation/table/periodic-evaluation-finalize-dialogs.tsx @@ -40,18 +40,16 @@ import { finalizeEvaluations } from "../service" // 등급 옵션 const GRADE_OPTIONS = [ - { value: "S", label: "S등급 (90점 이상)" }, - { value: "A", label: "A등급 (80-89점)" }, - { value: "B", label: "B등급 (70-79점)" }, - { value: "C", label: "C등급 (60-69점)" }, + { value: "A", label: "A등급 (95점 이상)" }, + { value: "B", label: "B등급 (90-95점 미만)" }, + { value: "C", label: "C등급 (60-90점 미만)" }, { value: "D", label: "D등급 (60점 미만)" }, ] as const // 점수에 따른 등급 계산 -const calculateGrade = (score: number): "S" | "A" | "B" | "C" | "D" => { - if (score >= 90) return "S" - if (score >= 80) return "A" - if (score >= 70) return "B" +const calculateGrade = (score: number): "A" | "B" | "C" | "D" => { + if (score >= 95) return "A" + if (score >= 90) return "B" if (score >= 60) return "C" return "D" } @@ -65,7 +63,7 @@ const evaluationItemSchema = z.object({ finalScore: z.coerce.number() .min(0, "점수는 0 이상이어야 합니다"), // .max(100, "점수는 100 이하여야 합니다"), - finalGrade: z.enum(["S", "A", "B", "C", "D"]), + finalGrade: z.enum(["A", "B", "C", "D"]), }) // 전체 폼 스키마 diff --git a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx index 39a95cc7..d910f916 100644 --- a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx +++ b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx @@ -49,7 +49,7 @@ export function PeriodicEvaluationsTableToolbarActions({ // 권한 체크 (방법 1 또는 방법 2 중 선택) const { hasRole, isLoading: roleLoading } = useAuthRole() - const canManageEvaluations = hasRole('정기평가') + const canManageEvaluations = hasRole('정기평가') || hasRole('admin') // 선택된 행들 const selectedRows = table.getFilteredSelectedRowModel().rows diff --git a/lib/items/service.ts b/lib/items/service.ts index c841efad..1b6d7e09 100644 --- a/lib/items/service.ts +++ b/lib/items/service.ts @@ -42,13 +42,12 @@ export async function getItems(input: GetItemsSchema) { if (input.search) { const s = `%${input.search}%`; globalWhere = or( - ilike(items.itemLevel, s), + ilike(items.ProjectNo, s), ilike(items.itemCode, s), ilike(items.itemName, s), ilike(items.smCode, s), ilike(items.packageCode, s), ilike(items.description, s), - ilike(items.changeDate, s) ); } diff --git a/lib/items/table/items-table.tsx b/lib/items/table/items-table.tsx index 92f805eb..8baaf69c 100644 --- a/lib/items/table/items-table.tsx +++ b/lib/items/table/items-table.tsx @@ -59,60 +59,36 @@ export function ItemsTable({ promises }: ItemsTableProps) { const advancedFilterFields: DataTableAdvancedFilterField<Item>[] = [ { - id: "itemLevel", - label: "레벨", - type: "number", - }, - { - id: "itemCode", - label: "자재그룹코드", + id: "ProjectNo", + label: "Project No", type: "text", }, { - id: "itemName", - label: "자재그룹이름", + id: "itemCode", + label: "PKG Code(PK)", type: "text", }, { id: "description", - label: "상세", + label: "PKG Code Decription", type: "text", }, { - id: "parentItemCode", - label: "부모 아이템 코드", + id: "packageCode", + label: "PKG Code", type: "text", }, { - id: "deleteFlag", - label: "삭제 플래그", - type: "text", - }, - { - id: "unitOfMeasure", - label: "단위", - type: "text", - }, - { - id: "steelType", - label: "강종", - type: "text", - }, - { - id: "gradeMaterial", - label: "등급 재질", - type: "text", - }, - { - id: "changeDate", - label: "변경일자", - type: "text", + id: "itemName", + label: "패키지 이름", + type: "text" }, { - id: "baseUnitOfMeasure", - label: "기본단위", + id: "smCode", + label: "SM Code", type: "text", }, + ] // 확장된 useDataTable 훅 사용 (pageSize 기반 자동 전환) diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts index c293c98e..0606f4a9 100644 --- a/lib/sedp/sync-form.ts +++ b/lib/sedp/sync-form.ts @@ -851,17 +851,18 @@ async function getUomById(projectCode: string, uomId: string): Promise<UOM | nul } // contractItemId 조회 함수 -async function getContractItemsByItemCodes(itemCodes: string[], projectId: number): Promise<Map<string, number>> { +async function getContractItemsByItemCodes(itemCodes: string[], projectId: number): Promise<Map<string, number[]>> { try { if (!itemCodes.length) return new Map(); // 먼저 itemCodes에 해당하는 item 레코드를 조회 const itemRecords = await db.select({ id: items.id, - itemCode: items.itemCode + itemCode: items.itemCode, + packageCode: items.packageCode }) .from(items) - .where(inArray(items.itemCode, itemCodes)); + .where(inArray(items.packageCode, itemCodes)); if (!itemRecords.length) { console.log(`No items found for itemCodes: ${itemCodes.join(', ')}`); @@ -885,21 +886,22 @@ async function getContractItemsByItemCodes(itemCodes: string[], projectId: numbe ) ); - // itemCode와 contractItemId의 매핑 생성 - const itemCodeToContractItemId = new Map<string, number>(); + // itemCode와 contractItemId 배열의 매핑 생성 + const itemCodeToContractItemIds = new Map<string, number[]>(); for (const item of itemRecords) { // itemCode가 null이 아닌 경우에만 처리 if (item.itemCode) { const matchedContractItems = contractItemRecords.filter(ci => ci.itemId === item.id); if (matchedContractItems.length > 0) { - // 일치하는 첫 번째 contractItem 사용 - itemCodeToContractItemId.set(item.itemCode, matchedContractItems[0].id); + // 일치하는 모든 contractItem을 배열로 저장 + const contractItemIds = matchedContractItems.map(ci => ci.id); + itemCodeToContractItemIds.set(item.itemCode, contractItemIds); } } } - return itemCodeToContractItemId; + return itemCodeToContractItemIds; } catch (error) { console.error('ContractItems 조회 중 오류 발생:', error); return new Map(); @@ -938,10 +940,10 @@ export async function saveFormMappingsAndMetas( const defaultAttributes = await getDefaulTAttributes(); /* ------------------------------------------------------------------ */ - /* 2. Contract‑item look‑up (TOOL_TYPE) */ + /* 2. Contract‑item look‑up (TOOL_TYPE) - 수정된 부분 */ /* ------------------------------------------------------------------ */ const uniqueItemCodes = [...new Set(newRegisters.filter(nr => nr.TOOL_TYPE).map(nr => nr.TOOL_TYPE as string))]; - const itemCodeToContractItemId = await getContractItemsByItemCodes(uniqueItemCodes, projectId); + const itemCodeToContractItemIds = await getContractItemsByItemCodes(uniqueItemCodes, projectId); /* ------------------------------------------------------------------ */ /* 3. Buffers for bulk insert */ @@ -1027,11 +1029,25 @@ export async function saveFormMappingsAndMetas( mappingsToSave.push({ projectId, tagTypeLabel: tp.description, classLabel: cls.label, formCode, formName: legacy?.DESC || formCode, remark: newReg.TOOL_TYPE || null, ep: newReg.EP_ID || legacy?.EP_ID || "", createdAt: new Date(), updatedAt: new Date() }); }); - /* ---------- 4‑d. contractItem ↔ form --------------------------- */ + /* ---------- 4‑d. contractItem ↔ form - 수정된 부분 -------------- */ if (newReg.TOOL_TYPE) { - const cId = itemCodeToContractItemId.get(newReg.TOOL_TYPE); - if (cId) { contractItemIdsWithForms.add(cId); formsToSave.push({ contractItemId: cId, formCode, formName: legacy?.DESC || formCode, eng: true, createdAt: new Date(), updatedAt: new Date() }); } - else console.warn(`itemCode ${newReg.TOOL_TYPE} 의 contractItemId 없음`); + const contractItemIds = itemCodeToContractItemIds.get(newReg.TOOL_TYPE); + if (contractItemIds && contractItemIds.length > 0) { + // 모든 contractItemId에 대해 form 생성 + contractItemIds.forEach(cId => { + contractItemIdsWithForms.add(cId); + formsToSave.push({ + contractItemId: cId, + formCode, + formName: legacy?.DESC || formCode, + eng: true, + createdAt: new Date(), + updatedAt: new Date() + }); + }); + } else { + console.warn(`itemCode ${newReg.TOOL_TYPE} 의 contractItemId 없음`); + } } } diff --git a/lib/sedp/sync-package.ts b/lib/sedp/sync-package.ts index c8f39ad8..cdbb5987 100644 --- a/lib/sedp/sync-package.ts +++ b/lib/sedp/sync-package.ts @@ -71,7 +71,7 @@ async function getCodeLists(projectCode: string): Promise<Map<string, CodeList>> interface CodeValue { VALUE: string; - DESCC: string; + DESC: string; ATTRIBUTES: Array<{ ATT_ID: string; VALUE: string; @@ -129,16 +129,17 @@ export async function syncItemsFromCodeLists(): Promise<void> { for (const codeValue of pkgNoCodeList.VALUES) { try { // ATTRIBUTES에서 필요한 값들 추출 - const packageCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'SHI_PACK_NO'); + const packageCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'PROJ_PACK_NO'); + const packageNameAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'PROJ_PACK_DESC'); const smCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'SM_code'); const itemData = { ProjectNo: project.code, itemCode: codeValue.VALUE, - itemName: codeValue.DESCC || '', + itemName: packageNameAttr?.VALUE || '' , packageCode: packageCodeAttr?.VALUE || '', smCode: smCodeAttr?.VALUE || null, - description: null, // 필요시 추가 매핑 + description: codeValue.DESC || "", // 필요시 추가 매핑 parentItemCode: null, // 필요시 추가 매핑 itemLevel: null, // 필요시 추가 매핑 deleteFlag: 'N', // 기본값 @@ -229,22 +230,23 @@ export async function syncItemsForProject(projectCode: string): Promise<void> { // ATTRIBUTES에서 필요한 값들 추출 const packageCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'SHI_PACK_NO'); const smCodeAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'SM_code'); + const packageNameAttr = codeValue.ATTRIBUTES?.find(attr => attr.ATT_ID === 'PROJ_PACK_DESC'); const itemData = { ProjectNo: projectCode, itemCode: codeValue.VALUE, - itemName: codeValue.DESCC || '', + itemName: packageNameAttr?.VALUE || '' , packageCode: packageCodeAttr?.VALUE || '', smCode: smCodeAttr?.VALUE || null, - description: null, - parentItemCode: null, - itemLevel: null, - deleteFlag: 'N', - unitOfMeasure: null, - steelType: null, - gradeMaterial: null, - changeDate: null, - baseUnitOfMeasure: null, + description: codeValue.DESC || "", // 필요시 추가 매핑 + parentItemCode: null, // 필요시 추가 매핑 + itemLevel: null, // 필요시 추가 매핑 + deleteFlag: 'N', // 기본값 + unitOfMeasure: null, // 필요시 추가 매핑 + steelType: null, // 필요시 추가 매핑 + gradeMaterial: null, // 필요시 추가 매핑 + changeDate: null, // 필요시 추가 매핑 + baseUnitOfMeasure: null, // 필요시 추가 매핑 updatedAt: new Date() }; diff --git a/lib/vendor-data/services copy.ts b/lib/vendor-data/services copy.ts new file mode 100644 index 00000000..7f0c47c1 --- /dev/null +++ b/lib/vendor-data/services copy.ts @@ -0,0 +1,99 @@ +"use server"; + +import db from "@/db/db" +import { items } from "@/db/schema/items" +import { projects } from "@/db/schema/projects" +import { Tag, tags } from "@/db/schema/vendorData" +import { eq } from "drizzle-orm" +import { revalidateTag, unstable_noStore } from "next/cache"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { contractItems, contracts } from "@/db/schema/contract"; + +// 스키마 import + +export interface ProjectWithContracts { + projectId: number + projectCode: string + projectName: string + projectType: string + + contracts: { + contractId: number + contractNo: string + contractName: string + // contractName 등 필요한 필드 추가 + packages: { + itemId: number + itemName: string + }[] + }[] +} + + +export async function getVendorProjectsAndContracts( + vendorId: number +): Promise<ProjectWithContracts[]> { + const rows = await db + .select({ + projectId: projects.id, + projectCode: projects.code, + projectName: projects.name, + projectType: projects.type, + + contractId: contracts.id, + contractNo: contracts.contractNo, + contractName: contracts.contractName, + + itemId: contractItems.id, + itemName: items.itemName, + }) + .from(contracts) + .innerJoin(projects, eq(contracts.projectId, projects.id)) + .innerJoin(contractItems, eq(contractItems.contractId, contracts.id)) + .innerJoin(items, eq(contractItems.itemId, items.id)) + .where(eq(contracts.vendorId, vendorId)) + + const projectMap = new Map<number, ProjectWithContracts>() + + for (const row of rows) { + // 1) 프로젝트 그룹 찾기 + let projectEntry = projectMap.get(row.projectId) + if (!projectEntry) { + // 새 프로젝트 항목 생성 + projectEntry = { + projectId: row.projectId, + projectCode: row.projectCode, + projectName: row.projectName, + projectType: row.projectType, + contracts: [], + } + projectMap.set(row.projectId, projectEntry) + } + + // 2) 프로젝트 안에서 계약(contractId) 찾기 + let contractEntry = projectEntry.contracts.find( + (c) => c.contractId === row.contractId + ) + if (!contractEntry) { + // 새 계약 항목 + contractEntry = { + contractId: row.contractId, + contractNo: row.contractNo, + contractName: row.contractName, + packages: [], + } + projectEntry.contracts.push(contractEntry) + } + + // 3) 계약의 packages 배열에 아이템 추가 + contractEntry.packages.push({ + itemId: row.itemId, + itemName: row.itemName, + }) + } + + return Array.from(projectMap.values()) +} + + +// 1) 태그 조회 diff --git a/lib/vendor-data/services.ts b/lib/vendor-data/services.ts index 7f0c47c1..0ec935b9 100644 --- a/lib/vendor-data/services.ts +++ b/lib/vendor-data/services.ts @@ -3,25 +3,22 @@ import db from "@/db/db" import { items } from "@/db/schema/items" import { projects } from "@/db/schema/projects" -import { Tag, tags } from "@/db/schema/vendorData" +import { Tag, tags } from "@/db/schema/vendorData" import { eq } from "drizzle-orm" import { revalidateTag, unstable_noStore } from "next/cache"; import { unstable_cache } from "@/lib/unstable-cache"; import { contractItems, contracts } from "@/db/schema/contract"; -// 스키마 import - export interface ProjectWithContracts { projectId: number projectCode: string projectName: string projectType: string - + contracts: { contractId: number contractNo: string contractName: string - // contractName 등 필요한 필드 추가 packages: { itemId: number itemName: string @@ -29,7 +26,6 @@ export interface ProjectWithContracts { }[] } - export async function getVendorProjectsAndContracts( vendorId: number ): Promise<ProjectWithContracts[]> { @@ -39,11 +35,11 @@ export async function getVendorProjectsAndContracts( projectCode: projects.code, projectName: projects.name, projectType: projects.type, - + contractId: contracts.id, contractNo: contracts.contractNo, contractName: contracts.contractName, - + itemId: contractItems.id, itemName: items.itemName, }) @@ -85,15 +81,20 @@ export async function getVendorProjectsAndContracts( projectEntry.contracts.push(contractEntry) } - // 3) 계약의 packages 배열에 아이템 추가 - contractEntry.packages.push({ - itemId: row.itemId, - itemName: row.itemName, - }) + // 3) 계약의 packages 배열에 아이템 추가 (중복 체크) + // itemName이 같은 항목이 이미 존재하는지 확인 + const existingItem = contractEntry.packages.find( + (pkg) => pkg.itemName === row.itemName + ) + + // 같은 itemName이 없는 경우에만 추가 + if (!existingItem) { + contractEntry.packages.push({ + itemId: row.itemId, + itemName: row.itemName, + }) + } } return Array.from(projectMap.values()) -} - - -// 1) 태그 조회 +}
\ No newline at end of file |
