diff options
Diffstat (limited to 'lib/b-rfq/vendor-response/response-detail-columns.tsx')
| -rw-r--r-- | lib/b-rfq/vendor-response/response-detail-columns.tsx | 653 |
1 files changed, 653 insertions, 0 deletions
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<TData> { + row: Row<TData> + type: 'upload' | 'waive' | 'edit' | 'detail' +} + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EnhancedVendorResponse> | 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 <span className="text-muted-foreground">파일 없음</span>; + } + + const latestRevision = revisions.find(r => r.isLatest) || revisions[0]; + const hasMultipleRevisions = revisions.length > 1; + const canDownload = latestRevision.filePath; + + return ( + <div className="space-y-1"> + <div className="flex items-center gap-2"> + {canDownload ? ( + <button + onClick={() => handleFileDownload( + latestRevision.filePath!, + latestRevision.originalFileName, + "client", + latestRevision.id + )} + className="font-medium text-sm text-blue-600 hover:text-blue-800 hover:underline text-left max-w-64 truncate" + title={`${latestRevision.originalFileName} - 클릭하여 다운로드`} + > + {latestRevision.originalFileName} + </button> + ) : ( + <span className="font-medium text-sm text-muted-foreground max-w-64 truncate" title={latestRevision.originalFileName}> + {latestRevision.originalFileName} + </span> + )} + + {canDownload && ( + <Button + size="sm" + variant="ghost" + className="h-6 w-6 p-0" + onClick={() => handleFileDownload( + latestRevision.filePath!, + latestRevision.originalFileName, + "client", + latestRevision.id + )} + title="파일 다운로드" + > + <Download className="h-3 w-3" /> + </Button> + )} + + {hasMultipleRevisions && ( + <Badge variant="outline" className="text-xs"> + v{latestRevision.revisionNo} + </Badge> + )} + </div> + + {hasMultipleRevisions && ( + <div className="text-xs text-muted-foreground"> + 총 {revisions.length}개 리비전 + </div> + )} + </div> + ); +} + +// 리비전 비교 컴포넌트 +function RevisionComparisonCell({ response }: { response: EnhancedVendorResponse }) { + const isUpToDate = response.isVersionMatched; + const hasResponse = !!response.respondedRevision; + const versionLag = response.versionLag || 0; + + return ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground">발주처:</span> + <Badge variant="secondary" className="text-xs font-mono"> + {response.currentRevision} + </Badge> + </div> + <div className="flex items-center gap-2"> + <span className="text-xs text-muted-foreground">응답:</span> + {hasResponse ? ( + <Badge + variant={isUpToDate ? "default" : "outline"} + className={cn( + "text-xs font-mono", + !isUpToDate && "text-blue-600 border-blue-300" + )} + > + {response.respondedRevision} + </Badge> + ) : ( + <span className="text-xs text-muted-foreground">-</span> + )} + </div> + {hasResponse && !isUpToDate && versionLag > 0 && ( + <div className="flex items-center gap-1 text-xs text-blue-600"> + <AlertCircle className="h-3 w-3" /> + <span>{versionLag}버전 차이</span> + </div> + )} + {response.hasMultipleRevisions && ( + <div className="flex items-center gap-1 text-xs text-muted-foreground"> + <GitBranch className="h-3 w-3" /> + <span>다중 리비전</span> + </div> + )} + </div> + ); +} + +// 코멘트 표시 컴포넌트 +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 <span className="text-xs text-muted-foreground">-</span>; + } + + return ( + <div className="space-y-1"> + {hasResponseComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-blue-500" title="벤더 응답 코멘트"></div> + <span className="text-xs text-blue-600 truncate max-w-32" title={response.responseComment}> + {response.responseComment} + </span> + </div> + )} + + {hasVendorComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-green-500" title="벤더 내부 메모"></div> + <span className="text-xs text-green-600 truncate max-w-32" title={response.vendorComment}> + {response.vendorComment} + </span> + </div> + )} + + {hasRevisionRequestComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-red-500" title="수정 요청 사유"></div> + <span className="text-xs text-red-600 truncate max-w-32" title={response.revisionRequestComment}> + {response.revisionRequestComment} + </span> + </div> + )} + + {hasClientComment && ( + <div className="flex items-center gap-1"> + <div className="w-2 h-2 rounded-full bg-orange-500" title="발주처 리비전 코멘트"></div> + <span className="text-xs text-orange-600 truncate max-w-32" + title={response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment}> + {response.attachment?.revisions?.find(r => r.revisionComment)?.revisionComment} + </span> + </div> + )} + + {/* <div className="text-xs text-muted-foreground text-center"> + {commentCount}개 + </div> */} + </div> + ); +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<EnhancedVendorResponse>[] { + return [ + // 시리얼 번호 - 핀고정용 최소 너비 + { + accessorKey: "serialNo", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="시리얼" /> + ), + cell: ({ row }) => ( + <div className="font-mono text-sm">{row.getValue("serialNo")}</div> + ), + + meta: { + excelHeader: "시리얼", + paddingFactor: 0.8 + }, + }, + + // 분류 - 핀고정용 적절한 너비 + { + accessorKey: "attachmentType", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="분류" /> + ), + cell: ({ row }) => ( + <div className="space-y-1"> + <div className="font-medium text-sm">{row.getValue("attachmentType")}</div> + {row.original.attachmentDescription && ( + <div className="text-xs text-muted-foreground truncate max-w-32" + title={row.original.attachmentDescription}> + {row.original.attachmentDescription} + </div> + )} + </div> + ), + + meta: { + excelHeader: "분류", + paddingFactor: 1.0 + }, + }, + + // 파일명 - 가장 긴 텍스트를 위한 여유 공간 + { + id: "fileName", + header: "파일명", + cell: ({ row }) => ( + <AttachmentFileNameCell revisions={row.original.attachment?.revisions || []} /> + ), + + meta: { + paddingFactor: 1.5 + }, + }, + + // 상태 - 뱃지 크기 고려 + { + accessorKey: "effectiveStatus", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => { + const statusInfo = getEffectiveStatusInfo(row.getValue("effectiveStatus")); + const StatusIcon = statusInfo.icon; + + return ( + <div className="space-y-1"> + <Badge variant={statusInfo.variant} className="flex items-center gap-1 w-fit"> + <StatusIcon className="h-3 w-3" /> + <span>{statusInfo.label}</span> + </Badge> + {row.original.needsUpdate && ( + <div className="text-xs text-blue-600 flex items-center gap-1"> + <RefreshCw className="h-3 w-3" /> + <span>업데이트 권장</span> + </div> + )} + </div> + ); + }, + + meta: { + excelHeader: "상태", + paddingFactor: 1.2 + }, + }, + + // 리비전 현황 - 복합 정보로 넓은 공간 필요 + { + id: "revisionStatus", + header: "리비전 현황", + cell: ({ row }) => <RevisionComparisonCell response={row.original} />, + + meta: { + paddingFactor: 1.3 + }, + }, + + // 요청일 - 날짜 형식 고정 + { + accessorKey: "requestedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="요청일" /> + ), + cell: ({ row }) => ( + <div className="text-sm flex items-center gap-1"> + <Calendar className="h-3 w-3 text-muted-foreground" /> + <span className="whitespace-nowrap">{formatDateTime(new Date(row.getValue("requestedAt")))}</span> + </div> + ), + + meta: { + excelHeader: "요청일", + paddingFactor: 0.9 + }, + }, + + // 응답일 - 날짜 형식 고정 + { + accessorKey: "respondedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="응답일" /> + ), + cell: ({ row }) => ( + <div className="text-sm"> + <span className="whitespace-nowrap"> + {row.getValue("respondedAt") + ? formatDateTime(new Date(row.getValue("respondedAt"))) + : "-" + } + </span> + </div> + ), + meta: { + excelHeader: "응답일", + paddingFactor: 0.9 + }, + }, + + // 응답파일 - 작은 공간 + { + accessorKey: "totalResponseFiles", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="응답파일" /> + ), + cell: ({ row }) => ( + <div className="text-center"> + <div className="text-sm font-medium"> + {row.getValue("totalResponseFiles")}개 + </div> + {row.original.latestResponseFileName && ( + <div className="text-xs text-muted-foreground truncate max-w-20" + title={row.original.latestResponseFileName}> + {row.original.latestResponseFileName} + </div> + )} + </div> + ), + meta: { + excelHeader: "응답파일", + paddingFactor: 0.8 + }, + }, + + // 코멘트 - 가변 텍스트 길이 + { + id: "comments", + header: "코멘트", + cell: ({ row }) => <CommentDisplayCell response={row.original} />, + // size: 180, + meta: { + paddingFactor: 1.4 + }, + }, + + // 진행도 - 중간 크기 + { + id: "progress", + header: "진행도", + cell: ({ row }) => ( + <div className="space-y-1 text-center"> + {row.original.hasMultipleRevisions && ( + <Badge variant="outline" className="text-xs"> + 다중 리비전 + </Badge> + )} + {row.original.versionLag !== undefined && row.original.versionLag > 0 && ( + <div className="text-xs text-blue-600 whitespace-nowrap"> + {row.original.versionLag}버전 차이 + </div> + )} + </div> + ), + // size: 100, + meta: { + paddingFactor: 1.1 + }, + }, + +{ + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const response = row.original; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + {/* 상태별 주요 액션들 */} + {response.effectiveStatus === "NOT_RESPONDED" && ( + <> + <DropdownMenuItem asChild> + <UploadResponseDialog + responseId={response.responseId} + attachmentType={response.attachmentType} + serialNo={response.serialNo} + currentRevision={response.currentRevision} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <Upload className="size-4 mr-2" /> + 업로드 + </div> + } + /> + </DropdownMenuItem> + <DropdownMenuItem asChild> + <WaiveResponseDialog + responseId={response.responseId} + attachmentType={response.attachmentType} + serialNo={response.serialNo} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <FileX className="size-4 mr-2" /> + 포기 + </div> + } + /> + </DropdownMenuItem> + </> + )} + + {response.effectiveStatus === "REVISION_REQUESTED" && ( + <DropdownMenuItem asChild> + <UploadResponseDialog + responseId={response.responseId} + attachmentType={response.attachmentType} + serialNo={response.serialNo} + currentRevision={response.currentRevision} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <FileText className="size-4 mr-2" /> + 수정 + </div> + } + /> + </DropdownMenuItem> + )} + + {response.effectiveStatus === "VERSION_MISMATCH" && ( + <DropdownMenuItem asChild> + <UploadResponseDialog + responseId={response.responseId} + attachmentType={response.attachmentType} + serialNo={response.serialNo} + currentRevision={response.currentRevision} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <RefreshCw className="size-4 mr-2" /> + 업데이트 + </div> + } + /> + </DropdownMenuItem> + )} + + {/* 구분선 - 주요 액션과 보조 액션 분리 */} + {(response.effectiveStatus === "NOT_RESPONDED" || + response.effectiveStatus === "REVISION_REQUESTED" || + response.effectiveStatus === "VERSION_MISMATCH") && + response.effectiveStatus !== "WAIVED" && ( + <DropdownMenuSeparator /> + )} + + {/* 공통 액션들 */} + {response.effectiveStatus !== "WAIVED" && ( + <DropdownMenuItem asChild> + <CommentEditDialog + responseId={response.responseId} + currentResponseComment={response.responseComment || ""} + currentVendorComment={response.vendorComment || ""} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <MessageSquare className="size-4 mr-2" /> + 코멘트 편집 + </div> + } + /> + </DropdownMenuItem> + )} + + <DropdownMenuItem asChild> + <ResponseDetailSheet + response={response} + trigger={ + <div className="flex items-center w-full cursor-pointer p-2"> + <FileText className="size-4 mr-2" /> + 상세보기 + </div> + } + /> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + + ) + }, + size: 40, +} + + ] +}
\ No newline at end of file |
