From 7a1524ba54f43d0f2a19e4bca2c6a2e0b01c5ef1 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Tue, 17 Jun 2025 09:02:32 +0000 Subject: (대표님) 20250617 18시 작업사항 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/b-rfq/initial/add-initial-rfq-dialog.tsx | 326 ++++++++------ lib/b-rfq/initial/delete-initial-rfq-dialog.tsx | 149 +++++++ lib/b-rfq/initial/initial-rfq-detail-columns.tsx | 358 ++++++++------- lib/b-rfq/initial/initial-rfq-detail-table.tsx | 74 +-- .../initial/initial-rfq-detail-toolbar-actions.tsx | 301 +++++++++---- lib/b-rfq/initial/update-initial-rfq-sheet.tsx | 496 +++++++++++++++++++++ 6 files changed, 1287 insertions(+), 417 deletions(-) create mode 100644 lib/b-rfq/initial/delete-initial-rfq-dialog.tsx create mode 100644 lib/b-rfq/initial/update-initial-rfq-sheet.tsx (limited to 'lib/b-rfq/initial') diff --git a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx index d0924be2..58a091ac 100644 --- a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx +++ b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx @@ -1,4 +1,3 @@ -// add-initial-rfq-dialog.tsx "use client" import * as React from "react" @@ -45,6 +44,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { cn, formatDate } from "@/lib/utils" import { addInitialRfqRecord, getIncotermsForSelection, getVendorsForSelection } from "../service" import { Calendar } from "@/components/ui/calendar" +import { InitialRfqDetailView } from "@/db/schema" // Initial RFQ 추가 폼 스키마 const addInitialRfqSchema = z.object({ @@ -70,22 +70,30 @@ const addInitialRfqSchema = z.object({ returnRevision: z.number().default(0), }) -type AddInitialRfqFormData = z.infer +export type AddInitialRfqFormData = z.infer interface Vendor { id: number vendorName: string vendorCode: string country: string + taxId: string status: string } +interface Incoterm { + id: number + code: string + description: string +} + interface AddInitialRfqDialogProps { rfqId: number onSuccess?: () => void + defaultValues?: InitialRfqDetailView // 선택된 항목의 기본값 } -export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogProps) { +export function AddInitialRfqDialog({ rfqId, onSuccess, defaultValues }: AddInitialRfqDialogProps) { const [open, setOpen] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) const [vendors, setVendors] = React.useState([]) @@ -95,16 +103,38 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro const [incotermsLoading, setIncotermsLoading] = React.useState(false) const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false) - const form = useForm({ - resolver: zodResolver(addInitialRfqSchema), - defaultValues: { + // 기본값 설정 (선택된 항목이 있으면 해당 값 사용, 없으면 일반 기본값) + const getDefaultFormValues = React.useCallback((): Partial => { + if (defaultValues) { + return { + vendorId: defaultValues.vendorId, + initialRfqStatus: "DRAFT", // 새로 추가할 때는 항상 DRAFT로 시작 + dueDate: defaultValues.dueDate || new Date(), + validDate: defaultValues.validDate, + incotermsCode: defaultValues.incotermsCode || "", + classification: defaultValues.classification || "", + sparepart: defaultValues.sparepart || "", + shortList: false, // 새로 추가할 때는 기본적으로 false + returnYn: false, + cpRequestYn: defaultValues.cpRequestYn || false, + prjectGtcYn: defaultValues.prjectGtcYn || false, + returnRevision: 0, + } + } + + return { initialRfqStatus: "DRAFT", shortList: false, returnYn: false, cpRequestYn: false, prjectGtcYn: false, returnRevision: 0, - }, + } + }, [defaultValues]) + + const form = useForm({ + resolver: zodResolver(addInitialRfqSchema), + defaultValues: getDefaultFormValues(), }) // 벤더 목록 로드 @@ -121,23 +151,27 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro } }, []) - // 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) - } - }, []) + // 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) { + // 폼을 기본값으로 리셋 + form.reset(getDefaultFormValues()) + + // 데이터 로드 if (vendors.length === 0) { loadVendors() } @@ -145,12 +179,12 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro loadIncoterms() } } - }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms]) + }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms, form, getDefaultFormValues]) // 다이얼로그 닫기 핸들러 const handleOpenChange = (newOpen: boolean) => { if (!newOpen && !isSubmitting) { - form.reset() + form.reset(getDefaultFormValues()) } setOpen(newOpen) } @@ -167,7 +201,7 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro if (result.success) { toast.success(result.message || "초기 RFQ가 성공적으로 추가되었습니다.") - form.reset() + form.reset(getDefaultFormValues()) handleOpenChange(false) onSuccess?.() } else { @@ -186,20 +220,32 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro const selectedVendor = vendors.find(vendor => vendor.id === form.watch("vendorId")) const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode")) + // 기본값이 있을 때 버튼 텍스트 변경 + const buttonText = defaultValues ? "유사 항목 추가" : "초기 RFQ 추가" + const dialogTitle = defaultValues ? "유사 초기 RFQ 추가" : "초기 RFQ 추가" + const dialogDescription = defaultValues + ? "선택된 항목을 기본값으로 하여 새로운 초기 RFQ를 추가합니다." + : "새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다." + return ( - 초기 RFQ 추가 + {dialogTitle} - 새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다. + {dialogDescription} + {defaultValues && ( +
+ 기본값 출처: {defaultValues.vendorName} ({defaultValues.vendorCode}) +
+ )}
@@ -263,7 +309,7 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro {vendor.vendorName}
- {vendor.vendorCode} • {vendor.country} + {vendor.vendorCode} • {vendor.country} • {vendor.taxId}
- ( - - 견적 마감일 - - - - - - - - - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - - - - - )} - /> - ( - - 견적 유효일 - - - - - - - - - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - - - - - )} - /> + ( + + 견적 마감일 * + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + + ( + + 견적 유효일 + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> - {/* Incoterms 및 GTC */} -
+ {/* Incoterms 선택 */} ( - Incoterms * + Incoterms @@ -391,9 +437,8 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro > {selectedIncoterm ? (
- - {selectedIncoterm.code} ({selectedIncoterm.description}) + {selectedIncoterm.code} - {selectedIncoterm.description}
) : ( @@ -419,18 +464,20 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro key={incoterm.id} value={`${incoterm.code} ${incoterm.description}`} onSelect={() => { - field.onChange(vendor.id) - setVendorSearchOpen(false) + field.onChange(incoterm.code) + setIncotermsSearchOpen(false) }} >
+
- {incoterm.code} {incoterm.description} + {incoterm.code} - {incoterm.description}
+
@@ -445,34 +492,41 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro
)} /> -
- {/* GTC 정보 */} + {/* 옵션 체크박스 */}
( - - GTC + - + - +
+ CP 요청 +
)} /> ( - - GTC 유효일 + - + - +
+ Project용 GTC 사용 +
)} /> @@ -501,7 +555,7 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro Spare part - + @@ -509,8 +563,6 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro />
- -
) -} - - +} \ No newline at end of file diff --git a/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx b/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx new file mode 100644 index 00000000..b5a231b7 --- /dev/null +++ b/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { InitialRfqDetailView } from "@/db/schema" +import { removeInitialRfqs } from "../service" + +interface DeleteInitialRfqDialogProps + extends React.ComponentPropsWithoutRef { + initialRfqs: Row["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteInitialRfqDialog({ + initialRfqs, + showTrigger = true, + onSuccess, + ...props +}: DeleteInitialRfqDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeInitialRfqs({ + ids: initialRfqs.map((rfq) => rfq.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("초기 RFQ가 삭제되었습니다") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + {initialRfqs.length}개의 + 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다. + + + + + + + + + + + ) + } + + return ( + + {showTrigger ? ( + + + + ) : null} + + + 정말로 삭제하시겠습니까? + + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + {initialRfqs.length}개의 + 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다. + + + + + + + + + + + ) +} \ No newline at end of file diff --git a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx index f7ac0960..02dfd765 100644 --- a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx +++ b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx @@ -3,8 +3,9 @@ import * as React from "react" import { type ColumnDef } from "@tanstack/react-table" +import { type Row } from "@tanstack/react-table" import { - Ellipsis, Building, Calendar, Eye, + Ellipsis, Building, Eye, Edit, Trash, MessageSquare, Settings, CheckCircle2, XCircle } from "lucide-react" @@ -14,17 +15,27 @@ import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger + DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuShortcut } from "@/components/ui/dropdown-menu" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { InitialRfqDetailView } from "@/db/schema" + + +// RowAction 타입 정의 +export interface DataTableRowAction { + row: Row + type: "update" | "delete" +} interface GetInitialRfqDetailColumnsProps { onSelectDetail?: (detail: any) => void + setRowAction?: React.Dispatch | null>> } export function getInitialRfqDetailColumns({ - onSelectDetail -}: GetInitialRfqDetailColumnsProps = {}): ColumnDef[] { + onSelectDetail, + setRowAction +}: GetInitialRfqDetailColumnsProps = {}): ColumnDef[] { return [ /** ───────────── 체크박스 ───────────── */ @@ -55,53 +66,6 @@ export function getInitialRfqDetailColumns({ }, /** ───────────── 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 }) => ( @@ -111,11 +75,10 @@ export function getInitialRfqDetailColumns({ 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" + case "DRAFT": return "outline" + case "Init. RFQ Sent": return "default" + case "Init. RFQ Answered": return "success" + case "S/L Decline": return "destructive" default: return "secondary" } } @@ -127,6 +90,30 @@ export function getInitialRfqDetailColumns({ }, size: 120 }, + { + accessorKey: "rfqCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.getValue("rfqCode") as string} +
+ ), + size: 120, + }, + { + accessorKey: "rfqRevision", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ Rev. {row.getValue("rfqRevision") as number} +
+ ), + size: 120, + }, /** ───────────── 벤더 정보 ───────────── */ { @@ -137,7 +124,8 @@ export function getInitialRfqDetailColumns({ cell: ({ row }) => { const vendorName = row.original.vendorName as string const vendorCode = row.original.vendorCode as string - const vendorCountry = row.original.vendorCountry as string + const vendorType = row.original.vendorCategory as string + const vendorCountry = row.original.vendorCountry === "KR" ? "D":"F" const businessSize = row.original.vendorBusinessSize as string return ( @@ -147,7 +135,7 @@ export function getInitialRfqDetailColumns({
{vendorName}
- {vendorCode} • {vendorCountry} + {vendorCode} • {vendorType} • {vendorCountry}
{businessSize && ( @@ -160,42 +148,67 @@ export function getInitialRfqDetailColumns({ size: 200, }, - /** ───────────── 날짜 정보 ───────────── */ { - accessorKey: "dueDate", + accessorKey: "cpRequestYn", header: ({ column }) => ( - + ), cell: ({ row }) => { - const dueDate = row.getValue("dueDate") as Date - const isOverdue = dueDate && new Date(dueDate) < new Date() - - return dueDate ? ( -
- -
-
{formatDate(dueDate)}
- {isOverdue && ( -
지연
- )} -
-
+ const cpRequest = row.getValue("cpRequestYn") as boolean + return cpRequest ? ( + + Yes + ) : ( - - + - ) }, - size: 120, + size: 60, }, { - accessorKey: "validDate", + accessorKey: "prjectGtcYn", header: ({ column }) => ( - + ), cell: ({ row }) => { - const validDate = row.getValue("validDate") as Date - return validDate ? ( + const projectGtc = row.getValue("prjectGtcYn") as boolean + return projectGtc ? ( + + Yes + + ) : ( + - + ) + }, + size: 100, + }, + { + accessorKey: "gtcYn", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const gtc = row.getValue("gtcYn") as boolean + return gtc ? ( + + Yes + + ) : ( + - + ) + }, + size: 60, + }, + { + accessorKey: "gtcValidDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const gtcValidDate = row.getValue("gtcValidDate") as string + return gtcValidDate ? (
- {formatDate(validDate)} + {gtcValidDate}
) : ( - @@ -204,7 +217,42 @@ export function getInitialRfqDetailColumns({ size: 100, }, - /** ───────────── Incoterms ───────────── */ + { + accessorKey: "classification", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const classification = row.getValue("classification") as string + return classification ? ( +
+ {classification} +
+ ) : ( + - + ) + }, + size: 120, + }, + + { + accessorKey: "sparepart", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const sparepart = row.getValue("sparepart") as string + return sparepart ? ( + + {sparepart} + + ) : ( + - + ) + }, + size: 100, + }, + { id: "incoterms", header: ({ column }) => ( @@ -230,84 +278,71 @@ export function getInitialRfqDetailColumns({ size: 120, }, - /** ───────────── 플래그 정보 ───────────── */ + /** ───────────── 날짜 정보 ───────────── */ { - id: "flags", + accessorKey: "validDate", 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 - - )} + const validDate = row.getValue("validDate") as Date + return validDate ? ( +
+ {formatDate(validDate)}
+ ) : ( + - ) }, - size: 150, + size: 100, }, - - /** ───────────── 분류 정보 ───────────── */ { - id: "classification", + accessorKey: "dueDate", header: ({ column }) => ( - + ), cell: ({ row }) => { - const classification = row.original.classification as string - const sparepart = row.original.sparepart as string + const dueDate = row.getValue("dueDate") as Date + const isOverdue = dueDate && new Date(dueDate) < new Date() - return ( -
- {classification && ( -
- {classification} -
- )} - {sparepart && ( - - {sparepart} - + return dueDate ? ( +
+
{formatDate(dueDate)}
+ {isOverdue && ( +
지연
)}
+ ) : ( + - ) }, size: 120, }, - - /** ───────────── 리비전 정보 ───────────── */ + { + accessorKey: "returnYn", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const returnFlag = row.getValue("returnYn") as boolean + return returnFlag ? ( + + Yes + + ) : ( + - + ) + }, + size: 70, + }, { accessorKey: "returnRevision", header: ({ column }) => ( - + ), cell: ({ row }) => { const revision = row.getValue("returnRevision") as number - return revision ? ( + return revision > 0 ? ( Rev. {revision} @@ -318,6 +353,25 @@ export function getInitialRfqDetailColumns({ size: 80, }, + { + accessorKey: "shortList", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const shortList = row.getValue("shortList") as boolean + return shortList ? ( + + + Yes + + ) : ( + - + ) + }, + size: 90, + }, + /** ───────────── 등록/수정 정보 ───────────── */ { accessorKey: "createdAt", @@ -333,7 +387,7 @@ export function getInitialRfqDetailColumns({
{formatDate(created)}
{updated && new Date(updated) > new Date(created) && (
- 수정: {formatDate(updated)} + 수정: {formatDate(updated, "KR")}
)}
@@ -346,7 +400,7 @@ export function getInitialRfqDetailColumns({ { id: "actions", enableHiding: false, - cell: ({ row }) => { + cell: function Cell({ row }) { return ( @@ -359,23 +413,29 @@ export function getInitialRfqDetailColumns({ - onSelectDetail?.(row.original)}> - - 상세 보기 - 벤더 응답 보기 - - - 설정 수정 - - - - 삭제 - + {setRowAction && ( + <> + setRowAction({ row, type: "update" })} + > + + 수정 + + setRowAction({ row, type: "delete" })} + > + + 삭제 + ⌘⌫ + + + )} + ) diff --git a/lib/b-rfq/initial/initial-rfq-detail-table.tsx b/lib/b-rfq/initial/initial-rfq-detail-table.tsx index fc8a5bc2..5ea6b0bf 100644 --- a/lib/b-rfq/initial/initial-rfq-detail-table.tsx +++ b/lib/b-rfq/initial/initial-rfq-detail-table.tsx @@ -6,8 +6,14 @@ 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 { + getInitialRfqDetailColumns, + type DataTableRowAction +} from "./initial-rfq-detail-columns" import { InitialRfqDetailTableToolbarActions } from "./initial-rfq-detail-toolbar-actions" +import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog" +import { UpdateInitialRfqSheet } from "./update-initial-rfq-sheet" +import { InitialRfqDetailView } from "@/db/schema" interface InitialRfqDetailTableProps { promises: Promise>> @@ -19,10 +25,14 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable // 선택된 상세 정보 const [selectedDetail, setSelectedDetail] = React.useState(null) + + // Row action 상태 (update/delete) + const [rowAction, setRowAction] = React.useState | null>(null) const columns = React.useMemo( () => getInitialRfqDetailColumns({ - onSelectDetail: setSelectedDetail + onSelectDetail: setSelectedDetail, + setRowAction: setRowAction }), [] ) @@ -62,11 +72,10 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable 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 }, + { label: "초안", value: "DRAFT", count: 0 }, + { label: "발송", value: "Init. RFQ Sent", count: 0 }, + { label: "응답", value: "Init. RFQ Answered", count: 0 }, + { label: "거절", value: "S/L Decline", count: 0 }, ], }, { @@ -136,11 +145,10 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable label: "초기 RFQ 상태", type: "multi-select", options: [ - { label: "대기", value: "PENDING" }, - { label: "발송", value: "SENT" }, - { label: "응답", value: "RESPONDED" }, - { label: "만료", value: "EXPIRED" }, - { label: "취소", value: "CANCELLED" }, + { label: "초안", value: "DRAFT" }, + { label: "발송", value: "Init. RFQ Sent" }, + { label: "응답", value: "Init. RFQ Answered" }, + { label: "거절", value: "S/L Decline" }, ], }, { @@ -216,7 +224,7 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, }, - getRowId: (originalRow) => originalRow.initialRfqId.toString(), + getRowId: (originalRow) => originalRow.initialRfqId ? originalRow.initialRfqId.toString():"1", shallow: false, clearOnDefault: true, }) @@ -236,28 +244,24 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable
- {/* 선택된 상세 정보 패널 (필요시 추가) */} - {selectedDetail && ( -
-

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

-
-
- 벤더: {selectedDetail.vendorName} -
-
- 국가: {selectedDetail.vendorCountry} -
-
- 마감일: {formatDate(selectedDetail.dueDate)} -
-
- 유효일: {formatDate(selectedDetail.validDate)} -
-
-
- )} + {/* Update Sheet */} + setRowAction(null)} + initialRfq={rowAction?.type === "update" ? rowAction.row.original : null} + /> + + {/* Delete Dialog */} + setRowAction(null)} + initialRfqs={rowAction?.type === "delete" ? [rowAction.row.original] : []} + showTrigger={false} + onSuccess={() => { + setRowAction(null) + // 테이블 리프레시는 revalidatePath로 자동 처리됨 + }} + /> ) } \ 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 index 981659d5..639d338d 100644 --- a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx +++ b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx @@ -1,109 +1,220 @@ -// initial-rfq-detail-toolbar-actions.tsx "use client" import * as React from "react" import { type Table } from "@tanstack/react-table" +import { useRouter } from "next/navigation" +import { toast } from "sonner" import { Button } from "@/components/ui/button" -import { - Download, - Mail, - RefreshCw, - Settings, - Trash2, - FileText +import { + Download, + Mail, + RefreshCw, + Settings, + Trash2, + FileText, + CheckCircle2, + Loader } from "lucide-react" +import { AddInitialRfqDialog } from "./add-initial-rfq-dialog" +import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog" +import { InitialRfqDetailView } from "@/db/schema" +import { sendBulkInitialRfqEmails } from "../service" interface InitialRfqDetailTableToolbarActionsProps { - table: Table - rfqId?: number + table: Table + rfqId?: number + onRefresh?: () => void // 데이터 새로고침 콜백 } export function InitialRfqDetailTableToolbarActions({ - table, - rfqId + table, + rfqId, + onRefresh }: 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 && ( + const router = useRouter() + + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedDetails = selectedRows.map((row) => row.original) + const selectedCount = selectedRows.length + + // 상태 관리 + const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) + const [isEmailSending, setIsEmailSending] = React.useState(false) + + const handleBulkEmail = async () => { + if (selectedCount === 0) return + + setIsEmailSending(true) + + try { + const initialRfqIds = selectedDetails.map(detail => detail.initialRfqId); + + const result = await sendBulkInitialRfqEmails({ + initialRfqIds, + language: "en" // 기본 영어, 필요시 사용자 설정으로 변경 + }) + + if (result.success) { + toast.success(result.message) + + // 에러가 있다면 별도 알림 + if (result.errors && result.errors.length > 0) { + setTimeout(() => { + toast.warning(`일부 오류 발생: ${result.errors?.join(', ')}`) + }, 1000) + } + + // 선택 해제 + table.toggleAllRowsSelected(false) + + // 데이터 새로고침 + if (onRefresh) { + onRefresh() + } + } else { + toast.error(result.message || "RFQ 발송에 실패했습니다.") + } + + } catch (error) { + console.error("Email sending error:", error) + toast.error("RFQ 발송 중 오류가 발생했습니다.") + } finally { + setIsEmailSending(false) + } + } + + const handleBulkDelete = () => { + // DRAFT가 아닌 상태의 RFQ 확인 + const nonDraftRfqs = selectedDetails.filter( + detail => detail.initialRfqStatus !== "DRAFT" + ) + + if (nonDraftRfqs.length > 0) { + const statusMessages = { + "Init. RFQ Sent": "이미 발송된", + "S/L Decline": "Short List 거절 처리된", + "Init. RFQ Answered": "답변 완료된" + } + + const nonDraftStatuses = [...new Set(nonDraftRfqs.map(rfq => rfq.initialRfqStatus))] + const statusText = nonDraftStatuses + .map(status => statusMessages[status as keyof typeof statusMessages] || status) + .join(", ") + + toast.error( + `${statusText} RFQ는 삭제할 수 없습니다. DRAFT 상태의 RFQ만 삭제 가능합니다.` + ) + return + } + + setShowDeleteDialog(true) + } + + // S/L 확정 버튼 클릭 + const handleSlConfirm = () => { + if (rfqId) { + router.push(`/evcp/b-rfq/${rfqId}`) + } + } + + // 초기 RFQ 추가 성공 시 처리 + const handleAddSuccess = () => { + // 선택 해제 + table.toggleAllRowsSelected(false) + + // 데이터 새로고침 + if (onRefresh) { + onRefresh() + } else { + // fallback으로 페이지 새로고침 + setTimeout(() => { + window.location.reload() + }, 1000) + } + } + + // 삭제 성공 시 처리 + const handleDeleteSuccess = () => { + // 선택 해제 + table.toggleAllRowsSelected(false) + setShowDeleteDialog(false) + + // 데이터 새로고침 + if (onRefresh) { + onRefresh() + } + } + + // 선택된 항목 중 첫 번째를 기본값으로 사용 + const defaultValues = selectedCount > 0 ? selectedDetails[0] : undefined + + const canDelete = selectedDetails.every(detail => detail.initialRfqStatus === "DRAFT") + const draftCount = selectedDetails.filter(detail => detail.initialRfqStatus === "DRAFT").length + + + return ( <> - - - +
+ {/** 선택된 항목이 있을 때만 표시되는 액션들 */} + {selectedCount > 0 && ( + <> + + + + + )} + + {/* S/L 확정 버튼 */} + {rfqId && ( + + )} + + {/* 초기 RFQ 추가 버튼 */} + {rfqId && ( + + )} +
+ + {/* 삭제 다이얼로그 */} + - )} - - {/** 항상 표시되는 액션들 */} - - - - - -
- ) -} + ) +} \ No newline at end of file diff --git a/lib/b-rfq/initial/update-initial-rfq-sheet.tsx b/lib/b-rfq/initial/update-initial-rfq-sheet.tsx new file mode 100644 index 00000000..a19b5172 --- /dev/null +++ b/lib/b-rfq/initial/update-initial-rfq-sheet.tsx @@ -0,0 +1,496 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { CalendarIcon, Loader, ChevronsUpDown, Check } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { format } from "date-fns" +import { ko } from "date-fns/locale" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Calendar } from "@/components/ui/calendar" +import { Checkbox } from "@/components/ui/checkbox" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + } from "@/components/ui/command" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { UpdateInitialRfqSchema, updateInitialRfqSchema } from "../validations" +import { getIncotermsForSelection, modifyInitialRfq } from "../service" +import { InitialRfqDetailView } from "@/db/schema" + +interface UpdateInitialRfqSheetProps + extends React.ComponentPropsWithRef { + initialRfq: InitialRfqDetailView | null +} + +interface Incoterm { + id: number + code: string + description: string +} + +export function UpdateInitialRfqSheet({ initialRfq, ...props }: UpdateInitialRfqSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [incoterms, setIncoterms] = React.useState([]) + const [incotermsLoading, setIncotermsLoading] = React.useState(false) + const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false) + + 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 (incoterms.length === 0) { + loadIncoterms() + } + }, [incoterms.length, loadIncoterms]) + + const form = useForm({ + resolver: zodResolver(updateInitialRfqSchema), + defaultValues: { + initialRfqStatus: initialRfq?.initialRfqStatus ?? "DRAFT", + dueDate: initialRfq?.dueDate ?? new Date(), + validDate: initialRfq?.validDate ?? undefined, + incotermsCode: initialRfq?.incotermsCode ?? "", + classification: initialRfq?.classification ?? "", + sparepart: initialRfq?.sparepart ?? "", + rfqRevision: initialRfq?.rfqRevision ?? 0, + shortList: initialRfq?.shortList ?? false, + returnYn: initialRfq?.returnYn ?? false, + cpRequestYn: initialRfq?.cpRequestYn ?? false, + prjectGtcYn: initialRfq?.prjectGtcYn ?? false, + }, + }) + + // initialRfq가 변경될 때 폼 값을 업데이트 + React.useEffect(() => { + if (initialRfq) { + form.reset({ + initialRfqStatus: initialRfq.initialRfqStatus ?? "DRAFT", + dueDate: initialRfq.dueDate, + validDate: initialRfq.validDate, + incotermsCode: initialRfq.incotermsCode ?? "", + classification: initialRfq.classification ?? "", + sparepart: initialRfq.sparepart ?? "", + shortList: initialRfq.shortList ?? false, + returnYn: initialRfq.returnYn ?? false, + rfqRevision: initialRfq.rfqRevision ?? 0, + cpRequestYn: initialRfq.cpRequestYn ?? false, + prjectGtcYn: initialRfq.prjectGtcYn ?? false, + }) + } + }, [initialRfq, form]) + + function onSubmit(input: UpdateInitialRfqSchema) { + startUpdateTransition(async () => { + if (!initialRfq || !initialRfq.initialRfqId) { + toast.error("유효하지 않은 RFQ입니다.") + return + } + + const { error } = await modifyInitialRfq({ + id: initialRfq.initialRfqId, + ...input, + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("초기 RFQ가 수정되었습니다") + }) + } + + const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode")) + + return ( + + + {/* 고정 헤더 */} + + 초기 RFQ 수정 + + 초기 RFQ 정보를 수정하고 변경사항을 저장하세요 + + + + {/* 스크롤 가능한 폼 영역 */} +
+
+ + {/* RFQ 리비전 */} + ( + + RFQ 리비전 + + field.onChange(parseInt(e.target.value) || 0)} + /> + + + + )} + /> + + {/* 마감일 */} + ( + + 마감일 * + + + + + + + + + date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + + {/* 유효일 */} + ( + + 유효일 + + + + + + + + + date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + + {/* Incoterms 코드 */} + ( + + Incoterms + + + + + + + + + + + 검색 결과가 없습니다. + + {incoterms.map((incoterm) => ( + { + field.onChange(incoterm.code) + setIncotermsSearchOpen(false) + }} + > +
+
+
+ {incoterm.code} - {incoterm.description} +
+
+ +
+
+ ))} +
+
+
+
+
+ +
+ )} + /> + {/* 체크박스 옵션들 */} +
+ ( + + + + +
+ Short List +
+
+ )} + /> + + ( + + + + +
+ 회신 여부 +
+
+ )} + /> + + {/* 선급 */} + ( + + 선급 + + + + + + )} + /> + + {/* 예비부품 */} + ( + + 예비부품 + + + + + + )} + /> + + + + + ( + + + + +
+ CP 요청 +
+
+ )} + /> + + ( + + + + +
+ 프로젝트 GTC +
+
+ )} + /> +
+ + {/* 하단 여백 */} +
+ + +
+ + {/* 고정 푸터 */} + + + + + + + + + ) +} \ No newline at end of file -- cgit v1.2.3