From 50adedf48ee4674ebe00f1ee72d93485183cdc51 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 5 Sep 2025 11:44:32 +0000 Subject: (대표님, 최겸, 임수민) EDP 입력 진행률, 견적목록관리, EDP excel import 오류수정, GTC-Contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attachment/revision-historty-dialog.tsx | 305 +++++ lib/rfq-last/attachment/rfq-attachments-table.tsx | 370 +++--- lib/rfq-last/attachment/vendor-response-table.tsx | 519 ++++++++ lib/rfq-last/service.ts | 1267 +++++++++++++++++++- lib/rfq-last/validations.ts | 90 +- lib/rfq-last/vendor/add-vendor-dialog.tsx | 307 +++++ .../vendor/batch-update-conditions-dialog.tsx | 1121 +++++++++++++++++ lib/rfq-last/vendor/rfq-vendor-table.tsx | 746 ++++++++++++ lib/rfq-last/vendor/vendor-detail-dialog.tsx | 0 .../vendor/vendor-response-status-card.tsx | 51 + 10 files changed, 4475 insertions(+), 301 deletions(-) create mode 100644 lib/rfq-last/attachment/revision-historty-dialog.tsx create mode 100644 lib/rfq-last/attachment/vendor-response-table.tsx create mode 100644 lib/rfq-last/vendor/add-vendor-dialog.tsx create mode 100644 lib/rfq-last/vendor/batch-update-conditions-dialog.tsx create mode 100644 lib/rfq-last/vendor/rfq-vendor-table.tsx create mode 100644 lib/rfq-last/vendor/vendor-detail-dialog.tsx create mode 100644 lib/rfq-last/vendor/vendor-response-status-card.tsx (limited to 'lib/rfq-last') diff --git a/lib/rfq-last/attachment/revision-historty-dialog.tsx b/lib/rfq-last/attachment/revision-historty-dialog.tsx new file mode 100644 index 00000000..6e4772cb --- /dev/null +++ b/lib/rfq-last/attachment/revision-historty-dialog.tsx @@ -0,0 +1,305 @@ +// @/lib/rfq-last/attachment/revision-history-dialog.tsx + +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + Download, + Eye, + FileText, + Clock, + User, + MessageSquare, + AlertCircle, + CheckCircle, +} from "lucide-react"; +import { format, formatDistanceToNow } from "date-fns"; +import { ko } from "date-fns/locale"; +import { toast } from "sonner"; +import { downloadFile } from "@/lib/file-download"; +import { + getRevisionHistory, + type AttachmentWithHistory, + type RevisionHistory, +} from "../service"; +import { formatFileSize } from "@/lib/utils"; + +interface RevisionHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + attachmentId: number; + attachmentName?: string; +} + +export function RevisionHistoryDialog({ + open, + onOpenChange, + attachmentId, + attachmentName, +}: RevisionHistoryDialogProps) { + const [loading, setLoading] = React.useState(false); + const [historyData, setHistoryData] = React.useState(null); + const [error, setError] = React.useState(null); + + // 다이얼로그가 열릴 때 데이터 로드 + React.useEffect(() => { + if (open && attachmentId) { + loadRevisionHistory(); + } + }, [open, attachmentId]); + + const loadRevisionHistory = async () => { + setLoading(true); + setError(null); + try { + const result = await getRevisionHistory(attachmentId); + if (result.success && result.data) { + setHistoryData(result.data); + } else { + setError(result.error || "리비전 히스토리를 불러올 수 없습니다."); + } + } catch (err) { + console.error("Load revision history error:", err); + setError("리비전 히스토리 조회 중 오류가 발생했습니다."); + } finally { + setLoading(false); + } + }; + + // 리비전 다운로드 + const handleDownloadRevision = async (revision: RevisionHistory) => { + try { + await downloadFile(revision.filePath, revision.originalFileName, { + action: 'download', + showToast: true, + }); + } catch (err) { + console.error("Download revision error:", err); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } + }; + + // 리비전 미리보기 + const handlePreviewRevision = async (revision: RevisionHistory) => { + try { + await downloadFile(revision.filePath, revision.originalFileName, { + action: 'preview', + showToast: true, + }); + } catch (err) { + console.error("Preview revision error:", err); + toast.error("파일 미리보기 중 오류가 발생했습니다."); + } + }; + + // 리비전 번호에 따른 색상 결정 + const getRevisionBadgeVariant = (isLatest: boolean) => { + return isLatest ? "default" : "secondary"; + }; + + return ( + + + + + + 리비전 히스토리 + + + {historyData?.originalFileName || attachmentName || "파일"}의 모든 버전 히스토리를 확인할 수 있습니다. + + + +
+ {loading ? ( +
+ + + +
+ ) : error ? ( + + + {error} + + ) : historyData ? ( + <> + {/* 파일 정보 헤더 */} +
+
+
+ 일련번호:{" "} + {historyData.serialNo || "-"} +
+
+ 현재 리비전:{" "} + + Rev. {historyData.currentRevision || "A"} + +
+ {historyData.description && ( +
+ 설명:{" "} + {historyData.description} +
+ )} +
+
+ + {/* 리비전 테이블 */} + + + + + 리비전 + 파일명 + 크기 + 업로드자 + 업로드일시 + 코멘트 + 작업 + + + + {historyData.revisions.length > 0 ? ( + historyData.revisions.map((revision) => ( + + + + Rev. {revision.revisionNo} + {revision.isLatest && ( + + )} + + + +
+ + {revision.originalFileName} + + {revision.fileName !== revision.originalFileName && ( + + ({revision.fileName}) + + )} +
+
+ + + {formatFileSize(revision.fileSize)} + + + +
+ + + {revision.createdByName || "Unknown"} + +
+
+ +
+ + + {format(new Date(revision.createdAt), "yyyy-MM-dd HH:mm")} + +
+ + {formatDistanceToNow(new Date(revision.createdAt), { + addSuffix: true, + locale: ko, + })} + +
+ + {revision.revisionComment ? ( +
+ + + {revision.revisionComment} + +
+ ) : ( + - + )} +
+ +
+ + +
+
+
+ )) + ) : ( + + + 리비전 히스토리가 없습니다. + + + )} +
+
+
+ + {/* 요약 정보 */} +
+ 총 {historyData.revisions.length}개의 리비전 + + 최초 업로드:{" "} + {historyData.revisions.length > 0 + ? format( + new Date( + historyData.revisions[historyData.revisions.length - 1].createdAt + ), + "yyyy년 MM월 dd일" + ) + : "-"} + +
+ + ) : null} +
+
+
+ ); +} \ No newline at end of file diff --git a/lib/rfq-last/attachment/rfq-attachments-table.tsx b/lib/rfq-last/attachment/rfq-attachments-table.tsx index a66e12a2..155fd412 100644 --- a/lib/rfq-last/attachment/rfq-attachments-table.tsx +++ b/lib/rfq-last/attachment/rfq-attachments-table.tsx @@ -1,7 +1,6 @@ "use client"; import * as React from "react"; -import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Card, CardContent } from "@/components/ui/card"; @@ -24,10 +23,8 @@ import { format, formatDistanceToNow } from "date-fns"; import { ko } from "date-fns/locale"; import { type ColumnDef } from "@tanstack/react-table"; import { Checkbox } from "@/components/ui/checkbox"; -import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; -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 { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; +import { ClientDataTable } from "@/components/client-data-table/data-table"; import { DropdownMenu, DropdownMenuContent, @@ -43,16 +40,16 @@ import { } from "@/components/ui/tooltip"; import type { DataTableAdvancedFilterField, - DataTableFilterField, DataTableRowAction, } from "@/types/table"; import { cn } from "@/lib/utils"; -import { getRfqLastAttachments } from "@/lib/rfq-last/service"; +import { getRfqAllAttachments } from "@/lib/rfq-last/service"; import { downloadFile } from "@/lib/file-download"; import { DeleteAttachmentsDialog } from "./delete-attachments-dialog"; import { AddAttachmentDialog } from "./add-attachment-dialog"; import { UpdateRevisionDialog } from "./update-revision-dialog"; -import { useQueryState ,parseAsStringEnum} from "nuqs"; +import { toast } from "sonner"; +import { RevisionHistoryDialog } from "./revision-historty-dialog"; // 타입 정의 interface RfqAttachment { @@ -77,9 +74,7 @@ interface RfqAttachment { interface RfqAttachmentsTableProps { rfqId: number; - initialDesignData: Awaited>; - initialPurchaseData: Awaited>; - className?: string; + initialData: RfqAttachment[]; } // 파일 타입별 아이콘 반환 @@ -112,31 +107,41 @@ const formatFileSize = (bytes: number | null) => { export function RfqAttachmentsTable({ rfqId, - initialDesignData, - initialPurchaseData, - className + initialData, }: RfqAttachmentsTableProps) { - const router = useRouter(); - const [activeTab, setActiveTab] = useQueryState( - 'tab', - parseAsStringEnum(['설계', '구매']) - .withDefault('설계') - .withOptions({ shallow: false }) - ); - - const [designData] = React.useState(initialDesignData); - const [purchaseData] = React.useState(initialPurchaseData); + const [activeTab, setActiveTab] = React.useState<'설계' | '구매'>('설계'); + const [data, setData] = React.useState(initialData); const [selectedAttachment, setSelectedAttachment] = React.useState(null); const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false); const [updateRevisionDialogOpen, setUpdateRevisionDialogOpen] = React.useState(false); + const [revisionHistoryDialogOpen, setRevisionHistoryDialogOpen] = React.useState(false); + const [addDialogOpen, setAddDialogOpen] = React.useState(false); const [isRefreshing, setIsRefreshing] = React.useState(false); + const [selectedRows, setSelectedRows] = React.useState([]); + + // 탭에 따른 데이터 필터링 + const filteredData = React.useMemo(() => { + return data.filter(item => item.attachmentType === activeTab); + }, [data, activeTab]); - // 새로고침 (router.refresh 사용) - const handleRefresh = React.useCallback(() => { + // 데이터 새로고침 + const handleRefresh = React.useCallback(async () => { setIsRefreshing(true); - router.refresh(); - setTimeout(() => setIsRefreshing(false), 1000); - }, [router]); + try { + const result = await getRfqAllAttachments(rfqId); + if (result.success && result.data) { + setData(result.data); + toast.success("데이터를 새로고침했습니다."); + } else { + toast.error("데이터를 불러오는데 실패했습니다."); + } + } catch (error) { + console.error("Refresh error:", error); + toast.error("새로고침 중 오류가 발생했습니다."); + } finally { + setIsRefreshing(false); + } + }, [rfqId]); // 액션 처리 const handleAction = React.useCallback(async (action: DataTableRowAction) => { @@ -162,8 +167,8 @@ export function RfqAttachmentsTable({ break; case "history": - // 리비전 이력 보기 - 별도 구현 필요 - console.log("History:", attachment); + setSelectedAttachment(attachment); + setRevisionHistoryDialogOpen(true); break; case "update": @@ -178,10 +183,35 @@ export function RfqAttachmentsTable({ } }, []); + // 선택된 항목 일괄 삭제 + const handleBulkDelete = React.useCallback(() => { + if (selectedRows.length === 0) { + toast.warning("삭제할 항목을 선택해주세요."); + return; + } + setDeleteDialogOpen(true); + }, [selectedRows]); + + // 선택된 항목 일괄 다운로드 + const handleBulkDownload = React.useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("다운로드할 항목을 선택해주세요."); + return; + } + + for (const attachment of selectedRows) { + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'download', + showToast: false + }); + } + } + toast.success(`${selectedRows.length}개 파일을 다운로드했습니다.`); + }, [selectedRows]); + // 컬럼 정의 - const getAttachmentColumns = React.useCallback(( - onAction: (action: DataTableRowAction) => void - ): ColumnDef[] => [ + const columns: ColumnDef[] = React.useMemo(() => [ { id: "select", header: ({ table }) => ( @@ -203,18 +233,21 @@ export function RfqAttachmentsTable({ size: 40, enableSorting: false, enableHiding: false, + enablePinning: true, }, { accessorKey: "serialNo", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => ( {row.original.serialNo || "-"} ), size: 100, + meta: { excelHeader: "일련번호" }, + enablePinning: true, }, { accessorKey: "originalFileName", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const file = row.original; return ( @@ -224,11 +257,6 @@ export function RfqAttachmentsTable({ {file.originalFileName || file.fileName || "-"} - {file.fileName && file.fileName !== file.originalFileName && ( - - ({file.fileName}) - - )} ); @@ -237,7 +265,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "description", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => (
{row.original.description || "-"} @@ -247,7 +275,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "currentRevision", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const revision = row.original.currentRevision; return revision ? ( @@ -262,7 +290,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "fileSize", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => ( {formatFileSize(row.original.fileSize)} @@ -270,30 +298,15 @@ export function RfqAttachmentsTable({ ), size: 80, }, - { - accessorKey: "fileType", - header: ({ column }) => , - cell: ({ row }) => { - const type = row.original.fileType; - return type ? ( - - {type.toUpperCase()} - - ) : ( - - - ); - }, - size: 80, - }, { accessorKey: "createdByName", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => row.original.createdByName || "-", size: 100, }, { accessorKey: "createdAt", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const date = row.original.createdAt; return date ? ( @@ -320,7 +333,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "updatedAt", - header: ({ column }) => , + header: ({ column }) => , cell: ({ row }) => { const date = row.original.updatedAt; return date ? format(new Date(date), "MM-dd HH:mm") : "-"; @@ -342,26 +355,26 @@ export function RfqAttachmentsTable({ - onAction({ row, type: "download" })}> + handleAction({ row, type: "download" })}> 다운로드 - onAction({ row, type: "preview" })}> + handleAction({ row, type: "preview" })}> 미리보기 - onAction({ row, type: "history" })}> + handleAction({ row, type: "history" })}> 리비전 이력 - onAction({ row, type: "update" })}> + handleAction({ row, type: "update" })}> 새 버전 업로드 onAction({ row, type: "delete" })} + onClick={() => handleAction({ row, type: "delete" })} className="text-red-600" > @@ -372,17 +385,9 @@ export function RfqAttachmentsTable({ ); }, size: 60, + enablePinning: true, }, - ], []); - - const columns = React.useMemo(() => getAttachmentColumns(handleAction), [getAttachmentColumns, handleAction]); - - const filterFields: DataTableFilterField[] = [ - { id: "serialNo", label: "일련번호" }, - { id: "originalFileName", label: "파일명" }, - { id: "description", label: "설명" }, - { id: "createdByName", label: "업로드자" }, - ]; + ], [handleAction]); const advancedFilterFields: DataTableAdvancedFilterField[] = [ { id: "serialNo", label: "일련번호", type: "text" }, @@ -406,121 +411,136 @@ export function RfqAttachmentsTable({ { id: "updatedAt", label: "수정일", type: "date" }, ]; - const { table: designTable } = useDataTable({ - data: designData.data, - columns, - pageCount: designData.pageCount, - rowCount: designData.data.length, - filterFields, - enableAdvancedFilter: true, - // 설계 탭용 파라미터 prefix - paramPrefix: 'design_', - initialState: { - sorting: [{ id: "createdAt", desc: true }], - }, - getRowId: (row) => String(row.id), - shallow: false, - clearOnDefault: true, - }); - - const { table: purchaseTable } = useDataTable({ - data: purchaseData.data, - columns, - pageCount: purchaseData.pageCount, - rowCount: purchaseData.data.length, - filterFields, - enableAdvancedFilter: true, - // 구매 탭용 파라미터 prefix - paramPrefix: 'purchase_', - initialState: { - sorting: [{ id: "createdAt", desc: true }], - }, - getRowId: (row) => String(row.id), - shallow: false, - clearOnDefault: true, - }); - + // 탭별 데이터 카운트 + const designCount = React.useMemo(() => + data.filter(item => item.attachmentType === "설계").length, [data] + ); + const purchaseCount = React.useMemo(() => + data.filter(item => item.attachmentType === "구매").length, [data] + ); - React.useEffect(() => { - router.refresh(); - }, [activeTab]); + // 추가 액션 버튼들 + const additionalActions = React.useMemo(() => ( +
+ {selectedRows.length > 0 && ( + <> + + + + )} + + + {/* 구매 탭에서만 파일 업로드 버튼 표시 */} + {activeTab === "구매" && ( + + )} +
+ ), [selectedRows, activeTab, isRefreshing, addDialogOpen, handleBulkDownload, handleBulkDelete, handleRefresh, rfqId]); return ( -
- +
+ setActiveTab(value as '설계' | '구매')} + >
설계 첨부파일 - {designData.data.length} + {designCount} 구매 첨부파일 - {purchaseData.data.length} + {purchaseCount} - -
- - - {/* 구매 탭에서만 파일 업로드 버튼 표시 */} - {activeTab === "구매" && ( - - )} -
- - - - - - - + + + {additionalActions} + + - - - - - - - + + + {additionalActions} + +
{/* 삭제 다이얼로그 */} - {selectedAttachment && ( + {(selectedAttachment || selectedRows.length > 0) && ( { + setDeleteDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} + attachments={selectedAttachment ? [selectedAttachment] : selectedRows} onSuccess={handleRefresh} /> )} @@ -529,11 +549,31 @@ export function RfqAttachmentsTable({ {selectedAttachment && ( { + setUpdateRevisionDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} attachment={selectedAttachment} onSuccess={handleRefresh} /> )} + + {/* 리비전 히스토리 다이얼로그 */} + {selectedAttachment && ( + { + setRevisionHistoryDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} + attachmentId={selectedAttachment.id} + attachmentName={selectedAttachment.originalFileName || selectedAttachment.fileName || undefined} + /> + )}
); } \ No newline at end of file diff --git a/lib/rfq-last/attachment/vendor-response-table.tsx b/lib/rfq-last/attachment/vendor-response-table.tsx new file mode 100644 index 00000000..6e1a02c8 --- /dev/null +++ b/lib/rfq-last/attachment/vendor-response-table.tsx @@ -0,0 +1,519 @@ +// @/lib/rfq-last/vendor/vendor-response-table.tsx + +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Download, + FileText, + RefreshCw, + Eye, + Trash2, + File, + FileImage, + FileSpreadsheet, + FileCode, + Building2, + Calendar, + AlertCircle +} from "lucide-react"; +import { format, formatDistanceToNow, isValid, isBefore, isAfter } from "date-fns"; +import { ko } from "date-fns/locale"; +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header"; +import { ClientDataTable } from "@/components/client-data-table/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import type { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table"; +import { cn } from "@/lib/utils"; +import { getRfqVendorAttachments } from "@/lib/rfq-last/service"; +import { downloadFile } from "@/lib/file-download"; +import { toast } from "sonner"; + +// 타입 정의 +interface VendorAttachment { + id: number; + vendorResponseId: number; + attachmentType: string; + documentNo: string | null; + fileName: string; + originalFileName: string; + filePath: string; + fileSize: number | null; + fileType: string | null; + description: string | null; + validFrom: Date | null; + validTo: Date | null; + uploadedBy: number; + uploadedAt: Date; + uploadedByName: string | null; + vendorId: number | null; + vendorName: string | null; + vendorCode: string | null; + responseStatus: "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소" | null; + responseVersion: number | null; +} + +interface VendorResponseTableProps { + rfqId: number; + initialData: VendorAttachment[]; +} + +// 파일 타입별 아이콘 반환 +const getFileIcon = (fileType: string | null) => { + if (!fileType) return ; + + const type = fileType.toLowerCase(); + if (type.includes('image') || ['jpg', 'jpeg', 'png', 'gif'].includes(type)) { + return ; + } + if (type.includes('excel') || type.includes('spreadsheet') || ['xls', 'xlsx'].includes(type)) { + return ; + } + if (type.includes('pdf')) { + return ; + } + if (type.includes('code') || ['js', 'ts', 'tsx', 'jsx', 'html', 'css'].includes(type)) { + return ; + } + return ; +}; + +// 파일 크기 포맷팅 +const formatFileSize = (bytes: number | null) => { + if (!bytes) return "-"; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`; +}; + +// 응답 상태별 색상 +const getStatusVariant = (status: string | null) => { + switch (status) { + case "작성중": return "outline"; + case "제출완료": return "default"; + case "수정요청": return "secondary"; + case "최종확정": return "success"; + case "취소": return "destructive"; + default: return "outline"; + } +}; + +// 유효기간 체크 +const checkValidity = (validTo: Date | null) => { + if (!validTo) return null; + const today = new Date(); + const expiry = new Date(validTo); + + if (isBefore(expiry, today)) { + return "expired"; + } else if (isBefore(expiry, new Date(today.getTime() + 30 * 24 * 60 * 60 * 1000))) { + return "expiring-soon"; // 30일 이내 만료 + } + return "valid"; +}; + +export function VendorResponseTable({ + rfqId, + initialData, +}: VendorResponseTableProps) { + const [data, setData] = React.useState(initialData); + const [isRefreshing, setIsRefreshing] = React.useState(false); + const [selectedRows, setSelectedRows] = React.useState([]); + + // 데이터 새로고침 + const handleRefresh = React.useCallback(async () => { + setIsRefreshing(true); + try { + const result = await getRfqVendorAttachments(rfqId); + if (result.success && result.data) { + setData(result.data); + toast.success("데이터를 새로고침했습니다."); + } else { + toast.error("데이터를 불러오는데 실패했습니다."); + } + } catch (error) { + console.error("Refresh error:", error); + toast.error("새로고침 중 오류가 발생했습니다."); + } finally { + setIsRefreshing(false); + } + }, [rfqId]); + + // 액션 처리 + const handleAction = React.useCallback(async (action: DataTableRowAction) => { + const attachment = action.row.original; + + switch (action.type) { + case "download": + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'download', + showToast: true + }); + } + break; + + case "preview": + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'preview', + showToast: true + }); + } + break; + } + }, []); + + // 선택된 항목 일괄 다운로드 + const handleBulkDownload = React.useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("다운로드할 항목을 선택해주세요."); + return; + } + + for (const attachment of selectedRows) { + if (attachment.filePath && attachment.originalFileName) { + await downloadFile(attachment.filePath, attachment.originalFileName, { + action: 'download', + showToast: false + }); + } + } + toast.success(`${selectedRows.length}개 파일을 다운로드했습니다.`); + }, [selectedRows]); + + // 컬럼 정의 + const columns: ColumnDef[] = React.useMemo(() => [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + enablePinning: true, + }, + { + accessorKey: "vendorName", + header: ({ column }) => , + cell: ({ row }) => { + const vendor = row.original; + return ( +
+ +
+ {vendor.vendorName || "-"} + {vendor.vendorCode} +
+
+ ); + }, + size: 150, + enablePinning: true, + }, + { + accessorKey: "attachmentType", + header: ({ column }) => , + cell: ({ row }) => { + const type = row.original.attachmentType; + return ( + + {type} + + ); + }, + size: 100, + }, + { + accessorKey: "documentNo", + header: ({ column }) => , + cell: ({ row }) => ( + {row.original.documentNo || "-"} + ), + size: 120, + }, + { + accessorKey: "originalFileName", + header: ({ column }) => , + cell: ({ row }) => { + const file = row.original; + return ( +
+ {getFileIcon(file.fileType)} +
+ + {file.originalFileName || file.fileName || "-"} + +
+
+ ); + }, + size: 300, + }, + { + accessorKey: "description", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.description || "-"} +
+ ), + size: 200, + }, + { + accessorKey: "validTo", + header: ({ column }) => , + cell: ({ row }) => { + const { validFrom, validTo } = row.original; + const validity = checkValidity(validTo); + + if (!validTo) return -; + + return ( + + + +
+ {validity === "expired" && ( + + )} + {validity === "expiring-soon" && ( + + )} + + {format(new Date(validTo), "yyyy-MM-dd")} + +
+
+ +

유효기간: {validFrom ? format(new Date(validFrom), "yyyy-MM-dd") : "?"} ~ {format(new Date(validTo), "yyyy-MM-dd")}

+ {validity === "expired" &&

만료됨

} + {validity === "expiring-soon" &&

곧 만료 예정

} +
+
+
+ ); + }, + size: 120, + }, + { + accessorKey: "responseStatus", + header: ({ column }) => , + cell: ({ row }) => { + const status = row.original.responseStatus; + return status ? ( + + {status} + + ) : ( + - + ); + }, + size: 100, + }, + { + accessorKey: "fileSize", + header: ({ column }) => , + cell: ({ row }) => ( + + {formatFileSize(row.original.fileSize)} + + ), + size: 80, + }, + { + accessorKey: "uploadedAt", + header: ({ column }) => , + cell: ({ row }) => { + const date = row.original.uploadedAt; + return date ? ( + + + + + {format(new Date(date), "MM-dd HH:mm")} + + + +

{format(new Date(date), "yyyy년 MM월 dd일 HH시 mm분")}

+

+ ({formatDistanceToNow(new Date(date), { addSuffix: true, locale: ko })}) +

+
+
+
+ ) : ( + "-" + ); + }, + size: 100, + }, + { + id: "actions", + header: "작업", + cell: ({ row }) => { + return ( + + + + + + handleAction({ row, type: "download" })}> + + 다운로드 + + handleAction({ row, type: "preview" })}> + + 미리보기 + + + + ); + }, + size: 60, + enablePinning: true, + }, + ], [handleAction]); + + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { id: "vendorName", label: "벤더명", type: "text" }, + { id: "vendorCode", label: "벤더코드", type: "text" }, + { + id: "attachmentType", + label: "문서 유형", + type: "select", + options: [ + { label: "견적서", value: "견적서" }, + { label: "기술제안서", value: "기술제안서" }, + { label: "인증서", value: "인증서" }, + { label: "카탈로그", value: "카탈로그" }, + { label: "도면", value: "도면" }, + { label: "테스트성적서", value: "테스트성적서" }, + { label: "기타", value: "기타" }, + ] + }, + { id: "documentNo", label: "문서번호", type: "text" }, + { id: "originalFileName", label: "파일명", type: "text" }, + { id: "description", label: "설명", type: "text" }, + { + id: "responseStatus", + label: "응답 상태", + type: "select", + options: [ + { label: "작성중", value: "작성중" }, + { label: "제출완료", value: "제출완료" }, + { label: "수정요청", value: "수정요청" }, + { label: "최종확정", value: "최종확정" }, + { label: "취소", value: "취소" }, + ] + }, + { id: "validFrom", label: "유효시작일", type: "date" }, + { id: "validTo", label: "유효종료일", type: "date" }, + { id: "uploadedAt", label: "업로드일", type: "date" }, + ]; + + // 추가 액션 버튼들 + const additionalActions = React.useMemo(() => ( +
+ {selectedRows.length > 0 && ( + + )} + +
+ ), [selectedRows, isRefreshing, handleBulkDownload, handleRefresh]); + + // 벤더별 그룹 카운트 + const vendorCounts = React.useMemo(() => { + const counts = new Map(); + data.forEach(item => { + const vendor = item.vendorName || "Unknown"; + counts.set(vendor, (counts.get(vendor) || 0) + 1); + }); + return counts; + }, [data]); + + return ( +
+ {/* 벤더별 요약 정보 */} +
+ {Array.from(vendorCounts.entries()).map(([vendor, count]) => ( + + {vendor}: {count} + + ))} +
+ + + {additionalActions} + +
+ ); +} \ No newline at end of file diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index ffeed1b1..67cb901f 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -1,12 +1,14 @@ // lib/rfq/service.ts 'use server' -import { unstable_cache, unstable_noStore } from "next/cache"; +import { revalidatePath, unstable_cache, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView } from "@/db/schema"; -import {sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; +import {paymentTerms,incoterms, rfqLastVendorQuotationItems,rfqLastVendorAttachments,rfqLastVendorResponses, RfqsLastView, rfqLastAttachmentRevisions, rfqLastAttachments, rfqsLast, rfqsLastView, users, rfqPrItems, prItemsLastView ,vendors, rfqLastDetails, rfqLastVendorResponseHistory, rfqLastDetailsView} from "@/db/schema"; +import { sql, and, desc, asc, like, ilike, or, eq, SQL, count, gte, lte, isNotNull, ne, inArray } from "drizzle-orm"; import { filterColumns } from "@/lib/filter-columns"; import { GetRfqLastAttachmentsSchema, GetRfqsSchema } from "./validations"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" export async function getRfqs(input: GetRfqsSchema) { unstable_noStore(); @@ -172,68 +174,57 @@ export const findRfqLastById = async (id: number): Promise }; -export async function getRfqLastAttachments( - input: GetRfqLastAttachmentsSchema, - rfqId: number, - attachmentType: "설계" | "구매" -) { +// 모든 첨부파일을 가져오는 새로운 서버 액션 +export async function getRfqAllAttachments(rfqId: number) { try { - const offset = (input.page - 1) * input.perPage - - // Advanced Filter 처리 (메인 테이블 기준) - const advancedWhere = filterColumns({ - table: rfqLastAttachments, - filters: input.filters, - joinOperator: input.joinOperator, - }) - - // 전역 검색 - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or( - ilike(rfqLastAttachments.serialNo, s), - ilike(rfqLastAttachments.description, s), - ilike(rfqLastAttachments.currentRevision, s), - ilike(rfqLastAttachmentRevisions.fileName, s), - ilike(rfqLastAttachmentRevisions.originalFileName, s) + // 데이터 조회 + const data = await db + .select({ + // 첨부파일 메인 정보 + id: rfqLastAttachments.id, + attachmentType: rfqLastAttachments.attachmentType, + serialNo: rfqLastAttachments.serialNo, + rfqId: rfqLastAttachments.rfqId, + currentRevision: rfqLastAttachments.currentRevision, + latestRevisionId: rfqLastAttachments.latestRevisionId, + description: rfqLastAttachments.description, + createdBy: rfqLastAttachments.createdBy, + createdAt: rfqLastAttachments.createdAt, + updatedAt: rfqLastAttachments.updatedAt, + + // 최신 리비전 파일 정보 + fileName: rfqLastAttachmentRevisions.fileName, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + filePath: rfqLastAttachmentRevisions.filePath, + fileSize: rfqLastAttachmentRevisions.fileSize, + fileType: rfqLastAttachmentRevisions.fileType, + revisionComment: rfqLastAttachmentRevisions.revisionComment, + + // 생성자 정보 + createdByName: users.name, + }) + .from(rfqLastAttachments) + .leftJoin( + rfqLastAttachmentRevisions, + and( + eq(rfqLastAttachments.latestRevisionId, rfqLastAttachmentRevisions.id), + eq(rfqLastAttachmentRevisions.isLatest, true) + ) ) - } + .leftJoin(users, eq(rfqLastAttachments.createdBy, users.id)) + .where(eq(rfqLastAttachments.rfqId, rfqId)) + .orderBy(desc(rfqLastAttachments.createdAt)) - // 파일 타입 필터 - let fileTypeWhere - if (input.fileType && input.fileType.length > 0) { - fileTypeWhere = inArray(rfqLastAttachmentRevisions.fileType, input.fileType) + return { + data, + success: true } - - // 최종 WHERE 절 - const finalWhere = and( - eq(rfqLastAttachments.rfqId, rfqId), - eq(rfqLastAttachments.attachmentType, attachmentType), - advancedWhere, - globalWhere, - fileTypeWhere - ) - - // 정렬 - const orderBy = input.sort.length > 0 - ? input.sort.map((item) => - item.desc - ? desc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments]) - : asc(rfqLastAttachments[item.id as keyof typeof rfqLastAttachments]) - ) - : [desc(rfqLastAttachments.createdAt)] - - // 데이터 조회 (기존 코드와 동일) - const { data, total } = await db.transaction(async (tx) => { - // ... 기존 조회 로직 - }) - - const pageCount = Math.ceil(total / input.perPage) - return { data, pageCount } } catch (err) { - console.error("getRfqAttachments error:", err) - return { data: [], pageCount: 0 } + console.error("getRfqAllAttachments error:", err) + return { + data: [], + success: false + } } } // 사용자 목록 조회 (필터용) @@ -689,3 +680,1159 @@ export async function getRfqBasicInfoAction(rfqId: number) { } } +export interface RevisionHistory { + id: number; + attachmentId: number; + revisionNo: string; + fileName: string; + originalFileName: string; + filePath: string; + fileSize: number; + fileType: string; + isLatest: boolean; + revisionComment: string | null; + createdBy: number; + createdAt: Date; + createdByName: string | null; +} + +export interface AttachmentWithHistory { + id: number; + serialNo: string | null; + description: string | null; + currentRevision: string | null; + originalFileName: string | null; + revisions: RevisionHistory[]; +} + +// 리비전 히스토리 조회 +export async function getRevisionHistory(attachmentId: number): Promise<{ + success: boolean; + data?: AttachmentWithHistory; + error?: string; +}> { + try { + // 첨부파일 기본 정보 조회 + const [attachment] = await db + .select({ + id: rfqLastAttachments.id, + serialNo: rfqLastAttachments.serialNo, + description: rfqLastAttachments.description, + currentRevision: rfqLastAttachments.currentRevision, + latestRevisionId: rfqLastAttachments.latestRevisionId, + }) + .from(rfqLastAttachments) + .where(eq(rfqLastAttachments.id, attachmentId)); + + if (!attachment) { + return { + success: false, + error: "첨부파일을 찾을 수 없습니다.", + }; + } + + // 최신 리비전 정보 조회 (파일명 가져오기 위해) + let originalFileName: string | null = null; + if (attachment.latestRevisionId) { + const [latestRevision] = await db + .select({ + originalFileName: rfqLastAttachmentRevisions.originalFileName, + }) + .from(rfqLastAttachmentRevisions) + .where(eq(rfqLastAttachmentRevisions.id, attachment.latestRevisionId)); + + originalFileName = latestRevision?.originalFileName || null; + } + + // 모든 리비전 히스토리 조회 + const revisions = await db + .select({ + id: rfqLastAttachmentRevisions.id, + attachmentId: rfqLastAttachmentRevisions.attachmentId, + revisionNo: rfqLastAttachmentRevisions.revisionNo, + fileName: rfqLastAttachmentRevisions.fileName, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + filePath: rfqLastAttachmentRevisions.filePath, + fileSize: rfqLastAttachmentRevisions.fileSize, + fileType: rfqLastAttachmentRevisions.fileType, + isLatest: rfqLastAttachmentRevisions.isLatest, + revisionComment: rfqLastAttachmentRevisions.revisionComment, + createdBy: rfqLastAttachmentRevisions.createdBy, + createdAt: rfqLastAttachmentRevisions.createdAt, + createdByName: users.name, + }) + .from(rfqLastAttachmentRevisions) + .leftJoin(users, eq(rfqLastAttachmentRevisions.createdBy, users.id)) + .where(eq(rfqLastAttachmentRevisions.attachmentId, attachmentId)) + .orderBy(desc(rfqLastAttachmentRevisions.createdAt)); + + return { + success: true, + data: { + ...attachment, + originalFileName, + revisions, + }, + }; + } catch (error) { + console.error("Get revision history error:", error); + return { + success: false, + error: "리비전 히스토리 조회 중 오류가 발생했습니다.", + }; + } +} + +// 특정 리비전 다운로드 URL 생성 +export async function getRevisionDownloadUrl(revisionId: number): Promise<{ + success: boolean; + data?: { + url: string; + fileName: string; + }; + error?: string; +}> { + try { + const [revision] = await db + .select({ + filePath: rfqLastAttachmentRevisions.filePath, + originalFileName: rfqLastAttachmentRevisions.originalFileName, + }) + .from(rfqLastAttachmentRevisions) + .where(eq(rfqLastAttachmentRevisions.id, revisionId)); + + if (!revision) { + return { + success: false, + error: "리비전을 찾을 수 없습니다.", + }; + } + + return { + success: true, + data: { + url: revision.filePath, + fileName: revision.originalFileName, + }, + }; + } catch (error) { + console.error("Get revision download URL error:", error); + return { + success: false, + error: "다운로드 URL 생성 중 오류가 발생했습니다.", + }; + } +} + +export async function getRfqVendorAttachments(rfqId: number) { + try { + // 데이터 조회 + const data = await db + .select({ + // 첨부파일 메인 정보 + id: rfqLastVendorAttachments.id, + vendorResponseId: rfqLastVendorAttachments.vendorResponseId, + attachmentType: rfqLastVendorAttachments.attachmentType, + documentNo: rfqLastVendorAttachments.documentNo, + + // 파일 정보 + fileName: rfqLastVendorAttachments.fileName, + originalFileName: rfqLastVendorAttachments.originalFileName, + filePath: rfqLastVendorAttachments.filePath, + fileSize: rfqLastVendorAttachments.fileSize, + fileType: rfqLastVendorAttachments.fileType, + + // 파일 설명 + description: rfqLastVendorAttachments.description, + + // 유효기간 + validFrom: rfqLastVendorAttachments.validFrom, + validTo: rfqLastVendorAttachments.validTo, + + // 업로드 정보 + uploadedBy: rfqLastVendorAttachments.uploadedBy, + uploadedAt: rfqLastVendorAttachments.uploadedAt, + + // 업로더 정보 + uploadedByName: users.name, + + // 벤더 정보 + vendorId: rfqLastVendorResponses.vendorId, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + + // 응답 상태 + responseStatus: rfqLastVendorResponses.status, + responseVersion: rfqLastVendorResponses.responseVersion, + }) + .from(rfqLastVendorAttachments) + .leftJoin( + rfqLastVendorResponses, + eq(rfqLastVendorAttachments.vendorResponseId, rfqLastVendorResponses.id) + ) + .leftJoin(users, eq(rfqLastVendorAttachments.uploadedBy, users.id)) + .leftJoin(vendors, eq(rfqLastVendorResponses.vendorId, vendors.id)) + .where(eq(rfqLastVendorResponses.rfqsLastId, rfqId)) + .orderBy(desc(rfqLastVendorAttachments.uploadedAt)) + + return { + vendorData, + vendorSuccess: true + } + } catch (err) { + console.error("getRfqVendorAttachments error:", err) + return { + vendorData: [], + vendorSuccess: false + } + } +} + + + +// 벤더 추가 액션 +export async function addVendorToRfq({ + rfqId, + vendorId, + conditions, +}: { + rfqId: number; + vendorId: number; + conditions: { + currency: string; + paymentTermsCode: string; + incotermsCode: string; + incotermsDetail?: string; + deliveryDate: Date; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + materialPriceRelatedYn?: boolean; + sparepartYn?: boolean; + firstYn?: boolean; + firstDescription?: string; + sparepartDescription?: string; + }; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + // 중복 체크 + const existing = await db + .select() + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId) + ) + ) + .limit(1); + + if (existing.length > 0) { + return { success: false, error: "이미 추가된 벤더입니다." }; + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 1. rfqLastDetails에 벤더 추가 + const [detail] = await tx + .insert(rfqLastDetails) + .values({ + rfqsLastId: rfqId, + vendorsId: vendorId, + ...conditions, + updatedBy: userId, + }) + .returning(); + + // 2. rfqLastVendorResponses에 초기 응답 레코드 생성 + const [response] = await tx + .insert(rfqLastVendorResponses) + .values({ + rfqsLastId: rfqId, + rfqLastDetailsId: detail.id, + vendorId: vendorId, + status: "초대됨", + responseVersion: 1, + isLatest: true, + currency: conditions.currency, + // 구매자 제시 조건 복사 (초기값) + vendorCurrency: conditions.currency, + vendorPaymentTermsCode: conditions.paymentTermsCode, + vendorIncotermsCode: conditions.incotermsCode, + vendorIncotermsDetail: conditions.incotermsDetail, + vendorDeliveryDate: conditions.deliveryDate, + vendorContractDuration: conditions.contractDuration, + vendorTaxCode: conditions.taxCode, + vendorPlaceOfShipping: conditions.placeOfShipping, + vendorPlaceOfDestination: conditions.placeOfDestination, + vendorMaterialPriceRelatedYn: conditions.materialPriceRelatedYn, + vendorSparepartYn: conditions.sparepartYn, + vendorFirstYn: conditions.firstYn, + vendorFirstDescription: conditions.firstDescription, + vendorSparepartDescription: conditions.sparepartDescription, + createdBy: user.id, + updatedBy: user.id, + }) + .returning(); + + // 3. 이력 기록 + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "생성", + newStatus: "초대됨", + changeDetails: { action: "벤더 초대", conditions }, + performedBy: userId, + }); + }); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { success: true }; + } catch (error) { + console.error("Add vendor error:", error); + return { success: false, error: "벤더 추가 중 오류가 발생했습니다." }; + } +} + +export async function addVendorsToRfq({ + rfqId, + vendorIds, + conditions, +}: { + rfqId: number; + vendorIds: number[]; + conditions?: { + currency: string; + paymentTermsCode: string; + incotermsCode: string; + incotermsDetail?: string; + deliveryDate: Date; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + materialPriceRelatedYn?: boolean; + sparepartYn?: boolean; + firstYn?: boolean; + firstDescription?: string; + sparepartDescription?: string; + } | null; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + + // 빈 배열 체크 + if (!vendorIds || vendorIds.length === 0) { + return { success: false, error: "벤더를 선택해주세요." }; + } + + // 중복 체크 - 이미 추가된 벤더들 확인 + const existingVendors = await db + .select({ + vendorId: rfqLastDetails.vendorsId, + }) + .from(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + inArray(rfqLastDetails.vendorsId, vendorIds) + ) + ); + + const existingVendorIds = existingVendors.map(v => v.vendorId); + const newVendorIds = vendorIds.filter(id => !existingVendorIds.includes(id)); + + if (newVendorIds.length === 0) { + return { + success: false, + error: "모든 벤더가 이미 추가되어 있습니다." + }; + } + + // 일부만 중복인 경우 경고 메시지 준비 + const skippedCount = vendorIds.length - newVendorIds.length; + + // 트랜잭션으로 처리 + const results = await db.transaction(async (tx) => { + const addedVendors = []; + + for (const vendorId of newVendorIds) { + // conditions가 없는 경우 기본값 설정 + const vendorConditions = conditions || { + currency: "USD", + paymentTermsCode: "NET30", + incotermsCode: "FOB", + deliveryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30일 후 + taxCode: "VV", + }; + + // 1. rfqLastDetails에 벤더 추가 + const [detail] = await tx + .insert(rfqLastDetails) + .values({ + rfqsLastId: rfqId, + vendorsId: vendorId, + ...vendorConditions, + updatedBy: userId, + }) + .returning(); + + // 2. rfqLastVendorResponses에 초기 응답 레코드 생성 + const [response] = await tx + .insert(rfqLastVendorResponses) + .values({ + rfqsLastId: rfqId, + rfqLastDetailsId: detail.id, + vendorId: vendorId, + status: "초대됨", + responseVersion: 1, + isLatest: true, + currency: vendorConditions.currency, + // 구매자 제시 조건 복사 (초기값) + vendorCurrency: vendorConditions.currency, + vendorPaymentTermsCode: vendorConditions.paymentTermsCode, + vendorIncotermsCode: vendorConditions.incotermsCode, + vendorIncotermsDetail: vendorConditions.incotermsDetail, + vendorDeliveryDate: vendorConditions.deliveryDate, + vendorContractDuration: vendorConditions.contractDuration, + vendorTaxCode: vendorConditions.taxCode, + vendorPlaceOfShipping: vendorConditions.placeOfShipping, + vendorPlaceOfDestination: vendorConditions.placeOfDestination, + vendorMaterialPriceRelatedYn: vendorConditions.materialPriceRelatedYn, + vendorSparepartYn: vendorConditions.sparepartYn, + vendorFirstYn: vendorConditions.firstYn, + vendorFirstDescription: vendorConditions.firstDescription, + vendorSparepartDescription: vendorConditions.sparepartDescription, + createdBy: userId, + updatedBy: userId, + }) + .returning(); + + // 3. 이력 기록 + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "생성", + newStatus: "초대됨", + changeDetails: { + action: "벤더 초대", + conditions: vendorConditions, + batchAdd: true, + totalVendors: newVendorIds.length + }, + performedBy: userId, + }); + + addedVendors.push({ + vendorId, + detailId: detail.id, + responseId: response.id, + }); + } + + return addedVendors; + }); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + // 성공 메시지 구성 + let message = `${results.length}개 벤더가 추가되었습니다.`; + if (skippedCount > 0) { + message += ` (${skippedCount}개는 이미 추가된 벤더로 제외)`; + } + + return { + success: true, + data: { + added: results.length, + skipped: skippedCount, + message, + } + }; + } catch (error) { + console.error("Add vendors error:", error); + return { + success: false, + error: "벤더 추가 중 오류가 발생했습니다." + }; + } +} + +// 벤더 조건 일괄 업데이트 함수 (추가) +export async function updateVendorConditionsBatch({ + rfqId, + vendorIds, + conditions, +}: { + rfqId: number; + vendorIds: number[]; + conditions: { + currency?: string; + paymentTermsCode?: string; + incotermsCode?: string; + incotermsDetail?: string; + deliveryDate?: Date; + contractDuration?: string; + taxCode?: string; + placeOfShipping?: string; + placeOfDestination?: string; + materialPriceRelatedYn?: boolean; + sparepartYn?: boolean; + firstYn?: boolean; + firstDescription?: string; + sparepartDescription?: string; + }; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + const userId = Number(session.user.id) + + if (!vendorIds || vendorIds.length === 0) { + return { success: false, error: "벤더를 선택해주세요." }; + } + + // 트랜잭션으로 처리 + await db.transaction(async (tx) => { + // 1. rfqLastDetails 업데이트 + await tx + .update(rfqLastDetails) + .set({ + ...conditions, + updatedBy: userId, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + inArray(rfqLastDetails.vendorsId, vendorIds) + ) + ); + + // 2. rfqLastVendorResponses의 구매자 제시 조건도 업데이트 + const vendorConditions = Object.keys(conditions).reduce((acc, key) => { + if (conditions[key] !== undefined) { + acc[`vendor${key.charAt(0).toUpperCase() + key.slice(1)}`] = conditions[key]; + } + return acc; + }, {}); + + await tx + .update(rfqLastVendorResponses) + .set({ + ...vendorConditions, + updatedBy: userId, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + inArray(rfqLastVendorResponses.vendorId, vendorIds), + eq(rfqLastVendorResponses.isLatest, true) + ) + ); + + // 3. 이력 기록 (각 벤더별로) + const responses = await tx + .select({ id: rfqLastVendorResponses.id }) + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + inArray(rfqLastVendorResponses.vendorId, vendorIds), + eq(rfqLastVendorResponses.isLatest, true) + ) + ); + + for (const response of responses) { + await tx.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "조건변경", + changeDetails: { + action: "조건 일괄 업데이트", + conditions, + batchUpdate: true, + totalVendors: vendorIds.length + }, + performedBy: userId, + }); + } + }); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { + success: true, + data: { + message: `${vendorIds.length}개 벤더의 조건이 업데이트되었습니다.` + } + }; + } catch (error) { + console.error("Update vendor conditions error:", error); + return { + success: false, + error: "조건 업데이트 중 오류가 발생했습니다." + }; + } +} + +// RFQ 발송 액션 +export async function sendRfqToVendors({ + rfqId, + vendorIds, +}: { + rfqId: number; + vendorIds: number[]; +}) { + try { + + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + const userId = Number(session.user.id) + + // 벤더별 응답 상태 업데이트 + for (const vendorId of vendorIds) { + const [response] = await db + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendorId), + eq(rfqLastVendorResponses.isLatest, true) + ) + ) + .limit(1); + + if (response) { + // 상태 업데이트 + await db + .update(rfqLastVendorResponses) + .set({ + status: "작성중", + updatedBy: userId, + updatedAt: new Date(), + }) + .where(eq(rfqLastVendorResponses.id, response.id)); + + // 이력 기록 + await db.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: response.id, + action: "발송", + previousStatus: response.status, + newStatus: "작성중", + changeDetails: { action: "RFQ 발송" }, + performedBy: userId, + }); + } + } + + // TODO: 실제 이메일 발송 로직 + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { success: true, count: vendorIds.length }; + } catch (error) { + console.error("Send RFQ error:", error); + return { success: false, error: "RFQ 발송 중 오류가 발생했습니다." }; + } +} + +// 벤더 삭제 액션 +export async function removeVendorFromRfq({ + rfqId, + vendorId, +}: { + rfqId: number; + vendorId: number; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + // 응답 체크 + const [response] = await db + .select() + .from(rfqLastVendorResponses) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.vendorId, vendorId), + eq(rfqLastVendorResponses.isLatest, true) + ) + ) + .limit(1); + + if (response && response.status !== "초대됨") { + return { + success: false, + error: "이미 진행 중인 벤더는 삭제할 수 없습니다." + }; + } + + // 삭제 + await db + .delete(rfqLastDetails) + .where( + and( + eq(rfqLastDetails.rfqsLastId, rfqId), + eq(rfqLastDetails.vendorsId, vendorId) + ) + ); + + revalidatePath(`/rfq-last/${rfqId}/vendor`); + + return { success: true }; + } catch (error) { + console.error("Remove vendor error:", error); + return { success: false, error: "벤더 삭제 중 오류가 발생했습니다." }; + } +} + +// 벤더 응답 상태 업데이트 +export async function updateVendorResponseStatus({ + responseId, + status, + reason, +}: { + responseId: number; + status: "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + reason?: string; +}) { + try { + const session = await getServerSession(authOptions) + + if (!session?.user) { + throw new Error("인증이 필요합니다.") + } + + + const [current] = await db + .select() + .from(rfqLastVendorResponses) + .where(eq(rfqLastVendorResponses.id, responseId)) + .limit(1); + + if (!current) { + return { success: false, error: "응답을 찾을 수 없습니다." }; + } + + // 상태 업데이트 + await db + .update(rfqLastVendorResponses) + .set({ + status, + submittedAt: status === "제출완료" ? new Date() : current.submittedAt, + updatedBy: Number(session.user.id), + updatedAt: new Date(), + }) + .where(eq(rfqLastVendorResponses.id, responseId)); + + // 이력 기록 + await db.insert(rfqLastVendorResponseHistory).values({ + vendorResponseId: responseId, + action: getActionFromStatus(status), + previousStatus: current.status, + newStatus: status, + changeReason: reason, + performedBy: Number(session.user.id), + }); + + revalidatePath(`/evcp/rfq-last/${current.rfqsLastId}/vendor`); + + return { success: true }; + } catch (error) { + console.error("Update status error:", error); + return { success: false, error: "상태 업데이트 중 오류가 발생했습니다." }; + } +} + +// 상태에 따른 액션 텍스트 +function getActionFromStatus(status: string): string { + switch (status) { + case "제출완료": return "제출"; + case "수정요청": return "반려"; + case "최종확정": return "승인"; + case "취소": return "취소"; + default: return "수정"; + } +} + +export async function getRfqVendorResponses(rfqId: number) { + try { + // 1. RFQ 기본 정보 조회 + const rfqData = await db + .select({ + id: rfqsLast.id, + rfqCode: rfqsLast.rfqCode, + title: rfqsLast.title, + status: rfqsLast.status, + startDate: rfqsLast.startDate, + endDate: rfqsLast.endDate, + }) + .from(rfqsLast) + .where(eq(rfqsLast.id, rfqId)) + .limit(1); + + if (!rfqData || rfqData.length === 0) { + return { + success: false, + error: "RFQ를 찾을 수 없습니다.", + data: null + }; + } + + // 2. RFQ 세부 정보 조회 (복수 버전이 있을 수 있음) + const details = await db + .select() + .from(rfqLastDetails) + .where(eq(rfqLastDetails.rfqsLastId, rfqId)) + .orderBy(desc(rfqLastDetails.version)); + + // 3. 벤더 응답 정보 조회 (벤더 정보, 제출자 정보 포함) + const vendorResponsesData = await db + .select({ + // 응답 기본 정보 + id: rfqLastVendorResponses.id, + rfqsLastId: rfqLastVendorResponses.rfqsLastId, + rfqLastDetailsId: rfqLastVendorResponses.rfqLastDetailsId, + responseVersion: rfqLastVendorResponses.responseVersion, + isLatest: rfqLastVendorResponses.isLatest, + status: rfqLastVendorResponses.status, + + // 벤더 정보 + vendorId: rfqLastVendorResponses.vendorId, + vendorCode: vendors.vendorCode, + vendorName: vendors.vendorName, + vendorEmail: vendors.email, + + // 제출 정보 + submittedAt: rfqLastVendorResponses.submittedAt, + submittedBy: rfqLastVendorResponses.submittedBy, + submittedByName: users.name, + + // 금액 정보 + totalAmount: rfqLastVendorResponses.totalAmount, + currency: rfqLastVendorResponses.currency, + + // 벤더 제안 조건 + vendorCurrency: rfqLastVendorResponses.vendorCurrency, + vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode, + vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode, + vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate, + vendorContractDuration: rfqLastVendorResponses.vendorContractDuration, + + // 초도품/Spare part 응답 + vendorFirstYn: rfqLastVendorResponses.vendorFirstYn, + vendorFirstAcceptance: rfqLastVendorResponses.vendorFirstAcceptance, + vendorSparepartYn: rfqLastVendorResponses.vendorSparepartYn, + vendorSparepartAcceptance: rfqLastVendorResponses.vendorSparepartAcceptance, + + // 비고 + generalRemark: rfqLastVendorResponses.generalRemark, + technicalProposal: rfqLastVendorResponses.technicalProposal, + + // 타임스탬프 + createdAt: rfqLastVendorResponses.createdAt, + updatedAt: rfqLastVendorResponses.updatedAt, + }) + .from(rfqLastVendorResponses) + .leftJoin(vendors, eq(rfqLastVendorResponses.vendorId, vendors.id)) + .leftJoin(users, eq(rfqLastVendorResponses.submittedBy, users.id)) + .where( + and( + eq(rfqLastVendorResponses.rfqsLastId, rfqId), + eq(rfqLastVendorResponses.isLatest, true) // 최신 버전만 조회 + ) + ) + .orderBy(desc(rfqLastVendorResponses.createdAt)); + + // 4. 각 벤더 응답별 견적 아이템 수와 첨부파일 수 계산 + const vendorResponsesWithCounts = await Promise.all( + vendorResponsesData.map(async (response) => { + // 견적 아이템 수 조회 + const itemCount = await db + .select({ count: sql`COUNT(*)::int` }) + .from(rfqLastVendorQuotationItems) + .where(eq(rfqLastVendorQuotationItems.vendorResponseId, response.id)); + + // 첨부파일 수 조회 + const attachmentCount = await db + .select({ count: sql`COUNT(*)::int` }) + .from(rfqLastVendorAttachments) + .where(eq(rfqLastVendorAttachments.vendorResponseId, response.id)); + + return { + ...response, + quotedItemCount: itemCount[0]?.count || 0, + attachmentCount: attachmentCount[0]?.count || 0, + }; + }) + ); + + // 5. 응답 데이터 정리 + const formattedResponses = vendorResponsesWithCounts.map(response => ({ + id: response.id, + rfqsLastId: response.rfqsLastId, + rfqLastDetailsId: response.rfqLastDetailsId, + responseVersion: response.responseVersion, + isLatest: response.isLatest, + status: response.status || "초대됨", // 기본값 설정 + + // 벤더 정보 + vendor: { + id: response.vendorId, + code: response.vendorCode, + name: response.vendorName, + email: response.vendorEmail, + }, + + // 제출 정보 + submission: { + submittedAt: response.submittedAt, + submittedBy: response.submittedBy, + submittedByName: response.submittedByName, + }, + + // 금액 정보 + pricing: { + totalAmount: response.totalAmount, + currency: response.currency || "USD", + vendorCurrency: response.vendorCurrency, + }, + + // 벤더 제안 조건 + vendorTerms: { + paymentTermsCode: response.vendorPaymentTermsCode, + incotermsCode: response.vendorIncotermsCode, + deliveryDate: response.vendorDeliveryDate, + contractDuration: response.vendorContractDuration, + }, + + // 초도품/Spare part + additionalRequirements: { + firstArticle: { + required: response.vendorFirstYn, + acceptance: response.vendorFirstAcceptance, + }, + sparePart: { + required: response.vendorSparepartYn, + acceptance: response.vendorSparepartAcceptance, + }, + }, + + // 카운트 정보 + counts: { + quotedItems: response.quotedItemCount, + attachments: response.attachmentCount, + }, + + // 비고 + remarks: { + general: response.generalRemark, + technical: response.technicalProposal, + }, + + // 타임스탬프 + timestamps: { + createdAt: response.createdAt, + updatedAt: response.updatedAt, + }, + })); + + return { + success: true, + data: formattedResponses, + rfq: rfqData[0], + details: details, + }; + + } catch (error) { + console.error("Failed to get vendor responses:", error); + return { + success: false, + error: error instanceof Error ? error.message : "벤더 응답 정보를 가져오는데 실패했습니다.", + data: null, + }; + } +} + +export async function getRfqWithDetails(rfqId: number) { + try { + // 1. RFQ 기본 정보 조회 (rfqsLastView 활용) + const [rfqData] = await db + .select() + .from(rfqsLastView) + .where(eq(rfqsLastView.id, rfqId)); + + if (!rfqData) { + return { success: false, error: "RFQ를 찾을 수 없습니다." }; + } + + // 2. 벤더별 상세 조건 조회 (rfqLastDetailsView 활용) + const details = await db + .select() + .from(rfqLastDetailsView) + .where(eq(rfqLastDetailsView.rfqId, rfqId)) + .orderBy(desc(rfqLastDetailsView.detailId)); + + return { + success: true, + data: { + // RFQ 기본 정보 (rfqsLastView에서 제공) + id: rfqData.id, + rfqCode: rfqData.rfqCode, + rfqType: rfqData.rfqType, + rfqTitle: rfqData.rfqTitle, + series: rfqData.series, + rfqSealedYn: rfqData.rfqSealedYn, + + // ITB 관련 + projectCompany: rfqData.projectCompany, + projectFlag: rfqData.projectFlag, + projectSite: rfqData.projectSite, + smCode: rfqData.smCode, + + // PR 정보 + prNumber: rfqData.prNumber, + prIssueDate: rfqData.prIssueDate, + + // 프로젝트 정보 + projectId: rfqData.projectId, + projectCode: rfqData.projectCode, + projectName: rfqData.projectName, + + // 아이템 정보 + itemCode: rfqData.itemCode, + itemName: rfqData.itemName, + + // 패키지 정보 + packageNo: rfqData.packageNo, + packageName: rfqData.packageName, + + // 날짜 및 상태 + dueDate: rfqData.dueDate, + rfqSendDate: rfqData.rfqSendDate, + status: rfqData.status, + + // PIC 정보 + picId: rfqData.picId, + picCode: rfqData.picCode, + picName: rfqData.picName, + picUserName: rfqData.picUserName, + engPicName: rfqData.engPicName, + + // 집계 정보 (View에서 이미 계산됨) + vendorCount: rfqData.vendorCount, + shortListedVendorCount: rfqData.shortListedVendorCount, + quotationReceivedCount: rfqData.quotationReceivedCount, + prItemsCount: rfqData.prItemsCount, + majorItemsCount: rfqData.majorItemsCount, + + // 견적 제출 정보 + earliestQuotationSubmittedAt: rfqData.earliestQuotationSubmittedAt, + + // Major Item 정보 + majorItemMaterialCode: rfqData.majorItemMaterialCode, + majorItemMaterialDescription: rfqData.majorItemMaterialDescription, + majorItemMaterialCategory: rfqData.majorItemMaterialCategory, + majorItemPrNo: rfqData.majorItemPrNo, + + // 감사 정보 + createdBy: rfqData.createdBy, + createdByUserName: rfqData.createdByUserName, + createdAt: rfqData.createdAt, + sentBy: rfqData.sentBy, + sentByUserName: rfqData.sentByUserName, + updatedBy: rfqData.updatedBy, + updatedByUserName: rfqData.updatedByUserName, + updatedAt: rfqData.updatedAt, + + // 비고 + remark: rfqData.remark, + + // 벤더별 상세 조건 (rfqLastDetailsView에서 제공) + details: details.map(d => ({ + detailId: d.detailId, + + // 벤더 정보 + vendorId: d.vendorId, + vendorName: d.vendorName, + vendorCode: d.vendorCode, + vendorCountry: d.vendorCountry, + + // 조건 정보 + currency: d.currency, + paymentTermsCode: d.paymentTermsCode, + paymentTermsDescription: d.paymentTermsDescription, + incotermsCode: d.incotermsCode, + incotermsDescription: d.incotermsDescription, + incotermsDetail: d.incotermsDetail, + deliveryDate: d.deliveryDate, + contractDuration: d.contractDuration, + taxCode: d.taxCode, + placeOfShipping: d.placeOfShipping, + placeOfDestination: d.placeOfDestination, + + // Boolean 필드들 + shortList: d.shortList, + returnYn: d.returnYn, + returnedAt: d.returnedAt, + prjectGtcYn: d.prjectGtcYn, + generalGtcYn: d.generalGtcYn, + ndaYn: d.ndaYn, + agreementYn: d.agreementYn, + materialPriceRelatedYn: d.materialPriceRelatedYn, + sparepartYn: d.sparepartYn, + firstYn: d.firstYn, + + // 설명 필드 + firstDescription: d.firstDescription, + sparepartDescription: d.sparepartDescription, + remark: d.remark, + cancelReason: d.cancelReason, + + // 견적 관련 정보 (View에서 이미 계산됨) + hasQuotation: d.hasQuotation, + quotationStatus: d.quotationStatus, + quotationTotalPrice: d.quotationTotalPrice, + quotationVersion: d.quotationVersion, + quotationVersionCount: d.quotationVersionCount, + lastQuotationDate: d.lastQuotationDate, + quotationSubmittedAt: d.quotationSubmittedAt, + + // 감사 정보 + updatedBy: d.updatedBy, + updatedByUserName: d.updatedByUserName, + updatedAt: d.updatedAt, + })), + } + }; + } catch (error) { + console.error("Get RFQ with details error:", error); + return { success: false, error: "데이터 조회 중 오류가 발생했습니다." }; + } +} \ No newline at end of file diff --git a/lib/rfq-last/validations.ts b/lib/rfq-last/validations.ts index 34110141..5615db7a 100644 --- a/lib/rfq-last/validations.ts +++ b/lib/rfq-last/validations.ts @@ -71,87 +71,25 @@ export const searchParamsRfqTabCache = createSearchParamsCache({ tab: parseAsStringEnum(['design', 'purchase']).withDefault('design'), }) -// 설계 탭 전용 파라미터 -export const searchParamsRfqDesignCache = createSearchParamsCache({ - design_page: parseAsInteger.withDefault(1), - design_perPage: parseAsInteger.withDefault(10), - design_sort: getSortingStateParser().withDefault([ - { id: "createdAt", desc: true }, - ]), - design_search: parseAsString.withDefault(""), - design_fileType: parseAsArrayOf(z.string()).withDefault([]), - design_filters: getFiltersStateParser().withDefault([]), - design_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), -}) -// 구매 탭 전용 파라미터 -export const searchParamsRfqPurchaseCache = createSearchParamsCache({ - purchase_page: parseAsInteger.withDefault(1), - purchase_perPage: parseAsInteger.withDefault(10), - purchase_sort: getSortingStateParser().withDefault([ - { id: "createdAt", desc: true }, - ]), - purchase_search: parseAsString.withDefault(""), - purchase_fileType: parseAsArrayOf(z.string()).withDefault([]), - purchase_filters: getFiltersStateParser().withDefault([]), - purchase_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), -}) // 통합 파라미터 캐시 (모든 파라미터를 한 번에 파싱) -export const searchParamsRfqAttachmentsCache = createSearchParamsCache({ - // 공통 - tab: parseAsStringEnum(['design', 'purchase']).withDefault('design'), +export const searchParamsRfqAttachmentsCache =createSearchParamsCache({ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - - // 설계 탭 파라미터 - design_page: parseAsInteger.withDefault(1), - design_perPage: parseAsInteger.withDefault(10), - design_sort: getSortingStateParser().withDefault([ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser().withDefault([ { id: "createdAt", desc: true }, ]), - design_search: parseAsString.withDefault(""), - design_fileType: parseAsArrayOf(z.string()).withDefault([]), - design_filters: getFiltersStateParser().withDefault([]), - design_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - - // 구매 탭 파라미터 - purchase_page: parseAsInteger.withDefault(1), - purchase_perPage: parseAsInteger.withDefault(10), - purchase_sort: getSortingStateParser().withDefault([ - { id: "createdAt", desc: true }, - ]), - purchase_search: parseAsString.withDefault(""), - purchase_fileType: parseAsArrayOf(z.string()).withDefault([]), - purchase_filters: getFiltersStateParser().withDefault([]), - purchase_joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), -}) + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), -// 타입 정의 -export type GetRfqLastAttachmentsSchema = { - page: number - perPage: number - sort: Array<{ id: string; desc: boolean }> - search: string - fileType: string[] - filters: any[] - joinOperator: "and" | "or" - attachmentType?: string[] -} -// 헬퍼 함수: prefix가 붙은 파라미터를 일반 파라미터로 변환 -export function extractTabParams( - allParams: Awaited>, - tabPrefix: 'design' | 'purchase' -): GetRfqLastAttachmentsSchema { - const prefix = `${tabPrefix}_` - - return { - page: allParams[`${prefix}page` as keyof typeof allParams] as number, - perPage: allParams[`${prefix}perPage` as keyof typeof allParams] as number, - sort: allParams[`${prefix}sort` as keyof typeof allParams] as any, - search: allParams[`${prefix}search` as keyof typeof allParams] as string, - fileType: allParams[`${prefix}fileType` as keyof typeof allParams] as string[], - filters: allParams[`${prefix}filters` as keyof typeof allParams] as any[], - joinOperator: allParams[`${prefix}joinOperator` as keyof typeof allParams] as "and" | "or", - } -} \ No newline at end of file +}); + + +// 타입 정의 +export type GetRfqLastAttachmentsSchema =Awaited< +ReturnType +>; diff --git a/lib/rfq-last/vendor/add-vendor-dialog.tsx b/lib/rfq-last/vendor/add-vendor-dialog.tsx new file mode 100644 index 00000000..d8745298 --- /dev/null +++ b/lib/rfq-last/vendor/add-vendor-dialog.tsx @@ -0,0 +1,307 @@ +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover"; +import { Check, ChevronsUpDown, Loader2, X, Plus } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { addVendorsToRfq } from "../service"; +import { getVendorsForSelection } from "@/lib/b-rfq/service"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Info } from "lucide-react"; + +interface AddVendorDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + onSuccess: () => void; +} + +export function AddVendorDialog({ + open, + onOpenChange, + rfqId, + onSuccess, +}: AddVendorDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [vendorOpen, setVendorOpen] = React.useState(false); + const [vendorList, setVendorList] = React.useState([]); + const [selectedVendors, setSelectedVendors] = React.useState([]); + + // 벤더 로드 + const loadVendors = React.useCallback(async () => { + try { + const result = await getVendorsForSelection(); + if (result) { + setVendorList(result); + } + } catch (error) { + console.error("Failed to load vendors:", error); + toast.error("벤더 목록을 불러오는데 실패했습니다."); + } + }, []); + + React.useEffect(() => { + if (open) { + loadVendors(); + } + }, [open, loadVendors]); + + // 초기화 + React.useEffect(() => { + if (!open) { + setSelectedVendors([]); + } + }, [open]); + + // 벤더 추가 + const handleAddVendor = (vendor: any) => { + if (!selectedVendors.find(v => v.id === vendor.id)) { + setSelectedVendors([...selectedVendors, vendor]); + } + setVendorOpen(false); + }; + + // 벤더 제거 + const handleRemoveVendor = (vendorId: number) => { + setSelectedVendors(selectedVendors.filter(v => v.id !== vendorId)); + }; + + // 제출 처리 - 벤더만 추가 + const handleSubmit = async () => { + if (selectedVendors.length === 0) { + toast.error("최소 1개 이상의 벤더를 선택해주세요."); + return; + } + + setIsLoading(true); + + try { + const vendorIds = selectedVendors.map(v => v.id); + const result = await addVendorsToRfq({ + rfqId, + vendorIds, + // 기본값으로 벤더만 추가 (상세 조건은 나중에 일괄 입력) + conditions: null, + }); + + if (result.success) { + toast.success( +
+

{selectedVendors.length}개 벤더가 추가되었습니다.

+

+ 벤더 목록에서 '정보 일괄 입력' 버튼으로 조건을 설정하세요. +

+
+ ); + onSuccess(); + onOpenChange(false); + } else { + toast.error(result.error || "벤더 추가에 실패했습니다."); + } + } catch (error) { + console.error("Submit error:", error); + toast.error("오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + // 이미 선택된 벤더인지 확인 + const isVendorSelected = (vendorId: number) => { + return selectedVendors.some(v => v.id === vendorId); + }; + + return ( + + + {/* 헤더 */} + + 벤더 추가 + + 견적 요청을 보낼 벤더를 선택하세요. 조건 설정은 추가 후 일괄로 진행할 수 있습니다. + + + + {/* 컨텐츠 영역 */} +
+
+ {/* 안내 메시지 */} + + + + 여기서는 벤더만 선택합니다. 납기일, 결제조건 등의 상세 정보는 벤더 추가 후 + '정보 일괄 입력' 기능으로 한 번에 설정할 수 있습니다. + + + + {/* 벤더 선택 카드 */} + + +
+ 벤더 선택 + + {selectedVendors.length}개 선택됨 + +
+ + RFQ를 발송할 벤더를 선택하세요. 여러 개 선택 가능합니다. + +
+ +
+ {/* 벤더 추가 버튼 */} + + + + + + + + { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > + 검색 결과가 없습니다. + + {vendorList + .filter(vendor => !isVendorSelected(vendor.id)) + .map((vendor) => ( + handleAddVendor(vendor)} + > +
+ + {vendor.vendorCode} + + {vendor.vendorName} + {vendor.country && ( + + {vendor.country} + + )} +
+
+ ))} +
+
+
+
+
+ + {/* 선택된 벤더 목록 */} + {selectedVendors.length > 0 && ( +
+ + +
+ {selectedVendors.map((vendor, index) => ( +
+
+ + {index + 1}. + + + {vendor.vendorCode} + + + {vendor.vendorName} + +
+ +
+ ))} +
+
+
+ )} + + {/* 벤더가 없는 경우 메시지 */} + {selectedVendors.length === 0 && ( +
+

아직 선택된 벤더가 없습니다.

+

위 버튼을 클릭하여 벤더를 추가하세요.

+
+ )} +
+
+
+
+
+ + {/* 푸터 */} + + + + +
+
+ ); +} \ No newline at end of file diff --git a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx new file mode 100644 index 00000000..1b8fa528 --- /dev/null +++ b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx @@ -0,0 +1,1121 @@ +"use client"; + +import * as React from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { Calendar } from "@/components/ui/calendar"; +import { CalendarIcon, Loader2, Info, Package, Check, ChevronsUpDown } from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { updateVendorConditionsBatch } from "../service"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + getIncotermsForSelection, + getPaymentTermsForSelection, + getPlaceOfShippingForSelection, + getPlaceOfDestinationForSelection +} from "@/lib/procurement-select/service"; + +interface BatchUpdateConditionsDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rfqId: number; + rfqCode: string; + selectedVendors: Array<{ + id: number; + vendorName: string; + vendorCode: string; + }>; + onSuccess: () => void; +} + +// 타입 정의 +interface SelectOption { + id: number; + code: string; + description: string; +} + +// 폼 스키마 +const formSchema = z.object({ + currency: z.string().optional(), + paymentTermsCode: z.string().optional(), + incotermsCode: z.string().optional(), + incotermsDetail: z.string().optional(), + contractDuration: z.string().optional(), + taxCode: z.string().optional(), + placeOfShipping: z.string().optional(), + placeOfDestination: z.string().optional(), + deliveryDate: z.date().optional(), + materialPriceRelatedYn: z.boolean().default(false), + sparepartYn: z.boolean().default(false), + firstYn: z.boolean().default(false), + firstDescription: z.string().optional(), + sparepartDescription: z.string().optional(), +}); + +type FormValues = z.infer; + +const currencies = ["USD", "EUR", "KRW", "JPY", "CNY"]; + +export function BatchUpdateConditionsDialog({ + open, + onOpenChange, + rfqId, + rfqCode, + selectedVendors, + onSuccess, +}: BatchUpdateConditionsDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + + // Select 옵션들 상태 + const [incoterms, setIncoterms] = React.useState([]); + const [paymentTerms, setPaymentTerms] = React.useState([]); + const [shippingPlaces, setShippingPlaces] = React.useState([]); + const [destinationPlaces, setDestinationPlaces] = React.useState([]); + + // 로딩 상태 + const [incotermsLoading, setIncotermsLoading] = React.useState(false); + const [paymentTermsLoading, setPaymentTermsLoading] = React.useState(false); + const [shippingLoading, setShippingLoading] = React.useState(false); + const [destinationLoading, setDestinationLoading] = React.useState(false); + + // Popover 열림 상태 + const [incotermsOpen, setIncotermsOpen] = React.useState(false); + const [paymentTermsOpen, setPaymentTermsOpen] = React.useState(false); + const [shippingOpen, setShippingOpen] = React.useState(false); + const [destinationOpen, setDestinationOpen] = React.useState(false); + const [calendarOpen, setCalendarOpen] = React.useState(false); + + // 체크박스로 각 필드 업데이트 여부 관리 + const [fieldsToUpdate, setFieldsToUpdate] = React.useState({ + currency: false, + paymentTermsCode: false, + incoterms: false, + deliveryDate: false, + contractDuration: false, + taxCode: false, + shipping: false, + materialPrice: false, + sparepart: false, + first: false, + }); + + // 폼 초기화 + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + currency: "", + paymentTermsCode: "", + incotermsCode: "", + incotermsDetail: "", + contractDuration: "", + taxCode: "", + placeOfShipping: "", + placeOfDestination: "", + materialPriceRelatedYn: false, + sparepartYn: false, + firstYn: false, + firstDescription: "", + sparepartDescription: "", + }, + }); + + // 데이터 로드 함수들 + const loadIncoterms = React.useCallback(async () => { + setIncotermsLoading(true); + try { + const data = await getIncotermsForSelection(); + setIncoterms(data); + } catch (error) { + console.error("Failed to load incoterms:", error); + toast.error("Incoterms 목록을 불러오는데 실패했습니다."); + } finally { + setIncotermsLoading(false); + } + }, []); + + const loadPaymentTerms = React.useCallback(async () => { + setPaymentTermsLoading(true); + try { + const data = await getPaymentTermsForSelection(); + setPaymentTerms(data); + } catch (error) { + console.error("Failed to load payment terms:", error); + toast.error("결제조건 목록을 불러오는데 실패했습니다."); + } finally { + setPaymentTermsLoading(false); + } + }, []); + + const loadShippingPlaces = React.useCallback(async () => { + setShippingLoading(true); + try { + const data = await getPlaceOfShippingForSelection(); + setShippingPlaces(data); + } catch (error) { + console.error("Failed to load shipping places:", error); + toast.error("선적지 목록을 불러오는데 실패했습니다."); + } finally { + setShippingLoading(false); + } + }, []); + + const loadDestinationPlaces = React.useCallback(async () => { + setDestinationLoading(true); + try { + const data = await getPlaceOfDestinationForSelection(); + setDestinationPlaces(data); + } catch (error) { + console.error("Failed to load destination places:", error); + toast.error("도착지 목록을 불러오는데 실패했습니다."); + } finally { + setDestinationLoading(false); + } + }, []); + + // 초기 데이터 로드 + React.useEffect(() => { + if (open) { + loadIncoterms(); + loadPaymentTerms(); + loadShippingPlaces(); + loadDestinationPlaces(); + } + }, [open, loadIncoterms, loadPaymentTerms, loadShippingPlaces, loadDestinationPlaces]); + + // 다이얼로그 닫힐 때 초기화 + React.useEffect(() => { + if (!open) { + form.reset(); + setFieldsToUpdate({ + currency: false, + paymentTermsCode: false, + incoterms: false, + deliveryDate: false, + contractDuration: false, + taxCode: false, + shipping: false, + materialPrice: false, + sparepart: false, + first: false, + }); + } + }, [open, form]); + + // 제출 처리 + const onSubmit = async (data: FormValues) => { + const hasFieldsToUpdate = Object.values(fieldsToUpdate).some(v => v); + if (!hasFieldsToUpdate) { + toast.error("최소 1개 이상의 변경할 항목을 선택해주세요."); + return; + } + + // 선택된 필드만 포함하여 conditions 객체 생성 + const conditions: any = {}; + + if (fieldsToUpdate.currency && data.currency) { + conditions.currency = data.currency; + } + if (fieldsToUpdate.paymentTermsCode && data.paymentTermsCode) { + conditions.paymentTermsCode = data.paymentTermsCode; + } + if (fieldsToUpdate.incoterms) { + if (data.incotermsCode) conditions.incotermsCode = data.incotermsCode; + if (data.incotermsDetail) conditions.incotermsDetail = data.incotermsDetail; + } + if (fieldsToUpdate.deliveryDate && data.deliveryDate) { + conditions.deliveryDate = data.deliveryDate; + } + if (fieldsToUpdate.contractDuration) { + conditions.contractDuration = data.contractDuration; + } + if (fieldsToUpdate.taxCode) { + conditions.taxCode = data.taxCode; + } + if (fieldsToUpdate.shipping) { + conditions.placeOfShipping = data.placeOfShipping; + conditions.placeOfDestination = data.placeOfDestination; + } + if (fieldsToUpdate.materialPrice) { + conditions.materialPriceRelatedYn = data.materialPriceRelatedYn; + } + if (fieldsToUpdate.sparepart) { + conditions.sparepartYn = data.sparepartYn; + if (data.sparepartYn) { + conditions.sparepartDescription = data.sparepartDescription; + } + } + if (fieldsToUpdate.first) { + conditions.firstYn = data.firstYn; + if (data.firstYn) { + conditions.firstDescription = data.firstDescription; + } + } + + setIsLoading(true); + + try { + const vendorIds = selectedVendors.map(v => v.id); + const result = await updateVendorConditionsBatch({ + rfqId, + vendorIds, + conditions, + }); + + if (result.success) { + toast.success(result.data?.message || "조건이 성공적으로 업데이트되었습니다."); + onSuccess(); + onOpenChange(false); + } else { + toast.error(result.error || "조건 업데이트에 실패했습니다."); + } + } catch (error) { + console.error("Submit error:", error); + toast.error("오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + const getUpdateCount = () => { + return Object.values(fieldsToUpdate).filter(v => v).length; + }; + + // 선택된 옵션 찾기 헬퍼 함수들 + const selectedIncoterm = incoterms.find(i => i.code === form.watch("incotermsCode")); + const selectedPaymentTerm = paymentTerms.find(p => p.code === form.watch("paymentTermsCode")); + const selectedShipping = shippingPlaces.find(s => s.code === form.watch("placeOfShipping")); + const selectedDestination = destinationPlaces.find(d => d.code === form.watch("placeOfDestination")); + + return ( + + + {/* 헤더 */} + + 조건 일괄 설정 + + 선택한 {selectedVendors.length}개 벤더에 동일한 조건을 적용합니다. + 변경하려는 항목만 체크하고 값을 입력하세요. + + + +
+ + {/* 스크롤 가능한 컨텐츠 영역 */} + +
+ {/* 선택된 벤더 정보 */} + + +
+ + + 대상 벤더 + + {selectedVendors.length}개 +
+
+ +
+ {selectedVendors.map((vendor) => ( + + {vendor.vendorCode} - {vendor.vendorName} + + ))} +
+
+
+ + {/* 안내 메시지 */} + + + + 체크박스를 선택한 항목만 업데이트됩니다. + 선택하지 않은 항목은 기존 값이 유지됩니다. + + + + {/* 기본 조건 설정 */} + + + 기본 조건 + + + {/* 통화 */} +
+ + setFieldsToUpdate({ ...fieldsToUpdate, currency: !!checked }) + } + /> + ( + + + 통화 + +
+ + + + + + + + + + 검색 결과가 없습니다. + + {currencies.map((currency) => ( + field.onChange(currency)} + > + {currency} + + + ))} + + + + + + + +
+
+ )} + /> +
+ + {/* 결제 조건 */} +
+ + setFieldsToUpdate({ ...fieldsToUpdate, paymentTermsCode: !!checked }) + } + /> + ( + + + 결제 조건 + +
+ + + + + + + + + + + 검색 결과가 없습니다. + + {paymentTerms.map((term) => ( + { + field.onChange(term.code); + setPaymentTermsOpen(false); + }} + > +
+ {term.code} + - + {term.description} + +
+
+ ))} +
+
+
+
+
+ +
+
+ )} + /> +
+ + {/* 인코텀즈 */} +
+ + setFieldsToUpdate({ ...fieldsToUpdate, incoterms: !!checked }) + } + /> +
+ +
+ ( + + + + + + + + + + + + 검색 결과가 없습니다. + + {incoterms.map((incoterm) => ( + { + field.onChange(incoterm.code); + setIncotermsOpen(false); + }} + > +
+ {incoterm.code} + - + {incoterm.description} + +
+
+ ))} +
+
+
+
+
+ +
+ )} + /> + {/* ( + + + + + + + )} + /> */} +
+
+
+ + {/* 납기일 */} + {!rfqCode.startsWith("F") && ( +
+ + setFieldsToUpdate({ ...fieldsToUpdate, deliveryDate: !!checked }) + } + /> + ( + + + 납기일 + +
+ + + + + + + + { + field.onChange(date); + setCalendarOpen(false); + }} + initialFocus + /> + + + +
+
+ )} + /> +
+ )} + + {/* 계약 기간 */} + {rfqCode.startsWith("F") && ( +
+ + setFieldsToUpdate({ ...fieldsToUpdate, contractDuration: !!checked }) + } + /> + ( + + + 계약 기간 + +
+ + + + +
+
+ )} + /> +
+ )} + + {/* 세금 코드 */} +
+ + setFieldsToUpdate({ ...fieldsToUpdate, taxCode: !!checked }) + } + /> + ( + + + 세금 코드 + +
+ + + + +
+
+ )} + /> +
+ + {/* 선적지/도착지 */} +
+ + setFieldsToUpdate({ ...fieldsToUpdate, shipping: !!checked }) + } + /> +
+ ( + + + 선적지 + +
+ + + + + + + + + + + 검색 결과가 없습니다. + + {shippingPlaces.map((place) => ( + { + field.onChange(place.code); + setShippingOpen(false); + }} + > +
+ {place.code} + - + {place.description} + +
+
+ ))} +
+
+
+
+
+ +
+
+ )} + /> + + ( + + + 도착지 + +
+ + + + + + + + + + + 검색 결과가 없습니다. + + {destinationPlaces.map((place) => ( + { + field.onChange(place.code); + setDestinationOpen(false); + }} + > +
+ {place.code} + - + {place.description} + +
+
+ ))} +
+
+
+
+
+ +
+
+ )} + /> +
+
+
+
+ + {/* 추가 옵션 */} + + + 추가 옵션 + + + {/* 연동제 적용 */} +
+ + setFieldsToUpdate({ ...fieldsToUpdate, materialPrice: !!checked }) + } + /> + ( + +
+ + 연동제 적용 + +
+ 원자재 가격 연동 여부 +
+
+ + + +
+ )} + /> +
+ + {/* Spare Part */} +
+
+ + setFieldsToUpdate({ ...fieldsToUpdate, sparepart: !!checked }) + } + /> + ( + +
+ + Spare Part + +
+ 예비 부품 요구사항 +
+
+ + + +
+ )} + /> +
+ {form.watch("sparepartYn") && fieldsToUpdate.sparepart && ( + ( + + +