diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-17 09:02:32 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-17 09:02:32 +0000 |
| commit | 7a1524ba54f43d0f2a19e4bca2c6a2e0b01c5ef1 (patch) | |
| tree | daa214d404c7fc78b32419a028724e5671a6c7a4 /lib/b-rfq/initial | |
| parent | fa6a6093014c5d60188edfc9c4552e81c4b97bd1 (diff) | |
(대표님) 20250617 18시 작업사항
Diffstat (limited to 'lib/b-rfq/initial')
| -rw-r--r-- | lib/b-rfq/initial/add-initial-rfq-dialog.tsx | 326 | ||||
| -rw-r--r-- | lib/b-rfq/initial/delete-initial-rfq-dialog.tsx | 149 | ||||
| -rw-r--r-- | lib/b-rfq/initial/initial-rfq-detail-columns.tsx | 358 | ||||
| -rw-r--r-- | lib/b-rfq/initial/initial-rfq-detail-table.tsx | 74 | ||||
| -rw-r--r-- | lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx | 301 | ||||
| -rw-r--r-- | lib/b-rfq/initial/update-initial-rfq-sheet.tsx | 496 |
6 files changed, 1287 insertions, 417 deletions
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<typeof addInitialRfqSchema> +export type AddInitialRfqFormData = z.infer<typeof addInitialRfqSchema> 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<Vendor[]>([]) @@ -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<AddInitialRfqFormData>({ - resolver: zodResolver(addInitialRfqSchema), - defaultValues: { + // 기본값 설정 (선택된 항목이 있으면 해당 값 사용, 없으면 일반 기본값) + const getDefaultFormValues = React.useCallback((): Partial<AddInitialRfqFormData> => { + 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<AddInitialRfqFormData>({ + 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 ( <Dialog open={open} onOpenChange={handleOpenChange}> <DialogTrigger asChild> <Button variant="outline" size="sm" className="gap-2"> <Plus className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">초기 RFQ 추가</span> + <span className="hidden sm:inline">{buttonText}</span> </Button> </DialogTrigger> <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto"> <DialogHeader> - <DialogTitle>초기 RFQ 추가</DialogTitle> + <DialogTitle>{dialogTitle}</DialogTitle> <DialogDescription> - 새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다. + {dialogDescription} + {defaultValues && ( + <div className="mt-2 p-2 bg-muted rounded-md text-sm"> + <strong>기본값 출처:</strong> {defaultValues.vendorName} ({defaultValues.vendorCode}) + </div> + )} </DialogDescription> </DialogHeader> @@ -263,7 +309,7 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro {vendor.vendorName} </div> <div className="text-sm text-muted-foreground"> - {vendor.vendorCode} • {vendor.country} + {vendor.vendorCode} • {vendor.country} • {vendor.taxId} </div> </div> <Check @@ -287,98 +333,98 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro {/* 날짜 필드들 */} <div className="grid grid-cols-2 gap-4"> - <FormField - control={form.control} - name="dueDate" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>견적 마감일</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - className={cn( - "w-full pl-3 text-left font-normal", - !field.value && "text-muted-foreground" - )} - > - {field.value ? ( - formatDate(field.value,"KR") - ) : ( - <span>견적 유효일을 선택하세요</span> - )} - <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={field.value} - onSelect={field.onChange} - disabled={(date) => - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - <FormField - control={form.control} - name="validDate" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>견적 유효일</FormLabel> - <Popover> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - className={cn( - "w-full pl-3 text-left font-normal", - !field.value && "text-muted-foreground" - )} - > - {field.value ? ( - formatDate(field.value,"KR") - ) : ( - <span>견적 유효일을 선택하세요</span> - )} - <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> - </Button> - </FormControl> - </PopoverTrigger> - <PopoverContent className="w-auto p-0" align="start"> - <Calendar - mode="single" - selected={field.value} - onSelect={field.onChange} - disabled={(date) => - date < new Date() || date < new Date("1900-01-01") - } - initialFocus - /> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>견적 마감일 *</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + formatDate(field.value, "KR") + ) : ( + <span>견적 마감일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="validDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>견적 유효일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + formatDate(field.value, "KR") + ) : ( + <span>견적 유효일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> </div> - {/* Incoterms 및 GTC */} - <div className="grid grid-cols-2 gap-4"> + {/* Incoterms 선택 */} <FormField control={form.control} - name="vendorId" + name="incotermsCode" render={({ field }) => ( <FormItem className="flex flex-col"> - <FormLabel>Incoterms *</FormLabel> + <FormLabel>Incoterms</FormLabel> <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}> <PopoverTrigger asChild> <FormControl> @@ -391,9 +437,8 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro > {selectedIncoterm ? ( <div className="flex items-center gap-2"> - <Building className="h-4 w-4" /> <span className="truncate"> - {selectedIncoterm.code} ({selectedIncoterm.description}) + {selectedIncoterm.code} - {selectedIncoterm.description} </span> </div> ) : ( @@ -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) }} > <div className="flex items-center gap-2 w-full"> + <div className="flex-1 min-w-0"> <div className="font-medium truncate"> - {incoterm.code} {incoterm.description} + {incoterm.code} - {incoterm.description} </div> + </div> <Check className={cn( "ml-auto h-4 w-4", - incoterm.id === field.value ? "opacity-100" : "opacity-0" + incoterm.code === field.value ? "opacity-100" : "opacity-0" )} /> </div> @@ -445,34 +492,41 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro </FormItem> )} /> - </div> - {/* GTC 정보 */} + {/* 옵션 체크박스 */} <div className="grid grid-cols-2 gap-4"> <FormField control={form.control} - name="gtc" + name="cpRequestYn" render={({ field }) => ( - <FormItem> - <FormLabel>GTC</FormLabel> + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> <FormControl> - <Input placeholder="GTC 정보" {...field} /> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> </FormControl> - <FormMessage /> + <div className="space-y-1 leading-none ml-2"> + <FormLabel>CP 요청</FormLabel> + </div> </FormItem> )} /> <FormField control={form.control} - name="gtcValidDate" + name="prjectGtcYn" render={({ field }) => ( - <FormItem> - <FormLabel>GTC 유효일</FormLabel> + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> <FormControl> - <Input placeholder="GTC 유효일" {...field} /> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> </FormControl> - <FormMessage /> + <div className="space-y-1 leading-none ml-2"> + <FormLabel>Project용 GTC 사용</FormLabel> + </div> </FormItem> )} /> @@ -501,7 +555,7 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro <FormItem> <FormLabel>Spare part</FormLabel> <FormControl> - <Input placeholder="예비부품 정보" {...field} /> + <Input placeholder="O1, O2" {...field} /> </FormControl> <FormMessage /> </FormItem> @@ -509,8 +563,6 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro /> </div> - - <DialogFooter> <Button type="button" @@ -529,6 +581,4 @@ export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogPro </DialogContent> </Dialog> ) -} - - +}
\ 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<typeof Dialog> { + initialRfqs: Row<InitialRfqDetailView>["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 ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({initialRfqs.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + <span className="font-medium">{initialRfqs.length}개</span>의 + 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 ({initialRfqs.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + <span className="font-medium">{initialRfqs.length}개</span>의 + 초기 RFQ{initialRfqs.length === 1 ? "를" : "들을"} 영구적으로 삭제합니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 삭제 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ 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<TData> { + row: Row<TData> + type: "update" | "delete" +} interface GetInitialRfqDetailColumnsProps { onSelectDetail?: (detail: any) => void + setRowAction?: React.Dispatch<React.SetStateAction<DataTableRowAction<InitialRfqDetailView> | null>> } export function getInitialRfqDetailColumns({ - onSelectDetail -}: GetInitialRfqDetailColumnsProps = {}): ColumnDef<any>[] { + onSelectDetail, + setRowAction +}: GetInitialRfqDetailColumnsProps = {}): ColumnDef<InitialRfqDetailView>[] { return [ /** ───────────── 체크박스 ───────────── */ @@ -56,53 +67,6 @@ export function getInitialRfqDetailColumns({ /** ───────────── RFQ 정보 ───────────── */ { - accessorKey: "rfqCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 코드" /> - ), - cell: ({ row }) => ( - <Button - variant="link" - className="p-0 h-auto font-medium text-blue-600 hover:text-blue-800" - onClick={() => onSelectDetail?.(row.original)} - > - {row.getValue("rfqCode") as string} - </Button> - ), - size: 120, - }, - { - accessorKey: "rfqStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 상태" /> - ), - 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 ( - <Badge variant={getStatusColor(status) as any}> - {status} - </Badge> - ) - }, - size: 140 - }, - { accessorKey: "initialRfqStatus", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="초기 RFQ 상태" /> @@ -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 }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 코드" /> + ), + cell: ({ row }) => ( + <div className="text-sm"> + {row.getValue("rfqCode") as string} + </div> + ), + size: 120, + }, + { + accessorKey: "rfqRevision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 리비전" /> + ), + cell: ({ row }) => ( + <div className="text-sm"> + Rev. {row.getValue("rfqRevision") as number} + </div> + ), + 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({ <div className="font-medium">{vendorName}</div> </div> <div className="text-sm text-muted-foreground"> - {vendorCode} • {vendorCountry} + {vendorCode} • {vendorType} • {vendorCountry} </div> {businessSize && ( <Badge variant="outline" className="text-xs"> @@ -160,42 +148,67 @@ export function getInitialRfqDetailColumns({ size: 200, }, - /** ───────────── 날짜 정보 ───────────── */ { - accessorKey: "dueDate", + accessorKey: "cpRequestYn", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="마감일" /> + <DataTableColumnHeaderSimple column={column} title="CP" /> ), cell: ({ row }) => { - const dueDate = row.getValue("dueDate") as Date - const isOverdue = dueDate && new Date(dueDate) < new Date() - - return dueDate ? ( - <div className={`flex items-center gap-2 ${isOverdue ? 'text-red-600' : ''}`}> - <Calendar className="h-4 w-4" /> - <div> - <div className="font-medium">{formatDate(dueDate)}</div> - {isOverdue && ( - <div className="text-xs text-red-600">지연</div> - )} - </div> - </div> + const cpRequest = row.getValue("cpRequestYn") as boolean + return cpRequest ? ( + <Badge variant="outline" className="text-xs"> + Yes + </Badge> ) : ( - <span className="text-muted-foreground">-</span> + <span className="text-muted-foreground text-xs">-</span> ) }, - size: 120, + size: 60, }, { - accessorKey: "validDate", + accessorKey: "prjectGtcYn", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="유효일" /> + <DataTableColumnHeaderSimple column={column} title="Project GTC" /> ), cell: ({ row }) => { - const validDate = row.getValue("validDate") as Date - return validDate ? ( + const projectGtc = row.getValue("prjectGtcYn") as boolean + return projectGtc ? ( + <Badge variant="outline" className="text-xs"> + Yes + </Badge> + ) : ( + <span className="text-muted-foreground text-xs">-</span> + ) + }, + size: 100, + }, + { + accessorKey: "gtcYn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="GTC" /> + ), + cell: ({ row }) => { + const gtc = row.getValue("gtcYn") as boolean + return gtc ? ( + <Badge variant="outline" className="text-xs"> + Yes + </Badge> + ) : ( + <span className="text-muted-foreground text-xs">-</span> + ) + }, + size: 60, + }, + { + accessorKey: "gtcValidDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="GTC 유효일" /> + ), + cell: ({ row }) => { + const gtcValidDate = row.getValue("gtcValidDate") as string + return gtcValidDate ? ( <div className="text-sm"> - {formatDate(validDate)} + {gtcValidDate} </div> ) : ( <span className="text-muted-foreground">-</span> @@ -204,7 +217,42 @@ export function getInitialRfqDetailColumns({ size: 100, }, - /** ───────────── Incoterms ───────────── */ + { + accessorKey: "classification", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="선급" /> + ), + cell: ({ row }) => { + const classification = row.getValue("classification") as string + return classification ? ( + <div className="text-sm font-medium max-w-[120px] truncate" title={classification}> + {classification} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 120, + }, + + { + accessorKey: "sparepart", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Spare Part" /> + ), + cell: ({ row }) => { + const sparepart = row.getValue("sparepart") as string + return sparepart ? ( + <Badge variant="outline" className="text-xs"> + {sparepart} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 100, + }, + { id: "incoterms", header: ({ column }) => ( @@ -230,84 +278,71 @@ export function getInitialRfqDetailColumns({ size: 120, }, - /** ───────────── 플래그 정보 ───────────── */ + /** ───────────── 날짜 정보 ───────────── */ { - id: "flags", + accessorKey: "validDate", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="플래그" /> + <DataTableColumnHeaderSimple column={column} title="유효일" /> ), 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 ( - <div className="flex flex-wrap gap-1"> - {shortList && ( - <Badge variant="secondary" className="text-xs"> - <CheckCircle2 className="h-3 w-3 mr-1" /> - Short List - </Badge> - )} - {returnYn && ( - <Badge variant="outline" className="text-xs"> - Return - </Badge> - )} - {cpRequestYn && ( - <Badge variant="outline" className="text-xs"> - CP Request - </Badge> - )} - {prjectGtcYn && ( - <Badge variant="outline" className="text-xs"> - GTC - </Badge> - )} + const validDate = row.getValue("validDate") as Date + return validDate ? ( + <div className="text-sm"> + {formatDate(validDate)} </div> + ) : ( + <span className="text-muted-foreground">-</span> ) }, - size: 150, + size: 100, }, - - /** ───────────── 분류 정보 ───────────── */ { - id: "classification", + accessorKey: "dueDate", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="분류" /> + <DataTableColumnHeaderSimple column={column} title="마감일" /> ), 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 ( - <div className="space-y-1"> - {classification && ( - <div className="text-sm font-medium max-w-[120px] truncate" title={classification}> - {classification} - </div> - )} - {sparepart && ( - <Badge variant="outline" className="text-xs"> - {sparepart} - </Badge> + return dueDate ? ( + <div className={`${isOverdue ? 'text-red-600' : ''}`}> + <div className="font-medium">{formatDate(dueDate)}</div> + {isOverdue && ( + <div className="text-xs text-red-600">지연</div> )} </div> + ) : ( + <span className="text-muted-foreground">-</span> ) }, size: 120, }, - - /** ───────────── 리비전 정보 ───────────── */ + { + accessorKey: "returnYn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 회신여부" /> + ), + cell: ({ row }) => { + const returnFlag = row.getValue("returnYn") as boolean + return returnFlag ? ( + <Badge variant="outline" className="text-xs"> + Yes + </Badge> + ) : ( + <span className="text-muted-foreground text-xs">-</span> + ) + }, + size: 70, + }, { accessorKey: "returnRevision", header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="리비전" /> + <DataTableColumnHeaderSimple column={column} title="회신 리비전" /> ), cell: ({ row }) => { const revision = row.getValue("returnRevision") as number - return revision ? ( + return revision > 0 ? ( <Badge variant="outline"> Rev. {revision} </Badge> @@ -318,6 +353,25 @@ export function getInitialRfqDetailColumns({ size: 80, }, + { + accessorKey: "shortList", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Short List" /> + ), + cell: ({ row }) => { + const shortList = row.getValue("shortList") as boolean + return shortList ? ( + <Badge variant="secondary" className="text-xs"> + <CheckCircle2 className="h-3 w-3 mr-1" /> + Yes + </Badge> + ) : ( + <span className="text-muted-foreground text-xs">-</span> + ) + }, + size: 90, + }, + /** ───────────── 등록/수정 정보 ───────────── */ { accessorKey: "createdAt", @@ -333,7 +387,7 @@ export function getInitialRfqDetailColumns({ <div className="text-sm">{formatDate(created)}</div> {updated && new Date(updated) > new Date(created) && ( <div className="text-xs text-blue-600"> - 수정: {formatDate(updated)} + 수정: {formatDate(updated, "KR")} </div> )} </div> @@ -346,7 +400,7 @@ export function getInitialRfqDetailColumns({ { id: "actions", enableHiding: false, - cell: ({ row }) => { + cell: function Cell({ row }) { return ( <DropdownMenu> <DropdownMenuTrigger asChild> @@ -359,23 +413,29 @@ export function getInitialRfqDetailColumns({ </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> - <DropdownMenuItem onClick={() => onSelectDetail?.(row.original)}> - <Eye className="mr-2 h-4 w-4" /> - 상세 보기 - </DropdownMenuItem> <DropdownMenuItem> <MessageSquare className="mr-2 h-4 w-4" /> 벤더 응답 보기 </DropdownMenuItem> <DropdownMenuSeparator /> - <DropdownMenuItem> - <Settings className="mr-2 h-4 w-4" /> - 설정 수정 - </DropdownMenuItem> - <DropdownMenuItem className="text-red-600"> - <XCircle className="mr-2 h-4 w-4" /> - 삭제 - </DropdownMenuItem> + {setRowAction && ( + <> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + <Trash className="mr-2 h-4 w-4" /> + 삭제 + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </> + )} + </DropdownMenuContent> </DropdownMenu> ) 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<Awaited<ReturnType<typeof getInitialRfqDetail>>> @@ -19,10 +25,14 @@ export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTable // 선택된 상세 정보 const [selectedDetail, setSelectedDetail] = React.useState<any>(null) + + // Row action 상태 (update/delete) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<InitialRfqDetailView> | 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 </DataTable> </div> - {/* 선택된 상세 정보 패널 (필요시 추가) */} - {selectedDetail && ( - <div className="border rounded-lg p-4"> - <h3 className="text-lg font-semibold mb-2"> - 상세 정보: {selectedDetail.rfqCode} - </h3> - <div className="grid grid-cols-2 gap-4 text-sm"> - <div> - <strong>벤더:</strong> {selectedDetail.vendorName} - </div> - <div> - <strong>국가:</strong> {selectedDetail.vendorCountry} - </div> - <div> - <strong>마감일:</strong> {formatDate(selectedDetail.dueDate)} - </div> - <div> - <strong>유효일:</strong> {formatDate(selectedDetail.validDate)} - </div> - </div> - </div> - )} + {/* Update Sheet */} + <UpdateInitialRfqSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + initialRfq={rowAction?.type === "update" ? rowAction.row.original : null} + /> + + {/* Delete Dialog */} + <DeleteInitialRfqDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + initialRfqs={rowAction?.type === "delete" ? [rowAction.row.original] : []} + showTrigger={false} + onSuccess={() => { + setRowAction(null) + // 테이블 리프레시는 revalidatePath로 자동 처리됨 + }} + /> </div> ) }
\ 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<any> - rfqId?: number + table: Table<InitialRfqDetailView> + 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 ( - <div className="flex items-center gap-2"> - {/** 선택된 항목이 있을 때만 표시되는 액션들 */} - {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 ( <> - <Button - variant="outline" - size="sm" - onClick={handleBulkEmail} - className="h-8" - > - <Mail className="mr-2 h-4 w-4" /> - 이메일 발송 ({selectedCount}) - </Button> - - <Button - variant="outline" - size="sm" - onClick={handleBulkDelete} - className="h-8 text-red-600 hover:text-red-700" - > - <Trash2 className="mr-2 h-4 w-4" /> - 삭제 ({selectedCount}) - </Button> + <div className="flex items-center gap-2"> + {/** 선택된 항목이 있을 때만 표시되는 액션들 */} + {selectedCount > 0 && ( + <> + <Button + variant="outline" + size="sm" + onClick={handleBulkEmail} + className="h-8" + disabled={isEmailSending} + > + {isEmailSending ? <Loader className="mr-2 h-4 w-4 animate-spin" /> : <Mail className="mr-2 h-4 w-4" />} + RFQ 발송 ({selectedCount}) + </Button> + + <Button + variant="outline" + size="sm" + onClick={handleBulkDelete} + className="h-8 text-red-600 hover:text-red-700" + disabled={!canDelete || selectedCount === 0} + title={!canDelete ? "DRAFT 상태의 RFQ만 삭제할 수 있습니다" : ""} + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 ({draftCount}/{selectedCount}) + </Button> + </> + )} + + {/* S/L 확정 버튼 */} + {rfqId && ( + <Button + variant="default" + size="sm" + onClick={handleSlConfirm} + className="h-8" + > + <CheckCircle2 className="mr-2 h-4 w-4" /> + S/L 확정 + </Button> + )} + + {/* 초기 RFQ 추가 버튼 */} + {rfqId && ( + <AddInitialRfqDialog + rfqId={rfqId} + onSuccess={handleAddSuccess} + defaultValues={defaultValues} + /> + )} + </div> + + {/* 삭제 다이얼로그 */} + <DeleteInitialRfqDialog + open={showDeleteDialog} + onOpenChange={setShowDeleteDialog} + initialRfqs={selectedDetails} + showTrigger={false} + onSuccess={handleDeleteSuccess} + /> </> - )} - - {/** 항상 표시되는 액션들 */} - <Button - variant="outline" - size="sm" - onClick={handleExport} - className="h-8" - > - <Download className="mr-2 h-4 w-4" /> - 엑스포트 - </Button> - - <Button - variant="outline" - size="sm" - onClick={handleRefresh} - className="h-8" - > - <RefreshCw className="mr-2 h-4 w-4" /> - 새로고침 - </Button> - - <Button - variant="outline" - size="sm" - className="h-8" - > - <Settings className="mr-2 h-4 w-4" /> - 설정 - </Button> - </div> - ) -} + ) +}
\ 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<typeof Sheet> { + 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<Incoterm[]>([]) + 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<UpdateInitialRfqSchema>({ + 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 ( + <Sheet {...props}> + <SheetContent className="flex flex-col h-full sm:max-w-md"> + {/* 고정 헤더 */} + <SheetHeader className="flex-shrink-0 text-left pb-6"> + <SheetTitle>초기 RFQ 수정</SheetTitle> + <SheetDescription> + 초기 RFQ 정보를 수정하고 변경사항을 저장하세요 + </SheetDescription> + </SheetHeader> + + {/* 스크롤 가능한 폼 영역 */} + <div className="flex-1 overflow-y-auto"> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4 pr-2" + > + {/* RFQ 리비전 */} + <FormField + control={form.control} + name="rfqRevision" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ 리비전</FormLabel> + <FormControl> + <Input + type="number" + min="0" + placeholder="0" + {...field} + onChange={(e) => field.onChange(parseInt(e.target.value) || 0)} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 마감일 */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>마감일 *</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant={"outline"} + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "PPP", { locale: ko }) + ) : ( + <span>날짜를 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 유효일 */} + <FormField + control={form.control} + name="validDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>유효일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant={"outline"} + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "PPP", { locale: ko }) + ) : ( + <span>날짜를 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* Incoterms 코드 */} + <FormField + control={form.control} + name="incotermsCode" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>Incoterms</FormLabel> + <Popover open={incotermsSearchOpen} onOpenChange={setIncotermsSearchOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={incotermsSearchOpen} + className="justify-between" + disabled={incotermsLoading} + > + {selectedIncoterm ? ( + <div className="flex items-center gap-2"> + <span className="truncate"> + {selectedIncoterm.code} - {selectedIncoterm.description} + </span> + </div> + ) : ( + <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="코드 또는 내용으로 검색..." + className="h-9" + /> + <CommandList> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup> + {incoterms.map((incoterm) => ( + <CommandItem + key={incoterm.id} + value={`${incoterm.code} ${incoterm.description}`} + onSelect={() => { + field.onChange(incoterm.code) + setIncotermsSearchOpen(false) + }} + > + <div className="flex items-center gap-2 w-full"> + <div className="flex-1 min-w-0"> + <div className="font-medium truncate"> + {incoterm.code} - {incoterm.description} + </div> + </div> + <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> + )} + /> + {/* 체크박스 옵션들 */} + <div className="space-y-3"> + <FormField + control={form.control} + name="shortList" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none ml-2"> + <FormLabel>Short List</FormLabel> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="returnYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none ml-2"> + <FormLabel>회신 여부</FormLabel> + </div> + </FormItem> + )} + /> + + {/* 선급 */} + <FormField + control={form.control} + name="classification" + render={({ field }) => ( + <FormItem> + <FormLabel>선급</FormLabel> + <FormControl> + <Input + placeholder="선급" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 예비부품 */} + <FormField + control={form.control} + name="sparepart" + render={({ field }) => ( + <FormItem> + <FormLabel>예비부품</FormLabel> + <FormControl> + <Input + placeholder="O1, O2" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + + + + <FormField + control={form.control} + name="cpRequestYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none ml-2"> + <FormLabel>CP 요청</FormLabel> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="prjectGtcYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none ml-2"> + <FormLabel>프로젝트 GTC</FormLabel> + </div> + </FormItem> + )} + /> + </div> + + {/* 하단 여백 */} + <div className="h-4" /> + </form> + </Form> + </div> + + {/* 고정 푸터 */} + <SheetFooter className="flex-shrink-0 gap-2 pt-6 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + onClick={form.handleSubmit(onSubmit)} + disabled={isUpdatePending} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 저장 + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file |
