diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-05 11:44:32 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-05 11:44:32 +0000 |
| commit | 50adedf48ee4674ebe00f1ee72d93485183cdc51 (patch) | |
| tree | 18053ab04d94c750028eee5d5d2f16ba4f38f50e /lib/rfq-last | |
| parent | 66d64b482f2b6b52b0dd396ef998f27d491c70dd (diff) | |
(대표님, 최겸, 임수민) EDP 입력 진행률, 견적목록관리, EDP excel import 오류수정, GTC-Contract
Diffstat (limited to 'lib/rfq-last')
| -rw-r--r-- | lib/rfq-last/attachment/revision-historty-dialog.tsx | 305 | ||||
| -rw-r--r-- | lib/rfq-last/attachment/rfq-attachments-table.tsx | 370 | ||||
| -rw-r--r-- | lib/rfq-last/attachment/vendor-response-table.tsx | 519 | ||||
| -rw-r--r-- | lib/rfq-last/service.ts | 1267 | ||||
| -rw-r--r-- | lib/rfq-last/validations.ts | 90 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/add-vendor-dialog.tsx | 307 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/batch-update-conditions-dialog.tsx | 1121 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 746 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/vendor-detail-dialog.tsx | 0 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/vendor-response-status-card.tsx | 51 |
10 files changed, 4475 insertions, 301 deletions
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<AttachmentWithHistory | null>(null); + const [error, setError] = React.useState<string | null>(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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-5xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + 리비전 히스토리 + </DialogTitle> + <DialogDescription> + {historyData?.originalFileName || attachmentName || "파일"}의 모든 버전 히스토리를 확인할 수 있습니다. + </DialogDescription> + </DialogHeader> + + <div className="mt-4"> + {loading ? ( + <div className="space-y-3"> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-10 w-full" /> + </div> + ) : error ? ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription>{error}</AlertDescription> + </Alert> + ) : historyData ? ( + <> + {/* 파일 정보 헤더 */} + <div className="mb-4 p-3 bg-muted rounded-lg"> + <div className="grid grid-cols-2 gap-2 text-sm"> + <div> + <span className="text-muted-foreground">일련번호:</span>{" "} + <span className="font-medium font-mono">{historyData.serialNo || "-"}</span> + </div> + <div> + <span className="text-muted-foreground">현재 리비전:</span>{" "} + <Badge variant="default" className="ml-1"> + Rev. {historyData.currentRevision || "A"} + </Badge> + </div> + {historyData.description && ( + <div className="col-span-2"> + <span className="text-muted-foreground">설명:</span>{" "} + <span className="font-medium">{historyData.description}</span> + </div> + )} + </div> + </div> + + {/* 리비전 테이블 */} + <ScrollArea className="h-[400px] rounded-md border"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[80px]">리비전</TableHead> + <TableHead>파일명</TableHead> + <TableHead className="w-[80px]">크기</TableHead> + <TableHead className="w-[100px]">업로드자</TableHead> + <TableHead className="w-[150px]">업로드일시</TableHead> + <TableHead>코멘트</TableHead> + <TableHead className="w-[100px] text-center">작업</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {historyData.revisions.length > 0 ? ( + historyData.revisions.map((revision) => ( + <TableRow key={revision.id}> + <TableCell> + <Badge + variant={getRevisionBadgeVariant(revision.isLatest)} + className="font-mono" + > + Rev. {revision.revisionNo} + {revision.isLatest && ( + <CheckCircle className="ml-1 h-3 w-3" /> + )} + </Badge> + </TableCell> + <TableCell> + <div className="flex flex-col"> + <span className="text-sm font-medium truncate max-w-[200px]" title={revision.originalFileName}> + {revision.originalFileName} + </span> + {revision.fileName !== revision.originalFileName && ( + <span className="text-xs text-muted-foreground truncate max-w-[200px]"> + ({revision.fileName}) + </span> + )} + </div> + </TableCell> + <TableCell> + <span className="text-sm text-muted-foreground"> + {formatFileSize(revision.fileSize)} + </span> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <User className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm"> + {revision.createdByName || "Unknown"} + </span> + </div> + </TableCell> + <TableCell> + <div className="flex items-center gap-1"> + <Clock className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm"> + {format(new Date(revision.createdAt), "yyyy-MM-dd HH:mm")} + </span> + </div> + <span className="text-xs text-muted-foreground"> + {formatDistanceToNow(new Date(revision.createdAt), { + addSuffix: true, + locale: ko, + })} + </span> + </TableCell> + <TableCell> + {revision.revisionComment ? ( + <div className="flex items-start gap-1"> + <MessageSquare className="h-3 w-3 text-muted-foreground mt-0.5" /> + <span className="text-sm text-muted-foreground"> + {revision.revisionComment} + </span> + </div> + ) : ( + <span className="text-sm text-muted-foreground">-</span> + )} + </TableCell> + <TableCell> + <div className="flex items-center justify-center gap-1"> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleDownloadRevision(revision)} + title="다운로드" + > + <Download className="h-4 w-4" /> + </Button> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handlePreviewRevision(revision)} + title="미리보기" + > + <Eye className="h-4 w-4" /> + </Button> + </div> + </TableCell> + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={7} className="text-center text-muted-foreground py-8"> + 리비전 히스토리가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </ScrollArea> + + {/* 요약 정보 */} + <div className="mt-4 flex items-center justify-between text-sm text-muted-foreground"> + <span>총 {historyData.revisions.length}개의 리비전</span> + <span> + 최초 업로드:{" "} + {historyData.revisions.length > 0 + ? format( + new Date( + historyData.revisions[historyData.revisions.length - 1].createdAt + ), + "yyyy년 MM월 dd일" + ) + : "-"} + </span> + </div> + </> + ) : null} + </div> + </DialogContent> + </Dialog> + ); +}
\ 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<ReturnType<typeof getRfqLastAttachments>>; - initialPurchaseData: Awaited<ReturnType<typeof getRfqLastAttachments>>; - 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<RfqAttachment[]>(initialData); const [selectedAttachment, setSelectedAttachment] = React.useState<RfqAttachment | null>(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<RfqAttachment[]>([]); + + // 탭에 따른 데이터 필터링 + 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<RfqAttachment>) => { @@ -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<RfqAttachment>) => void - ): ColumnDef<RfqAttachment>[] => [ + const columns: ColumnDef<RfqAttachment>[] = 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 }) => <DataTableColumnHeaderSimple column={column} title="일련번호" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="일련번호" />, cell: ({ row }) => ( <span className="font-mono text-sm">{row.original.serialNo || "-"}</span> ), size: 100, + meta: { excelHeader: "일련번호" }, + enablePinning: true, }, { accessorKey: "originalFileName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="파일명" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="파일명" />, cell: ({ row }) => { const file = row.original; return ( @@ -224,11 +257,6 @@ export function RfqAttachmentsTable({ <span className="text-sm font-medium truncate max-w-[250px]" title={file.originalFileName || ""}> {file.originalFileName || file.fileName || "-"} </span> - {file.fileName && file.fileName !== file.originalFileName && ( - <span className="text-xs text-muted-foreground truncate max-w-[250px]"> - ({file.fileName}) - </span> - )} </div> </div> ); @@ -237,7 +265,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "description", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="설명" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />, cell: ({ row }) => ( <div className="max-w-[200px] truncate" title={row.original.description || ""}> {row.original.description || "-"} @@ -247,7 +275,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "currentRevision", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리비전" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="리비전" />, cell: ({ row }) => { const revision = row.original.currentRevision; return revision ? ( @@ -262,7 +290,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "fileSize", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="크기" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="크기" />, cell: ({ row }) => ( <span className="text-sm text-muted-foreground"> {formatFileSize(row.original.fileSize)} @@ -271,29 +299,14 @@ export function RfqAttachmentsTable({ size: 80, }, { - accessorKey: "fileType", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="타입" />, - cell: ({ row }) => { - const type = row.original.fileType; - return type ? ( - <Badge variant="secondary" className="text-xs"> - {type.toUpperCase()} - </Badge> - ) : ( - <span className="text-muted-foreground">-</span> - ); - }, - size: 80, - }, - { accessorKey: "createdByName", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업로드자" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드자" />, cell: ({ row }) => row.original.createdByName || "-", size: 100, }, { accessorKey: "createdAt", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업로드일" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드일" />, cell: ({ row }) => { const date = row.original.createdAt; return date ? ( @@ -320,7 +333,7 @@ export function RfqAttachmentsTable({ }, { accessorKey: "updatedAt", - header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="수정일" />, + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="수정일" />, cell: ({ row }) => { const date = row.original.updatedAt; return date ? format(new Date(date), "MM-dd HH:mm") : "-"; @@ -342,26 +355,26 @@ export function RfqAttachmentsTable({ </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={() => onAction({ row, type: "download" })}> + <DropdownMenuItem onClick={() => handleAction({ row, type: "download" })}> <Download className="mr-2 h-4 w-4" /> 다운로드 </DropdownMenuItem> - <DropdownMenuItem onClick={() => onAction({ row, type: "preview" })}> + <DropdownMenuItem onClick={() => handleAction({ row, type: "preview" })}> <Eye className="mr-2 h-4 w-4" /> 미리보기 </DropdownMenuItem> <DropdownMenuSeparator /> - <DropdownMenuItem onClick={() => onAction({ row, type: "history" })}> + <DropdownMenuItem onClick={() => handleAction({ row, type: "history" })}> <History className="mr-2 h-4 w-4" /> 리비전 이력 </DropdownMenuItem> - <DropdownMenuItem onClick={() => onAction({ row, type: "update" })}> + <DropdownMenuItem onClick={() => handleAction({ row, type: "update" })}> <Upload className="mr-2 h-4 w-4" /> 새 버전 업로드 </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem - onClick={() => onAction({ row, type: "delete" })} + onClick={() => handleAction({ row, type: "delete" })} className="text-red-600" > <Trash2 className="mr-2 h-4 w-4" /> @@ -372,17 +385,9 @@ export function RfqAttachmentsTable({ ); }, size: 60, + enablePinning: true, }, - ], []); - - const columns = React.useMemo(() => getAttachmentColumns(handleAction), [getAttachmentColumns, handleAction]); - - const filterFields: DataTableFilterField<RfqAttachment>[] = [ - { id: "serialNo", label: "일련번호" }, - { id: "originalFileName", label: "파일명" }, - { id: "description", label: "설명" }, - { id: "createdByName", label: "업로드자" }, - ]; + ], [handleAction]); const advancedFilterFields: DataTableAdvancedFilterField<RfqAttachment>[] = [ { 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(() => ( + <div className="flex items-center gap-2"> + {selectedRows.length > 0 && ( + <> + <Button + variant="outline" + size="sm" + onClick={handleBulkDownload} + > + <Download className="h-4 w-4 mr-2" /> + 다운로드 ({selectedRows.length}) + </Button> + <Button + variant="outline" + size="sm" + onClick={handleBulkDelete} + className="text-red-600" + > + <Trash2 className="h-4 w-4 mr-2" /> + 삭제 ({selectedRows.length}) + </Button> + </> + )} + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + disabled={isRefreshing} + > + <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> + 새로고침 + </Button> + + {/* 구매 탭에서만 파일 업로드 버튼 표시 */} + {activeTab === "구매" && ( + <AddAttachmentDialog + rfqId={rfqId} + attachmentType="구매" + onSuccess={handleRefresh} + open={addDialogOpen} + onOpenChange={setAddDialogOpen} + /> + )} + </div> + ), [selectedRows, activeTab, isRefreshing, addDialogOpen, handleBulkDownload, handleBulkDelete, handleRefresh, rfqId]); return ( - <div className={cn("w-full space-y-4", className)}> - <Tabs value={activeTab} onValueChange={setActiveTab}> + <div className={cn("w-full space-y-4")}> + <Tabs + value={activeTab} + onValueChange={(value) => setActiveTab(value as '설계' | '구매')} + > <div className="flex items-center justify-between mb-4"> <TabsList> <TabsTrigger value="설계"> 설계 첨부파일 <Badge variant="secondary" className="ml-2"> - {designData.data.length} + {designCount} </Badge> </TabsTrigger> <TabsTrigger value="구매"> 구매 첨부파일 <Badge variant="secondary" className="ml-2"> - {purchaseData.data.length} + {purchaseCount} </Badge> </TabsTrigger> </TabsList> - - <div className="flex items-center gap-2"> - <Button - variant="outline" - size="sm" - onClick={handleRefresh} - disabled={isRefreshing} - > - <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> - 새로고침 - </Button> - - {/* 구매 탭에서만 파일 업로드 버튼 표시 */} - {activeTab === "구매" && ( - <AddAttachmentDialog - rfqId={rfqId} - attachmentType="구매" - onSuccess={handleRefresh} - /> - )} - </div> </div> <TabsContent value="설계" className="mt-0"> - <Card> - <CardContent className="p-0"> - <DataTable table={designTable}> - <DataTableAdvancedToolbar - table={designTable} - filterFields={advancedFilterFields} - shallow={false} - /> - </DataTable> - </CardContent> - </Card> + + <ClientDataTable + columns={columns} + data={filteredData} + advancedFilterFields={advancedFilterFields} + autoSizeColumns={true} + compact={true} + maxHeight="34rem" + onSelectedRowsChange={setSelectedRows} + initialColumnPinning={{ + left: ["select", "serialNo"], + right: ["actions"], + }} + > + {additionalActions} + </ClientDataTable> + </TabsContent> <TabsContent value="구매" className="mt-0"> - <Card> - <CardContent className="p-0"> - <DataTable table={purchaseTable}> - <DataTableAdvancedToolbar - table={purchaseTable} - filterFields={advancedFilterFields} - shallow={false} - /> - </DataTable> - </CardContent> - </Card> + + <ClientDataTable + columns={columns} + data={filteredData} + advancedFilterFields={advancedFilterFields} + autoSizeColumns={true} + compact={true} + maxHeight="34rem" + onSelectedRowsChange={setSelectedRows} + initialColumnPinning={{ + left: ["select", "serialNo"], + right: ["actions"], + }} + > + {additionalActions} + </ClientDataTable> + </TabsContent> </Tabs> {/* 삭제 다이얼로그 */} - {selectedAttachment && ( + {(selectedAttachment || selectedRows.length > 0) && ( <DeleteAttachmentsDialog open={deleteDialogOpen} - onOpenChange={setDeleteDialogOpen} - attachments={[selectedAttachment]} + onOpenChange={(open) => { + setDeleteDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} + attachments={selectedAttachment ? [selectedAttachment] : selectedRows} onSuccess={handleRefresh} /> )} @@ -529,11 +549,31 @@ export function RfqAttachmentsTable({ {selectedAttachment && ( <UpdateRevisionDialog open={updateRevisionDialogOpen} - onOpenChange={setUpdateRevisionDialogOpen} + onOpenChange={(open) => { + setUpdateRevisionDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} attachment={selectedAttachment} onSuccess={handleRefresh} /> )} + + {/* 리비전 히스토리 다이얼로그 */} + {selectedAttachment && ( + <RevisionHistoryDialog + open={revisionHistoryDialogOpen} + onOpenChange={(open) => { + setRevisionHistoryDialogOpen(open); + if (!open) { + setSelectedAttachment(null); + } + }} + attachmentId={selectedAttachment.id} + attachmentName={selectedAttachment.originalFileName || selectedAttachment.fileName || undefined} + /> + )} </div> ); }
\ 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 <File className="h-4 w-4" />; + + const type = fileType.toLowerCase(); + if (type.includes('image') || ['jpg', 'jpeg', 'png', 'gif'].includes(type)) { + return <FileImage className="h-4 w-4 text-blue-500" />; + } + if (type.includes('excel') || type.includes('spreadsheet') || ['xls', 'xlsx'].includes(type)) { + return <FileSpreadsheet className="h-4 w-4 text-green-500" />; + } + if (type.includes('pdf')) { + return <FileText className="h-4 w-4 text-red-500" />; + } + if (type.includes('code') || ['js', 'ts', 'tsx', 'jsx', 'html', 'css'].includes(type)) { + return <FileCode className="h-4 w-4 text-purple-500" />; + } + return <File className="h-4 w-4 text-gray-500" />; +}; + +// 파일 크기 포맷팅 +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<VendorAttachment[]>(initialData); + const [isRefreshing, setIsRefreshing] = React.useState(false); + const [selectedRows, setSelectedRows] = React.useState<VendorAttachment[]>([]); + + // 데이터 새로고침 + 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<VendorAttachment>) => { + 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<VendorAttachment>[] = React.useMemo(() => [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + enablePinning: true, + }, + { + accessorKey: "vendorName", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="벤더" />, + cell: ({ row }) => { + const vendor = row.original; + return ( + <div className="flex items-center gap-2"> + <Building2 className="h-4 w-4 text-muted-foreground" /> + <div className="flex flex-col"> + <span className="text-sm font-medium">{vendor.vendorName || "-"}</span> + <span className="text-xs text-muted-foreground">{vendor.vendorCode}</span> + </div> + </div> + ); + }, + size: 150, + enablePinning: true, + }, + { + accessorKey: "attachmentType", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="문서 유형" />, + cell: ({ row }) => { + const type = row.original.attachmentType; + return ( + <Badge variant="outline" className="font-mono"> + {type} + </Badge> + ); + }, + size: 100, + }, + { + accessorKey: "documentNo", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="문서번호" />, + cell: ({ row }) => ( + <span className="font-mono text-sm">{row.original.documentNo || "-"}</span> + ), + size: 120, + }, + { + accessorKey: "originalFileName", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="파일명" />, + cell: ({ row }) => { + const file = row.original; + return ( + <div className="flex items-center gap-2"> + {getFileIcon(file.fileType)} + <div className="flex flex-col"> + <span className="text-sm font-medium truncate max-w-[250px]" title={file.originalFileName}> + {file.originalFileName || file.fileName || "-"} + </span> + </div> + </div> + ); + }, + size: 300, + }, + { + accessorKey: "description", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />, + cell: ({ row }) => ( + <div className="max-w-[200px] truncate" title={row.original.description || ""}> + {row.original.description || "-"} + </div> + ), + size: 200, + }, + { + accessorKey: "validTo", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="유효기간" />, + cell: ({ row }) => { + const { validFrom, validTo } = row.original; + const validity = checkValidity(validTo); + + if (!validTo) return <span className="text-muted-foreground">-</span>; + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className="flex items-center gap-2"> + {validity === "expired" && ( + <AlertCircle className="h-4 w-4 text-red-500" /> + )} + {validity === "expiring-soon" && ( + <AlertCircle className="h-4 w-4 text-yellow-500" /> + )} + <span className={cn( + "text-sm", + validity === "expired" && "text-red-500", + validity === "expiring-soon" && "text-yellow-500" + )}> + {format(new Date(validTo), "yyyy-MM-dd")} + </span> + </div> + </TooltipTrigger> + <TooltipContent> + <p>유효기간: {validFrom ? format(new Date(validFrom), "yyyy-MM-dd") : "?"} ~ {format(new Date(validTo), "yyyy-MM-dd")}</p> + {validity === "expired" && <p className="text-red-500">만료됨</p>} + {validity === "expiring-soon" && <p className="text-yellow-500">곧 만료 예정</p>} + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); + }, + size: 120, + }, + { + accessorKey: "responseStatus", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="응답 상태" />, + cell: ({ row }) => { + const status = row.original.responseStatus; + return status ? ( + <Badge variant={getStatusVariant(status)}> + {status} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "fileSize", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="크기" />, + cell: ({ row }) => ( + <span className="text-sm text-muted-foreground"> + {formatFileSize(row.original.fileSize)} + </span> + ), + size: 80, + }, + { + accessorKey: "uploadedAt", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드일" />, + cell: ({ row }) => { + const date = row.original.uploadedAt; + return date ? ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="text-sm cursor-help"> + {format(new Date(date), "MM-dd HH:mm")} + </span> + </TooltipTrigger> + <TooltipContent> + <p>{format(new Date(date), "yyyy년 MM월 dd일 HH시 mm분")}</p> + <p className="text-xs text-muted-foreground"> + ({formatDistanceToNow(new Date(date), { addSuffix: true, locale: ko })}) + </p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) : ( + "-" + ); + }, + size: 100, + }, + { + id: "actions", + header: "작업", + cell: ({ row }) => { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M3.625 7.5C3.625 8.12132 3.12132 8.625 2.5 8.625C1.87868 8.625 1.375 8.12132 1.375 7.5C1.375 6.87868 1.87868 6.375 2.5 6.375C3.12132 6.375 3.625 6.87868 3.625 7.5ZM8.625 7.5C8.625 8.12132 8.12132 8.625 7.5 8.625C6.87868 8.625 6.375 8.12132 6.375 7.5C6.375 6.87868 6.87868 6.375 7.5 6.375C8.12132 6.375 8.625 6.87868 8.625 7.5ZM12.5 8.625C13.1213 8.625 13.625 8.12132 13.625 7.5C13.625 6.87868 13.1213 6.375 12.5 6.375C11.8787 6.375 11.375 6.87868 11.375 7.5C11.375 8.12132 11.8787 8.625 12.5 8.625Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path> + </svg> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => handleAction({ row, type: "download" })}> + <Download className="mr-2 h-4 w-4" /> + 다운로드 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => handleAction({ row, type: "preview" })}> + <Eye className="mr-2 h-4 w-4" /> + 미리보기 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + size: 60, + enablePinning: true, + }, + ], [handleAction]); + + const advancedFilterFields: DataTableAdvancedFilterField<VendorAttachment>[] = [ + { 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(() => ( + <div className="flex items-center gap-2"> + {selectedRows.length > 0 && ( + <Button + variant="outline" + size="sm" + onClick={handleBulkDownload} + > + <Download className="h-4 w-4 mr-2" /> + 다운로드 ({selectedRows.length}) + </Button> + )} + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + disabled={isRefreshing} + > + <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> + 새로고침 + </Button> + </div> + ), [selectedRows, isRefreshing, handleBulkDownload, handleRefresh]); + + // 벤더별 그룹 카운트 + const vendorCounts = React.useMemo(() => { + const counts = new Map<string, number>(); + data.forEach(item => { + const vendor = item.vendorName || "Unknown"; + counts.set(vendor, (counts.get(vendor) || 0) + 1); + }); + return counts; + }, [data]); + + return ( + <div className={cn("w-full space-y-4")}> + {/* 벤더별 요약 정보 */} + <div className="flex gap-2 flex-wrap"> + {Array.from(vendorCounts.entries()).map(([vendor, count]) => ( + <Badge key={vendor} variant="secondary"> + {vendor}: {count} + </Badge> + ))} + </div> + + <ClientDataTable + columns={columns} + data={data} + advancedFilterFields={advancedFilterFields} + autoSizeColumns={true} + compact={true} + maxHeight="34rem" + onSelectedRowsChange={setSelectedRows} + initialColumnPinning={{ + left: ["select", "vendorName"], + right: ["actions"], + }} + > + {additionalActions} + </ClientDataTable> + </div> + ); +}
\ 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<RfqsLastView | null> }; -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<RfqLastAttachments>().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<RfqLastAttachments>().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<RfqLastAttachments>().withDefault([ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<RfqLastAttachments>().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<RfqLastAttachments>().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<ReturnType<typeof searchParamsRfqAttachmentsCache.parse>>, - 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<typeof searchParamsRfqAttachmentsCache.parse> +>; 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<any[]>([]); + const [selectedVendors, setSelectedVendors] = React.useState<any[]>([]); + + // 벤더 로드 + 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( + <div> + <p>{selectedVendors.length}개 벤더가 추가되었습니다.</p> + <p className="text-sm text-muted-foreground mt-1"> + 벤더 목록에서 '정보 일괄 입력' 버튼으로 조건을 설정하세요. + </p> + </div> + ); + 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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl max-h-[80vh] p-0 flex flex-col"> + {/* 헤더 */} + <DialogHeader className="p-6 pb-0"> + <DialogTitle>벤더 추가</DialogTitle> + <DialogDescription> + 견적 요청을 보낼 벤더를 선택하세요. 조건 설정은 추가 후 일괄로 진행할 수 있습니다. + </DialogDescription> + </DialogHeader> + + {/* 컨텐츠 영역 */} + <div className="flex-1 px-6 py-4 overflow-y-auto"> + <div className="space-y-4"> + {/* 안내 메시지 */} + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + 여기서는 벤더만 선택합니다. 납기일, 결제조건 등의 상세 정보는 벤더 추가 후 + '정보 일괄 입력' 기능으로 한 번에 설정할 수 있습니다. + </AlertDescription> + </Alert> + + {/* 벤더 선택 카드 */} + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <CardTitle className="text-lg">벤더 선택</CardTitle> + <Badge variant="outline" className="ml-2"> + {selectedVendors.length}개 선택됨 + </Badge> + </div> + <CardDescription> + RFQ를 발송할 벤더를 선택하세요. 여러 개 선택 가능합니다. + </CardDescription> + </CardHeader> + <CardContent> + <div className="space-y-4"> + {/* 벤더 추가 버튼 */} + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className="w-full justify-between" + disabled={vendorList.length === 0} + > + <span className="flex items-center gap-2"> + <Plus className="h-4 w-4" /> + 벤더 선택하기 + </span> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[500px] p-0" align="start"> + <Command> + <CommandInput placeholder="벤더명 또는 코드로 검색..." /> + <CommandList + onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }} + > + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {vendorList + .filter(vendor => !isVendorSelected(vendor.id)) + .map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorCode} ${vendor.vendorName}`} + onSelect={() => handleAddVendor(vendor)} + > + <div className="flex items-center gap-2 w-full"> + <Badge variant="outline" className="shrink-0"> + {vendor.vendorCode} + </Badge> + <span className="truncate">{vendor.vendorName}</span> + {vendor.country && ( + <span className="text-xs text-muted-foreground ml-auto"> + {vendor.country} + </span> + )} + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + + {/* 선택된 벤더 목록 */} + {selectedVendors.length > 0 && ( + <div className="space-y-2"> + <Label className="text-sm text-muted-foreground">선택된 벤더 목록</Label> + <ScrollArea className="h-[200px] w-full rounded-md border p-4"> + <div className="space-y-2"> + {selectedVendors.map((vendor, index) => ( + <div + key={vendor.id} + className="flex items-center justify-between p-2 rounded-lg bg-secondary/50" + > + <div className="flex items-center gap-2"> + <span className="text-sm text-muted-foreground"> + {index + 1}. + </span> + <Badge variant="outline"> + {vendor.vendorCode} + </Badge> + <span className="text-sm font-medium"> + {vendor.vendorName} + </span> + </div> + <Button + variant="ghost" + size="sm" + onClick={() => handleRemoveVendor(vendor.id)} + className="h-8 w-8 p-0" + > + <X className="h-4 w-4" /> + </Button> + </div> + ))} + </div> + </ScrollArea> + </div> + )} + + {/* 벤더가 없는 경우 메시지 */} + {selectedVendors.length === 0 && ( + <div className="text-center py-8 text-muted-foreground"> + <p className="text-sm">아직 선택된 벤더가 없습니다.</p> + <p className="text-xs mt-1">위 버튼을 클릭하여 벤더를 추가하세요.</p> + </div> + )} + </div> + </CardContent> + </Card> + </div> + </div> + + {/* 푸터 */} + <DialogFooter className="p-6 pt-0 border-t"> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={isLoading || selectedVendors.length === 0} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {selectedVendors.length > 0 + ? `${selectedVendors.length}개 벤더 추가` + : '벤더 추가' + } + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ 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<typeof formSchema>; + +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<SelectOption[]>([]); + const [paymentTerms, setPaymentTerms] = React.useState<SelectOption[]>([]); + const [shippingPlaces, setShippingPlaces] = React.useState<SelectOption[]>([]); + const [destinationPlaces, setDestinationPlaces] = React.useState<SelectOption[]>([]); + + // 로딩 상태 + 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<FormValues>({ + 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 ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl h-[90vh] p-0 flex flex-col"> + {/* 헤더 */} + <DialogHeader className="p-6 pb-0"> + <DialogTitle>조건 일괄 설정</DialogTitle> + <DialogDescription> + 선택한 {selectedVendors.length}개 벤더에 동일한 조건을 적용합니다. + 변경하려는 항목만 체크하고 값을 입력하세요. + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0"> + {/* 스크롤 가능한 컨텐츠 영역 */} + <ScrollArea className="flex-1 px-6"> + <div className="grid gap-4 py-4"> + {/* 선택된 벤더 정보 */} + <Card> + <CardHeader className="pb-3"> + <div className="flex items-center justify-between"> + <CardTitle className="text-lg flex items-center gap-2"> + <Package className="h-5 w-5" /> + 대상 벤더 + </CardTitle> + <Badge>{selectedVendors.length}개</Badge> + </div> + </CardHeader> + <CardContent> + <div className="flex flex-wrap gap-2"> + {selectedVendors.map((vendor) => ( + <Badge key={vendor.id} variant="secondary"> + {vendor.vendorCode} - {vendor.vendorName} + </Badge> + ))} + </div> + </CardContent> + </Card> + + {/* 안내 메시지 */} + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + 체크박스를 선택한 항목만 업데이트됩니다. + 선택하지 않은 항목은 기존 값이 유지됩니다. + </AlertDescription> + </Alert> + + {/* 기본 조건 설정 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">기본 조건</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 통화 */} + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.currency} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, currency: !!checked }) + } + /> + <FormField + control={form.control} + name="currency" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.currency && "text-muted-foreground" + )}> + 통화 + </FormLabel> + <div className="col-span-2"> + <FormControl> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + className="w-full justify-between" + disabled={!fieldsToUpdate.currency} + > + {field.value || "통화 선택"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="통화 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {currencies.map((currency) => ( + <CommandItem + key={currency} + value={currency} + onSelect={() => field.onChange(currency)} + > + {currency} + <Check + className={cn( + "ml-auto h-4 w-4", + currency === field.value ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + </FormControl> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + + {/* 결제 조건 */} + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.paymentTermsCode} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, paymentTermsCode: !!checked }) + } + /> + <FormField + control={form.control} + name="paymentTermsCode" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.paymentTermsCode && "text-muted-foreground" + )}> + 결제 조건 + </FormLabel> + <div className="col-span-2"> + <Popover open={paymentTermsOpen} onOpenChange={setPaymentTermsOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={paymentTermsOpen} + className="w-full justify-between" + disabled={!fieldsToUpdate.paymentTermsCode || paymentTermsLoading} + > + {selectedPaymentTerm ? ( + <span className="truncate"> + {selectedPaymentTerm.code} - {selectedPaymentTerm.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {paymentTermsLoading ? "로딩 중..." : "결제조건 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="코드 또는 설명으로 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {paymentTerms.map((term) => ( + <CommandItem + key={term.id} + value={`${term.code} ${term.description}`} + onSelect={() => { + field.onChange(term.code); + setPaymentTermsOpen(false); + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{term.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{term.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + term.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + + {/* 인코텀즈 */} + <div className="flex items-start gap-4"> + <Checkbox + className="mt-3" + checked={fieldsToUpdate.incoterms} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, incoterms: !!checked }) + } + /> + <div className="flex-1 grid grid-cols-3 gap-4"> + <Label className={cn( + "text-right pt-2", + !fieldsToUpdate.incoterms && "text-muted-foreground" + )}> + 인코텀즈 + </Label> + <div className="col-span-2 space-y-2"> + <FormField + control={form.control} + name="incotermsCode" + render={({ field }) => ( + <FormItem> + <Popover open={incotermsOpen} onOpenChange={setIncotermsOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={incotermsOpen} + className="w-full justify-between" + disabled={!fieldsToUpdate.incoterms || incotermsLoading} + > + {selectedIncoterm ? ( + <span className="truncate"> + {selectedIncoterm.code} - {selectedIncoterm.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {incotermsLoading ? "로딩 중..." : "인코텀즈 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="코드 또는 설명으로 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {incoterms.map((incoterm) => ( + <CommandItem + key={incoterm.id} + value={`${incoterm.code} ${incoterm.description}`} + onSelect={() => { + field.onChange(incoterm.code); + setIncotermsOpen(false); + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{incoterm.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{incoterm.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + incoterm.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + {/* <FormField + control={form.control} + name="incotermsDetail" + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="인코텀즈 상세 (예: 부산항)" + {...field} + disabled={!fieldsToUpdate.incoterms} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> */} + </div> + </div> + </div> + + {/* 납기일 */} + {!rfqCode.startsWith("F") && ( + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.deliveryDate} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, deliveryDate: !!checked }) + } + /> + <FormField + control={form.control} + name="deliveryDate" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.deliveryDate && "text-muted-foreground" + )}> + 납기일 + </FormLabel> + <div className="col-span-2"> + <Popover open={calendarOpen} onOpenChange={setCalendarOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full justify-start text-left font-normal", + !field.value && "text-muted-foreground" + )} + disabled={!fieldsToUpdate.deliveryDate} + > + <CalendarIcon className="mr-2 h-4 w-4" /> + {field.value ? ( + format(field.value, "yyyy-MM-dd", { locale: ko }) + ) : ( + <span>날짜를 선택하세요</span> + )} + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0"> + <Calendar + mode="single" + selected={field.value} + onSelect={(date) => { + field.onChange(date); + setCalendarOpen(false); + }} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + )} + + {/* 계약 기간 */} + {rfqCode.startsWith("F") && ( + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.contractDuration} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, contractDuration: !!checked }) + } + /> + <FormField + control={form.control} + name="contractDuration" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.contractDuration && "text-muted-foreground" + )}> + 계약 기간 + </FormLabel> + <div className="col-span-2"> + <FormControl> + <Input + placeholder="예: 12개월" + {...field} + disabled={!fieldsToUpdate.contractDuration} + /> + </FormControl> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + )} + + {/* 세금 코드 */} + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.taxCode} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, taxCode: !!checked }) + } + /> + <FormField + control={form.control} + name="taxCode" + render={({ field }) => ( + <FormItem className="flex-1 grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.taxCode && "text-muted-foreground" + )}> + 세금 코드 + </FormLabel> + <div className="col-span-2"> + <FormControl> + <Input + {...field} + disabled={!fieldsToUpdate.taxCode} + /> + </FormControl> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + + {/* 선적지/도착지 */} + <div className="flex items-start gap-4"> + <Checkbox + className="mt-3" + checked={fieldsToUpdate.shipping} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, shipping: !!checked }) + } + /> + <div className="flex-1 space-y-2"> + <FormField + control={form.control} + name="placeOfShipping" + render={({ field }) => ( + <FormItem className="grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.shipping && "text-muted-foreground" + )}> + 선적지 + </FormLabel> + <div className="col-span-2"> + <Popover open={shippingOpen} onOpenChange={setShippingOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={shippingOpen} + className="w-full justify-between" + disabled={!fieldsToUpdate.shipping || shippingLoading} + > + {selectedShipping ? ( + <span className="truncate"> + {selectedShipping.code} - {selectedShipping.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {shippingLoading ? "로딩 중..." : "선적지 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="선적지 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {shippingPlaces.map((place) => ( + <CommandItem + key={place.id} + value={`${place.code} ${place.description}`} + onSelect={() => { + field.onChange(place.code); + setShippingOpen(false); + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{place.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{place.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + place.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="placeOfDestination" + render={({ field }) => ( + <FormItem className="grid grid-cols-3 items-center gap-4"> + <FormLabel className={cn( + "text-right", + !fieldsToUpdate.shipping && "text-muted-foreground" + )}> + 도착지 + </FormLabel> + <div className="col-span-2"> + <Popover open={destinationOpen} onOpenChange={setDestinationOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={destinationOpen} + className="w-full justify-between" + disabled={!fieldsToUpdate.shipping || destinationLoading} + > + {selectedDestination ? ( + <span className="truncate"> + {selectedDestination.code} - {selectedDestination.description} + </span> + ) : ( + <span className="text-muted-foreground"> + {destinationLoading ? "로딩 중..." : "도착지 선택"} + </span> + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-full p-0" align="start"> + <Command> + <CommandInput placeholder="도착지 검색..." /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {destinationPlaces.map((place) => ( + <CommandItem + key={place.id} + value={`${place.code} ${place.description}`} + onSelect={() => { + field.onChange(place.code); + setDestinationOpen(false); + }} + > + <div className="flex items-center gap-2 w-full"> + <span className="font-medium">{place.code}</span> + <span className="text-muted-foreground">-</span> + <span className="truncate">{place.description}</span> + <Check + className={cn( + "ml-auto h-4 w-4", + place.code === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </div> + </FormItem> + )} + /> + </div> + </div> + </CardContent> + </Card> + + {/* 추가 옵션 */} + <Card> + <CardHeader> + <CardTitle className="text-lg">추가 옵션</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {/* 연동제 적용 */} + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.materialPrice} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, materialPrice: !!checked }) + } + /> + <FormField + control={form.control} + name="materialPriceRelatedYn" + render={({ field }) => ( + <FormItem className="flex-1 flex items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className={cn( + !fieldsToUpdate.materialPrice && "text-muted-foreground" + )}> + 연동제 적용 + </FormLabel> + <div className="text-sm text-muted-foreground"> + 원자재 가격 연동 여부 + </div> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + disabled={!fieldsToUpdate.materialPrice} + /> + </FormControl> + </FormItem> + )} + /> + </div> + + {/* Spare Part */} + <div className="space-y-2"> + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.sparepart} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, sparepart: !!checked }) + } + /> + <FormField + control={form.control} + name="sparepartYn" + render={({ field }) => ( + <FormItem className="flex-1 flex items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className={cn( + !fieldsToUpdate.sparepart && "text-muted-foreground" + )}> + Spare Part + </FormLabel> + <div className="text-sm text-muted-foreground"> + 예비 부품 요구사항 + </div> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + disabled={!fieldsToUpdate.sparepart} + /> + </FormControl> + </FormItem> + )} + /> + </div> + {form.watch("sparepartYn") && fieldsToUpdate.sparepart && ( + <FormField + control={form.control} + name="sparepartDescription" + render={({ field }) => ( + <FormItem className="ml-7"> + <FormControl> + <Textarea + placeholder="Spare Part 요구사항을 입력하세요..." + {...field} + disabled={!fieldsToUpdate.sparepart} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + + {/* 초도품 관리 */} + <div className="space-y-2"> + <div className="flex items-center gap-4"> + <Checkbox + checked={fieldsToUpdate.first} + onCheckedChange={(checked) => + setFieldsToUpdate({ ...fieldsToUpdate, first: !!checked }) + } + /> + <FormField + control={form.control} + name="firstYn" + render={({ field }) => ( + <FormItem className="flex-1 flex items-center justify-between"> + <div className="space-y-0.5"> + <FormLabel className={cn( + !fieldsToUpdate.first && "text-muted-foreground" + )}> + 초도품 관리 + </FormLabel> + <div className="text-sm text-muted-foreground"> + 초도품 관리 요구사항 + </div> + </div> + <FormControl> + <Switch + checked={field.value} + onCheckedChange={field.onChange} + disabled={!fieldsToUpdate.first} + /> + </FormControl> + </FormItem> + )} + /> + </div> + {form.watch("firstYn") && fieldsToUpdate.first && ( + <FormField + control={form.control} + name="firstDescription" + render={({ field }) => ( + <FormItem className="ml-7"> + <FormControl> + <Textarea + placeholder="초도품 관리 요구사항을 입력하세요..." + {...field} + disabled={!fieldsToUpdate.first} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + </div> + </CardContent> + </Card> + </div> + </ScrollArea> + + {/* 푸터 */} + <DialogFooter className="p-6 pt-4 border-t"> + <div className="flex items-center justify-between w-full"> + <div className="text-sm text-muted-foreground"> + {getUpdateCount() > 0 + ? `${getUpdateCount()}개 항목 선택됨` + : '변경할 항목을 선택하세요' + } + </div> + <div className="flex gap-2"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading || getUpdateCount() === 0} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {getUpdateCount() > 0 + ? `${getUpdateCount()}개 항목 업데이트` + : '조건 업데이트' + } + </Button> + </div> + </div> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx new file mode 100644 index 00000000..b6d42804 --- /dev/null +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -0,0 +1,746 @@ +"use client"; + +import * as React from "react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Plus, + Send, + Eye, + Edit, + Trash2, + Building2, + Calendar, + DollarSign, + FileText, + RefreshCw, + Mail, + CheckCircle, + Clock, + XCircle, + AlertCircle, + Settings2, + ClipboardList, + Globe, + Package, + MapPin, + Info +} from "lucide-react"; +import { format } 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 } from "@/types/table"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { AddVendorDialog } from "./add-vendor-dialog"; +import { BatchUpdateConditionsDialog } from "./batch-update-conditions-dialog"; +// import { VendorDetailDialog } from "./vendor-detail-dialog"; + +// 타입 정의 +interface RfqDetail { + detailId: number; + vendorId: number | null; + vendorName: string | null; + vendorCode: string | null; + vendorCountry: string | null; + vendorCategory?: string | null; // 업체분류 + vendorGrade?: string | null; // AVL 등급 + basicContract?: string | null; // 기본계약 + shortList: boolean; + currency: string | null; + paymentTermsCode: string | null; + paymentTermsDescription: string | null; + incotermsCode: string | null; + incotermsDescription: string | null; + incotermsDetail?: string | null; + deliveryDate: Date | null; + contractDuration: string | null; + taxCode: string | null; + placeOfShipping?: string | null; + placeOfDestination?: string | null; + materialPriceRelatedYn?: boolean | null; + sparepartYn?: boolean | null; + firstYn?: boolean | null; + firstDescription?: string | null; + sparepartDescription?: string | null; + updatedAt?: Date | null; + updatedByUserName?: string | null; +} + +interface VendorResponse { + id: number; + vendorId: number; + status: "초대됨" | "작성중" | "제출완료" | "수정요청" | "최종확정" | "취소"; + responseVersion: number; + isLatest: boolean; + submittedAt: Date | null; + totalAmount: number | null; + currency: string | null; + vendorDeliveryDate: Date | null; + quotedItemCount?: number; + attachmentCount?: number; +} + +interface RfqVendorTableProps { + rfqId: number; + rfqCode?: string; + rfqDetails: RfqDetail[]; + vendorResponses: VendorResponse[]; +} + +// 상태별 아이콘 반환 +const getStatusIcon = (status: string) => { + switch (status) { + case "초대됨": return <Mail className="h-4 w-4" />; + case "작성중": return <Clock className="h-4 w-4" />; + case "제출완료": return <CheckCircle className="h-4 w-4" />; + case "수정요청": return <AlertCircle className="h-4 w-4" />; + case "최종확정": return <FileText className="h-4 w-4" />; + case "취소": return <XCircle className="h-4 w-4" />; + default: return <Clock className="h-4 w-4" />; + } +}; + +// 상태별 색상 +const getStatusVariant = (status: string) => { + switch (status) { + case "초대됨": return "secondary"; + case "작성중": return "outline"; + case "제출완료": return "default"; + case "수정요청": return "warning"; + case "최종확정": return "success"; + case "취소": return "destructive"; + default: return "outline"; + } +}; + +// 데이터 병합 (rfqDetails + vendorResponses) +const mergeVendorData = ( + rfqDetails: RfqDetail[], + vendorResponses: VendorResponse[], + rfqCode?: string +): (RfqDetail & { response?: VendorResponse; rfqCode?: string })[] => { + return rfqDetails.map(detail => { + const response = vendorResponses.find( + r => r.vendorId === detail.vendorId && r.isLatest + ); + return { ...detail, response, rfqCode }; + }); +}; + +// 추가 조건 포맷팅 +const formatAdditionalConditions = (data: any) => { + const conditions = []; + if (data.firstYn) conditions.push("초도품"); + if (data.materialPriceRelatedYn) conditions.push("연동제"); + if (data.sparepartYn) conditions.push("스페어"); + return conditions.length > 0 ? conditions.join(", ") : "-"; +}; + +export function RfqVendorTable({ + rfqId, + rfqCode, + rfqDetails, + vendorResponses, +}: RfqVendorTableProps) { + const [isRefreshing, setIsRefreshing] = React.useState(false); + const [selectedRows, setSelectedRows] = React.useState<any[]>([]); + const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false); + const [isBatchUpdateOpen, setIsBatchUpdateOpen] = React.useState(false); + const [selectedVendor, setSelectedVendor] = React.useState<any | null>(null); + + // 데이터 병합 + const mergedData = React.useMemo( + () => mergeVendorData(rfqDetails, vendorResponses, rfqCode), + [rfqDetails, vendorResponses, rfqCode] + ); + + // 액션 처리 + const handleAction = React.useCallback(async (action: string, vendor: any) => { + switch (action) { + case "view": + setSelectedVendor(vendor); + break; + + case "send": + // RFQ 발송 로직 + toast.info(`${vendor.vendorName}에게 RFQ를 발송합니다.`); + break; + + case "edit": + // 수정 로직 + toast.info("수정 기능은 준비중입니다."); + break; + + case "delete": + // 삭제 로직 + if (confirm(`${vendor.vendorName}을(를) 삭제하시겠습니까?`)) { + toast.success(`${vendor.vendorName}이(가) 삭제되었습니다.`); + } + break; + + case "response-detail": + // 회신 상세 보기 + toast.info(`${vendor.vendorName}의 회신 상세를 확인합니다.`); + break; + } + }, []); + + // 선택된 벤더들에게 일괄 발송 + const handleBulkSend = React.useCallback(async () => { + if (selectedRows.length === 0) { + toast.warning("발송할 벤더를 선택해주세요."); + return; + } + + const vendorNames = selectedRows.map(r => r.vendorName).join(", "); + if (confirm(`선택한 ${selectedRows.length}개 벤더에게 RFQ를 발송하시겠습니까?\n\n${vendorNames}`)) { + toast.success(`${selectedRows.length}개 벤더에게 RFQ를 발송했습니다.`); + setSelectedRows([]); + } + }, [selectedRows]); + + + // 컬럼 정의 (확장된 버전) + const columns: ColumnDef<any>[] = React.useMemo(() => [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")} + onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)} + aria-label="select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(v) => row.toggleSelected(!!v)} + aria-label="select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "rfqCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="ITB/RFQ/견적 No." />, + cell: ({ row }) => { + return ( + <span className="font-mono text-xs">{row.original.rfqCode || "-"}</span> + ); + }, + size: 120, + }, + // { + // accessorKey: "response.responseVersion", + // header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Rev" />, + // cell: ({ row }) => { + // const version = row.original.response?.responseVersion; + // return version ? ( + // <Badge variant="outline" className="font-mono">v{version}</Badge> + // ) : ( + // <span className="text-muted-foreground">-</span> + // ); + // }, + // size: 60, + // }, + { + accessorKey: "vendorName", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="협력업체정보" />, + cell: ({ row }) => { + const vendor = row.original; + return ( + <div className="flex items-center gap-2"> + <Building2 className="h-4 w-4 text-muted-foreground" /> + <div className="flex flex-col"> + <span className="text-sm font-medium">{vendor.vendorName || "-"}</span> + <span className="text-xs text-muted-foreground">{vendor.vendorCode}</span> + </div> + </div> + ); + }, + size: 180, + }, + { + accessorKey: "vendorCategory", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업체분류" />, + cell: ({ row }) => row.original.vendorCategory || "-", + size: 100, + }, + { + accessorKey: "vendorCountry", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="내외자 (위치)" />, + cell: ({ row }) => { + const country = row.original.vendorCountry; + const isLocal = country === "KR" || country === "한국"; + return ( + <Badge variant={isLocal ? "default" : "secondary"}> + {country || "-"} + </Badge> + ); + }, + size: 100, + }, + { + accessorKey: "vendorGrade", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="AVL 정보 (등급)" />, + cell: ({ row }) => { + const grade = row.original.vendorGrade; + if (!grade) return <span className="text-muted-foreground">-</span>; + + const gradeColor = { + "A": "text-green-600", + "B": "text-blue-600", + "C": "text-yellow-600", + "D": "text-red-600", + }[grade] || "text-gray-600"; + + return <span className={cn("font-semibold", gradeColor)}>{grade}</span>; + }, + size: 100, + }, + { + accessorKey: "basicContract", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약" />, + cell: ({ row }) => row.original.basicContract || "-", + size: 100, + }, + { + accessorKey: "currency", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="요청 통화" />, + cell: ({ row }) => { + const currency = row.original.currency; + return currency ? ( + <Badge variant="outline">{currency}</Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 80, + }, + { + accessorKey: "paymentTermsCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="지급조건" />, + cell: ({ row }) => { + const code = row.original.paymentTermsCode; + const desc = row.original.paymentTermsDescription; + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="text-sm">{code || "-"}</span> + </TooltipTrigger> + {desc && ( + <TooltipContent> + <p>{desc}</p> + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + ); + }, + size: 100, + }, + { + accessorKey: "taxCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Tax" />, + cell: ({ row }) => row.original.taxCode || "-", + size: 60, + }, + { + accessorKey: "deliveryDate", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="계약납기일/기간" />, + cell: ({ row }) => { + const deliveryDate = row.original.deliveryDate; + const contractDuration = row.original.contractDuration; + + return ( + <div className="flex flex-col gap-0.5"> + {deliveryDate && ( + <span className="text-xs"> + {format(new Date(deliveryDate), "yyyy-MM-dd")} + </span> + )} + {contractDuration && ( + <span className="text-xs text-muted-foreground">{contractDuration}</span> + )} + {!deliveryDate && !contractDuration && ( + <span className="text-muted-foreground">-</span> + )} + </div> + ); + }, + size: 120, + }, + { + accessorKey: "incotermsCode", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Incoterms" />, + cell: ({ row }) => { + const code = row.original.incotermsCode; + const detail = row.original.incotermsDetail; + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className="flex items-center gap-1"> + <Globe className="h-3 w-3 text-muted-foreground" /> + <span className="text-sm">{code || "-"}</span> + </div> + </TooltipTrigger> + {detail && ( + <TooltipContent> + <p>{detail}</p> + </TooltipContent> + )} + </Tooltip> + </TooltipProvider> + ); + }, + size: 100, + }, + { + accessorKey: "placeOfShipping", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="선적지" />, + cell: ({ row }) => { + const place = row.original.placeOfShipping; + return place ? ( + <div className="flex items-center gap-1"> + <MapPin className="h-3 w-3 text-muted-foreground" /> + <span className="text-xs">{place}</span> + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "placeOfDestination", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="도착지" />, + cell: ({ row }) => { + const place = row.original.placeOfDestination; + return place ? ( + <div className="flex items-center gap-1"> + <Package className="h-3 w-3 text-muted-foreground" /> + <span className="text-xs">{place}</span> + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + id: "additionalConditions", + header: "추가조건", + cell: ({ row }) => { + const conditions = formatAdditionalConditions(row.original); + if (conditions === "-") { + return <span className="text-muted-foreground">-</span>; + } + + const items = conditions.split(", "); + return ( + <div className="flex flex-wrap gap-1"> + {items.map((item, idx) => ( + <Badge key={idx} variant="outline" className="text-xs"> + {item} + </Badge> + ))} + </div> + ); + }, + size: 120, + }, + { + accessorKey: "response.submittedAt", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />, + cell: ({ row }) => { + const submittedAt = row.original.response?.submittedAt; + const status = row.original.response?.status; + + if (!submittedAt) { + return <Badge variant="outline">미참여</Badge>; + } + + return ( + <div className="flex flex-col gap-0.5"> + <Badge variant="default" className="text-xs">참여</Badge> + <span className="text-xs text-muted-foreground"> + {format(new Date(submittedAt), "MM-dd")} + </span> + </div> + ); + }, + size: 100, + }, + { + id: "responseDetail", + header: "회신상세", + cell: ({ row }) => { + const hasResponse = !!row.original.response?.submittedAt; + + if (!hasResponse) { + return <span className="text-muted-foreground text-xs">-</span>; + } + + return ( + <Button + variant="ghost" + size="sm" + onClick={() => handleAction("response-detail", row.original)} + className="h-7 px-2" + > + <Eye className="h-3 w-3 mr-1" /> + 상세 + </Button> + ); + }, + size: 80, + }, + { + accessorKey: "shortList", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Short List" />, + cell: ({ row }) => ( + row.original.shortList ? ( + <Badge variant="default">선정</Badge> + ) : ( + <Badge variant="outline">대기</Badge> + ) + ), + size: 80, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정일" />, + cell: ({ row }) => { + const date = row.original.updatedAt; + return date ? ( + <span className="text-xs text-muted-foreground"> + {format(new Date(date), "MM-dd HH:mm")} + </span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + accessorKey: "updatedByUserName", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정자" />, + cell: ({ row }) => { + const name = row.original.updatedByUserName; + return name ? ( + <span className="text-xs">{name}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 100, + }, + { + id: "actions", + header: "작업", + cell: ({ row }) => { + const vendor = row.original; + const hasResponse = !!vendor.response; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M3.625 7.5C3.625 8.12132 3.12132 8.625 2.5 8.625C1.87868 8.625 1.375 8.12132 1.375 7.5C1.375 6.87868 1.87868 6.375 2.5 6.375C3.12132 6.375 3.625 6.87868 3.625 7.5ZM8.625 7.5C8.625 8.12132 8.12132 8.625 7.5 8.625C6.87868 8.625 6.375 8.12132 6.375 7.5C6.375 6.87868 6.87868 6.375 7.5 6.375C8.12132 6.375 8.625 6.87868 8.625 7.5ZM12.5 8.625C13.1213 8.625 13.625 8.12132 13.625 7.5C13.625 6.87868 13.1213 6.375 12.5 6.375C11.8787 6.375 11.375 6.87868 11.375 7.5C11.375 8.12132 11.8787 8.625 12.5 8.625Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path> + </svg> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => handleAction("view", vendor)}> + <Eye className="mr-2 h-4 w-4" /> + 상세보기 + </DropdownMenuItem> + {!hasResponse && ( + <DropdownMenuItem onClick={() => handleAction("send", vendor)}> + <Send className="mr-2 h-4 w-4" /> + RFQ 발송 + </DropdownMenuItem> + )} + <DropdownMenuItem onClick={() => handleAction("edit", vendor)}> + <Edit className="mr-2 h-4 w-4" /> + 조건 수정 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => handleAction("delete", vendor)} + className="text-red-600" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ); + }, + size: 60, + }, + ], [handleAction]); + + const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ + { id: "vendorName", label: "벤더명", type: "text" }, + { id: "vendorCode", label: "벤더코드", type: "text" }, + { id: "vendorCountry", label: "국가", type: "text" }, + { + id: "response.status", + label: "응답 상태", + type: "select", + options: [ + { label: "초대됨", value: "초대됨" }, + { label: "작성중", value: "작성중" }, + { label: "제출완료", value: "제출완료" }, + { label: "수정요청", value: "수정요청" }, + { label: "최종확정", value: "최종확정" }, + { label: "취소", value: "취소" }, + ] + }, + { + id: "shortList", + label: "Short List", + type: "select", + options: [ + { label: "선정", value: "true" }, + { label: "대기", value: "false" }, + ] + }, + ]; + + // 선택된 벤더 정보 (BatchUpdate용) + const selectedVendorsForBatch = React.useMemo(() => { + return selectedRows.map(row => ({ + id: row.vendorId, + vendorName: row.vendorName, + vendorCode: row.vendorCode, + })); + }, [selectedRows]); + + // 추가 액션 버튼들 + const additionalActions = React.useMemo(() => ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={() => setIsAddDialogOpen(true)} + > + <Plus className="h-4 w-4 mr-2" /> + 벤더 추가 + </Button> + {selectedRows.length > 0 && ( + <> + <Button + variant="outline" + size="sm" + onClick={() => setIsBatchUpdateOpen(true)} + > + <Settings2 className="h-4 w-4 mr-2" /> + 정보 일괄 입력 ({selectedRows.length}) + </Button> + <Button + variant="outline" + size="sm" + onClick={handleBulkSend} + > + <Send className="h-4 w-4 mr-2" /> + 선택 발송 ({selectedRows.length}) + </Button> + </> + )} + <Button + variant="outline" + size="sm" + onClick={() => { + setIsRefreshing(true); + setTimeout(() => { + setIsRefreshing(false); + toast.success("데이터를 새로고침했습니다."); + }, 1000); + }} + disabled={isRefreshing} + > + <RefreshCw className={cn("h-4 w-4 mr-2", isRefreshing && "animate-spin")} /> + 새로고침 + </Button> + </div> + ), [selectedRows, isRefreshing, handleBulkSend]); + + return ( + <> + <ClientDataTable + columns={columns} + data={mergedData} + advancedFilterFields={advancedFilterFields} + autoSizeColumns={false} + compact={true} + maxHeight="34rem" + onSelectedRowsChange={setSelectedRows} + > + {additionalActions} + </ClientDataTable> + + {/* 벤더 추가 다이얼로그 */} + <AddVendorDialog + open={isAddDialogOpen} + onOpenChange={setIsAddDialogOpen} + rfqId={rfqId} + onSuccess={() => { + toast.success("벤더가 추가되었습니다."); + setIsAddDialogOpen(false); + }} + /> + + {/* 조건 일괄 설정 다이얼로그 */} + <BatchUpdateConditionsDialog + open={isBatchUpdateOpen} + onOpenChange={setIsBatchUpdateOpen} + rfqId={rfqId} + rfqCode={rfqCode} + selectedVendors={selectedVendorsForBatch} + onSuccess={() => { + toast.success("조건이 업데이트되었습니다."); + setIsBatchUpdateOpen(false); + setSelectedRows([]); + }} + /> + + {/* 벤더 상세 다이얼로그 */} + {/* {selectedVendor && ( + <VendorDetailDialog + open={!!selectedVendor} + onOpenChange={(open) => !open && setSelectedVendor(null)} + vendor={selectedVendor} + rfqId={rfqId} + /> + )} */} + </> + ); +}
\ No newline at end of file diff --git a/lib/rfq-last/vendor/vendor-detail-dialog.tsx b/lib/rfq-last/vendor/vendor-detail-dialog.tsx new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/lib/rfq-last/vendor/vendor-detail-dialog.tsx diff --git a/lib/rfq-last/vendor/vendor-response-status-card.tsx b/lib/rfq-last/vendor/vendor-response-status-card.tsx new file mode 100644 index 00000000..d4ef8dd3 --- /dev/null +++ b/lib/rfq-last/vendor/vendor-response-status-card.tsx @@ -0,0 +1,51 @@ +import { Card, CardContent } from "@/components/ui/card"; +import { LucideIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface VendorResponseStatusCardProps { + title: string; + count: number; + icon: LucideIcon; + variant?: "default" | "primary" | "secondary" | "success" | "warning" | "destructive"; +} + +const variantStyles = { + default: "border-gray-200 bg-gray-50/50", + primary: "border-blue-200 bg-blue-50/50", + secondary: "border-purple-200 bg-purple-50/50", + success: "border-green-200 bg-green-50/50", + warning: "border-yellow-200 bg-yellow-50/50", + destructive: "border-red-200 bg-red-50/50", +}; + +const iconStyles = { + default: "text-gray-600", + primary: "text-blue-600", + secondary: "text-purple-600", + success: "text-green-600", + warning: "text-yellow-600", + destructive: "text-red-600", +}; + +export function VendorResponseStatusCard({ + title, + count, + icon: Icon, + variant = "default", +}: VendorResponseStatusCardProps) { + return ( + <Card className={cn("border", variantStyles[variant])}> + <CardContent className="p-4"> + <div className="flex items-center justify-between"> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground">{title}</p> + <p className="text-2xl font-bold">{count}</p> + </div> + <div className={cn("p-2 rounded-full bg-white/80", iconStyles[variant])}> + <Icon className="h-5 w-5" /> + </div> + </div> + </CardContent> + </Card> + ); +}
\ No newline at end of file |
