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 | |
| parent | c72d0897f7b37843109c86f61d97eba05ba3ca0d (diff) | |
(대표님) 20250613 16시 10분 global css, b-rfq, document 등
Diffstat (limited to 'lib/b-rfq')
| -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 | ||||
| -rw-r--r-- | lib/b-rfq/service.ts | 218 | ||||
| -rw-r--r-- | lib/b-rfq/validations.ts | 100 |
6 files changed, 1608 insertions, 2 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> + ) +} diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts index e60e446d..0dc61832 100644 --- a/lib/b-rfq/service.ts +++ b/lib/b-rfq/service.ts @@ -4,15 +4,16 @@ import { revalidateTag, unstable_cache } from "next/cache" import { count, desc, asc, and, or, gte, lte, ilike, eq, inArray, sql } from "drizzle-orm" import { filterColumns } from "@/lib/filter-columns" import db from "@/db/db" -import { RfqDashboardView, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, projects, users, vendorAttachmentResponses, vendors } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 +import { Incoterm, RfqDashboardView, Vendor, bRfqAttachmentRevisions, bRfqs, bRfqsAttachments, incoterms, initialRfq, initialRfqDetailView, projects, users, vendorAttachmentResponses, vendors } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 import { rfqDashboardView } from "@/db/schema" // 뷰 import import type { SQL } from "drizzle-orm" -import { AttachmentRecord, CreateRfqInput, DeleteAttachmentsInput, GetRFQDashboardSchema, GetRfqAttachmentsSchema, attachmentRecordSchema, createRfqServerSchema, deleteAttachmentsSchema } from "./validations" +import { AttachmentRecord, CreateRfqInput, DeleteAttachmentsInput, GetInitialRfqDetailSchema, GetRFQDashboardSchema, GetRfqAttachmentsSchema, attachmentRecordSchema, createRfqServerSchema, deleteAttachmentsSchema } from "./validations" import { getServerSession } from "next-auth/next" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { unlink } from "fs/promises" const tag = { + initialRfqDetail:"initial-rfq", rfqDashboard: 'rfq-dashboard', rfq: (id: number) => `rfq-${id}`, rfqAttachments: (rfqId: number) => `rfq-attachments-${rfqId}`, @@ -1017,4 +1018,217 @@ export async function deleteRfqAttachments(input: DeleteAttachmentsInput) { message: error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.", } } +} + + + +//Initial RFQ + +export async function getInitialRfqDetail(input: GetInitialRfqDetailSchema, rfqId?: number) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // 1) 고급 필터 조건 + let advancedWhere: SQL<unknown> | undefined = undefined; + if (input.filters && input.filters.length > 0) { + advancedWhere = filterColumns({ + table: initialRfqDetailView, + filters: input.filters, + joinOperator: input.joinOperator || 'and', + }); + } + + // 2) 기본 필터 조건 + let basicWhere: SQL<unknown> | undefined = undefined; + if (input.basicFilters && input.basicFilters.length > 0) { + basicWhere = filterColumns({ + table: initialRfqDetailView, + filters: input.basicFilters, + joinOperator: input.basicJoinOperator || 'and', + }); + } + + let rfqIdWhere: SQL<unknown> | undefined = undefined; + if (rfqId) { + rfqIdWhere = eq(initialRfqDetailView.rfqId, rfqId); + } + + + // 3) 글로벌 검색 조건 + let globalWhere: SQL<unknown> | undefined = undefined; + if (input.search) { + const s = `%${input.search}%`; + + const validSearchConditions: SQL<unknown>[] = []; + + const rfqCodeCondition = ilike(initialRfqDetailView.rfqCode, s); + if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition); + + const vendorNameCondition = ilike(initialRfqDetailView.vendorName, s); + if (vendorNameCondition) validSearchConditions.push(vendorNameCondition); + + const vendorCodeCondition = ilike(initialRfqDetailView.vendorCode, s); + if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition); + + const vendorCountryCondition = ilike(initialRfqDetailView.vendorCountry, s); + if (vendorCountryCondition) validSearchConditions.push(vendorCountryCondition); + + const incotermsDescriptionCondition = ilike(initialRfqDetailView.incotermsDescription, s); + if (incotermsDescriptionCondition) validSearchConditions.push(incotermsDescriptionCondition); + + const classificationCondition = ilike(initialRfqDetailView.classification, s); + if (classificationCondition) validSearchConditions.push(classificationCondition); + + const sparepartCondition = ilike(initialRfqDetailView.sparepart, s); + if (sparepartCondition) validSearchConditions.push(sparepartCondition); + + if (validSearchConditions.length > 0) { + globalWhere = or(...validSearchConditions); + } + } + + + // 5) 최종 WHERE 조건 생성 + const whereConditions: SQL<unknown>[] = []; + + if (advancedWhere) whereConditions.push(advancedWhere); + if (basicWhere) whereConditions.push(basicWhere); + if (globalWhere) whereConditions.push(globalWhere); + if (rfqIdWhere) whereConditions.push(rfqIdWhere); + + const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; + + // 6) 전체 데이터 수 조회 + const totalResult = await db + .select({ count: count() }) + .from(initialRfqDetailView) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + + if (total === 0) { + return { data: [], pageCount: 0, total: 0 }; + } + + console.log(total); + + // 7) 정렬 및 페이징 처리된 데이터 조회 + const orderByColumns = input.sort.map((sort) => { + const column = sort.id as keyof typeof initialRfqDetailView.$inferSelect; + return sort.desc ? desc(initialRfqDetailView[column]) : asc(initialRfqDetailView[column]); + }); + + if (orderByColumns.length === 0) { + orderByColumns.push(desc(initialRfqDetailView.createdAt)); + } + + const initialRfqData = await db + .select() + .from(initialRfqDetailView) + .where(finalWhere) + .orderBy(...orderByColumns) + .limit(input.perPage) + .offset(offset); + + const pageCount = Math.ceil(total / input.perPage); + + return { data: initialRfqData, pageCount, total }; + } catch (err) { + console.error("Error in getInitialRfqDetail:", err); + return { data: [], pageCount: 0, total: 0 }; + } + }, + [JSON.stringify(input)], + { revalidate: 3600, tags: [tag.initialRfqDetail] }, + )(); +} + +export async function getVendorsForSelection() { + try { + const vendorsData = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + country: vendors.country, + status: vendors.status, + }) + .from(vendors) + // .where( + // and( + // ne(vendors.status, "BLACKLISTED"), + // ne(vendors.status, "REJECTED") + // ) + // ) + .orderBy(vendors.vendorName) + + return vendorsData.map(vendor => ({ + id: vendor.id, + vendorName: vendor.vendorName || "", + vendorCode: vendor.vendorCode || "", + country: vendor.country || "", + status: vendor.status, + })) + } catch (error) { + console.error("Error fetching vendors:", error) + throw new Error("Failed to fetch vendors") + } +} + +export async function addInitialRfqRecord(data: AddInitialRfqFormData & { rfqId: number }) { + try { + const [newRecord] = await db + .insert(initialRfq) + .values({ + rfqId: data.rfqId, + vendorId: data.vendorId, + initialRfqStatus: data.initialRfqStatus, + dueDate: data.dueDate, + validDate: data.validDate, + incotermsCode: data.incotermsCode, + gtc: data.gtc, + gtcValidDate: data.gtcValidDate, + classification: data.classification, + sparepart: data.sparepart, + shortList: data.shortList, + returnYn: data.returnYn, + cpRequestYn: data.cpRequestYn, + prjectGtcYn: data.prjectGtcYn, + returnRevision: data.returnRevision, + }) + .returning() + + return { + success: true, + message: "초기 RFQ가 성공적으로 추가되었습니다.", + data: newRecord, + } + } catch (error) { + console.error("Error adding initial RFQ:", error) + return { + success: false, + message: "초기 RFQ 추가에 실패했습니다.", + error, + } + } +} + +export async function getIncotermsForSelection() { + try { + const incotermData = await db + .select({ + code: incoterms.code, + description: incoterms.description, + }) + .from(incoterms) + .orderBy(incoterms.code) + + return incotermData + + } catch (error) { + console.error("Error fetching incoterms:", error) + throw new Error("Failed to fetch incoterms") + } }
\ No newline at end of file diff --git a/lib/b-rfq/validations.ts b/lib/b-rfq/validations.ts index df95b1d2..15cc9425 100644 --- a/lib/b-rfq/validations.ts +++ b/lib/b-rfq/validations.ts @@ -165,3 +165,103 @@ export const deleteAttachmentsSchema = z.object({ }) export type DeleteAttachmentsInput = z.infer<typeof deleteAttachmentsSchema> + + +//Inital RFQ +export const searchParamsInitialRfqDetailCache = createSearchParamsCache({ + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 - initialRfqDetailView 기반 + sort: getSortingStateParser<{ + rfqId: number; + rfqCode: string; + rfqStatus: string; + initialRfqId: number; + initialRfqStatus: string; + vendorId: number; + vendorCode: string; + vendorName: string; + vendorCountry: string; + vendorBusinessSize: string; + dueDate: Date; + validDate: Date; + incotermsCode: string; + incotermsDescription: string; + shortList: boolean; + returnYn: boolean; + cpRequestYn: boolean; + prjectGtcYn: boolean; + returnRevision: number; + gtc: string; + gtcValidDate: string; + classification: string; + sparepart: string; + createdAt: Date; + updatedAt: Date; + }>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 기본 필터 + basicFilters: getFiltersStateParser().withDefault([]), + basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + // Initial RFQ Detail 특화 필터 + rfqCode: parseAsString.withDefault(""), + rfqStatus: parseAsStringEnum([ + "DRAFT", + "Doc. Received", + "PIC Assigned", + "Doc. Confirmed", + "Init. RFQ Sent", + "Init. RFQ Answered", + "TBE started", + "TBE finished", + "Final RFQ Sent", + "Quotation Received", + "Vendor Selected" + ]), + initialRfqStatus: parseAsStringEnum([ + "PENDING", + "SENT", + "RESPONDED", + "EXPIRED", + "CANCELLED" + ]), + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + vendorCountry: parseAsString.withDefault(""), + vendorBusinessSize: parseAsStringEnum([ + "LARGE", + "MEDIUM", + "SMALL", + "STARTUP" + ]), + incotermsCode: parseAsString.withDefault(""), + dueDateFrom: parseAsString.withDefault(""), + dueDateTo: parseAsString.withDefault(""), + validDateFrom: parseAsString.withDefault(""), + validDateTo: parseAsString.withDefault(""), + shortList: parseAsStringEnum(["true", "false"]), + returnYn: parseAsStringEnum(["true", "false"]), + cpRequestYn: parseAsStringEnum(["true", "false"]), + prjectGtcYn: parseAsStringEnum(["true", "false"]), + classification: parseAsString.withDefault(""), + sparepart: parseAsString.withDefault(""), +}); + +export type GetInitialRfqDetailSchema = Awaited<ReturnType<typeof searchParamsInitialRfqDetailCache.parse>>; + + |
