summaryrefslogtreecommitdiff
path: root/lib/b-rfq
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-13 07:11:18 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-13 07:11:18 +0000
commit0fddf148402fd6b99a1b3800d73679899bcb2ed3 (patch)
treeeb51c02e6fa6037ddcc38a3b57d10d8c739125cf /lib/b-rfq
parentc72d0897f7b37843109c86f61d97eba05ba3ca0d (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.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
-rw-r--r--lib/b-rfq/service.ts218
-rw-r--r--lib/b-rfq/validations.ts100
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>>;
+
+