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.tsx152
-rw-r--r--lib/b-rfq/vendor-response/waive-response-dialog.tsx210
8 files changed, 0 insertions, 2397 deletions
diff --git a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx b/lib/b-rfq/vendor-response/comment-edit-dialog.tsx
deleted file mode 100644
index 0c2c0c62..00000000
--- a/lib/b-rfq/vendor-response/comment-edit-dialog.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-// 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
deleted file mode 100644
index bc27d103..00000000
--- a/lib/b-rfq/vendor-response/response-detail-columns.tsx
+++ /dev/null
@@ -1,653 +0,0 @@
-"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
deleted file mode 100644
index da7f9b01..00000000
--- a/lib/b-rfq/vendor-response/response-detail-sheet.tsx
+++ /dev/null
@@ -1,358 +0,0 @@
-// 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
deleted file mode 100644
index 124d5241..00000000
--- a/lib/b-rfq/vendor-response/response-detail-table.tsx
+++ /dev/null
@@ -1,161 +0,0 @@
-"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
deleted file mode 100644
index b4b306d6..00000000
--- a/lib/b-rfq/vendor-response/upload-response-dialog.tsx
+++ /dev/null
@@ -1,325 +0,0 @@
-// 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
deleted file mode 100644
index 47b7570b..00000000
--- a/lib/b-rfq/vendor-response/vendor-responses-table-columns.tsx
+++ /dev/null
@@ -1,351 +0,0 @@
-// 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
deleted file mode 100644
index 02a5fa59..00000000
--- a/lib/b-rfq/vendor-response/vendor-responses-table.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-// 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 },
- ]
- },
-
-
- ];
-
- // 고급 필터 필드
- const advancedFilterFields: DataTableAdvancedFilterField<VendorRfqResponseSummary>[] = [
-
- {
- 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
deleted file mode 100644
index 5ded4da3..00000000
--- a/lib/b-rfq/vendor-response/waive-response-dialog.tsx
+++ /dev/null
@@ -1,210 +0,0 @@
-// 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