summaryrefslogtreecommitdiff
path: root/lib/b-rfq/vendor-response
diff options
context:
space:
mode:
Diffstat (limited to 'lib/b-rfq/vendor-response')
-rw-r--r--lib/b-rfq/vendor-response/comment-edit-dialog.tsx187
-rw-r--r--lib/b-rfq/vendor-response/response-detail-columns.tsx653
-rw-r--r--lib/b-rfq/vendor-response/response-detail-sheet.tsx358
-rw-r--r--lib/b-rfq/vendor-response/response-detail-table.tsx161
-rw-r--r--lib/b-rfq/vendor-response/upload-response-dialog.tsx325
-rw-r--r--lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx351
-rw-r--r--lib/b-rfq/vendor-response/vendor-responses-table.tsx160
-rw-r--r--lib/b-rfq/vendor-response/waive-response-dialog.tsx210
8 files changed, 2405 insertions, 0 deletions
diff --git a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx b/lib/b-rfq/vendor-response/comment-edit-dialog.tsx
new file mode 100644
index 00000000..0c2c0c62
--- /dev/null
+++ b/lib/b-rfq/vendor-response/comment-edit-dialog.tsx
@@ -0,0 +1,187 @@
+// components/rfq/comment-edit-dialog.tsx
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Textarea } from "@/components/ui/textarea";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import { MessageSquare, Loader2 } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+import { useRouter } from "next/navigation";
+
+const commentFormSchema = z.object({
+ responseComment: z.string().optional(),
+ vendorComment: z.string().optional(),
+});
+
+type CommentFormData = z.infer<typeof commentFormSchema>;
+
+interface CommentEditDialogProps {
+ responseId: number;
+ currentResponseComment?: string;
+ currentVendorComment?: string;
+ trigger?: React.ReactNode;
+ onSuccess?: () => void;
+}
+
+export function CommentEditDialog({
+ responseId,
+ currentResponseComment,
+ currentVendorComment,
+ trigger,
+ onSuccess,
+}: CommentEditDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [isSaving, setIsSaving] = useState(false);
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const form = useForm<CommentFormData>({
+ resolver: zodResolver(commentFormSchema),
+ defaultValues: {
+ responseComment: currentResponseComment || "",
+ vendorComment: currentVendorComment || "",
+ },
+ });
+
+ const onSubmit = async (data: CommentFormData) => {
+ setIsSaving(true);
+
+ try {
+ const response = await fetch("/api/vendor-responses/update-comment", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ responseId,
+ responseComment: data.responseComment,
+ vendorComment: data.vendorComment,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.message || "코멘트 업데이트 실패");
+ }
+
+ toast({
+ title: "코멘트 업데이트 완료",
+ description: "코멘트가 성공적으로 업데이트되었습니다.",
+ });
+
+ setOpen(false);
+
+ router.refresh();
+ onSuccess?.();
+
+ } catch (error) {
+ console.error("Comment update error:", error);
+ toast({
+ title: "업데이트 실패",
+ description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsSaving(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ {trigger || (
+ <Button size="sm" variant="outline">
+ <MessageSquare className="h-3 w-3 mr-1" />
+ 코멘트
+ </Button>
+ )}
+ </DialogTrigger>
+ <DialogContent className="max-w-lg">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 코멘트 수정
+ </DialogTitle>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 응답 코멘트 */}
+ <FormField
+ control={form.control}
+ name="responseComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>응답 코멘트</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="응답에 대한 설명을 입력하세요..."
+ className="resize-none"
+ rows={3}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더 코멘트 */}
+ <FormField
+ control={form.control}
+ name="vendorComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더 코멘트 (내부용)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="내부 참고용 코멘트를 입력하세요..."
+ className="resize-none"
+ rows={3}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 버튼 */}
+ <div className="flex justify-end gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isSaving}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isSaving}>
+ {isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
+ {isSaving ? "저장 중..." : "저장"}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
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
diff --git a/lib/b-rfq/vendor-response/response-detail-sheet.tsx b/lib/b-rfq/vendor-response/response-detail-sheet.tsx
new file mode 100644
index 00000000..da7f9b01
--- /dev/null
+++ b/lib/b-rfq/vendor-response/response-detail-sheet.tsx
@@ -0,0 +1,358 @@
+// components/rfq/response-detail-sheet.tsx
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import {
+ FileText,
+ Upload,
+ Download,
+ AlertCircle,
+ MessageSquare,
+ FileCheck,
+ Eye
+} from "lucide-react";
+import { formatDateTime, formatFileSize } from "@/lib/utils";
+import { cn } from "@/lib/utils";
+import type { EnhancedVendorResponse } from "@/lib/b-rfq/service";
+
+// 파일 다운로드 핸들러 (API 사용)
+async function handleFileDownload(
+ filePath: string,
+ fileName: string,
+ type: "client" | "vendor" = "client",
+ id?: number
+) {
+ try {
+ const params = new URLSearchParams({
+ path: filePath,
+ type: type,
+ });
+
+ // ID가 있으면 추가
+ 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}`);
+ }
+
+ // Blob으로 파일 데이터 받기
+ const blob = await response.blob();
+
+ // 임시 URL 생성하여 다운로드
+ 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);
+
+ console.log("✅ 파일 다운로드 성공:", fileName);
+
+ } catch (error) {
+ console.error("❌ 파일 다운로드 실패:", error);
+
+ // 사용자에게 에러 알림 (토스트나 알럿으로 대체 가능)
+ alert(`파일 다운로드에 실패했습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
+ }
+}
+
+// 효과적인 상태별 아이콘 및 색상
+function getEffectiveStatusInfo(effectiveStatus: string) {
+ switch (effectiveStatus) {
+ case "NOT_RESPONDED":
+ return {
+ label: "미응답",
+ variant: "outline" as const
+ };
+ case "UP_TO_DATE":
+ return {
+ label: "최신",
+ variant: "default" as const
+ };
+ case "VERSION_MISMATCH":
+ return {
+ label: "업데이트 필요",
+ variant: "secondary" as const
+ };
+ case "REVISION_REQUESTED":
+ return {
+ label: "수정요청",
+ variant: "secondary" as const
+ };
+ case "WAIVED":
+ return {
+ label: "포기",
+ variant: "outline" as const
+ };
+ default:
+ return {
+ label: effectiveStatus,
+ variant: "outline" as const
+ };
+ }
+}
+
+interface ResponseDetailSheetProps {
+ response: EnhancedVendorResponse;
+ trigger?: React.ReactNode;
+}
+
+export function ResponseDetailSheet({ response, trigger }: ResponseDetailSheetProps) {
+ const hasMultipleRevisions = response.attachment?.revisions && response.attachment.revisions.length > 1;
+ const hasResponseFiles = response.responseAttachments && response.responseAttachments.length > 0;
+
+ return (
+ <Sheet>
+ <SheetTrigger asChild>
+ {trigger || (
+ <Button size="sm" variant="ghost">
+ <Eye className="h-3 w-3 mr-1" />
+ 상세
+ </Button>
+ )}
+ </SheetTrigger>
+ <SheetContent side="right" className="w-[600px] sm:w-[800px] overflow-y-auto">
+ <SheetHeader>
+ <SheetTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 상세 정보 - {response.serialNo}
+ </SheetTitle>
+ <SheetDescription>
+ {response.attachmentType} • {response.attachment?.revisions?.[0]?.originalFileName}
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="space-y-6 mt-6">
+ {/* 기본 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold flex items-center gap-2">
+ <AlertCircle className="h-4 w-4" />
+ 기본 정보
+ </h3>
+ <div className="grid grid-cols-2 gap-4 p-4 bg-muted/30 rounded-lg">
+ <div>
+ <div className="text-sm text-muted-foreground">상태</div>
+ <div className="font-medium">{getEffectiveStatusInfo(response.effectiveStatus).label}</div>
+ </div>
+ <div>
+ <div className="text-sm text-muted-foreground">현재 리비전</div>
+ <div className="font-medium">{response.currentRevision}</div>
+ </div>
+ <div>
+ <div className="text-sm text-muted-foreground">응답 리비전</div>
+ <div className="font-medium">{response.respondedRevision || "-"}</div>
+ </div>
+ <div>
+ <div className="text-sm text-muted-foreground">응답일</div>
+ <div className="font-medium">
+ {response.respondedAt ? formatDateTime(new Date(response.respondedAt)) : "-"}
+ </div>
+ </div>
+ <div>
+ <div className="text-sm text-muted-foreground">요청일</div>
+ <div className="font-medium">
+ {formatDateTime(new Date(response.requestedAt))}
+ </div>
+ </div>
+ <div>
+ <div className="text-sm text-muted-foreground">응답 파일 수</div>
+ <div className="font-medium">{response.totalResponseFiles}개</div>
+ </div>
+ </div>
+ </div>
+
+ {/* 코멘트 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold flex items-center gap-2">
+ <MessageSquare className="h-4 w-4" />
+ 코멘트
+ </h3>
+ <div className="space-y-3">
+ {response.responseComment && (
+ <div className="p-3 border-l-4 border-blue-500 bg-blue-50">
+ <div className="text-sm font-medium text-blue-700 mb-1">발주처 응답 코멘트</div>
+ <div className="text-sm">{response.responseComment}</div>
+ </div>
+ )}
+ {response.vendorComment && (
+ <div className="p-3 border-l-4 border-green-500 bg-green-50">
+ <div className="text-sm font-medium text-green-700 mb-1">내부 메모</div>
+ <div className="text-sm">{response.vendorComment}</div>
+ </div>
+ )}
+ {response.attachment?.revisions?.find(r => r.revisionComment) && (
+ <div className="p-3 border-l-4 border-orange-500 bg-orange-50">
+ <div className="text-sm font-medium text-orange-700 mb-1">발주처 요청 사항</div>
+ <div className="text-sm">
+ {response.attachment.revisions.find(r => r.revisionComment)?.revisionComment}
+ </div>
+ </div>
+ )}
+ {!response.responseComment && !response.vendorComment && !response.attachment?.revisions?.find(r => r.revisionComment) && (
+ <div className="text-center text-muted-foreground py-4 bg-muted/20 rounded-lg">
+ 코멘트가 없습니다.
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 발주처 리비전 히스토리 */}
+ {hasMultipleRevisions && (
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold flex items-center gap-2">
+ <FileCheck className="h-4 w-4" />
+ 발주처 리비전 히스토리 ({response.attachment!.revisions.length}개)
+ </h3>
+ <div className="space-y-3">
+ {response.attachment!.revisions
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
+ .map((revision) => (
+ <div
+ key={revision.id}
+ className={cn(
+ "flex items-center justify-between p-4 rounded-lg border",
+ revision.isLatest ? "bg-blue-50 border-blue-200" : "bg-white"
+ )}
+ >
+ <div className="flex items-center gap-3 flex-1">
+ <Badge variant={revision.isLatest ? "default" : "outline"}>
+ {revision.revisionNo}
+ </Badge>
+ <div className="flex-1">
+ <div className="font-medium text-sm">{revision.originalFileName}</div>
+ <div className="text-xs text-muted-foreground">
+ {formatFileSize(revision.fileSize)} • {formatDateTime(new Date(revision.createdAt))}
+ </div>
+ {revision.revisionComment && (
+ <div className="text-xs text-muted-foreground mt-1 italic">
+ "{revision.revisionComment}"
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {revision.isLatest && (
+ <Badge variant="secondary" className="text-xs">최신</Badge>
+ )}
+ {revision.revisionNo === response.respondedRevision && (
+ <Badge variant="outline" className="text-xs text-green-600 border-green-300">
+ 응답됨
+ </Badge>
+ )}
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => {
+ if (revision.filePath) {
+ handleFileDownload(
+ revision.filePath,
+ revision.originalFileName,
+ "client",
+ revision.id
+ );
+ }
+ }}
+ disabled={!revision.filePath}
+ title="파일 다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 벤더 응답 파일들 */}
+ {hasResponseFiles && (
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold flex items-center gap-2">
+ <Upload className="h-4 w-4" />
+ 벤더 응답 파일들 ({response.totalResponseFiles}개)
+ </h3>
+ <div className="space-y-3">
+ {response.responseAttachments!
+ .sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())
+ .map((file) => (
+ <div key={file.id} className="flex items-center justify-between p-4 rounded-lg border bg-green-50 border-green-200">
+ <div className="flex items-center gap-3 flex-1">
+ <Badge variant="outline" className="bg-green-100">
+ 파일 #{file.fileSequence}
+ </Badge>
+ <div className="flex-1">
+ <div className="font-medium text-sm">{file.originalFileName}</div>
+ <div className="text-xs text-muted-foreground">
+ {formatFileSize(file.fileSize)} • {formatDateTime(new Date(file.uploadedAt))}
+ </div>
+ {file.description && (
+ <div className="text-xs text-muted-foreground mt-1 italic">
+ "{file.description}"
+ </div>
+ )}
+ </div>
+ </div>
+
+ <div className="flex items-center gap-2">
+ {file.isLatestResponseFile && (
+ <Badge variant="secondary" className="text-xs">최신</Badge>
+ )}
+ <Button
+ size="sm"
+ variant="ghost"
+ onClick={() => {
+ if (file.filePath) {
+ handleFileDownload(
+ file.filePath,
+ file.originalFileName,
+ "vendor",
+ file.id
+ );
+ }
+ }}
+ disabled={!file.filePath}
+ title="파일 다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {!hasMultipleRevisions && !hasResponseFiles && (
+ <div className="text-center text-muted-foreground py-8 bg-muted/20 rounded-lg">
+ <FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
+ <p>추가 파일이나 리비전 정보가 없습니다.</p>
+ </div>
+ )}
+ </div>
+ </SheetContent>
+ </Sheet>
+ );
+} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/response-detail-table.tsx b/lib/b-rfq/vendor-response/response-detail-table.tsx
new file mode 100644
index 00000000..124d5241
--- /dev/null
+++ b/lib/b-rfq/vendor-response/response-detail-table.tsx
@@ -0,0 +1,161 @@
+"use client"
+
+import * as React from "react"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import type { EnhancedVendorResponse } from "@/lib/b-rfq/service"
+import { DataTableAdvancedFilterField } from "@/types/table"
+import { DataTableRowAction, getColumns } from "./response-detail-columns"
+
+interface FinalRfqResponseTableProps {
+ data: EnhancedVendorResponse[]
+ // ✅ 헤더 정보를 props로 받기
+ statistics?: {
+ total: number
+ upToDate: number
+ versionMismatch: number
+ pending: number
+ revisionRequested: number
+ waived: number
+ }
+ showHeader?: boolean
+ title?: string
+}
+
+/**
+ * FinalRfqResponseTable: RFQ 응답 데이터를 표시하는 표
+ */
+export function FinalRfqResponseTable({
+ data,
+ statistics,
+ showHeader = true,
+ title = "첨부파일별 응답 현황"
+}: FinalRfqResponseTableProps) {
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<EnhancedVendorResponse> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // 고급 필터 필드 정의
+ const advancedFilterFields: DataTableAdvancedFilterField<EnhancedVendorResponse>[] = [
+ {
+ id: "effectiveStatus",
+ label: "상태",
+ type: "select",
+ options: [
+ { label: "미응답", value: "NOT_RESPONDED" },
+ { label: "최신", value: "UP_TO_DATE" },
+ { label: "업데이트 필요", value: "VERSION_MISMATCH" },
+ { label: "수정요청", value: "REVISION_REQUESTED" },
+ { label: "포기", value: "WAIVED" },
+ ],
+ },
+ {
+ id: "attachmentType",
+ label: "첨부파일 분류",
+ type: "text",
+ },
+ {
+ id: "serialNo",
+ label: "시리얼 번호",
+ type: "text",
+ },
+ {
+ id: "isVersionMatched",
+ label: "버전 일치",
+ type: "select",
+ options: [
+ { label: "일치", value: "true" },
+ { label: "불일치", value: "false" },
+ ],
+ },
+ {
+ id: "hasMultipleRevisions",
+ label: "다중 리비전",
+ type: "select",
+ options: [
+ { label: "있음", value: "true" },
+ { label: "없음", value: "false" },
+ ],
+ },
+ ]
+
+ if (data.length === 0) {
+ return (
+ <div className="border rounded-lg p-12 text-center">
+ <div className="mx-auto mb-4 h-12 w-12 text-muted-foreground">
+ 📄
+ </div>
+ <p className="text-muted-foreground">응답할 첨부파일이 없습니다.</p>
+ </div>
+ )
+ }
+
+ return (
+ // ✅ 상위 컨테이너 구조 단순화 및 너비 제한 해제
+<>
+ {/* 코멘트 범례 */}
+ <div className="flex items-center gap-6 text-xs text-muted-foreground bg-muted/30 p-3 rounded-lg">
+ <span className="font-medium">코멘트 범례:</span>
+ <div className="flex items-center gap-1">
+ <div className="w-2 h-2 rounded-full bg-blue-500"></div>
+ <span>벤더 응답</span>
+ </div>
+ <div className="flex items-center gap-1">
+ <div className="w-2 h-2 rounded-full bg-green-500"></div>
+ <span>내부 메모</span>
+ </div>
+ <div className="flex items-center gap-1">
+ <div className="w-2 h-2 rounded-full bg-red-500"></div>
+ <span>수정 요청</span>
+ </div>
+ <div className="flex items-center gap-1">
+ <div className="w-2 h-2 rounded-full bg-orange-500"></div>
+ <span>발주처 리비전</span>
+ </div>
+ </div>
+ <div style={{
+ width: '100%',
+ maxWidth: '100%',
+ overflow: 'hidden',
+ contain: 'layout'
+ }}>
+ {/* 데이터 테이블 - 컨테이너 제약 최소화 */}
+ <ClientDataTable
+ data={data}
+ columns={columns}
+ advancedFilterFields={advancedFilterFields}
+ autoSizeColumns={true}
+ compact={true}
+ // ✅ RFQ 테이블에 맞는 컬럼 핀고정
+ initialColumnPinning={{
+ left: ["serialNo", "attachmentType"],
+ right: ["actions"]
+ }}
+ >
+ {showHeader && (
+ <div className="flex items-center justify-between">
+
+ {statistics && (
+ <div className="flex items-center gap-4 text-sm text-muted-foreground">
+ <span>전체 {statistics.total}개</span>
+ <span className="text-green-600">최신 {statistics.upToDate}개</span>
+ <span className="text-blue-600">업데이트필요 {statistics.versionMismatch}개</span>
+ <span className="text-orange-600">미응답 {statistics.pending}개</span>
+ {statistics.revisionRequested > 0 && (
+ <span className="text-yellow-600">수정요청 {statistics.revisionRequested}개</span>
+ )}
+ {statistics.waived > 0 && (
+ <span className="text-gray-600">포기 {statistics.waived}개</span>
+ )}
+ </div>
+ )}
+ </div>
+ )}
+ </ClientDataTable>
+ </div>
+ </>
+ )
+}
diff --git a/lib/b-rfq/vendor-response/upload-response-dialog.tsx b/lib/b-rfq/vendor-response/upload-response-dialog.tsx
new file mode 100644
index 00000000..b4b306d6
--- /dev/null
+++ b/lib/b-rfq/vendor-response/upload-response-dialog.tsx
@@ -0,0 +1,325 @@
+// components/rfq/upload-response-dialog.tsx
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import { Upload, FileText, X, Loader2 } from "lucide-react";
+import { useToast } from "@/hooks/use-toast"
+import { useRouter } from "next/navigation";
+
+const uploadFormSchema = z.object({
+ files: z.array(z.instanceof(File)).min(1, "최소 1개의 파일을 선택해주세요"),
+ responseComment: z.string().optional(),
+ vendorComment: z.string().optional(),
+});
+
+type UploadFormData = z.infer<typeof uploadFormSchema>;
+
+interface UploadResponseDialogProps {
+ responseId: number;
+ attachmentType: string;
+ serialNo: string;
+ currentRevision: string;
+ trigger?: React.ReactNode;
+ onSuccess?: () => void;
+}
+
+export function UploadResponseDialog({
+ responseId,
+ attachmentType,
+ serialNo,
+ currentRevision,
+ trigger,
+ onSuccess,
+}: UploadResponseDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [isUploading, setIsUploading] = useState(false);
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const form = useForm<UploadFormData>({
+ resolver: zodResolver(uploadFormSchema),
+ defaultValues: {
+ files: [],
+ responseComment: "",
+ vendorComment: "",
+ },
+ });
+
+ const selectedFiles = form.watch("files");
+
+ const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const files = Array.from(e.target.files || []);
+ if (files.length > 0) {
+ form.setValue("files", files);
+ }
+ };
+
+ const removeFile = (index: number) => {
+ const currentFiles = form.getValues("files");
+ const newFiles = currentFiles.filter((_, i) => i !== index);
+ form.setValue("files", newFiles);
+ };
+
+ const formatFileSize = (bytes: number): string => {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+ };
+
+ const handleOpenChange = (newOpen: boolean) => {
+ setOpen(newOpen);
+ // 다이얼로그가 닫힐 때 form 리셋
+ if (!newOpen) {
+ form.reset();
+ }
+ };
+
+ const handleCancel = () => {
+ form.reset();
+ setOpen(false);
+ };
+
+ const onSubmit = async (data: UploadFormData) => {
+ setIsUploading(true);
+
+ try {
+ // 1. 각 파일을 업로드 API로 전송
+ const uploadedFiles = [];
+
+ for (const file of data.files) {
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("responseId", responseId.toString());
+ formData.append("description", ""); // 필요시 파일별 설명 추가 가능
+
+ const uploadResponse = await fetch("/api/vendor-responses/upload", {
+ method: "POST",
+ body: formData,
+ });
+
+ if (!uploadResponse.ok) {
+ const error = await uploadResponse.json();
+ throw new Error(error.message || "파일 업로드 실패");
+ }
+
+ const uploadResult = await uploadResponse.json();
+ uploadedFiles.push(uploadResult);
+ }
+
+ // 2. vendor response 상태 업데이트 (서버에서 자동으로 리비전 증가)
+ const updateResponse = await fetch("/api/vendor-responses/update", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ responseId,
+ responseStatus: "RESPONDED",
+ // respondedRevision 제거 - 서버에서 자동 처리
+ responseComment: data.responseComment,
+ vendorComment: data.vendorComment,
+ respondedAt: new Date().toISOString(),
+ }),
+ });
+
+ if (!updateResponse.ok) {
+ const error = await updateResponse.json();
+ throw new Error(error.message || "응답 상태 업데이트 실패");
+ }
+
+ const updateResult = await updateResponse.json();
+
+ toast({
+ title: "업로드 완료",
+ description: `${data.files.length}개 파일이 성공적으로 업로드되었습니다. (${updateResult.newRevision})`,
+ });
+
+ setOpen(false);
+ form.reset();
+
+ router.refresh();
+ onSuccess?.();
+
+ } catch (error) {
+ console.error("Upload error:", error);
+ toast({
+ title: "업로드 실패",
+ description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogTrigger asChild>
+ {trigger || (
+ <Button size="sm">
+ <Upload className="h-3 w-3 mr-1" />
+ 업로드
+ </Button>
+ )}
+ </DialogTrigger>
+ <DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5" />
+ 응답 파일 업로드
+ </DialogTitle>
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Badge variant="outline">{serialNo}</Badge>
+ <span>{attachmentType}</span>
+ <Badge variant="secondary">{currentRevision}</Badge>
+ <span className="text-xs text-blue-600">→ 벤더 응답 리비전 자동 증가</span>
+ </div>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 파일 선택 */}
+ <FormField
+ control={form.control}
+ name="files"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>파일 선택</FormLabel>
+ <FormControl>
+ <div className="space-y-4">
+ <Input
+ type="file"
+ multiple
+ onChange={handleFileSelect}
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.zip,.rar"
+ className="cursor-pointer"
+ />
+ <div className="text-xs text-muted-foreground">
+ 지원 파일: PDF, DOC, DOCX, XLS, XLSX, PNG, JPG, ZIP, RAR (최대 10MB)
+ </div>
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선택된 파일 목록 */}
+ {selectedFiles.length > 0 && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">선택된 파일 ({selectedFiles.length}개)</div>
+ <div className="space-y-2 max-h-40 overflow-y-auto">
+ {selectedFiles.map((file, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
+ >
+ <div className="flex items-center gap-2 flex-1 min-w-0">
+ <FileText className="h-4 w-4 text-muted-foreground flex-shrink-0" />
+ <div className="min-w-0 flex-1">
+ <div className="text-sm font-medium truncate">{file.name}</div>
+ <div className="text-xs text-muted-foreground">
+ {formatFileSize(file.size)}
+ </div>
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeFile(index)}
+ className="flex-shrink-0 ml-2"
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 응답 코멘트 */}
+ <FormField
+ control={form.control}
+ name="responseComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>응답 코멘트</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="응답에 대한 설명을 입력하세요..."
+ className="resize-none"
+ rows={3}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 벤더 코멘트 */}
+ <FormField
+ control={form.control}
+ name="vendorComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>벤더 코멘트 (내부용)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="내부 참고용 코멘트를 입력하세요..."
+ className="resize-none"
+ rows={2}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 버튼 */}
+ <div className="flex justify-end gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isUploading || selectedFiles.length === 0}>
+ {isUploading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
+ {isUploading ? "업로드 중..." : "업로드"}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx b/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx
new file mode 100644
index 00000000..47b7570b
--- /dev/null
+++ b/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx
@@ -0,0 +1,351 @@
+// lib/vendor-responses/table/vendor-responses-table-columns.tsx
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import {
+ Ellipsis, FileText, Pencil, Edit, Trash2,
+ Eye, MessageSquare, Clock, CheckCircle, AlertTriangle, FileX
+} from "lucide-react"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import Link from "next/link"
+import { useRouter } from "next/navigation"
+import { VendorResponseDetail } from "../service"
+import { VendorRfqResponseSummary } from "../validations"
+
+// 응답 상태에 따른 배지 컴포넌트
+function ResponseStatusBadge({ status }: { status: string }) {
+ switch (status) {
+ case "NOT_RESPONDED":
+ return (
+ <Badge variant="outline" className="text-orange-600 border-orange-600">
+ <Clock className="mr-1 h-3 w-3" />
+ 미응답
+ </Badge>
+ )
+ case "RESPONDED":
+ return (
+ <Badge variant="default" className="bg-green-600 text-white">
+ <CheckCircle className="mr-1 h-3 w-3" />
+ 응답완료
+ </Badge>
+ )
+ case "REVISION_REQUESTED":
+ return (
+ <Badge variant="secondary" className="text-yellow-600 border-yellow-600">
+ <AlertTriangle className="mr-1 h-3 w-3" />
+ 수정요청
+ </Badge>
+ )
+ case "WAIVED":
+ return (
+ <Badge variant="outline" className="text-gray-600 border-gray-600">
+ <FileX className="mr-1 h-3 w-3" />
+ 포기
+ </Badge>
+ )
+ default:
+ return <Badge>{status}</Badge>
+ }
+}
+
+
+type NextRouter = ReturnType<typeof useRouter>;
+
+interface GetColumnsProps {
+ router: NextRouter
+}
+
+/**
+ * tanstack table 컬럼 정의
+ */
+export function getColumns({
+ router,
+}: GetColumnsProps): ColumnDef<VendorResponseDetail>[] {
+
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorRfqResponseSummary> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) actions 컬럼 (작성하기 버튼만)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<VendorRfqResponseSummary> = {
+ id: "actions",
+ enableHiding: false,
+ cell: ({ row }) => {
+ const vendorId = row.original.vendorId
+ const rfqRecordId = row.original.rfqRecordId
+ const rfqType = row.original.rfqType
+ const rfqCode = row.original.rfq?.rfqCode || "RFQ"
+
+ return (
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => router.push(`/partners/rfq-answer/${vendorId}/${rfqRecordId}`)}
+ className="h-8 px-3"
+ >
+ <Edit className="h-4 w-4 mr-1" />
+ 작성하기
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>{rfqCode} 응답 작성하기</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ )
+ },
+ size: 100,
+ minSize: 100,
+ maxSize: 150,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) 컬럼 정의 배열
+ // ----------------------------------------------------------------
+ const columnDefinitions = [
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ group: "RFQ 정보",
+ size: 120,
+ minSize: 100,
+ maxSize: 150,
+ },
+
+ {
+ id: "rfqDueDate",
+ label: "RFQ 마감일",
+ group: "RFQ 정보",
+ size: 120,
+ minSize: 100,
+ maxSize: 150,
+ },
+
+ {
+ id: "overallStatus",
+ label: "전체 상태",
+ group: null,
+ size: 80,
+ minSize: 60,
+ maxSize: 100,
+ },
+ {
+ id: "totalAttachments",
+ label: "총 첨부파일",
+ group: "응답 통계",
+ size: 100,
+ minSize: 80,
+ maxSize: 120,
+ },
+ {
+ id: "respondedCount",
+ label: "응답완료",
+ group: "응답 통계",
+ size: 100,
+ minSize: 80,
+ maxSize: 120,
+ },
+ {
+ id: "pendingCount",
+ label: "미응답",
+ group: "응답 통계",
+ size: 100,
+ minSize: 80,
+ maxSize: 120,
+ },
+ {
+ id: "responseRate",
+ label: "응답률",
+ group: "진행률",
+ size: 100,
+ minSize: 80,
+ maxSize: 120,
+ },
+ {
+ id: "completionRate",
+ label: "완료율",
+ group: "진행률",
+ size: 100,
+ minSize: 80,
+ maxSize: 120,
+ },
+ {
+ id: "requestedAt",
+ label: "요청일",
+ group: "날짜 정보",
+ size: 120,
+ minSize: 100,
+ maxSize: 150,
+ },
+ {
+ id: "lastRespondedAt",
+ label: "최종 응답일",
+ group: "날짜 정보",
+ size: 120,
+ minSize: 100,
+ maxSize: 150,
+ },
+ ];
+
+ // ----------------------------------------------------------------
+ // 4) 그룹별로 컬럼 정리 (중첩 헤더 생성)
+ // ----------------------------------------------------------------
+ const groupMap: Record<string, ColumnDef<VendorRfqResponseSummary>[]> = {}
+
+ columnDefinitions.forEach((cfg) => {
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // 개별 컬럼 정의
+ const columnDef: ColumnDef<VendorRfqResponseSummary> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ cell: ({ row, cell }) => {
+ // 각 컬럼별 특별한 렌더링 처리
+ switch (cfg.id) {
+ case "rfqCode":
+ return row.original.rfq?.rfqCode || "-"
+
+
+ case "rfqDueDate":
+ const dueDate = row.original.rfq?.dueDate;
+ return dueDate ? formatDate(new Date(dueDate)) : "-";
+
+ case "overallStatus":
+ return <ResponseStatusBadge status={row.original.overallStatus} />
+
+ case "totalAttachments":
+ return (
+ <div className="text-center font-medium">
+ {row.original.totalAttachments}
+ </div>
+ )
+
+ case "respondedCount":
+ return (
+ <div className="text-center text-green-600 font-medium">
+ {row.original.respondedCount}
+ </div>
+ )
+
+ case "pendingCount":
+ return (
+ <div className="text-center text-orange-600 font-medium">
+ {row.original.pendingCount}
+ </div>
+ )
+
+ case "responseRate":
+ const responseRate = row.original.responseRate;
+ return (
+ <div className="text-center">
+ <span className={`font-medium ${responseRate >= 80 ? 'text-green-600' : responseRate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
+ {responseRate}%
+ </span>
+ </div>
+ )
+
+ case "completionRate":
+ const completionRate = row.original.completionRate;
+ return (
+ <div className="text-center">
+ <span className={`font-medium ${completionRate >= 80 ? 'text-green-600' : completionRate >= 50 ? 'text-yellow-600' : 'text-red-600'}`}>
+ {completionRate}%
+ </span>
+ </div>
+ )
+
+ case "requestedAt":
+ return formatDateTime(new Date(row.original.requestedAt))
+
+ case "lastRespondedAt":
+ const lastRespondedAt = row.original.lastRespondedAt;
+ return lastRespondedAt ? formatDateTime(new Date(lastRespondedAt)) : "-";
+
+ default:
+ return row.getValue(cfg.id) ?? ""
+ }
+ },
+ size: cfg.size,
+ minSize: cfg.minSize,
+ maxSize: cfg.maxSize,
+ }
+
+ groupMap[groupName].push(columnDef)
+ })
+
+ // ----------------------------------------------------------------
+ // 5) 그룹별 중첩 컬럼 생성
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<VendorRfqResponseSummary>[] = []
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹이 없는 컬럼들은 직접 추가
+ nestedColumns.push(...colDefs)
+ } else {
+ // 그룹이 있는 컬럼들은 중첩 구조로 추가
+ nestedColumns.push({
+ id: groupName,
+ header: groupName,
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 6) 최종 컬럼 배열
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/vendor-responses-table.tsx b/lib/b-rfq/vendor-response/vendor-responses-table.tsx
new file mode 100644
index 00000000..251b1ad0
--- /dev/null
+++ b/lib/b-rfq/vendor-response/vendor-responses-table.tsx
@@ -0,0 +1,160 @@
+// lib/vendor-responses/table/vendor-responses-table.tsx
+"use client"
+
+import * as React from "react"
+import { type DataTableAdvancedFilterField, type DataTableFilterField, type DataTableRowAction } from "@/types/table"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { Button } from "@/components/ui/button"
+import { useRouter } from "next/navigation"
+import { getColumns } from "./vendor-responses-table-columns"
+import { VendorRfqResponseSummary } from "../validations"
+
+interface VendorResponsesTableProps {
+ promises: Promise<[{ data: VendorRfqResponseSummary[], pageCount: number, totalCount: number }]>;
+}
+
+export function VendorResponsesTable({ promises }: VendorResponsesTableProps) {
+ const [{ data, pageCount, totalCount }] = React.use(promises);
+ const router = useRouter();
+
+ console.log(data, "vendor responses data")
+
+ // 선택된 행 액션 상태
+
+ // 테이블 컬럼 정의
+ const columns = React.useMemo(() => getColumns({
+ router,
+ }), [router]);
+
+ // 상태별 응답 수 계산 (전체 상태 기준)
+ const statusCounts = React.useMemo(() => {
+ return {
+ NOT_RESPONDED: data.filter(r => r.overallStatus === "NOT_RESPONDED").length,
+ RESPONDED: data.filter(r => r.overallStatus === "RESPONDED").length,
+ REVISION_REQUESTED: data.filter(r => r.overallStatus === "REVISION_REQUESTED").length,
+ WAIVED: data.filter(r => r.overallStatus === "WAIVED").length,
+ };
+ }, [data]);
+
+
+ // 필터 필드
+ const filterFields: DataTableFilterField<VendorRfqResponseSummary>[] = [
+ {
+ id: "overallStatus",
+ label: "전체 상태",
+ options: [
+ { label: "미응답", value: "NOT_RESPONDED", count: statusCounts.NOT_RESPONDED },
+ { label: "응답완료", value: "RESPONDED", count: statusCounts.RESPONDED },
+ { label: "수정요청", value: "REVISION_REQUESTED", count: statusCounts.REVISION_REQUESTED },
+ { label: "포기", value: "WAIVED", count: statusCounts.WAIVED },
+ ]
+ },
+
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ placeholder: "RFQ 번호 검색...",
+ }
+ ];
+
+ // 고급 필터 필드
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorRfqResponseSummary>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ 번호",
+ type: "text",
+ },
+ {
+ id: "overallStatus",
+ label: "전체 상태",
+ type: "multi-select",
+ options: [
+ { label: "미응답", value: "NOT_RESPONDED" },
+ { label: "응답완료", value: "RESPONDED" },
+ { label: "수정요청", value: "REVISION_REQUESTED" },
+ { label: "포기", value: "WAIVED" },
+ ],
+ },
+ {
+ id: "rfqType",
+ label: "RFQ 타입",
+ type: "multi-select",
+ options: [
+ { label: "초기 RFQ", value: "INITIAL" },
+ { label: "최종 RFQ", value: "FINAL" },
+ ],
+ },
+ {
+ id: "responseRate",
+ label: "응답률",
+ type: "number",
+ },
+ {
+ id: "completionRate",
+ label: "완료율",
+ type: "number",
+ },
+ {
+ id: "requestedAt",
+ label: "요청일",
+ type: "date",
+ },
+ {
+ id: "lastRespondedAt",
+ label: "최종 응답일",
+ type: "date",
+ },
+ ];
+
+ // useDataTable 훅 사용
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ enableColumnResizing: true,
+ columnResizeMode: 'onChange',
+ initialState: {
+ sorting: [{ id: "updatedAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ defaultColumn: {
+ minSize: 50,
+ maxSize: 500,
+ },
+ });
+
+ return (
+ <div className="w-full">
+ <div className="flex items-center justify-between py-4">
+ <div className="flex items-center space-x-2">
+ <span className="text-sm text-muted-foreground">
+ 총 {totalCount}개의 응답 요청
+ </span>
+ </div>
+ </div>
+
+ <div className="overflow-x-auto">
+ <DataTable
+ table={table}
+ className="min-w-full"
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ {/* 추가적인 액션 버튼들을 여기에 추가할 수 있습니다 */}
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/lib/b-rfq/vendor-response/waive-response-dialog.tsx b/lib/b-rfq/vendor-response/waive-response-dialog.tsx
new file mode 100644
index 00000000..5ded4da3
--- /dev/null
+++ b/lib/b-rfq/vendor-response/waive-response-dialog.tsx
@@ -0,0 +1,210 @@
+// components/rfq/waive-response-dialog.tsx
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Textarea } from "@/components/ui/textarea";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import { FileX, Loader2, AlertTriangle } from "lucide-react";
+import { useToast } from "@/hooks/use-toast";
+import { useRouter } from "next/navigation";
+
+const waiveFormSchema = z.object({
+ responseComment: z.string().min(1, "포기 사유를 입력해주세요"),
+ vendorComment: z.string().optional(),
+});
+
+type WaiveFormData = z.infer<typeof waiveFormSchema>;
+
+interface WaiveResponseDialogProps {
+ responseId: number;
+ attachmentType: string;
+ serialNo: string;
+ trigger?: React.ReactNode;
+ onSuccess?: () => void;
+}
+
+export function WaiveResponseDialog({
+ responseId,
+ attachmentType,
+ serialNo,
+ trigger,
+ onSuccess,
+}: WaiveResponseDialogProps) {
+ const [open, setOpen] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const { toast } = useToast();
+ const router = useRouter();
+
+ const form = useForm<WaiveFormData>({
+ resolver: zodResolver(waiveFormSchema),
+ defaultValues: {
+ responseComment: "",
+ vendorComment: "",
+ },
+ });
+
+ const onSubmit = async (data: WaiveFormData) => {
+ setIsSubmitting(true);
+
+ try {
+ const response = await fetch("/api/vendor-responses/waive", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ responseId,
+ responseComment: data.responseComment,
+ vendorComment: data.vendorComment,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ throw new Error(error.message || "응답 포기 처리 실패");
+ }
+
+ toast({
+ title: "응답 포기 완료",
+ description: "해당 항목에 대한 응답이 포기 처리되었습니다.",
+ });
+
+ setOpen(false);
+ form.reset();
+
+ router.refresh();
+ onSuccess?.();
+
+ } catch (error) {
+ console.error("Waive error:", error);
+ toast({
+ title: "처리 실패",
+ description: error instanceof Error ? error.message : "알 수 없는 오류가 발생했습니다.",
+ variant: "destructive",
+ });
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={setOpen}>
+ <DialogTrigger asChild>
+ {trigger || (
+ <Button size="sm" variant="outline">
+ <FileX className="h-3 w-3 mr-1" />
+ 포기
+ </Button>
+ )}
+ </DialogTrigger>
+ <DialogContent className="max-w-lg">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2 text-orange-600">
+ <FileX className="h-5 w-5" />
+ 응답 포기
+ </DialogTitle>
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <Badge variant="outline">{serialNo}</Badge>
+ <span>{attachmentType}</span>
+ </div>
+ </DialogHeader>
+
+ <div className="bg-orange-50 border border-orange-200 rounded-lg p-4 mb-4">
+ <div className="flex items-center gap-2 text-orange-800 text-sm font-medium mb-2">
+ <AlertTriangle className="h-4 w-4" />
+ 주의사항
+ </div>
+ <p className="text-orange-700 text-sm">
+ 응답을 포기하면 해당 항목에 대한 입찰 참여가 불가능합니다.
+ 포기 사유를 명확히 기입해 주세요.
+ </p>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ {/* 포기 사유 (필수) */}
+ <FormField
+ control={form.control}
+ name="responseComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-red-600">
+ 포기 사유 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="응답을 포기하는 사유를 구체적으로 입력하세요..."
+ className="resize-none"
+ rows={4}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 내부 코멘트 (선택) */}
+ <FormField
+ control={form.control}
+ name="vendorComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>내부 코멘트 (선택)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="내부 참고용 코멘트를 입력하세요..."
+ className="resize-none"
+ rows={2}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 버튼 */}
+ <div className="flex justify-end gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ variant="destructive"
+ disabled={isSubmitting}
+ >
+ {isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
+ {isSubmitting ? "처리 중..." : "포기하기"}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file