diff options
Diffstat (limited to 'lib/b-rfq/initial')
| -rw-r--r-- | lib/b-rfq/initial/add-initial-rfq-dialog.tsx | 584 | ||||
| -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 | 446 | ||||
| -rw-r--r-- | lib/b-rfq/initial/initial-rfq-detail-table.tsx | 267 | ||||
| -rw-r--r-- | lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx | 287 | ||||
| -rw-r--r-- | lib/b-rfq/initial/short-list-confirm-dialog.tsx | 269 | ||||
| -rw-r--r-- | lib/b-rfq/initial/update-initial-rfq-sheet.tsx | 496 |
7 files changed, 0 insertions, 2498 deletions
diff --git a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx deleted file mode 100644 index 58a091ac..00000000 --- a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx +++ /dev/null @@ -1,584 +0,0 @@ -"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" -import { InitialRfqDetailView } from "@/db/schema" - -// 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), -}) - -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, defaultValues }: AddInitialRfqDialogProps) { - const [open, setOpen] = React.useState(false) - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [vendors, setVendors] = React.useState<Vendor[]>([]) - const [vendorsLoading, setVendorsLoading] = React.useState(false) - const [vendorSearchOpen, setVendorSearchOpen] = React.useState(false) - const [incoterms, setIncoterms] = React.useState<Incoterm[]>([]) - const [incotermsLoading, setIncotermsLoading] = React.useState(false) - const [incotermsSearchOpen, setIncotermsSearchOpen] = React.useState(false) - - // 기본값 설정 (선택된 항목이 있으면 해당 값 사용, 없으면 일반 기본값) - 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(), - }) - - // 벤더 목록 로드 - 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) { - // 폼을 기본값으로 리셋 - form.reset(getDefaultFormValues()) - - // 데이터 로드 - if (vendors.length === 0) { - loadVendors() - } - if (incoterms.length === 0) { - loadIncoterms() - } - } - }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms, form, getDefaultFormValues]) - - // 다이얼로그 닫기 핸들러 - const handleOpenChange = (newOpen: boolean) => { - if (!newOpen && !isSubmitting) { - form.reset(getDefaultFormValues()) - } - 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(getDefaultFormValues()) - 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")) - - // 기본값이 있을 때 버튼 텍스트 변경 - 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">{buttonText}</span> - </Button> - </DialogTrigger> - - <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle>{dialogTitle}</DialogTitle> - <DialogDescription> - {dialogDescription} - {defaultValues && ( - <div className="mt-2 p-2 bg-muted rounded-md text-sm"> - <strong>기본값 출처:</strong> {defaultValues.vendorName} ({defaultValues.vendorCode}) - </div> - )} - </DialogDescription> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> - {/* 벤더 선택 */} - <FormField - control={form.control} - name="vendorId" - render={({ field }) => ( - <FormItem className="flex flex-col"> - <FormLabel>벤더 선택 *</FormLabel> - <Popover open={vendorSearchOpen} onOpenChange={setVendorSearchOpen}> - <PopoverTrigger asChild> - <FormControl> - <Button - variant="outline" - role="combobox" - aria-expanded={vendorSearchOpen} - className="justify-between" - disabled={vendorsLoading} - > - {selectedVendor ? ( - <div className="flex items-center gap-2"> - <Building className="h-4 w-4" /> - <span className="truncate"> - {selectedVendor.vendorName} ({selectedVendor.vendorCode}) - </span> - </div> - ) : ( - <span className="text-muted-foreground"> - {vendorsLoading ? "로딩 중..." : "벤더를 선택하세요"} - </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> - {vendors.map((vendor) => ( - <CommandItem - key={vendor.id} - value={`${vendor.vendorName} ${vendor.vendorCode}`} - onSelect={() => { - field.onChange(vendor.id) - setVendorSearchOpen(false) - }} - > - <div className="flex items-center gap-2 w-full"> - <Building className="h-4 w-4" /> - <div className="flex-1 min-w-0"> - <div className="font-medium truncate"> - {vendor.vendorName} - </div> - <div className="text-sm text-muted-foreground"> - {vendor.vendorCode} • {vendor.country} • {vendor.taxId} - </div> - </div> - <Check - className={cn( - "ml-auto h-4 w-4", - vendor.id === field.value ? "opacity-100" : "opacity-0" - )} - /> - </div> - </CommandItem> - ))} - </CommandGroup> - </CommandList> - </Command> - </PopoverContent> - </Popover> - <FormMessage /> - </FormItem> - )} - /> - - {/* 날짜 필드들 */} - <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> - )} - /> - </div> - - {/* 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="grid grid-cols-2 gap-4"> - <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>Project용 GTC 사용</FormLabel> - </div> - </FormItem> - )} - /> - </div> - - {/* 분류 정보 */} - <div className="grid grid-cols-2 gap-4"> - <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>Spare part</FormLabel> - <FormControl> - <Input placeholder="O1, O2" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => handleOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button type="submit" disabled={isSubmitting}> - {isSubmitting ? "추가 중..." : "추가"} - </Button> - </DialogFooter> - </form> - </Form> - </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 deleted file mode 100644 index b5a231b7..00000000 --- a/lib/b-rfq/initial/delete-initial-rfq-dialog.tsx +++ /dev/null @@ -1,149 +0,0 @@ -"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 deleted file mode 100644 index 2d9c3a68..00000000 --- a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx +++ /dev/null @@ -1,446 +0,0 @@ -// initial-rfq-detail-columns.tsx -"use client" - -import * as React from "react" -import { type ColumnDef } from "@tanstack/react-table" -import { type Row } from "@tanstack/react-table" -import { - Ellipsis, Building, Eye, Edit, Trash, - 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, 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, - setRowAction -}: GetInitialRfqDetailColumnsProps = {}): ColumnDef<InitialRfqDetailView>[] { - - return [ - /** ───────────── 체크박스 ───────────── */ - { - id: "select", - header: ({ table }) => ( - <Checkbox - checked={ - table.getIsAllPageRowsSelected() || - (table.getIsSomePageRowsSelected() && "indeterminate") - } - onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-0.5" - /> - ), - cell: ({ row }) => ( - <Checkbox - checked={row.getIsSelected()} - onCheckedChange={(value) => row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-0.5" - /> - ), - size: 40, - enableSorting: false, - enableHiding: false, - }, - - /** ───────────── RFQ 정보 ───────────── */ - { - accessorKey: "initialRfqStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ 상태" /> - ), - cell: ({ row }) => { - const status = row.getValue("initialRfqStatus") as string - const getInitialStatusColor = (status: string) => { - switch (status) { - 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" - } - } - return ( - <Badge variant={getInitialStatusColor(status) as any}> - {status} - </Badge> - ) - }, - size: 120 - }, - { - accessorKey: "rfqCode", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="RFQ No." /> - ), - 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, - }, - - /** ───────────── 벤더 정보 ───────────── */ - { - id: "vendorInfo", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="벤더 정보" /> - ), - cell: ({ row }) => { - const vendorName = row.original.vendorName as string - const vendorCode = row.original.vendorCode 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 ( - <div className="space-y-1"> - <div className="flex items-center gap-2"> - <Building className="h-4 w-4 text-muted-foreground" /> - <div className="font-medium">{vendorName}</div> - </div> - <div className="text-sm text-muted-foreground"> - {vendorCode} • {vendorType} • {vendorCountry} - </div> - {businessSize && ( - <Badge variant="outline" className="text-xs"> - {businessSize} - </Badge> - )} - </div> - ) - }, - size: 200, - }, - - { - accessorKey: "cpRequestYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="CP" /> - ), - cell: ({ row }) => { - const cpRequest = row.getValue("cpRequestYn") as boolean - return cpRequest ? ( - <Badge variant="outline" className="text-xs"> - Yes - </Badge> - ) : ( - <span className="text-muted-foreground text-xs">-</span> - ) - }, - size: 60, - }, - { - accessorKey: "prjectGtcYn", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Project GTC" /> - ), - cell: ({ row }) => { - 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"> - {gtcValidDate} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 100, - }, - - { - 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 }) => ( - <DataTableColumnHeaderSimple column={column} title="Incoterms" /> - ), - cell: ({ row }) => { - const code = row.original.incotermsCode as string - const description = row.original.incotermsDescription as string - - return code ? ( - <div className="space-y-1"> - <Badge variant="outline">{code}</Badge> - {description && ( - <div className="text-xs text-muted-foreground max-w-[150px] truncate" title={description}> - {description} - </div> - )} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 120, - }, - - /** ───────────── 날짜 정보 ───────────── */ - { - accessorKey: "validDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="유효일" /> - ), - cell: ({ row }) => { - const validDate = row.getValue("validDate") as Date - return validDate ? ( - <div className="text-sm"> - {formatDate(validDate, "KR")} - </div> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - size: 100, - }, - { - accessorKey: "dueDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="마감일" /> - ), - cell: ({ row }) => { - const dueDate = row.getValue("dueDate") as Date - const isOverdue = dueDate && new Date(dueDate) < new Date() - - return dueDate ? ( - <div className={`${isOverdue ? 'text-red-600' : ''}`}> - <div className="font-medium">{formatDate(dueDate, "KR")}</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="회신 리비전" /> - ), - cell: ({ row }) => { - const revision = row.getValue("returnRevision") as number - return revision > 0 ? ( - <Badge variant="outline"> - Rev. {revision} - </Badge> - ) : ( - <span className="text-muted-foreground">-</span> - ) - }, - 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", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="등록일" /> - ), - cell: ({ row }) => { - const created = row.getValue("createdAt") as Date - const updated = row.original.updatedAt as Date - - return ( - <div className="space-y-1"> - <div className="text-sm">{formatDate(created, "KR")}</div> - {updated && new Date(updated) > new Date(created) && ( - <div className="text-xs text-blue-600"> - 수정: {formatDate(updated, "KR")} - </div> - )} - </div> - ) - }, - size: 120, - }, - - /** ───────────── 액션 ───────────── */ - { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-48"> - <DropdownMenuItem> - <MessageSquare className="mr-2 h-4 w-4" /> - 벤더 응답 보기 - </DropdownMenuItem> - <DropdownMenuSeparator /> - {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> - ) - }, - 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 deleted file mode 100644 index 5ea6b0bf..00000000 --- a/lib/b-rfq/initial/initial-rfq-detail-table.tsx +++ /dev/null @@ -1,267 +0,0 @@ -"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, - 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>>> - rfqId?: number -} - -export function InitialRfqDetailTable({ promises, rfqId }: InitialRfqDetailTableProps) { - const { data, pageCount } = React.use(promises) - - // 선택된 상세 정보 - 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, - setRowAction: setRowAction - }), - [] - ) - - /** - * 필터 필드 정의 - */ - const filterFields: DataTableFilterField<any>[] = [ - { - 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: "DRAFT", count: 0 }, - { label: "발송", value: "Init. RFQ Sent", count: 0 }, - { label: "응답", value: "Init. RFQ Answered", count: 0 }, - { label: "거절", value: "S/L Decline", 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<any>[] = [ - { - 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: "DRAFT" }, - { label: "발송", value: "Init. RFQ Sent" }, - { label: "응답", value: "Init. RFQ Answered" }, - { label: "거절", value: "S/L Decline" }, - ], - }, - { - 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 ? originalRow.initialRfqId.toString():"1", - shallow: false, - clearOnDefault: true, - }) - - return ( - <div className="space-y-6"> - {/* 메인 테이블 */} - <div className="h-full w-full"> - <DataTable table={table} className="h-full"> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <InitialRfqDetailTableToolbarActions table={table} rfqId={rfqId} /> - </DataTableAdvancedToolbar> - </DataTable> - </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 deleted file mode 100644 index c26bda28..00000000 --- a/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx +++ /dev/null @@ -1,287 +0,0 @@ -"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, - CheckCircle2, - Loader -} from "lucide-react" -import { AddInitialRfqDialog } from "./add-initial-rfq-dialog" -import { DeleteInitialRfqDialog } from "./delete-initial-rfq-dialog" -import { ShortListConfirmDialog } from "./short-list-confirm-dialog" -import { InitialRfqDetailView } from "@/db/schema" -import { sendBulkInitialRfqEmails } from "../service" - -interface InitialRfqDetailTableToolbarActionsProps { - table: Table<InitialRfqDetailView> - rfqId?: number - onRefresh?: () => void // 데이터 새로고침 콜백 -} - -export function InitialRfqDetailTableToolbarActions({ - table, - rfqId, - onRefresh -}: InitialRfqDetailTableToolbarActionsProps) { - 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 [showShortListDialog, setShowShortListDialog] = React.useState(false) - const [isEmailSending, setIsEmailSending] = React.useState(false) - - // 전체 벤더 리스트 가져오기 (ShortList 확정용) - const allVendors = table.getRowModel().rows.map(row => row.original) - -const handleBulkEmail = async () => { - if (selectedCount === 0) return - - setIsEmailSending(true) - - try { - const initialRfqIds = selectedDetails - .map(detail => detail.initialRfqId) - .filter((id): id is number => id !== null); - - if (initialRfqIds.length === 0) { - toast.error("유효한 RFQ ID가 없습니다.") - return - } - - 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 || allVendors.length === 0) { - toast.error("S/L 확정할 벤더가 없습니다.") - return - } - - // 진행 가능한 상태 확인 - const validVendors = allVendors.filter(vendor => - vendor.initialRfqStatus === "Init. RFQ Answered" || - vendor.initialRfqStatus === "Init. RFQ Sent" - ) - - if (validVendors.length === 0) { - toast.error("S/L 확정이 가능한 벤더가 없습니다. (RFQ 발송 또는 응답 완료된 벤더만 가능)") - return - } - - setShowShortListDialog(true) - } - - // 초기 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() - } - } - - // Short List 확정 성공 시 처리 - const handleShortListSuccess = () => { - // 선택 해제 - table.toggleAllRowsSelected(false) - setShowShortListDialog(false) - - // 데이터 새로고침 - if (onRefresh) { - onRefresh() - } - - // 최종 RFQ 페이지로 이동 - if (rfqId) { - toast.success("Short List가 확정되었습니다. 최종 RFQ 페이지로 이동합니다.") - setTimeout(() => { - router.push(`/evcp/b-rfq/${rfqId}`) - }, 1500) - } - } - - // 선택된 항목 중 첫 번째를 기본값으로 사용 - const defaultValues = selectedCount > 0 ? selectedDetails[0] : undefined - - const canDelete = selectedDetails.every(detail => detail.initialRfqStatus === "DRAFT") - const draftCount = selectedDetails.filter(detail => detail.initialRfqStatus === "DRAFT").length - - // S/L 확정 가능한 벤더 수 - const validForShortList = allVendors.filter(vendor => - vendor.initialRfqStatus === "Init. RFQ Answered" || - vendor.initialRfqStatus === "Init. RFQ Sent" - ).length - - return ( - <> - <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" - disabled={validForShortList === 0} - title={validForShortList === 0 ? "S/L 확정이 가능한 벤더가 없습니다" : `${validForShortList}개 벤더 중 Short List 선택`} - > - <CheckCircle2 className="mr-2 h-4 w-4" /> - S/L 확정 ({validForShortList}) - </Button> - )} - - {/* 초기 RFQ 추가 버튼 */} - {rfqId && ( - <AddInitialRfqDialog - rfqId={rfqId} - onSuccess={handleAddSuccess} - defaultValues={defaultValues} - /> - )} - </div> - - {/* 삭제 다이얼로그 */} - <DeleteInitialRfqDialog - open={showDeleteDialog} - onOpenChange={setShowDeleteDialog} - initialRfqs={selectedDetails} - showTrigger={false} - onSuccess={handleDeleteSuccess} - /> - - {/* Short List 확정 다이얼로그 */} - {rfqId && ( - <ShortListConfirmDialog - open={showShortListDialog} - onOpenChange={setShowShortListDialog} - rfqId={rfqId} - vendors={allVendors.filter(vendor => - vendor.initialRfqStatus === "Init. RFQ Answered" || - vendor.initialRfqStatus === "Init. RFQ Sent" - )} - onSuccess={handleShortListSuccess} - /> - )} - </> - ) -}
\ No newline at end of file diff --git a/lib/b-rfq/initial/short-list-confirm-dialog.tsx b/lib/b-rfq/initial/short-list-confirm-dialog.tsx deleted file mode 100644 index 92c62dc0..00000000 --- a/lib/b-rfq/initial/short-list-confirm-dialog.tsx +++ /dev/null @@ -1,269 +0,0 @@ -"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 { Loader2, Building, CheckCircle2, XCircle } from "lucide-react" - -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Form, - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/components/ui/form" -import { Badge } from "@/components/ui/badge" -import { Separator } from "@/components/ui/separator" -import { ScrollArea } from "@/components/ui/scroll-area" - -import { shortListConfirm } from "../service" -import { InitialRfqDetailView } from "@/db/schema" - -const shortListSchema = z.object({ - selectedVendorIds: z.array(z.number()).min(1, "최소 1개 이상의 벤더를 선택해야 합니다."), -}) - -type ShortListFormData = z.infer<typeof shortListSchema> - -interface ShortListConfirmDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - rfqId: number - vendors: InitialRfqDetailView[] - onSuccess?: () => void -} - -export function ShortListConfirmDialog({ - open, - onOpenChange, - rfqId, - vendors, - onSuccess -}: ShortListConfirmDialogProps) { - const [isLoading, setIsLoading] = React.useState(false) - - const form = useForm<ShortListFormData>({ - resolver: zodResolver(shortListSchema), - defaultValues: { - selectedVendorIds: vendors - .filter(vendor => vendor.shortList === true) - .map(vendor => vendor.vendorId) - .filter(Boolean) as number[] - }, - }) - - const watchedSelectedIds = form.watch("selectedVendorIds") - - // 선택된/탈락된 벤더 계산 - const selectedVendors = vendors.filter(vendor => - vendor.vendorId && watchedSelectedIds.includes(vendor.vendorId) - ) - const rejectedVendors = vendors.filter(vendor => - vendor.vendorId && !watchedSelectedIds.includes(vendor.vendorId) - ) - - async function onSubmit(data: ShortListFormData) { - if (!rfqId) return - - setIsLoading(true) - - try { - const result = await shortListConfirm({ - rfqId, - selectedVendorIds: data.selectedVendorIds, - rejectedVendorIds: vendors - .filter(v => v.vendorId && !data.selectedVendorIds.includes(v.vendorId)) - .map(v => v.vendorId!) - }) - - if (result.success) { - toast.success(result.message) - onOpenChange(false) - form.reset() - onSuccess?.() - } else { - toast.error(result.message || "Short List 확정에 실패했습니다.") - } - } catch (error) { - console.error("Short List confirm error:", error) - toast.error("Short List 확정 중 오류가 발생했습니다.") - } finally { - setIsLoading(false) - } - } - - const handleVendorToggle = (vendorId: number, checked: boolean) => { - const currentSelected = form.getValues("selectedVendorIds") - - if (checked) { - form.setValue("selectedVendorIds", [...currentSelected, vendorId]) - } else { - form.setValue("selectedVendorIds", currentSelected.filter(id => id !== vendorId)) - } - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-4xl max-h-[80vh]"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <CheckCircle2 className="h-5 w-5 text-green-600" /> - Short List 확정 - </DialogTitle> - <DialogDescription> - 최종 RFQ로 진행할 벤더를 선택해주세요. 선택되지 않은 벤더에게는 자동으로 Letter of Regret이 발송됩니다. - </DialogDescription> - </DialogHeader> - - <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> - <FormField - control={form.control} - name="selectedVendorIds" - render={() => ( - <FormItem> - <FormLabel className="text-base font-semibold"> - 벤더 선택 ({vendors.length}개 업체) - </FormLabel> - <FormControl> - <ScrollArea className="h-[400px] border rounded-md p-4"> - <div className="space-y-4"> - {vendors.map((vendor) => { - const isSelected = vendor.vendorId && watchedSelectedIds.includes(vendor.vendorId) - - return ( - <div - key={vendor.vendorId} - className={`flex items-start space-x-3 p-3 rounded-lg border transition-colors ${ - isSelected - ? 'border-green-200 bg-green-50' - : 'border-red-100 bg-red-50' - }`} - > - <Checkbox - checked={isSelected} - onCheckedChange={(checked) => - vendor.vendorId && handleVendorToggle(vendor.vendorId, !!checked) - } - className="mt-1" - /> - <div className="flex-1 space-y-2"> - <div className="flex items-center gap-2"> - <Building className="h-4 w-4 text-muted-foreground" /> - <span className="font-medium">{vendor.vendorName}</span> - {isSelected ? ( - <Badge variant="secondary" className="bg-green-100 text-green-800"> - 선택됨 - </Badge> - ) : ( - <Badge variant="secondary" className="bg-red-100 text-red-800"> - 탈락 - </Badge> - )} - </div> - <div className="text-sm text-muted-foreground"> - <span className="font-mono">{vendor.vendorCode}</span> - {vendor.vendorCountry && ( - <> - <span className="mx-2">•</span> - <span>{vendor.vendorCountry === "KR" ? "국내" : "해외"}</span> - </> - )} - {vendor.vendorCategory && ( - <> - <span className="mx-2">•</span> - <span>{vendor.vendorCategory}</span> - </> - )} - {vendor.vendorBusinessSize && ( - <> - <span className="mx-2">•</span> - <span>{vendor.vendorBusinessSize}</span> - </> - )} - </div> - <div className="text-xs text-muted-foreground"> - RFQ 상태: <Badge variant="outline" className="text-xs"> - {vendor.initialRfqStatus} - </Badge> - </div> - </div> - </div> - ) - })} - </div> - </ScrollArea> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> - - {/* 요약 정보 */} - <div className="grid grid-cols-2 gap-4 p-4 bg-muted/50 rounded-lg"> - <div className="space-y-2"> - <div className="flex items-center gap-2 text-green-700"> - <CheckCircle2 className="h-4 w-4" /> - <span className="font-medium">선택된 벤더</span> - </div> - <div className="text-2xl font-bold text-green-700"> - {selectedVendors.length}개 업체 - </div> - {selectedVendors.length > 0 && ( - <div className="text-sm text-muted-foreground"> - {selectedVendors.map(v => v.vendorName).join(", ")} - </div> - )} - </div> - <div className="space-y-2"> - <div className="flex items-center gap-2 text-red-700"> - <XCircle className="h-4 w-4" /> - <span className="font-medium">탈락 벤더</span> - </div> - <div className="text-2xl font-bold text-red-700"> - {rejectedVendors.length}개 업체 - </div> - {rejectedVendors.length > 0 && ( - <div className="text-sm text-muted-foreground"> - Letter of Regret 발송 예정 - </div> - )} - </div> - </div> - - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isLoading} - > - 취소 - </Button> - <Button - type="submit" - disabled={isLoading || selectedVendors.length === 0} - > - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Short List 확정 - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ 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 deleted file mode 100644 index a19b5172..00000000 --- a/lib/b-rfq/initial/update-initial-rfq-sheet.tsx +++ /dev/null @@ -1,496 +0,0 @@ -"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 |
