summaryrefslogtreecommitdiff
path: root/lib/b-rfq/initial
diff options
context:
space:
mode:
Diffstat (limited to 'lib/b-rfq/initial')
-rw-r--r--lib/b-rfq/initial/add-initial-rfq-dialog.tsx534
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-columns.tsx386
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-table.tsx263
-rw-r--r--lib/b-rfq/initial/initial-rfq-detail-toolbar-actions.tsx109
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>
+ )
+}