From 7a1524ba54f43d0f2a19e4bca2c6a2e0b01c5ef1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 17 Jun 2025 09:02:32 +0000 Subject: (대표님) 20250617 18시 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vendor-response/response-detail-columns.tsx | 653 +++++++++++++++++++++ 1 file changed, 653 insertions(+) create mode 100644 lib/b-rfq/vendor-response/response-detail-columns.tsx (limited to 'lib/b-rfq/vendor-response/response-detail-columns.tsx') diff --git a/lib/b-rfq/vendor-response/response-detail-columns.tsx b/lib/b-rfq/vendor-response/response-detail-columns.tsx new file mode 100644 index 00000000..bc27d103 --- /dev/null +++ b/lib/b-rfq/vendor-response/response-detail-columns.tsx @@ -0,0 +1,653 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import type { Row } from "@tanstack/react-table" +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" +import { formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + FileText, + Upload, + CheckCircle, + Clock, + AlertTriangle, + FileX, + Download, + AlertCircle, + RefreshCw, + Calendar, + MessageSquare, + GitBranch, + Ellipsis +} from "lucide-react" +import { cn } from "@/lib/utils" +import type { EnhancedVendorResponse } from "@/lib/b-rfq/service" +import { UploadResponseDialog } from "./upload-response-dialog" +import { CommentEditDialog } from "./comment-edit-dialog" +import { WaiveResponseDialog } from "./waive-response-dialog" +import { ResponseDetailSheet } from "./response-detail-sheet" + +export interface DataTableRowAction { + row: Row + type: 'upload' | 'waive' | 'edit' | 'detail' +} + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +// 파일 다운로드 핸들러 +async function handleFileDownload( + filePath: string, + fileName: string, + type: "client" | "vendor" = "client", + id?: number +) { + try { + const params = new URLSearchParams({ + path: filePath, + type: type, + }); + + if (id) { + if (type === "client") { + params.append("revisionId", id.toString()); + } else { + params.append("responseFileId", id.toString()); + } + } + + const response = await fetch(`/api/rfq-attachments/download?${params.toString()}`); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `Download failed: ${response.status}`); + } + + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + } catch (error) { + console.error("❌ 파일 다운로드 실패:", error); + alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`); + } +} + +// 상태별 정보 반환 +function getEffectiveStatusInfo(effectiveStatus: string) { + switch (effectiveStatus) { + case "NOT_RESPONDED": + return { + icon: Clock, + label: "미응답", + variant: "outline" as const, + color: "text-orange-600" + }; + case "UP_TO_DATE": + return { + icon: CheckCircle, + label: "최신", + variant: "default" as const, + color: "text-green-600" + }; + case "VERSION_MISMATCH": + return { + icon: RefreshCw, + label: "업데이트 필요", + variant: "secondary" as const, + color: "text-blue-600" + }; + case "REVISION_REQUESTED": + return { + icon: AlertTriangle, + label: "수정요청", + variant: "secondary" as const, + color: "text-yellow-600" + }; + case "WAIVED": + return { + icon: FileX, + label: "포기", + variant: "outline" as const, + color: "text-gray-600" + }; + default: + return { + icon: FileText, + label: effectiveStatus, + variant: "outline" as const, + color: "text-gray-600" + }; + } +} + +// 파일명 컴포넌트 +function AttachmentFileNameCell({ revisions }: { + revisions: Array<{ + id: number; + originalFileName: string; + revisionNo: string; + isLatest: boolean; + filePath?: string; + fileSize: number; + createdAt: string; + revisionComment?: string; + }> +}) { + if (!revisions || revisions.length === 0) { + return 파일 없음; + } + + const latestRevision = revisions.find(r => r.isLatest) || revisions[0]; + const hasMultipleRevisions = revisions.length > 1; + const canDownload = latestRevision.filePath; + + return ( +
+
+ {canDownload ? ( + + ) : ( + + {latestRevision.originalFileName} + + )} + + {canDownload && ( + + )} + + {hasMultipleRevisions && ( + + v{latestRevision.revisionNo} + + )} +
+ + {hasMultipleRevisions && ( +
+ 총 {revisions.length}개 리비전 +
+ )} +
+ ); +} + +// 리비전 비교 컴포넌트 +function RevisionComparisonCell({ response }: { response: EnhancedVendorResponse }) { + const isUpToDate = response.isVersionMatched; + const hasResponse = !!response.respondedRevision; + const versionLag = response.versionLag || 0; + + return ( +
+
+ 발주처: + + {response.currentRevision} + +
+
+ 응답: + {hasResponse ? ( + + {response.respondedRevision} + + ) : ( + - + )} +
+ {hasResponse && !isUpToDate && versionLag > 0 && ( +
+ + {versionLag}버전 차이 +
+ )} + {response.hasMultipleRevisions && ( +
+ + 다중 리비전 +
+ )} +
+ ); +} + +// 코멘트 표시 컴포넌트 +function CommentDisplayCell({ response }: { response: EnhancedVendorResponse }) { + const hasResponseComment = !!response.responseComment; + const hasVendorComment = !!response.vendorComment; + const hasRevisionRequestComment = !!response.revisionRequestComment; + const hasClientComment = !!response.attachment?.revisions?.find(r => r.revisionComment); + + const commentCount = [hasResponseComment, hasVendorComment, hasRevisionRequestComment, hasClientComment].filter(Boolean).length; + + if (commentCount === 0) { + return -; + } + + return ( +
+ {hasResponseComment && ( +
+
+ + {response.responseComment} + +
+ )} + + {hasVendorComment && ( +
+
+ + {response.vendorComment} + +
+ )} + + {hasRevisionRequestComment && ( +
+
+ + {response.revisionRequestComment} + +
+ )} + + {hasClientComment && ( +
+
+ r.revisionComment)?.revisionComment}> + {response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment} + +
+ )} + + {/*
+ {commentCount}개 +
*/} +
+ ); +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef[] { + return [ + // 시리얼 번호 - 핀고정용 최소 너비 + { + accessorKey: "serialNo", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.getValue("serialNo")}
+ ), + + meta: { + excelHeader: "시리얼", + paddingFactor: 0.8 + }, + }, + + // 분류 - 핀고정용 적절한 너비 + { + accessorKey: "attachmentType", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+
{row.getValue("attachmentType")}
+ {row.original.attachmentDescription && ( +
+ {row.original.attachmentDescription} +
+ )} +
+ ), + + meta: { + excelHeader: "분류", + paddingFactor: 1.0 + }, + }, + + // 파일명 - 가장 긴 텍스트를 위한 여유 공간 + { + id: "fileName", + header: "파일명", + cell: ({ row }) => ( + + ), + + meta: { + paddingFactor: 1.5 + }, + }, + + // 상태 - 뱃지 크기 고려 + { + accessorKey: "effectiveStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const statusInfo = getEffectiveStatusInfo(row.getValue("effectiveStatus")); + const StatusIcon = statusInfo.icon; + + return ( +
+ + + {statusInfo.label} + + {row.original.needsUpdate && ( +
+ + 업데이트 권장 +
+ )} +
+ ); + }, + + meta: { + excelHeader: "상태", + paddingFactor: 1.2 + }, + }, + + // 리비전 현황 - 복합 정보로 넓은 공간 필요 + { + id: "revisionStatus", + header: "리비전 현황", + cell: ({ row }) => , + + meta: { + paddingFactor: 1.3 + }, + }, + + // 요청일 - 날짜 형식 고정 + { + accessorKey: "requestedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ + {formatDateTime(new Date(row.getValue("requestedAt")))} +
+ ), + + meta: { + excelHeader: "요청일", + paddingFactor: 0.9 + }, + }, + + // 응답일 - 날짜 형식 고정 + { + accessorKey: "respondedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ + {row.getValue("respondedAt") + ? formatDateTime(new Date(row.getValue("respondedAt"))) + : "-" + } + +
+ ), + meta: { + excelHeader: "응답일", + paddingFactor: 0.9 + }, + }, + + // 응답파일 - 작은 공간 + { + accessorKey: "totalResponseFiles", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+
+ {row.getValue("totalResponseFiles")}개 +
+ {row.original.latestResponseFileName && ( +
+ {row.original.latestResponseFileName} +
+ )} +
+ ), + meta: { + excelHeader: "응답파일", + paddingFactor: 0.8 + }, + }, + + // 코멘트 - 가변 텍스트 길이 + { + id: "comments", + header: "코멘트", + cell: ({ row }) => , + // size: 180, + meta: { + paddingFactor: 1.4 + }, + }, + + // 진행도 - 중간 크기 + { + id: "progress", + header: "진행도", + cell: ({ row }) => ( +
+ {row.original.hasMultipleRevisions && ( + + 다중 리비전 + + )} + {row.original.versionLag !== undefined && row.original.versionLag > 0 && ( +
+ {row.original.versionLag}버전 차이 +
+ )} +
+ ), + // size: 100, + meta: { + paddingFactor: 1.1 + }, + }, + +{ + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const response = row.original; + + return ( + + + + + + {/* 상태별 주요 액션들 */} + {response.effectiveStatus === "NOT_RESPONDED" && ( + <> + + + + 업로드 + + } + /> + + + + + 포기 + + } + /> + + + )} + + {response.effectiveStatus === "REVISION_REQUESTED" && ( + + + + 수정 + + } + /> + + )} + + {response.effectiveStatus === "VERSION_MISMATCH" && ( + + + + 업데이트 + + } + /> + + )} + + {/* 구분선 - 주요 액션과 보조 액션 분리 */} + {(response.effectiveStatus === "NOT_RESPONDED" || + response.effectiveStatus === "REVISION_REQUESTED" || + response.effectiveStatus === "VERSION_MISMATCH") && + response.effectiveStatus !== "WAIVED" && ( + + )} + + {/* 공통 액션들 */} + {response.effectiveStatus !== "WAIVED" && ( + + + + 코멘트 편집 + + } + /> + + )} + + + + + 상세보기 + + } + /> + + + + + ) + }, + size: 40, +} + + ] +} \ No newline at end of file -- cgit v1.2.3