diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-13 07:11:18 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-06-13 07:11:18 +0000 |
| commit | 0fddf148402fd6b99a1b3800d73679899bcb2ed3 (patch) | |
| tree | eb51c02e6fa6037ddcc38a3b57d10d8c739125cf /lib/b-rfq/initial | |
| parent | c72d0897f7b37843109c86f61d97eba05ba3ca0d (diff) | |
(대표님) 20250613 16시 10분 global css, b-rfq, document 등
Diffstat (limited to 'lib/b-rfq/initial')
| -rw-r--r-- | lib/b-rfq/initial/add-initial-rfq-dialog.tsx | 534 | ||||
| -rw-r--r-- | lib/b-rfq/initial/initial-rfq-detail-columns.tsx | 386 | ||||
| -rw-r--r-- | lib/b-rfq/initial/initial-rfq-detail-table.tsx | 263 | ||||
| -rw-r--r-- | lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx | 109 |
4 files changed, 1292 insertions, 0 deletions
diff --git a/lib/b-rfq/initial/add-initial-rfq-dialog.tsx b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx new file mode 100644 index 00000000..d0924be2 --- /dev/null +++ b/lib/b-rfq/initial/add-initial-rfq-dialog.tsx @@ -0,0 +1,534 @@ +// add-initial-rfq-dialog.tsx +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Plus, Check, ChevronsUpDown, Search, Building, CalendarIcon } from "lucide-react" +import { toast } from "sonner" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Checkbox } from "@/components/ui/checkbox" +import { cn, formatDate } from "@/lib/utils" +import { addInitialRfqRecord, getIncotermsForSelection, getVendorsForSelection } from "../service" +import { Calendar } from "@/components/ui/calendar" + +// Initial RFQ 추가 폼 스키마 +const addInitialRfqSchema = z.object({ + vendorId: z.number({ + required_error: "벤더를 선택해주세요.", + }), + initialRfqStatus: z.enum(["DRAFT", "Init. RFQ Sent", "S/L Decline", "Init. RFQ Answered"], { + required_error: "초기 RFQ 상태를 선택해주세요.", + }).default("DRAFT"), + dueDate: z.date({ + required_error: "마감일을 선택해주세요.", + }), + validDate: z.date().optional(), + incotermsCode: z.string().optional(), + gtc: z.string().optional(), + gtcValidDate: z.string().optional(), + classification: z.string().optional(), + sparepart: z.string().optional(), + shortList: z.boolean().default(false), + returnYn: z.boolean().default(false), + cpRequestYn: z.boolean().default(false), + prjectGtcYn: z.boolean().default(false), + returnRevision: z.number().default(0), +}) + +type AddInitialRfqFormData = z.infer<typeof addInitialRfqSchema> + +interface Vendor { + id: number + vendorName: string + vendorCode: string + country: string + status: string +} + +interface AddInitialRfqDialogProps { + rfqId: number + onSuccess?: () => void +} + +export function AddInitialRfqDialog({ rfqId, onSuccess }: AddInitialRfqDialogProps) { + const [open, setOpen] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) + const [vendors, setVendors] = React.useState<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 form = useForm<AddInitialRfqFormData>({ + resolver: zodResolver(addInitialRfqSchema), + defaultValues: { + initialRfqStatus: "DRAFT", + shortList: false, + returnYn: false, + cpRequestYn: false, + prjectGtcYn: false, + returnRevision: 0, + }, + }) + + // 벤더 목록 로드 + const loadVendors = React.useCallback(async () => { + setVendorsLoading(true) + try { + const vendorList = await getVendorsForSelection() + setVendors(vendorList) + } catch (error) { + console.error("Failed to load vendors:", error) + toast.error("벤더 목록을 불러오는데 실패했습니다.") + } finally { + setVendorsLoading(false) + } + }, []) + + // Incoterms 목록 로드 + const loadIncoterms = React.useCallback(async () => { + setIncotermsLoading(true) + try { + const incotermsList = await getIncotermsForSelection() + setIncoterms(incotermsList) + } catch (error) { + console.error("Failed to load incoterms:", error) + toast.error("Incoterms 목록을 불러오는데 실패했습니다.") + } finally { + setIncotermsLoading(false) + } + }, []) + + // 다이얼로그 열릴 때 벤더 목록 로드 + React.useEffect(() => { + if (open) { + if (vendors.length === 0) { + loadVendors() + } + if (incoterms.length === 0) { + loadIncoterms() + } + } + }, [open, vendors.length, incoterms.length, loadVendors, loadIncoterms]) + + // 다이얼로그 닫기 핸들러 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen && !isSubmitting) { + form.reset() + } + setOpen(newOpen) + } + + // 폼 제출 + const onSubmit = async (data: AddInitialRfqFormData) => { + setIsSubmitting(true) + + try { + const result = await addInitialRfqRecord({ + ...data, + rfqId, + }) + + if (result.success) { + toast.success(result.message || "초기 RFQ가 성공적으로 추가되었습니다.") + form.reset() + handleOpenChange(false) + onSuccess?.() + } else { + toast.error(result.message || "초기 RFQ 추가에 실패했습니다.") + } + + } catch (error) { + console.error("Submit error:", error) + toast.error("초기 RFQ 추가 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + // 선택된 벤더 정보 + const selectedVendor = vendors.find(vendor => vendor.id === form.watch("vendorId")) + const selectedIncoterm = incoterms.find(incoterm => incoterm.code === form.watch("incotermsCode")) + + return ( + <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> + </Button> + </DialogTrigger> + + <DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle>초기 RFQ 추가</DialogTitle> + <DialogDescription> + 새로운 벤더를 대상으로 하는 초기 RFQ를 추가합니다. + </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} + </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 및 GTC */} + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="vendorId" + 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"> + <Building className="h-4 w-4" /> + <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(vendor.id) + setVendorSearchOpen(false) + }} + > + <div className="flex items-center gap-2 w-full"> + <div className="font-medium truncate"> + {incoterm.code} {incoterm.description} + </div> + <Check + className={cn( + "ml-auto h-4 w-4", + incoterm.id === field.value ? "opacity-100" : "opacity-0" + )} + /> + </div> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* GTC 정보 */} + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="gtc" + render={({ field }) => ( + <FormItem> + <FormLabel>GTC</FormLabel> + <FormControl> + <Input placeholder="GTC 정보" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="gtcValidDate" + render={({ field }) => ( + <FormItem> + <FormLabel>GTC 유효일</FormLabel> + <FormControl> + <Input placeholder="GTC 유효일" {...field} /> + </FormControl> + <FormMessage /> + </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="예비부품 정보" {...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> + ) +} + + diff --git a/lib/b-rfq/initial/initial-rfq-detail-columns.tsx b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx new file mode 100644 index 00000000..f7ac0960 --- /dev/null +++ b/lib/b-rfq/initial/initial-rfq-detail-columns.tsx @@ -0,0 +1,386 @@ +// initial-rfq-detail-columns.tsx +"use client" + +import * as React from "react" +import { type ColumnDef } from "@tanstack/react-table" +import { + Ellipsis, Building, Calendar, Eye, + MessageSquare, Settings, CheckCircle2, XCircle +} from "lucide-react" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" + +interface GetInitialRfqDetailColumnsProps { + onSelectDetail?: (detail: any) => void +} + +export function getInitialRfqDetailColumns({ + onSelectDetail +}: GetInitialRfqDetailColumnsProps = {}): ColumnDef<any>[] { + + 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: "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 상태" /> + ), + cell: ({ row }) => { + const status = row.getValue("initialRfqStatus") as string + const getInitialStatusColor = (status: string) => { + switch (status) { + case "PENDING": return "outline" + case "SENT": return "default" + case "RESPONDED": return "success" + case "EXPIRED": return "destructive" + case "CANCELLED": return "secondary" + default: return "secondary" + } + } + return ( + <Badge variant={getInitialStatusColor(status) as any}> + {status} + </Badge> + ) + }, + 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 vendorCountry = row.original.vendorCountry as string + 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} • {vendorCountry} + </div> + {businessSize && ( + <Badge variant="outline" className="text-xs"> + {businessSize} + </Badge> + )} + </div> + ) + }, + size: 200, + }, + + /** ───────────── 날짜 정보 ───────────── */ + { + 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={`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> + ) : ( + <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)} + </div> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 100, + }, + + /** ───────────── Incoterms ───────────── */ + { + 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, + }, + + /** ───────────── 플래그 정보 ───────────── */ + { + id: "flags", + header: ({ column }) => ( + <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> + )} + </div> + ) + }, + size: 150, + }, + + /** ───────────── 분류 정보 ───────────── */ + { + id: "classification", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="분류" /> + ), + cell: ({ row }) => { + const classification = row.original.classification as string + const sparepart = row.original.sparepart as string + + 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> + )} + </div> + ) + }, + size: 120, + }, + + /** ───────────── 리비전 정보 ───────────── */ + { + accessorKey: "returnRevision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="리비전" /> + ), + cell: ({ row }) => { + const revision = row.getValue("returnRevision") as number + return revision ? ( + <Badge variant="outline"> + Rev. {revision} + </Badge> + ) : ( + <span className="text-muted-foreground">-</span> + ) + }, + size: 80, + }, + + /** ───────────── 등록/수정 정보 ───────────── */ + { + 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)}</div> + {updated && new Date(updated) > new Date(created) && ( + <div className="text-xs text-blue-600"> + 수정: {formatDate(updated)} + </div> + )} + </div> + ) + }, + size: 120, + }, + + /** ───────────── 액션 ───────────── */ + { + id: "actions", + enableHiding: false, + 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 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> + </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 new file mode 100644 index 00000000..fc8a5bc2 --- /dev/null +++ b/lib/b-rfq/initial/initial-rfq-detail-table.tsx @@ -0,0 +1,263 @@ +"use client" + +import * as React from "react" +import { type DataTableAdvancedFilterField, type DataTableFilterField } from "@/types/table" +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getInitialRfqDetail } from "../service" // 앞서 만든 서버 액션 +import { getInitialRfqDetailColumns } from "./initial-rfq-detail-columns" +import { InitialRfqDetailTableToolbarActions } from "./initial-rfq-detail-toolbar-actions" + +interface InitialRfqDetailTableProps { + promises: Promise<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) + + const columns = React.useMemo( + () => getInitialRfqDetailColumns({ + onSelectDetail: setSelectedDetail + }), + [] + ) + + /** + * 필터 필드 정의 + */ + 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: "PENDING", count: 0 }, + { label: "발송", value: "SENT", count: 0 }, + { label: "응답", value: "RESPONDED", count: 0 }, + { label: "만료", value: "EXPIRED", count: 0 }, + { label: "취소", value: "CANCELLED", count: 0 }, + ], + }, + { + id: "vendorCountry", + label: "벤더 국가", + options: [ + { label: "한국", value: "KR", count: 0 }, + { label: "중국", value: "CN", count: 0 }, + { label: "일본", value: "JP", count: 0 }, + { label: "미국", value: "US", count: 0 }, + { label: "독일", value: "DE", count: 0 }, + ], + }, + ] + + /** + * 고급 필터 필드 + */ + const advancedFilterFields: DataTableAdvancedFilterField<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: "PENDING" }, + { label: "발송", value: "SENT" }, + { label: "응답", value: "RESPONDED" }, + { label: "만료", value: "EXPIRED" }, + { label: "취소", value: "CANCELLED" }, + ], + }, + { + id: "vendorBusinessSize", + label: "벤더 규모", + type: "multi-select", + options: [ + { label: "대기업", value: "LARGE" }, + { label: "중기업", value: "MEDIUM" }, + { label: "소기업", value: "SMALL" }, + { label: "스타트업", value: "STARTUP" }, + ], + }, + { + id: "incotermsCode", + label: "Incoterms", + type: "text", + }, + { + id: "dueDate", + label: "마감일", + type: "date", + }, + { + id: "validDate", + label: "유효일", + type: "date", + }, + { + id: "shortList", + label: "Short List", + type: "boolean", + }, + { + id: "returnYn", + label: "Return 여부", + type: "boolean", + }, + { + id: "cpRequestYn", + label: "CP Request 여부", + type: "boolean", + }, + { + id: "prjectGtcYn", + label: "Project GTC 여부", + type: "boolean", + }, + { + id: "classification", + label: "분류", + type: "text", + }, + { + id: "sparepart", + label: "예비부품", + type: "text", + }, + { + id: "createdAt", + label: "등록일", + type: "date", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => originalRow.initialRfqId.toString(), + shallow: false, + clearOnDefault: true, + }) + + return ( + <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> + + {/* 선택된 상세 정보 패널 (필요시 추가) */} + {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> + )} + </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 new file mode 100644 index 00000000..981659d5 --- /dev/null +++ b/lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx @@ -0,0 +1,109 @@ +// initial-rfq-detail-toolbar-actions.tsx +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Button } from "@/components/ui/button" +import { + Download, + Mail, + RefreshCw, + Settings, + Trash2, + FileText +} from "lucide-react" + +interface InitialRfqDetailTableToolbarActionsProps { + table: Table<any> + rfqId?: number +} + +export function InitialRfqDetailTableToolbarActions({ + table, + rfqId +}: InitialRfqDetailTableToolbarActionsProps) { + + // 선택된 행들 가져오기 + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedDetails = selectedRows.map((row) => row.original) + const selectedCount = selectedRows.length + + const handleBulkEmail = () => { + console.log("Bulk email to selected vendors:", selectedDetails) + // 벌크 이메일 로직 구현 + } + + const handleBulkDelete = () => { + console.log("Bulk delete selected items:", selectedDetails) + // 벌크 삭제 로직 구현 + table.toggleAllRowsSelected(false) + } + + const handleExport = () => { + console.log("Export data:", selectedCount > 0 ? selectedDetails : "all data") + // 데이터 엑스포트 로직 구현 + } + + const handleRefresh = () => { + window.location.reload() + } + + return ( + <div className="flex items-center gap-2"> + {/** 선택된 항목이 있을 때만 표시되는 액션들 */} + {selectedCount > 0 && ( + <> + <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> + </> + )} + + {/** 항상 표시되는 액션들 */} + <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> + ) +} |
