summaryrefslogtreecommitdiff
path: root/lib/b-rfq/vendor-response/response-detail-columns.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-17 09:02:32 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-17 09:02:32 +0000
commit7a1524ba54f43d0f2a19e4bca2c6a2e0b01c5ef1 (patch)
treedaa214d404c7fc78b32419a028724e5671a6c7a4 /lib/b-rfq/vendor-response/response-detail-columns.tsx
parentfa6a6093014c5d60188edfc9c4552e81c4b97bd1 (diff)
(대표님) 20250617 18시 작업사항
Diffstat (limited to 'lib/b-rfq/vendor-response/response-detail-columns.tsx')
-rw-r--r--lib/b-rfq/vendor-response/response-detail-columns.tsx653
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