From 7a1524ba54f43d0f2a19e4bca2c6a2e0b01c5ef1 Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
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<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
-- 
cgit v1.2.3

