From 0fddf148402fd6b99a1b3800d73679899bcb2ed3 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Fri, 13 Jun 2025 07:11:18 +0000 Subject: (대표님) 20250613 16시 10분 global css, b-rfq, document 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/b-rfq/initial/add-initial-rfq-dialog.tsx | 534 ++++++++++++ lib/b-rfq/initial/initial-rfq-detail-columns.tsx | 386 +++++++++ lib/b-rfq/initial/initial-rfq-detail-table.tsx | 263 ++++++ .../initial/initial-rfq-detail-toolbar-actions.tsx | 109 +++ lib/b-rfq/service.ts | 218 ++++- lib/b-rfq/validations.ts | 100 +++ lib/vendor-document-list/import-service.ts | 4 +- .../ship/enhanced-doc-table-columns.tsx | 616 +++++++------- .../ship/enhanced-doc-table-toolbar-actions.tsx | 73 +- .../ship/enhanced-document-sheet.tsx | 939 --------------------- .../ship/enhanced-documents-table.tsx | 135 ++- .../ship/import-from-dolce-button.tsx | 258 ++++-- .../ship/revision-upload-dialog.tsx | 629 -------------- .../ship/simplified-document-edit-dialog.tsx | 287 ------- .../ship/stage-revision-expanded-content.tsx | 752 ----------------- .../ship/stage-revision-sheet.tsx | 86 -- .../ship/swp-workflow-panel.tsx | 370 -------- lib/vendor-document-list/ship/update-doc-sheet.tsx | 267 ------ 18 files changed, 2220 insertions(+), 3806 deletions(-) create mode 100644 lib/b-rfq/initial/add-initial-rfq-dialog.tsx create mode 100644 lib/b-rfq/initial/initial-rfq-detail-columns.tsx create mode 100644 lib/b-rfq/initial/initial-rfq-detail-table.tsx create mode 100644 lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx delete mode 100644 lib/vendor-document-list/ship/enhanced-document-sheet.tsx delete mode 100644 lib/vendor-document-list/ship/revision-upload-dialog.tsx delete mode 100644 lib/vendor-document-list/ship/simplified-document-edit-dialog.tsx delete mode 100644 lib/vendor-document-list/ship/stage-revision-expanded-content.tsx delete mode 100644 lib/vendor-document-list/ship/stage-revision-sheet.tsx delete mode 100644 lib/vendor-document-list/ship/swp-workflow-panel.tsx delete mode 100644 lib/vendor-document-list/ship/update-doc-sheet.tsx (limited to 'lib') diff --git a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx new file mode 100644 index 00000000..d0924be2 --- /dev/null +++ b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx @@ -0,0 +1,534 @@ +// add-initial-rfq-dialog.tsx +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Plus, Check, ChevronsUpDown, Search, Building, CalendarIcon } from "lucide-react" +import { toast } from "sonner" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +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 { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Checkbox } from "@/components/ui/checkbox" +import { cn, formatDate } from "@/lib/utils" +import { addInitialRfqRecord, getIncotermsForSelection, getVendorsForSelection } from "../service" +import { Calendar } from "@/components/ui/calendar" + +// Initial RFQ 추가 폼 스키마 +const addInitialRfqSchema = z.object({ + vendorId: z.number({ + required_error: "벤더를 선택해주세요.", + }), + initialRfqStatus: z.enum(["DRAFT", "Init. RFQ Sent", "S/L Decline", "Init. RFQ Answered"], { + required_error: "초기 RFQ 상태를 선택해주세요.", + }).default("DRAFT"), + dueDate: z.date({ + required_error: "마감일을 선택해주세요.", + }), + validDate: z.date().optional(), + incotermsCode: z.string().optional(), + gtc: z.string().optional(), + gtcValidDate: z.string().optional(), + classification: z.string().optional(), + sparepart: z.string().optional(), + shortList: z.boolean().default(false), + returnYn: z.boolean().default(false), + cpRequestYn: z.boolean().default(false), + prjectGtcYn: z.boolean().default(false), + returnRevision: z.number().default(0), +}) + +type AddInitialRfqFormData = z.infer + +interface Vendor { + id: number + vendorName: string + vendorCode: string + country: string + status: string +} + +interface AddInitialRfqDialogProps { + rfqId: number + onSuccess?: () => void +} + +export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogProps) { + const [open, setOpen] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [vendors, setVendors] = React.useState([]) + const [vendorsLoading, setVendorsLoading] = React.useState(false) + const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false) + const [incoterms, setIncoterms] = React.useState([]) + const [incotermsLoading, setIncotermsLoading] = React.useState(false) + const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false) + + const form = useForm({ + resolver: zodResolver(addInitialRfqSchema), + defaultValues: { + initialRfqStatus: "DRAFT", + shortList: false, + returnYn: false, + cpRequestYn: false, + prjectGtcYn: false, + returnRevision: 0, + }, + }) + + // 벤더 목록 로드 + const loadVendors = React.useCallback(async () => { + setVendorsLoading(true) + try { + const vendorList = await getVendorsForSelection() + setVendors(vendorList) + } catch (error) { + console.error("Failed to load vendors:", error) + toast.error("벤더 목록을 불러오는데 실패했습니다.") + } finally { + setVendorsLoading(false) + } + }, []) + + // Incoterms 목록 로드 + const loadIncoterms = React.useCallback(async () => { + setIncotermsLoading(true) + try { + const incotermsList = await getIncotermsForSelection() + setIncoterms(incotermsList) + } catch (error) { + console.error("Failed to load incoterms:", error) + toast.error("Incoterms 목록을 불러오는데 실패했습니다.") + } finally { + setIncotermsLoading(false) + } + }, []) + + // 다이얼로그 열릴 때 벤더 목록 로드 + React.useEffect(() => { + if (open) { + if (vendors.length === 0) { + loadVendors() + } + if (incoterms.length === 0) { + loadIncoterms() + } + } + }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms]) + + // 다이얼로그 닫기 핸들러 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen && !isSubmitting) { + form.reset() + } + setOpen(newOpen) + } + + // 폼 제출 + const onSubmit = async (data: AddInitialRfqFormData) => { + setIsSubmitting(true) + + try { + const result = await addInitialRfqRecord({ + ...data, + rfqId, + }) + + if (result.success) { + toast.success(result.message || "초기 RFQ가 성공적으로 추가되었습니다.") + form.reset() + handleOpenChange(false) + onSuccess?.() + } else { + toast.error(result.message || "초기 RFQ 추가에 실패했습니다.") + } + + } catch (error) { + console.error("Submit error:", error) + toast.error("초기 RFQ 추가 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + // 선택된 벤더 정보 + const selectedVendor = vendors.find(vendor => vendor.id === form.watch("vendorId")) + const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode")) + + return ( + + + + + + + + 초기 RFQ 추가 + + 새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다. + + + +
+ + {/* 벤더 선택 */} + ( + + 벤더 선택 * + + + + + + + + + + + 검색 결과가 없습니다. + + {vendors.map((vendor) => ( + { + field.onChange(vendor.id) + setVendorSearchOpen(false) + }} + > +
+ +
+
+ {vendor.vendorName} +
+
+ {vendor.vendorCode} • {vendor.country} +
+
+ +
+
+ ))} +
+
+
+
+
+ +
+ )} + /> + + {/* 날짜 필드들 */} +
+ ( + + 견적 마감일 + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + ( + + 견적 유효일 + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> +
+ + {/* Incoterms 및 GTC */} +
+ ( + + Incoterms * + + + + + + + + + + + 검색 결과가 없습니다. + + {incoterms.map((incoterm) => ( + { + field.onChange(vendor.id) + setVendorSearchOpen(false) + }} + > +
+
+ {incoterm.code} {incoterm.description} +
+ +
+
+ ))} +
+
+
+
+
+ +
+ )} + /> +
+ + {/* GTC 정보 */} +
+ ( + + GTC + + + + + + )} + /> + + ( + + GTC 유효일 + + + + + + )} + /> +
+ + {/* 분류 정보 */} +
+ ( + + 선급 + + + + + + )} + /> + + ( + + Spare part + + + + + + )} + /> +
+ + + + + + + + + +
+
+ ) +} + + diff --git a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx new file mode 100644 index 00000000..f7ac0960 --- /dev/null +++ b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx @@ -0,0 +1,386 @@ +// initial-rfq-detail-columns.tsx +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { + Ellipsis, Building, Calendar, Eye, + MessageSquare, Settings, CheckCircle2, XCircle +} from "lucide-react" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" + +interface GetInitialRfqDetailColumnsProps { + onSelectDetail?: (detail: any) => void +} + +export function getInitialRfqDetailColumns({ + onSelectDetail +}: GetInitialRfqDetailColumnsProps = {}): ColumnDef[] { + + return [ + /** ───────────── 체크박스 ───────────── */ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + /** ───────────── RFQ 정보 ───────────── */ + { + accessorKey: "rfqCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + size: 120, + }, + { + accessorKey: "rfqStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const status = row.getValue("rfqStatus") as string + const getStatusColor = (status: string) => { + switch (status) { + case "DRAFT": return "secondary" + case "Doc. Received": return "outline" + case "PIC Assigned": return "default" + case "Doc. Confirmed": return "default" + case "Init. RFQ Sent": return "default" + case "Init. RFQ Answered": return "success" + case "TBE started": return "warning" + case "TBE finished": return "warning" + case "Final RFQ Sent": return "default" + case "Quotation Received": return "success" + case "Vendor Selected": return "success" + default: return "secondary" + } + } + return ( + + {status} + + ) + }, + size: 140 + }, + { + accessorKey: "initialRfqStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const status = row.getValue("initialRfqStatus") as string + const getInitialStatusColor = (status: string) => { + switch (status) { + case "PENDING": return "outline" + case "SENT": return "default" + case "RESPONDED": return "success" + case "EXPIRED": return "destructive" + case "CANCELLED": return "secondary" + default: return "secondary" + } + } + return ( + + {status} + + ) + }, + size: 120 + }, + + /** ───────────── 벤더 정보 ───────────── */ + { + id: "vendorInfo", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const vendorName = row.original.vendorName as string + const vendorCode = row.original.vendorCode as string + const vendorCountry = row.original.vendorCountry as string + const businessSize = row.original.vendorBusinessSize as string + + return ( +
+
+ +
{vendorName}
+
+
+ {vendorCode} • {vendorCountry} +
+ {businessSize && ( + + {businessSize} + + )} +
+ ) + }, + size: 200, + }, + + /** ───────────── 날짜 정보 ───────────── */ + { + accessorKey: "dueDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const dueDate = row.getValue("dueDate") as Date + const isOverdue = dueDate && new Date(dueDate) < new Date() + + return dueDate ? ( +
+ +
+
{formatDate(dueDate)}
+ {isOverdue && ( +
지연
+ )} +
+
+ ) : ( + - + ) + }, + size: 120, + }, + { + accessorKey: "validDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const validDate = row.getValue("validDate") as Date + return validDate ? ( +
+ {formatDate(validDate)} +
+ ) : ( + - + ) + }, + size: 100, + }, + + /** ───────────── Incoterms ───────────── */ + { + id: "incoterms", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const code = row.original.incotermsCode as string + const description = row.original.incotermsDescription as string + + return code ? ( +
+ {code} + {description && ( +
+ {description} +
+ )} +
+ ) : ( + - + ) + }, + size: 120, + }, + + /** ───────────── 플래그 정보 ───────────── */ + { + id: "flags", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const shortList = row.original.shortList as boolean + const returnYn = row.original.returnYn as boolean + const cpRequestYn = row.original.cpRequestYn as boolean + const prjectGtcYn = row.original.prjectGtcYn as boolean + + return ( +
+ {shortList && ( + + + Short List + + )} + {returnYn && ( + + Return + + )} + {cpRequestYn && ( + + CP Request + + )} + {prjectGtcYn && ( + + GTC + + )} +
+ ) + }, + size: 150, + }, + + /** ───────────── 분류 정보 ───────────── */ + { + id: "classification", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const classification = row.original.classification as string + const sparepart = row.original.sparepart as string + + return ( +
+ {classification && ( +
+ {classification} +
+ )} + {sparepart && ( + + {sparepart} + + )} +
+ ) + }, + size: 120, + }, + + /** ───────────── 리비전 정보 ───────────── */ + { + accessorKey: "returnRevision", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const revision = row.getValue("returnRevision") as number + return revision ? ( + + Rev. {revision} + + ) : ( + - + ) + }, + size: 80, + }, + + /** ───────────── 등록/수정 정보 ───────────── */ + { + accessorKey: "createdAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const created = row.getValue("createdAt") as Date + const updated = row.original.updatedAt as Date + + return ( +
+
{formatDate(created)}
+ {updated && new Date(updated) > new Date(created) && ( +
+ 수정: {formatDate(updated)} +
+ )} +
+ ) + }, + size: 120, + }, + + /** ───────────── 액션 ───────────── */ + { + id: "actions", + enableHiding: false, + cell: ({ row }) => { + return ( + + + + + + onSelectDetail?.(row.original)}> + + 상세 보기 + + + + 벤더 응답 보기 + + + + + 설정 수정 + + + + 삭제 + + + + ) + }, + size: 40, + }, + ] +} \ No newline at end of file diff --git a/lib/b-rfq/initial/initial-rfq-detail-table.tsx b/lib/b-rfq/initial/initial-rfq-detail-table.tsx new file mode 100644 index 00000000..fc8a5bc2 --- /dev/null +++ b/lib/b-rfq/initial/initial-rfq-detail-table.tsx @@ -0,0 +1,263 @@ +"use client" + +import * as React from "react" +import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getInitialRfqDetail } from "../service" // 앞서 만든 서버 액션 +import { getInitialRfqDetailColumns } from "./initial-rfq-detail-columns" +import { InitialRfqDetailTableToolbarActions } from "./initial-rfq-detail-toolbar-actions" + +interface InitialRfqDetailTableProps { + promises: Promise>> + rfqId?: number +} + +export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTableProps) { + const { data, pageCount } = React.use(promises) + + // 선택된 상세 정보 + const [selectedDetail, setSelectedDetail] = React.useState(null) + + const columns = React.useMemo( + () => getInitialRfqDetailColumns({ + onSelectDetail: setSelectedDetail + }), + [] + ) + + /** + * 필터 필드 정의 + */ + const filterFields: DataTableFilterField[] = [ + { + id: "rfqCode", + label: "RFQ 코드", + placeholder: "RFQ 코드로 검색...", + }, + { + id: "vendorName", + label: "벤더명", + placeholder: "벤더명으로 검색...", + }, + { + id: "rfqStatus", + label: "RFQ 상태", + options: [ + { label: "Draft", value: "DRAFT", count: 0 }, + { label: "문서 접수", value: "Doc. Received", count: 0 }, + { label: "담당자 배정", value: "PIC Assigned", count: 0 }, + { label: "문서 확정", value: "Doc. Confirmed", count: 0 }, + { label: "초기 RFQ 발송", value: "Init. RFQ Sent", count: 0 }, + { label: "초기 RFQ 응답", value: "Init. RFQ Answered", count: 0 }, + { label: "TBE 시작", value: "TBE started", count: 0 }, + { label: "TBE 완료", value: "TBE finished", count: 0 }, + { label: "최종 RFQ 발송", value: "Final RFQ Sent", count: 0 }, + { label: "견적 접수", value: "Quotation Received", count: 0 }, + { label: "벤더 선정", value: "Vendor Selected", count: 0 }, + ], + }, + { + id: "initialRfqStatus", + label: "초기 RFQ 상태", + options: [ + { label: "대기", value: "PENDING", count: 0 }, + { label: "발송", value: "SENT", count: 0 }, + { label: "응답", value: "RESPONDED", count: 0 }, + { label: "만료", value: "EXPIRED", count: 0 }, + { label: "취소", value: "CANCELLED", count: 0 }, + ], + }, + { + id: "vendorCountry", + label: "벤더 국가", + options: [ + { label: "한국", value: "KR", count: 0 }, + { label: "중국", value: "CN", count: 0 }, + { label: "일본", value: "JP", count: 0 }, + { label: "미국", value: "US", count: 0 }, + { label: "독일", value: "DE", count: 0 }, + ], + }, + ] + + /** + * 고급 필터 필드 + */ + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + id: "rfqCode", + label: "RFQ 코드", + type: "text", + }, + { + id: "vendorName", + label: "벤더명", + type: "text", + }, + { + id: "vendorCode", + label: "벤더 코드", + type: "text", + }, + { + id: "vendorCountry", + label: "벤더 국가", + type: "multi-select", + options: [ + { label: "한국", value: "KR" }, + { label: "중국", value: "CN" }, + { label: "일본", value: "JP" }, + { label: "미국", value: "US" }, + { label: "독일", value: "DE" }, + ], + }, + { + id: "rfqStatus", + label: "RFQ 상태", + type: "multi-select", + options: [ + { label: "Draft", value: "DRAFT" }, + { label: "문서 접수", value: "Doc. Received" }, + { label: "담당자 배정", value: "PIC Assigned" }, + { label: "문서 확정", value: "Doc. Confirmed" }, + { label: "초기 RFQ 발송", value: "Init. RFQ Sent" }, + { label: "초기 RFQ 응답", value: "Init. RFQ Answered" }, + { label: "TBE 시작", value: "TBE started" }, + { label: "TBE 완료", value: "TBE finished" }, + { label: "최종 RFQ 발송", value: "Final RFQ Sent" }, + { label: "견적 접수", value: "Quotation Received" }, + { label: "벤더 선정", value: "Vendor Selected" }, + ], + }, + { + id: "initialRfqStatus", + label: "초기 RFQ 상태", + type: "multi-select", + options: [ + { label: "대기", value: "PENDING" }, + { label: "발송", value: "SENT" }, + { label: "응답", value: "RESPONDED" }, + { label: "만료", value: "EXPIRED" }, + { label: "취소", value: "CANCELLED" }, + ], + }, + { + id: "vendorBusinessSize", + label: "벤더 규모", + type: "multi-select", + options: [ + { label: "대기업", value: "LARGE" }, + { label: "중기업", value: "MEDIUM" }, + { label: "소기업", value: "SMALL" }, + { label: "스타트업", value: "STARTUP" }, + ], + }, + { + id: "incotermsCode", + label: "Incoterms", + type: "text", + }, + { + id: "dueDate", + label: "마감일", + type: "date", + }, + { + id: "validDate", + label: "유효일", + type: "date", + }, + { + id: "shortList", + label: "Short List", + type: "boolean", + }, + { + id: "returnYn", + label: "Return 여부", + type: "boolean", + }, + { + id: "cpRequestYn", + label: "CP Request 여부", + type: "boolean", + }, + { + id: "prjectGtcYn", + label: "Project GTC 여부", + type: "boolean", + }, + { + id: "classification", + label: "분류", + type: "text", + }, + { + id: "sparepart", + label: "예비부품", + type: "text", + }, + { + id: "createdAt", + label: "등록일", + type: "date", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => originalRow.initialRfqId.toString(), + shallow: false, + clearOnDefault: true, + }) + + return ( +
+ {/* 메인 테이블 */} +
+ + + + + +
+ + {/* 선택된 상세 정보 패널 (필요시 추가) */} + {selectedDetail && ( +
+

+ 상세 정보: {selectedDetail.rfqCode} +

+
+
+ 벤더: {selectedDetail.vendorName} +
+
+ 국가: {selectedDetail.vendorCountry} +
+
+ 마감일: {formatDate(selectedDetail.dueDate)} +
+
+ 유효일: {formatDate(selectedDetail.validDate)} +
+
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx new file mode 100644 index 00000000..981659d5 --- /dev/null +++ b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx @@ -0,0 +1,109 @@ +// initial-rfq-detail-toolbar-actions.tsx +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { + Download, + Mail, + RefreshCw, + Settings, + Trash2, + FileText +} from "lucide-react" + +interface InitialRfqDetailTableToolbarActionsProps { + table: Table + rfqId?: number +} + +export function InitialRfqDetailTableToolbarActions({ + table, + rfqId +}: InitialRfqDetailTableToolbarActionsProps) { + + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedDetails = selectedRows.map((row) => row.original) + const selectedCount = selectedRows.length + + const handleBulkEmail = () => { + console.log("Bulk email to selected vendors:", selectedDetails) + // 벌크 이메일 로직 구현 + } + + const handleBulkDelete = () => { + console.log("Bulk delete selected items:", selectedDetails) + // 벌크 삭제 로직 구현 + table.toggleAllRowsSelected(false) + } + + const handleExport = () => { + console.log("Export data:", selectedCount > 0 ? selectedDetails : "all data") + // 데이터 엑스포트 로직 구현 + } + + const handleRefresh = () => { + window.location.reload() + } + + return ( +
+ {/** 선택된 항목이 있을 때만 표시되는 액션들 */} + {selectedCount > 0 && ( + <> + + + + + )} + + {/** 항상 표시되는 액션들 */} + + + + + +
+ ) +} diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts index e60e446d..0dc61832 100644 --- a/lib/b-rfq/service.ts +++ b/lib/b-rfq/service.ts @@ -4,15 +4,16 @@ import { revalidateTag, unstable_cache } from "next/cache" import { count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm" import { filterColumns } from "@/lib/filter-columns" import db from "@/db/db" -import { RfqDashboardView, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, projects, users, vendorAttachmentResponses, vendors } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 +import { Incoterm, RfqDashboardView, Vendor, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 import { rfqDashboardView } from "@/db/schema" // 뷰 import import type { SQL } from "drizzle-orm" -import { AttachmentRecord, CreateRfqInput, DeleteAttachmentsInput, GetRFQDashboardSchema, GetRfqAttachmentsSchema, attachmentRecordSchema, createRfqServerSchema, deleteAttachmentsSchema } from "./validations" +import { AttachmentRecord, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, attachmentRecordSchema, createRfqServerSchema, deleteAttachmentsSchema } from "./validations" import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { unlink } from "fs/promises" const tag = { + initialRfqDetail:"initial-rfq", rfqDashboard: 'rfq-dashboard', rfq: (id: number) => `rfq-${id}`, rfqAttachments: (rfqId: number) => `rfq-attachments-${rfqId}`, @@ -1017,4 +1018,217 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) { message: error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.", } } +} + + + +//Initial RFQ + +export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqId?: number) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 1) 고급 필터 조건 + let advancedWhere: SQL | undefined = undefined; + if (input.filters && input.filters.length > 0) { + advancedWhere = filterColumns({ + table: initialRfqDetailView, + filters: input.filters, + joinOperator: input.joinOperator || 'and', + }); + } + + // 2) 기본 필터 조건 + let basicWhere: SQL | undefined = undefined; + if (input.basicFilters && input.basicFilters.length > 0) { + basicWhere = filterColumns({ + table: initialRfqDetailView, + filters: input.basicFilters, + joinOperator: input.basicJoinOperator || 'and', + }); + } + + let rfqIdWhere: SQL | undefined = undefined; + if (rfqId) { + rfqIdWhere = eq(initialRfqDetailView.rfqId, rfqId); + } + + + // 3) 글로벌 검색 조건 + let globalWhere: SQL | undefined = undefined; + if (input.search) { + const s = `%${input.search}%`; + + const validSearchConditions: SQL[] = []; + + const rfqCodeCondition = ilike(initialRfqDetailView.rfqCode, s); + if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition); + + const vendorNameCondition = ilike(initialRfqDetailView.vendorName, s); + if (vendorNameCondition) validSearchConditions.push(vendorNameCondition); + + const vendorCodeCondition = ilike(initialRfqDetailView.vendorCode, s); + if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition); + + const vendorCountryCondition = ilike(initialRfqDetailView.vendorCountry, s); + if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition); + + const incotermsDescriptionCondition = ilike(initialRfqDetailView.incotermsDescription, s); + if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition); + + const classificationCondition = ilike(initialRfqDetailView.classification, s); + if (classificationCondition) validSearchConditions.push(classificationCondition); + + const sparepartCondition = ilike(initialRfqDetailView.sparepart, s); + if (sparepartCondition) validSearchConditions.push(sparepartCondition); + + if (validSearchConditions.length > 0) { + globalWhere = or(...validSearchConditions); + } + } + + + // 5) 최종 WHERE 조건 생성 + const whereConditions: SQL[] = []; + + if (advancedWhere) whereConditions.push(advancedWhere); + if (basicWhere) whereConditions.push(basicWhere); + if (globalWhere) whereConditions.push(globalWhere); + if (rfqIdWhere) whereConditions.push(rfqIdWhere); + + const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; + + // 6) 전체 데이터 수 조회 + const totalResult = await db + .select({ count: count() }) + .from(initialRfqDetailView) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + + if (total === 0) { + return { data: [], pageCount: 0, total: 0 }; + } + + console.log(total); + + // 7) 정렬 및 페이징 처리된 데이터 조회 + const orderByColumns = input.sort.map((sort) => { + const column = sort.id as keyof typeof initialRfqDetailView.$inferSelect; + return sort.desc ? desc(initialRfqDetailView[column]) : asc(initialRfqDetailView[column]); + }); + + if (orderByColumns.length === 0) { + orderByColumns.push(desc(initialRfqDetailView.createdAt)); + } + + const initialRfqData = await db + .select() + .from(initialRfqDetailView) + .where(finalWhere) + .orderBy(...orderByColumns) + .limit(input.perPage) + .offset(offset); + + const pageCount = Math.ceil(total / input.perPage); + + return { data: initialRfqData, pageCount, total }; + } catch (err) { + console.error("Error in getInitialRfqDetail:", err); + return { data: [], pageCount: 0, total: 0 }; + } + }, + [JSON.stringify(input)], + { revalidate: 3600, tags: [tag.initialRfqDetail] }, + )(); +} + +export async function getVendorsForSelection() { + try { + const vendorsData = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + country: vendors.country, + status: vendors.status, + }) + .from(vendors) + // .where( + // and( + // ne(vendors.status, "BLACKLISTED"), + // ne(vendors.status, "REJECTED") + // ) + // ) + .orderBy(vendors.vendorName) + + return vendorsData.map(vendor => ({ + id: vendor.id, + vendorName: vendor.vendorName || "", + vendorCode: vendor.vendorCode || "", + country: vendor.country || "", + status: vendor.status, + })) + } catch (error) { + console.error("Error fetching vendors:", error) + throw new Error("Failed to fetch vendors") + } +} + +export async function addInitialRfqRecord(data: AddInitialRfqFormData & { rfqId: number }) { + try { + const [newRecord] = await db + .insert(initialRfq) + .values({ + rfqId: data.rfqId, + vendorId: data.vendorId, + initialRfqStatus: data.initialRfqStatus, + dueDate: data.dueDate, + validDate: data.validDate, + incotermsCode: data.incotermsCode, + gtc: data.gtc, + gtcValidDate: data.gtcValidDate, + classification: data.classification, + sparepart: data.sparepart, + shortList: data.shortList, + returnYn: data.returnYn, + cpRequestYn: data.cpRequestYn, + prjectGtcYn: data.prjectGtcYn, + returnRevision: data.returnRevision, + }) + .returning() + + return { + success: true, + message: "초기 RFQ가 성공적으로 추가되었습니다.", + data: newRecord, + } + } catch (error) { + console.error("Error adding initial RFQ:", error) + return { + success: false, + message: "초기 RFQ 추가에 실패했습니다.", + error, + } + } +} + +export async function getIncotermsForSelection() { + try { + const incotermData = await db + .select({ + code: incoterms.code, + description: incoterms.description, + }) + .from(incoterms) + .orderBy(incoterms.code) + + return incotermData + + } catch (error) { + console.error("Error fetching incoterms:", error) + throw new Error("Failed to fetch incoterms") + } } \ No newline at end of file diff --git a/lib/b-rfq/validations.ts b/lib/b-rfq/validations.ts index df95b1d2..15cc9425 100644 --- a/lib/b-rfq/validations.ts +++ b/lib/b-rfq/validations.ts @@ -165,3 +165,103 @@ export const deleteAttachmentsSchema = z.object({ }) export type DeleteAttachmentsInput = z.infer + + +//Inital RFQ +export const searchParamsInitialRfqDetailCache = createSearchParamsCache({ + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 - initialRfqDetailView 기반 + sort: getSortingStateParser<{ + rfqId: number; + rfqCode: string; + rfqStatus: string; + initialRfqId: number; + initialRfqStatus: string; + vendorId: number; + vendorCode: string; + vendorName: string; + vendorCountry: string; + vendorBusinessSize: string; + dueDate: Date; + validDate: Date; + incotermsCode: string; + incotermsDescription: string; + shortList: boolean; + returnYn: boolean; + cpRequestYn: boolean; + prjectGtcYn: boolean; + returnRevision: number; + gtc: string; + gtcValidDate: string; + classification: string; + sparepart: string; + createdAt: Date; + updatedAt: Date; + }>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 기본 필터 + basicFilters: getFiltersStateParser().withDefault([]), + basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + // Initial RFQ Detail 특화 필터 + rfqCode: parseAsString.withDefault(""), + rfqStatus: parseAsStringEnum([ + "DRAFT", + "Doc. Received", + "PIC Assigned", + "Doc. Confirmed", + "Init. RFQ Sent", + "Init. RFQ Answered", + "TBE started", + "TBE finished", + "Final RFQ Sent", + "Quotation Received", + "Vendor Selected" + ]), + initialRfqStatus: parseAsStringEnum([ + "PENDING", + "SENT", + "RESPONDED", + "EXPIRED", + "CANCELLED" + ]), + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + vendorCountry: parseAsString.withDefault(""), + vendorBusinessSize: parseAsStringEnum([ + "LARGE", + "MEDIUM", + "SMALL", + "STARTUP" + ]), + incotermsCode: parseAsString.withDefault(""), + dueDateFrom: parseAsString.withDefault(""), + dueDateTo: parseAsString.withDefault(""), + validDateFrom: parseAsString.withDefault(""), + validDateTo: parseAsString.withDefault(""), + shortList: parseAsStringEnum(["true", "false"]), + returnYn: parseAsStringEnum(["true", "false"]), + cpRequestYn: parseAsStringEnum(["true", "false"]), + prjectGtcYn: parseAsStringEnum(["true", "false"]), + classification: parseAsString.withDefault(""), + sparepart: parseAsString.withDefault(""), +}); + +export type GetInitialRfqDetailSchema = Awaited>; + + diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts index d2a14980..344597fa 100644 --- a/lib/vendor-document-list/import-service.ts +++ b/lib/vendor-document-list/import-service.ts @@ -183,7 +183,6 @@ class ImportService { .where(eq(contracts.id, contractId)) .limit(1) - return result?.projectCode && result?.vendorCode ? { projectCode: result.projectCode, vendorCode: result.vendorCode } : null @@ -608,6 +607,9 @@ class ImportService { eq(documents.externalSystemType, sourceSystem) )) + console.log(contractId, "contractId") + + // 프로젝트 코드와 벤더 코드 조회 const contractInfo = await this.getContractInfoById(contractId) diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx index b80c0869..ad184378 100644 --- a/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx +++ b/lib/vendor-document-list/ship/enhanced-doc-table-columns.tsx @@ -16,123 +16,36 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Button } from "@/components/ui/button" -import { Badge } from "@/components/ui/badge" import { Ellipsis, - Calendar, - CalendarClock, - User, FileText, Eye, Edit, Trash2, - Building, - Code, - Settings } from "lucide-react" import { cn } from "@/lib/utils" import { SimplifiedDocumentsView } from "@/db/schema" +// DocumentSelectionContext를 import (실제 파일 경로에 맞게 수정 필요) +// 예: import { DocumentSelectionContext } from "../user-vendor-document-display" +// 또는: import { DocumentSelectionContext } from "./user-vendor-document-display" +import { DocumentSelectionContext } from "@/components/ship-vendor-document/user-vendor-document-table-container" + interface GetColumnsProps { setRowAction: React.Dispatch | null>> } -// 유틸리티 함수들 -const getDrawingKindText = (drawingKind: string) => { - switch (drawingKind) { - case 'B3': return 'B3 도면' - case 'B4': return 'B4 도면' - case 'B5': return 'B5 도면' - default: return drawingKind - } -} - -const getDrawingKindColor = (drawingKind: string) => { - switch (drawingKind) { - case 'B3': return 'bg-blue-100 text-blue-800' - case 'B4': return 'bg-green-100 text-green-800' - case 'B5': return 'bg-purple-100 text-purple-800' - default: return 'bg-gray-100 text-gray-800' - } -} - -// 스테이지별 이름 표시 컴포넌트 -const StageNameDisplay = ({ - stageName, - drawingKind, - isFirst = true -}: { - stageName: string | null, - drawingKind: string | null, - isFirst?: boolean -}) => { - if (!stageName) return - - - const stageType = isFirst ? "1차" : "2차" - const getExpectedStage = () => { - if (drawingKind === 'B4') return isFirst ? 'Pre' : 'Work' - if (drawingKind === 'B3') return isFirst ? 'Approval' : 'Work' - if (drawingKind === 'B5') return isFirst ? 'First' : 'Second' - return '' - } - - return ( -
-
{stageType} 스테이지
-
{stageName}
- {getExpectedStage() && ( -
({getExpectedStage()})
- )} -
- ) -} - -// 날짜 정보 표시 컴포넌트 -const StageDateInfo = ({ - planDate, - actualDate, - stageName -}: { - planDate: string | null - actualDate: string | null - stageName: string | null -}) => { - if (!planDate && !actualDate) { - return 날짜 미설정 - } - - const isCompleted = !!actualDate - const isLate = actualDate && planDate && new Date(actualDate) > new Date(planDate) - +// 날짜 표시 컴포넌트 (간단 버전) +const DateDisplay = ({ date, isSelected = false }: { date: string | null, isSelected?: boolean }) => { + if (!date) return - + return ( -
- {planDate && ( -
- 계획: - {formatDate(planDate)} -
- )} - {actualDate && ( -
- 실제: - - {formatDate(actualDate)} - -
- )} - {!actualDate && planDate && ( -
- 진행중 -
- )} - {isCompleted && ( -
- ✓ 완료 -
- )} -
+ + {formatDate(date)} + ) } @@ -140,36 +53,28 @@ export function getSimplifiedDocumentColumns({ setRowAction, }: GetColumnsProps): ColumnDef[] { - // 기본 컬럼들 - const baseColumns: ColumnDef[] = [ - // 체크박스 선택 + const columns: ColumnDef[] = [ + // 라디오 버튼 같은 체크박스 선택 { id: "select", header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> +
+ 선택 +
), + cell: ({ row }) => { + const doc = row.original + + return ( + + ) + }, size: 40, enableSorting: false, enableHiding: false, }, - // 문서번호 + Drawing Kind + // 문서번호 (선택된 행 하이라이트 적용) { accessorKey: "docNumber", header: ({ column }) => ( @@ -177,33 +82,19 @@ export function getSimplifiedDocumentColumns({ ), cell: ({ row }) => { const doc = row.original + return ( -
- {doc.docNumber} - {doc.vendorDocNumber && ( - - 벤더: {doc.vendorDocNumber} - - )} - {doc.drawingKind && ( - - {getDrawingKindText(doc.drawingKind)} - - )} -
+ ) }, - size: 140, + size: 120, enableResizing: true, meta: { excelHeader: "문서번호" }, }, - // 문서명 + 프로젝트/벤더 정보 + // 문서명 (선택된 행 하이라이트 적용) { accessorKey: "title", header: ({ column }) => ( @@ -211,148 +102,136 @@ export function getSimplifiedDocumentColumns({ ), cell: ({ row }) => { const doc = row.original + return ( -
-
- {doc.title} -
-
- {doc.pic && ( - - PIC: {doc.pic} - - )} - {doc.projectCode && ( -
- - {doc.projectCode} -
- )} - {doc.vendorName && ( -
- - {doc.vendorName} -
- )} -
-
+ ) }, - size: 200, enableResizing: true, meta: { excelHeader: "문서명" }, }, - // 첫 번째 스테이지 정보 + // 프로젝트 코드 { - accessorKey: "firstStageName", + accessorKey: "projectCode", header: ({ column }) => ( - + ), cell: ({ row }) => { - const doc = row.original + const projectCode = row.original.projectCode + return ( - + ) }, - size: 130, enableResizing: true, meta: { - excelHeader: "1차 스테이지" + excelHeader: "프로젝트" }, }, - // 첫 번째 스테이지 날짜 + // 1차 스테이지 그룹 { - accessorKey: "firstStagePlanDate", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const doc = row.original + id: "firstStageGroup", + header: ({ table }) => { + // 첫 번째 행의 firstStageName을 그룹 헤더로 사용 + const firstRow = table.getRowModel().rows[0]?.original + const stageName = firstRow?.firstStageName || "1차 스테이지" return ( - - ) - }, - size: 140, - enableResizing: true, - meta: { - excelHeader: "1차 일정" - }, - }, - - // 두 번째 스테이지 정보 - { - accessorKey: "secondStageName", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const doc = row.original - return ( - +
+ {stageName} +
) }, - size: 130, - enableResizing: true, - meta: { - excelHeader: "2차 스테이지" - }, + columns: [ + { + accessorKey: "firstStagePlanDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return + }, + enableResizing: true, + meta: { + excelHeader: "1차 계획일" + }, + }, + { + accessorKey: "firstStageActualDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return + }, + enableResizing: true, + meta: { + excelHeader: "1차 실제일" + }, + }, + ], }, - // 두 번째 스테이지 날짜 + // 2차 스테이지 그룹 { - accessorKey: "secondStagePlanDate", - header: ({ column }) => ( - - ), - cell: ({ row }) => { - const doc = row.original + id: "secondStageGroup", + header: ({ table }) => { + // 첫 번째 행의 secondStageName을 그룹 헤더로 사용 + const firstRow = table.getRowModel().rows[0]?.original + const stageName = firstRow?.secondStageName || "2차 스테이지" return ( - +
+ {stageName} +
) }, - size: 140, - enableResizing: true, - meta: { - excelHeader: "2차 일정" - }, + columns: [ + { + accessorKey: "secondStagePlanDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return + }, + enableResizing: true, + meta: { + excelHeader: "2차 계획일" + }, + }, + { + accessorKey: "secondStageActualDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + return + }, + enableResizing: true, + meta: { + excelHeader: "2차 실제일" + }, + }, + ], }, // 첨부파일 수 { accessorKey: "attachmentCount", header: ({ column }) => ( - + ), cell: ({ row }) => { const count = row.original.attachmentCount || 0 + return ( -
- - {count} -
+ ) }, - size: 80, + size: 60, enableResizing: true, meta: { excelHeader: "첨부파일" @@ -365,12 +244,11 @@ export function getSimplifiedDocumentColumns({ header: ({ column }) => ( ), - cell: ({ cell }) => ( - - {formatDateTime(cell.getValue() as Date)} - - ), - size: 140, + cell: ({ cell, row }) => { + return ( + + ) + }, enableResizing: true, meta: { excelHeader: "업데이트" @@ -378,50 +256,208 @@ export function getSimplifiedDocumentColumns({ }, // 액션 버튼 - { - id: "actions", - header: () => Actions, - cell: ({ row }) => { - const doc = row.original - return ( - - - - - - setRowAction({ type: "view", row: doc })} - > - - 보기 - - setRowAction({ type: "edit", row: doc })} - > - - 편집 - - - setRowAction({ type: "delete", row: doc })} - className="text-red-600" - > - - 삭제 - - - - - ) - }, - size: 50, - enableSorting: false, - enableHiding: false, - }, + // { + // id: "actions", + // header: () => Actions, + // cell: ({ row }) => { + // const doc = row.original + // return ( + // + // + // + // + // + // setRowAction({ type: "view", row: doc })} + // > + // + // 보기 + // + // setRowAction({ type: "edit", row: doc })} + // > + // + // 편집 + // + // + // setRowAction({ type: "delete", row: doc })} + // className="text-red-600" + // > + // + // 삭제 + // + // + // + // + // ) + // }, + // size: 50, + // enableSorting: false, + // enableHiding: false, + // }, ] - return baseColumns + return columns +} + +// 개별 셀 컴포넌트들 (Context 사용) +function SelectCell({ documentId }: { documentId: number }) { + const { selectedDocumentId, setSelectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === documentId; + + return ( +
+ { + const newSelection = isSelected ? null : documentId; + setSelectedDocumentId(newSelection); + }} + className="cursor-pointer w-4 h-4" + /> +
+ ); +} + +function DocNumberCell({ doc }: { doc: SimplifiedDocumentsView }) { + const { selectedDocumentId, setSelectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === doc.documentId; + + return ( +
{ + const newSelection = isSelected ? null : doc.documentId; + setSelectedDocumentId(newSelection); + }} + > + {doc.docNumber} +
+ ); +} + +function TitleCell({ doc }: { doc: SimplifiedDocumentsView }) { + const { selectedDocumentId, setSelectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === doc.documentId; + + return ( +
{ + const newSelection = isSelected ? null : doc.documentId; + setSelectedDocumentId(newSelection); + }} + > + {doc.title} +
+ ); +} + +function ProjectCodeCell({ projectCode, documentId }: { projectCode: string | null, documentId: number }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === documentId; + + if (!projectCode) return -; + + return ( + + {projectCode} + + ); +} + +function FirstStagePlanDateCell({ row }: { row: any }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === row.original.documentId; + + return ; +} + +function FirstStageActualDateCell({ row }: { row: any }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === row.original.documentId; + const date = row.original.firstStageActualDate; + + return ( +
+ + {date && ✓ 완료} +
+ ); +} + +function SecondStagePlanDateCell({ row }: { row: any }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === row.original.documentId; + + return ; +} + +function SecondStageActualDateCell({ row }: { row: any }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === row.original.documentId; + const date = row.original.secondStageActualDate; + + return ( +
+ + {date && ✓ 완료} +
+ ); +} + +function AttachmentCountCell({ count, documentId }: { count: number, documentId: number }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === documentId; + + return ( +
+ + + {count} + +
+ ); +} + +function UpdatedAtCell({ updatedAt, documentId }: { updatedAt: Date, documentId: number }) { + const { selectedDocumentId } = React.useContext(DocumentSelectionContext); + const isSelected = selectedDocumentId === documentId; + + return ( + + {formatDateTime(updatedAt)} + + ); } \ No newline at end of file diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx index 3960bbce..508d8c91 100644 --- a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx +++ b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx @@ -6,29 +6,18 @@ import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" -import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" -import { AddDocumentListDialog } from "./add-doc-dialog" -import { DeleteDocumentsDialog } from "./delete-docs-dialog" -import { BulkUploadDialog } from "./bulk-upload-dialog" -import type { EnhancedDocument } from "@/types/enhanced-documents" +import { SimplifiedDocumentsView } from "@/db/schema/vendorDocu" import { SendToSHIButton } from "./send-to-shi-button" import { ImportFromDOLCEButton } from "./import-from-dolce-button" -import { SWPWorkflowPanel } from "./swp-workflow-panel" interface EnhancedDocTableToolbarActionsProps { - table: Table + table: Table projectType: "ship" | "plant" - selectedPackageId: number - onNewDocument: () => void - onBulkAction: (action: string, selectedRows: any[]) => Promise } export function EnhancedDocTableToolbarActions({ table, projectType, - selectedPackageId, - onNewDocument, - onBulkAction }: EnhancedDocTableToolbarActionsProps) { const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = React.useState(false) @@ -61,45 +50,15 @@ export function EnhancedDocTableToolbarActions({ return (
- {/* 삭제 버튼 */} - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - row.original)} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) : null} - - {/* projectType에 따른 조건부 렌더링 */} - {projectType === "ship" ? ( + <> {/* SHIP: DOLCE에서 목록 가져오기 */} - ) : ( - <> - {/* PLANT: 수동 문서 추가 */} - - - )} - - {/* 일괄 업로드 버튼 (공통) */} - + {/* Export 버튼 (공통) */}
) } \ No newline at end of file diff --git a/lib/vendor-document-list/ship/enhanced-document-sheet.tsx b/lib/vendor-document-list/ship/enhanced-document-sheet.tsx deleted file mode 100644 index 88e342c8..00000000 --- a/lib/vendor-document-list/ship/enhanced-document-sheet.tsx +++ /dev/null @@ -1,939 +0,0 @@ -// enhanced-document-sheet.tsx -"use client" - -import * as React from "react" -import { zodResolver } from "@hookform/resolvers/zod" -import { useForm } from "react-hook-form" -import { toast } from "sonner" -import { z } from "zod" -import { useRouter } from "next/navigation" -import { - Loader, - Save, - Upload, - Calendar, - User, - FileText, - AlertTriangle, - CheckCircle, - Clock, - Plus, - X -} from "lucide-react" - -import { Button } from "@/components/ui/button" -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" -import { - Sheet, - SheetClose, - SheetContent, - SheetDescription, - SheetFooter, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet" -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "@/components/ui/tabs" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { Badge } from "@/components/ui/badge" -import { Separator } from "@/components/ui/separator" -import { ScrollArea } from "@/components/ui/scroll-area" -import { Calendar as CalendarComponent } from "@/components/ui/calendar" -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" -import { cn } from "@/lib/utils" -import { format } from "date-fns" -import { ko } from "date-fns/locale" -import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" - -// 드롭존과 파일 관련 컴포넌트들 -import { - Dropzone, - DropzoneDescription, - DropzoneInput, - DropzoneTitle, - DropzoneUploadIcon, - DropzoneZone, -} from "@/components/ui/dropzone" -import { - FileList, - FileListAction, - FileListDescription, - FileListHeader, - FileListIcon, - FileListInfo, - FileListItem, - FileListName, - FileListSize, -} from "@/components/ui/file-list" -import prettyBytes from "pretty-bytes" - -// 스키마 정의 -const enhancedDocumentSchema = z.object({ - // 기본 문서 정보 - docNumber: z.string().min(1, "문서번호는 필수입니다"), - title: z.string().min(1, "제목은 필수입니다"), - pic: z.string().optional(), - status: z.string().min(1, "상태는 필수입니다"), - issuedDate: z.date().optional(), - - // 스테이지 관리 (plant 타입에서만 수정 가능) - stages: z.array(z.object({ - id: z.number().optional(), - stageName: z.string().min(1, "스테이지명은 필수입니다"), - stageOrder: z.number(), - priority: z.enum(["HIGH", "MEDIUM", "LOW"]).default("MEDIUM"), - planDate: z.date().optional(), - assigneeName: z.string().optional(), - description: z.string().optional(), - })).optional(), - - // 리비전 업로드 (현재 스테이지에 대한) - newRevision: z.object({ - stage: z.string().optional(), - revision: z.string().optional(), - uploaderType: z.enum(["vendor", "client", "shi"]).default("vendor"), - uploaderName: z.string().optional(), - comment: z.string().optional(), - attachments: z.array(z.instanceof(File)).optional(), - }).optional(), -}) - -type EnhancedDocumentSchema = z.infer - -// 상태 옵션 정의 -const statusOptions = [ - { value: "ACTIVE", label: "활성" }, - { value: "INACTIVE", label: "비활성" }, - { value: "COMPLETED", label: "완료" }, - { value: "CANCELLED", label: "취소" }, -] - -const priorityOptions = [ - { value: "HIGH", label: "높음" }, - { value: "MEDIUM", label: "보통" }, - { value: "LOW", label: "낮음" }, -] - -const stageStatusOptions = [ - { value: "PLANNED", label: "계획됨" }, - { value: "IN_PROGRESS", label: "진행중" }, - { value: "SUBMITTED", label: "제출됨" }, - { value: "APPROVED", label: "승인됨" }, - { value: "REJECTED", label: "반려됨" }, - { value: "COMPLETED", label: "완료됨" }, -] - -interface EnhancedDocumentSheetProps - extends React.ComponentPropsWithRef { - document: EnhancedDocumentsView | null - projectType: "ship" | "plant" - mode: "view" | "edit" | "upload" | "schedule" | "approve" -} - -export function EnhancedDocumentSheet({ - document, - projectType, - mode = "view", - ...props -}: EnhancedDocumentSheetProps) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const [selectedFiles, setSelectedFiles] = React.useState([]) - const [uploadProgress, setUploadProgress] = React.useState(0) - const [activeTab, setActiveTab] = React.useState("info") - const router = useRouter() - - // 권한 계산 - const permissions = React.useMemo(() => { - const canEdit = projectType === "plant" || mode === "edit" - const canUpload = mode === "upload" || mode === "edit" - const canApprove = mode === "approve" && projectType === "ship" - const canSchedule = mode === "schedule" || (projectType === "plant" && mode === "edit") - - return { canEdit, canUpload, canApprove, canSchedule } - }, [projectType, mode]) - - const form = useForm({ - resolver: zodResolver(enhancedDocumentSchema), - defaultValues: { - docNumber: "", - title: "", - pic: "", - status: "ACTIVE", - issuedDate: undefined, - stages: [], - newRevision: { - stage: "", - revision: "", - uploaderType: "vendor", - uploaderName: "", - comment: "", - attachments: [], - }, - }, - }) - - // 폼 초기화 - React.useEffect(() => { - if (document) { - form.reset({ - docNumber: document.docNumber, - title: document.title, - pic: document.pic || "", - status: document.status, - issuedDate: document.issuedDate ? new Date(document.issuedDate) : undefined, - stages: document.allStages?.map((stage, index) => ({ - id: stage.id, - stageName: stage.stageName, - stageOrder: stage.stageOrder || index, - priority: stage.priority as "HIGH" | "MEDIUM" | "LOW" || "MEDIUM", - planDate: stage.planDate ? new Date(stage.planDate) : undefined, - assigneeName: stage.assigneeName || "", - description: "", - })) || [], - newRevision: { - stage: document.currentStageName || "", - revision: "", - uploaderType: "vendor", - uploaderName: "", - comment: "", - attachments: [], - }, - }) - - // 모드에 따른 기본 탭 설정 - if (mode === "upload") { - setActiveTab("upload") - } else if (mode === "schedule") { - setActiveTab("schedule") - } else if (mode === "approve") { - setActiveTab("approve") - } - } - }, [document, form, mode]) - - // 파일 처리 - const handleDropAccepted = (acceptedFiles: File[]) => { - const newFiles = [...selectedFiles, ...acceptedFiles] - setSelectedFiles(newFiles) - form.setValue('newRevision.attachments', newFiles) - } - - const removeFile = (index: number) => { - const updatedFiles = [...selectedFiles] - updatedFiles.splice(index, 1) - setSelectedFiles(updatedFiles) - form.setValue('newRevision.attachments', updatedFiles) - } - - // 스테이지 추가/제거 - const addStage = () => { - const currentStages = form.getValues("stages") || [] - const newStage = { - stageName: "", - stageOrder: currentStages.length, - priority: "MEDIUM" as const, - planDate: undefined, - assigneeName: "", - description: "", - } - form.setValue("stages", [...currentStages, newStage]) - } - - const removeStage = (index: number) => { - const currentStages = form.getValues("stages") || [] - const updatedStages = currentStages.filter((_, i) => i !== index) - form.setValue("stages", updatedStages) - } - - // 제출 처리 - function onSubmit(input: EnhancedDocumentSchema) { - startUpdateTransition(async () => { - if (!document) return - - try { - // 모드에 따른 다른 처리 - switch (mode) { - case "edit": - // 문서 정보 업데이트 + 스테이지 관리 - await updateDocumentInfo(input) - break - case "upload": - // 리비전 업로드 - await uploadRevision(input) - break - case "approve": - // 승인 처리 - await approveRevision(input) - break - case "schedule": - // 스케줄 관리 - await updateSchedule(input) - break - } - - form.reset() - setSelectedFiles([]) - props.onOpenChange?.(false) - toast.success("성공적으로 처리되었습니다") - router.refresh() - } catch (error) { - toast.error("처리 중 오류가 발생했습니다") - console.error(error) - } - }) - } - - // 개별 처리 함수들 - const updateDocumentInfo = async (input: EnhancedDocumentSchema) => { - // 문서 기본 정보 업데이트 API 호출 - console.log("문서 정보 업데이트:", input) - } - - const uploadRevision = async (input: EnhancedDocumentSchema) => { - if (!input.newRevision?.attachments?.length) { - throw new Error("파일을 선택해주세요") - } - - // 파일 업로드 처리 - const formData = new FormData() - formData.append("documentId", String(document?.documentId)) - formData.append("stage", input.newRevision.stage || "") - formData.append("revision", input.newRevision.revision || "") - formData.append("uploaderType", input.newRevision.uploaderType) - - input.newRevision.attachments.forEach((file) => { - formData.append("attachments", file) - }) - - // API 호출 - console.log("리비전 업로드:", formData) - } - - const approveRevision = async (input: EnhancedDocumentSchema) => { - // 승인 처리 API 호출 - console.log("리비전 승인:", input) - } - - const updateSchedule = async (input: EnhancedDocumentSchema) => { - // 스케줄 업데이트 API 호출 - console.log("스케줄 업데이트:", input) - } - - // 제목 및 설명 생성 - const getSheetTitle = () => { - switch (mode) { - case "edit": return "문서 정보 수정" - case "upload": return "리비전 업로드" - case "approve": return "문서 승인" - case "schedule": return "일정 관리" - default: return "문서 상세" - } - } - - const getSheetDescription = () => { - const docInfo = document ? `${document.docNumber} - ${document.title}` : "" - switch (mode) { - case "edit": return `문서 정보를 수정합니다. ${docInfo}` - case "upload": return `새 리비전을 업로드합니다. ${docInfo}` - case "approve": return `문서를 검토하고 승인 처리합니다. ${docInfo}` - case "schedule": return `문서의 일정을 관리합니다. ${docInfo}` - default: return docInfo - } - } - - return ( - - - - - {mode === "upload" && } - {mode === "approve" && } - {mode === "schedule" && } - {mode === "edit" && } - {getSheetTitle()} - - - {getSheetDescription()} - - - {/* 프로젝트 타입 및 권한 표시 */} -
- - {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} - - {document?.isOverdue && ( - - - 지연 - - )} - {document?.currentStagePriority === "HIGH" && ( - 높은 우선순위 - )} -
-
- -
- - - - 기본정보 - - 일정관리 - - - 리비전업로드 - - - 승인처리 - - - - {/* 기본 정보 탭 */} - - -
- ( - - 문서번호 - - - - - - )} - /> - - ( - - 제목 - - - - - - )} - /> - -
- ( - - 담당자 (PIC) - - - - - - )} - /> - - ( - - 상태 - - - - )} - /> -
- - ( - - 발행일 - - - - - - - - date > new Date()} - initialFocus - /> - - - - - )} - /> - - {/* 현재 상태 정보 표시 */} - {document && ( -
-

- - 현재 진행 상황 -

-
-
- 현재 스테이지: -

{document.currentStageName || "-"}

-
-
- 진행률: -

{document.progressPercentage || 0}%

-
-
- 최신 리비전: -

{document.latestRevision || "-"}

-
-
- 담당자: -

{document.currentStageAssigneeName || "-"}

-
-
-
- )} -
-
-
- - {/* 일정 관리 탭 */} - - -
-
-

스테이지 일정 관리

- {projectType === "plant" && ( - - )} -
- - {form.watch("stages")?.map((stage, index) => ( -
-
-
스테이지 {index + 1}
- {projectType === "plant" && ( - - )} -
- -
- ( - - 스테이지명 - - - - - - )} - /> - - ( - - 우선순위 - - - - )} - /> - - ( - - 계획일 - - - - - - - - - - - - - )} - /> - - ( - - 담당자 - - - - - - )} - /> -
-
- ))} -
-
-
- - {/* 리비전 업로드 탭 */} - - -
-
- ( - - 스테이지 - - - - - - )} - /> - - ( - - 리비전 - - - - - - )} - /> -
- - ( - - 업로더명 (선택) - - - - - - )} - /> - - ( - - 코멘트 (선택) - -