summaryrefslogtreecommitdiff
path: root/lib/procurement-rfqs
diff options
context:
space:
mode:
Diffstat (limited to 'lib/procurement-rfqs')
-rw-r--r--lib/procurement-rfqs/services.ts1
-rw-r--r--lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx512
-rw-r--r--lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx150
-rw-r--r--lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx369
-rw-r--r--lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx521
-rw-r--r--lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx449
-rw-r--r--lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx518
-rw-r--r--lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx665
-rw-r--r--lib/procurement-rfqs/table/rfq-filter-sheet.tsx686
-rw-r--r--lib/procurement-rfqs/table/rfq-table copy.tsx209
-rw-r--r--lib/procurement-rfqs/table/rfq-table.tsx411
11 files changed, 4095 insertions, 396 deletions
diff --git a/lib/procurement-rfqs/services.ts b/lib/procurement-rfqs/services.ts
index facdc9c9..32048768 100644
--- a/lib/procurement-rfqs/services.ts
+++ b/lib/procurement-rfqs/services.ts
@@ -2056,3 +2056,4 @@ export async function fetchQuotationItems(quotationIds: number[]) {
return { success: false, error: "견적 아이템을 조회하는 중 오류가 발생했습니다" }
}
}
+
diff --git a/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx
new file mode 100644
index 00000000..79524f58
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx
@@ -0,0 +1,512 @@
+"use client"
+
+import * as React from "react"
+import { useState } from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { toast } from "sonner"
+import { Check, ChevronsUpDown, File, Upload, X } from "lucide-react"
+
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { ProcurementRfqsView } from "@/db/schema"
+import { addVendorToRfq } from "@/lib/procurement-rfqs/services"
+import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { cn } from "@/lib/utils"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+// 필수 필드를 위한 커스텀 레이블 컴포넌트
+const RequiredLabel = ({ children }: { children: React.ReactNode }) => (
+ <FormLabel>
+ {children} <span className="text-red-500">*</span>
+ </FormLabel>
+);
+
+// 폼 유효성 검증 스키마
+const vendorFormSchema = z.object({
+ vendorId: z.string().min(1, "벤더를 선택해주세요"),
+ currency: z.string().min(1, "통화를 선택해주세요"),
+ paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"),
+ incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"),
+ incotermsDetail: z.string().optional(),
+ deliveryDate: z.string().optional(),
+ taxCode: z.string().optional(),
+ placeOfShipping: z.string().optional(),
+ placeOfDestination: z.string().optional(),
+ materialPriceRelatedYn: z.boolean().default(false),
+})
+
+type VendorFormValues = z.infer<typeof vendorFormSchema>
+
+interface AddVendorDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: ProcurementRfqsView | null
+ // 벤더 및 기타 옵션 데이터를 prop으로 받음
+ vendors?: { id: number; vendorName: string; vendorCode: string }[]
+ currencies?: { code: string; name: string }[]
+ paymentTerms?: { code: string; description: string }[]
+ incoterms?: { code: string; description: string }[]
+ onSuccess?: () => void
+ existingVendorIds?: number[]
+
+}
+
+export function AddVendorDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+ vendors = [],
+ currencies = [],
+ paymentTerms = [],
+ incoterms = [],
+ onSuccess,
+ existingVendorIds = [], // 기본값 빈 배열
+}: AddVendorDialogProps) {
+
+
+ const availableVendors = React.useMemo(() => {
+ return vendors.filter(vendor => !existingVendorIds.includes(vendor.id));
+ }, [vendors, existingVendorIds]);
+
+
+ // 파일 업로드 상태 관리
+ const [attachments, setAttachments] = useState<File[]>([])
+ const [isSubmitting, setIsSubmitting] = useState(false)
+
+ // 벤더 선택을 위한 팝오버 상태
+ const [vendorOpen, setVendorOpen] = useState(false)
+
+ const form = useForm<VendorFormValues>({
+ resolver: zodResolver(vendorFormSchema),
+ defaultValues: {
+ vendorId: "",
+ currency: "",
+ paymentTermsCode: "",
+ incotermsCode: "",
+ incotermsDetail: "",
+ deliveryDate: "",
+ taxCode: "",
+ placeOfShipping: "",
+ placeOfDestination: "",
+ materialPriceRelatedYn: false,
+ },
+ })
+
+ // 폼 제출 핸들러
+ async function onSubmit(values: VendorFormValues) {
+ if (!selectedRfq) {
+ toast.error("선택된 RFQ가 없습니다")
+ return
+ }
+
+ try {
+ setIsSubmitting(true)
+
+ // FormData 생성
+ const formData = new FormData()
+ formData.append("rfqId", selectedRfq.id.toString())
+
+ // 폼 데이터 추가
+ Object.entries(values).forEach(([key, value]) => {
+ formData.append(key, value.toString())
+ })
+
+ // 첨부파일 추가
+ attachments.forEach((file, index) => {
+ formData.append(`attachment-${index}`, file)
+ })
+
+ // 서버 액션 호출
+ const result = await addVendorToRfq(formData)
+
+ if (result.success) {
+ toast.success("벤더가 성공적으로 추가되었습니다")
+ onOpenChange(false)
+ form.reset()
+ setAttachments([])
+ onSuccess?.()
+ } else {
+ toast.error(result.message || "벤더 추가 중 오류가 발생했습니다")
+ }
+ } catch (error) {
+ console.error("벤더 추가 오류:", error)
+ toast.error("벤더 추가 중 오류가 발생했습니다")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 파일 업로드 핸들러
+ const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
+ if (event.target.files && event.target.files.length > 0) {
+ const newFiles = Array.from(event.target.files)
+ setAttachments((prev) => [...prev, ...newFiles])
+ }
+ }
+
+ // 파일 삭제 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments((prev) => prev.filter((_, i) => i !== index))
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */}
+ <DialogContent className="sm:max-w-[600px] p-0 h-[85vh] flex flex-col overflow-hidden" style={{maxHeight:'85vh'}}>
+ {/* 고정 헤더 */}
+ <div className="p-6 border-b">
+ <DialogHeader>
+ <DialogTitle>벤더 추가</DialogTitle>
+ <DialogDescription>
+ {selectedRfq ? (
+ <>
+ <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다.
+ </>
+ ) : (
+ "RFQ에 벤더를 추가합니다."
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ </div>
+
+ {/* 스크롤 가능한 콘텐츠 영역 */}
+ <div className="flex-1 overflow-y-auto p-6">
+ <Form {...form}>
+ <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ {/* 검색 가능한 벤더 선택 필드 */}
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <RequiredLabel>벤더</RequiredLabel>
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value
+ ? vendors.find((vendor) => String(vendor.id) === field.value)
+ ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})`
+ : "벤더를 선택하세요"
+ : "벤더를 선택하세요"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="벤더 검색..." />
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <CommandList>
+ <ScrollArea className="h-60">
+ <CommandGroup>
+ {availableVendors.length > 0 ? (
+ availableVendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode}`}
+ onSelect={() => {
+ form.setValue("vendorId", String(vendor.id), {
+ shouldValidate: true,
+ })
+ setVendorOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ String(vendor.id) === field.value
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {vendor.vendorName} ({vendor.vendorCode})
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem disabled>추가 가능한 벤더가 없습니다</CommandItem>
+ )}
+ </CommandGroup>
+ </ScrollArea>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <RequiredLabel>통화</RequiredLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.name} ({currency.code})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="paymentTermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <RequiredLabel>지불 조건</RequiredLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="지불 조건 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {paymentTerms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="incotermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <RequiredLabel>인코텀즈</RequiredLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {incoterms.map((incoterm) => (
+ <SelectItem key={incoterm.code} value={incoterm.code}>
+ {incoterm.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 나머지 필드들은 동일하게 유지 */}
+ <FormField
+ control={form.control}
+ name="incotermsDetail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>인코텀즈 세부사항</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="인코텀즈 세부사항" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="deliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>납품 예정일</FormLabel>
+ <FormControl>
+ <Input {...field} type="date" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="taxCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>세금 코드</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="세금 코드" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="placeOfShipping"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선적지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="선적지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="placeOfDestination"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>도착지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="도착지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="materialPriceRelatedYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
+ <FormControl>
+ <input
+ type="checkbox"
+ checked={field.value}
+ onChange={field.onChange}
+ className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>하도급대금 연동제 여부</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 업로드 섹션 */}
+ <div className="space-y-2">
+ <Label>첨부 파일</Label>
+ <div className="border rounded-md p-4">
+ <div className="flex items-center justify-center w-full">
+ <label
+ htmlFor="file-upload"
+ className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100"
+ >
+ <div className="flex flex-col items-center justify-center pt-5 pb-6">
+ <Upload className="w-8 h-8 mb-2 text-gray-500" />
+ <p className="mb-2 text-sm text-gray-500">
+ <span className="font-semibold">클릭하여 파일 업로드</span> 또는 파일을 끌어 놓으세요
+ </p>
+ <p className="text-xs text-gray-500">PDF, DOCX, XLSX, JPG, PNG (최대 10MB)</p>
+ </div>
+ <input
+ id="file-upload"
+ type="file"
+ className="hidden"
+ multiple
+ onChange={handleFileUpload}
+ />
+ </label>
+ </div>
+
+ {/* 업로드된 파일 목록 */}
+ {attachments.length > 0 && (
+ <div className="mt-4 space-y-2">
+ <h4 className="text-sm font-medium">업로드된 파일</h4>
+ <ul className="space-y-2">
+ {attachments.map((file, index) => (
+ <li
+ key={index}
+ className="flex items-center justify-between p-2 text-sm bg-gray-50 rounded-md"
+ >
+ <div className="flex items-center space-x-2">
+ <File className="w-4 h-4 text-gray-500" />
+ <span className="truncate max-w-[250px]">{file.name}</span>
+ <span className="text-gray-500 text-xs">
+ ({(file.size / 1024).toFixed(1)} KB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="w-4 h-4 text-gray-500" />
+ </Button>
+ </li>
+ ))}
+ </ul>
+ </div>
+ )}
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+
+ {/* 고정 푸터 */}
+ <div className="p-6 border-t">
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="vendor-form"
+ disabled={isSubmitting}
+ >
+ {isSubmitting ? "처리 중..." : "벤더 추가"}
+ </Button>
+ </DialogFooter>
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx
new file mode 100644
index 00000000..49d982e1
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx
@@ -0,0 +1,150 @@
+"use client"
+
+import * as React from "react"
+import { type RfqDetailView } from "./rfq-detail-column"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { deleteRfqDetail } from "@/lib/procurement-rfqs/services"
+
+
+interface DeleteRfqDetailDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ detail: RfqDetailView | null
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteRfqDetailDialog({
+ detail,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteRfqDetailDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ if (!detail) return
+
+ startDeleteTransition(async () => {
+ try {
+ const result = await deleteRfqDetail(detail.detailId)
+
+ if (!result.success) {
+ toast.error(result.message || "삭제 중 오류가 발생했습니다")
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("RFQ 벤더 정보가 삭제되었습니다")
+ onSuccess?.()
+ } catch (error) {
+ console.error("RFQ 벤더 삭제 오류:", error)
+ toast.error("삭제 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="destructive" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="선택한 RFQ 벤더 정보 삭제"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx
new file mode 100644
index 00000000..ce5e7767
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx
@@ -0,0 +1,369 @@
+"use client"
+
+import * as React from "react"
+import type { ColumnDef, Row } from "@tanstack/react-table";
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { Ellipsis, MessageCircle } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+export interface DataTableRowAction<TData> {
+ row: Row<TData>;
+ type: "delete" | "update" | "communicate"; // communicate 타입 추가
+}
+
+// procurementRfqDetailsView 타입 정의 (DB 스키마에 맞게 조정 필요)
+export interface RfqDetailView {
+ detailId: number
+ rfqId: number
+ rfqCode: string
+ vendorId?: number | null // 벤더 ID 필드 추가
+ projectCode: string | null
+ projectName: string | null
+ vendorCountry: string | null
+ itemCode: string | null
+ itemName: string | null
+ vendorName: string | null
+ vendorCode: string | null
+ currency: string | null
+ paymentTermsCode: string | null
+ paymentTermsDescription: string | null
+ incotermsCode: string | null
+ incotermsDescription: string | null
+ incotermsDetail: string | null
+ deliveryDate: Date | null
+ taxCode: string | null
+ placeOfShipping: string | null
+ placeOfDestination: string | null
+ materialPriceRelatedYn: boolean | null
+ hasQuotation: boolean | null
+ updatedByUserName: string | null
+ quotationStatus: string | null
+ updatedAt: Date | null
+ prItemsCount: number
+ majorItemsCount: number
+ quotationVersion:number | null
+ // 커뮤니케이션 관련 필드 추가
+ commentCount?: number // 전체 코멘트 수
+ unreadCount?: number // 읽지 않은 코멘트 수
+ lastCommentDate?: Date // 마지막 코멘트 날짜
+}
+
+interface GetColumnsProps<TData> {
+ setRowAction: React.Dispatch<
+ React.SetStateAction<DataTableRowAction<TData> | null>
+ >;
+ unreadMessages?: Record<number, number>; // 벤더 ID별 읽지 않은 메시지 수
+}
+
+export function getRfqDetailColumns({
+ setRowAction,
+ unreadMessages = {},
+}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
+ return [
+ {
+ accessorKey: "quotationStatus",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 상태" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("quotationStatus")}</div>,
+ meta: {
+ excelHeader: "견적 상태"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "quotationVersion",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적 버전" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("quotationVersion")}</div>,
+ meta: {
+ excelHeader: "견적 버전"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>,
+ meta: {
+ excelHeader: "벤더 코드"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="벤더명" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("vendorName")}</div>,
+ meta: {
+ excelHeader: "벤더명"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "vendorType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="내외자" />
+ ),
+ cell: ({ row }) => <div>{row.original.vendorCountry === "KR"?"D":"F"}</div>,
+ meta: {
+ excelHeader: "내외자"
+ },
+ enableResizing: true,
+ size: 80,
+ },
+ {
+ accessorKey: "currency",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="통화" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("currency")}</div>,
+ meta: {
+ excelHeader: "통화"
+ },
+ enableResizing: true,
+ size: 80,
+ },
+ {
+ accessorKey: "paymentTermsCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="지불 조건 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("paymentTermsCode")}</div>,
+ meta: {
+ excelHeader: "지불 조건 코드"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "paymentTermsDescription",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="지불 조건" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("paymentTermsDescription")}</div>,
+ meta: {
+ excelHeader: "지불 조건"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "incotermsCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="인코텀스 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("incotermsCode")}</div>,
+ meta: {
+ excelHeader: "인코텀스 코드"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "incotermsDescription",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="인코텀스" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("incotermsDescription")}</div>,
+ meta: {
+ excelHeader: "인코텀스"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "incotermsDetail",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="인코텀스 상세" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("incotermsDetail")}</div>,
+ meta: {
+ excelHeader: "인코텀스 상세"
+ },
+ enableResizing: true,
+ size: 160,
+ },
+ {
+ accessorKey: "deliveryDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="납품일" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDate(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "납품일"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "taxCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="세금 코드" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("taxCode")}</div>,
+ meta: {
+ excelHeader: "세금 코드"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "placeOfShipping",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="선적지" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("placeOfShipping")}</div>,
+ meta: {
+ excelHeader: "선적지"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "placeOfDestination",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="도착지" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("placeOfDestination")}</div>,
+ meta: {
+ excelHeader: "도착지"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "materialPriceRelatedYn",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="하도급대금 연동" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("materialPriceRelatedYn") ? "Y" : "N"}</div>,
+ meta: {
+ excelHeader: "하도급대금 연동"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ {
+ accessorKey: "updatedByUserName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정자" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("updatedByUserName")}</div>,
+ meta: {
+ excelHeader: "수정자"
+ },
+ enableResizing: true,
+ size: 120,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일시" />
+ ),
+ cell: ({ cell }) => {
+ const value = cell.getValue();
+ return value ? formatDateTime(value as Date, "KR") : "";
+ },
+ meta: {
+ excelHeader: "수정일시"
+ },
+ enableResizing: true,
+ size: 140,
+ },
+ // 커뮤니케이션 컬럼 추가
+ {
+ id: "communication",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="커뮤니케이션" />
+ ),
+ cell: ({ row }) => {
+ const vendorId = row.original.vendorId || 0;
+ const unreadCount = unreadMessages[vendorId] || 0;
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative p-0 h-8 w-8 flex items-center justify-center"
+ onClick={() => setRowAction({ row, type: "communicate" })}
+ >
+ <MessageCircle className="h-4 w-4" />
+ {unreadCount > 0 && (
+ <Badge
+ variant="destructive"
+ className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs"
+ >
+ {unreadCount}
+ </Badge>
+ )}
+ </Button>
+ );
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-7 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+ ]
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx
new file mode 100644
index 00000000..ad9a19e7
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx
@@ -0,0 +1,521 @@
+"use client"
+
+import * as React from "react"
+import { useEffect, useState } from "react"
+import {
+ DataTableRowAction,
+ getRfqDetailColumns,
+ RfqDetailView
+} from "./rfq-detail-column"
+import { toast } from "sonner"
+
+import { Skeleton } from "@/components/ui/skeleton"
+import { Card, CardContent } from "@/components/ui/card"
+import { Badge } from "@/components/ui/badge"
+import { ProcurementRfqsView } from "@/db/schema"
+import {
+ fetchCurrencies,
+ fetchIncoterms,
+ fetchPaymentTerms,
+ fetchRfqDetails,
+ fetchVendors,
+ fetchUnreadMessages
+} from "@/lib/procurement-rfqs/services"
+import { ClientDataTable } from "@/components/client-data-table/data-table"
+import { AddVendorDialog } from "./add-vendor-dialog"
+import { Button } from "@/components/ui/button"
+import { Loader2, UserPlus, BarChart2 } from "lucide-react" // 아이콘 추가
+import { DeleteRfqDetailDialog } from "./delete-vendor-dialog"
+import { UpdateRfqDetailSheet } from "./update-vendor-sheet"
+import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
+import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" // 새로운 컴포넌트 임포트
+
+// 프로퍼티 정의
+interface RfqDetailTablesProps {
+ selectedRfq: ProcurementRfqsView | null
+ maxHeight?: string | number
+}
+
+// 데이터 타입 정의
+interface Vendor {
+ id: number;
+ vendorName: string;
+ vendorCode: string | null; // Update this to allow null
+ // 기타 필요한 벤더 속성들
+}
+
+interface Currency {
+ code: string;
+ name: string;
+}
+
+interface PaymentTerm {
+ code: string;
+ description: string;
+}
+
+interface Incoterm {
+ code: string;
+ description: string;
+}
+
+export function RfqDetailTables({ selectedRfq , maxHeight}: RfqDetailTablesProps) {
+
+ console.log("selectedRfq", selectedRfq)
+ // 상태 관리
+ const [isLoading, setIsLoading] = useState(false)
+ const [isRefreshing, setIsRefreshing] = useState(false)
+ const [details, setDetails] = useState<RfqDetailView[]>([])
+ const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false)
+ const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false)
+ const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false)
+ const [selectedDetail, setSelectedDetail] = React.useState<RfqDetailView | null>(null)
+
+ const [vendors, setVendors] = React.useState<Vendor[]>([])
+ const [currencies, setCurrencies] = React.useState<Currency[]>([])
+ const [paymentTerms, setPaymentTerms] = React.useState<PaymentTerm[]>([])
+ const [incoterms, setIncoterms] = React.useState<Incoterm[]>([])
+ const [isAdddialogLoading, setIsAdddialogLoading] = useState(false)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null)
+
+ // 벤더 커뮤니케이션 상태 관리
+ const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false)
+ const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null)
+
+ // 읽지 않은 메시지 개수
+ const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
+ const [isUnreadLoading, setIsUnreadLoading] = useState(false)
+
+ // 견적 비교 다이얼로그 상태 관리 (추가)
+ const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false)
+
+ const existingVendorIds = React.useMemo(() => {
+ return details.map(detail => Number(detail.vendorId)).filter(Boolean);
+ }, [details]);
+
+ const handleAddVendor = async () => {
+ try {
+ setIsAdddialogLoading(true)
+
+ // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈)
+ const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([
+ fetchVendors(),
+ fetchCurrencies(),
+ fetchPaymentTerms(),
+ fetchIncoterms()
+ ])
+
+ setVendors(vendorsData.data || [])
+ setCurrencies(currenciesData.data || [])
+ setPaymentTerms(paymentTermsData.data || [])
+ setIncoterms(incotermsData.data || [])
+
+ setVendorDialogOpen(true)
+ } catch (error) {
+ console.error("데이터 로드 오류:", error)
+ toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsAdddialogLoading(false)
+ }
+ }
+
+ // 견적 비교 다이얼로그 열기 핸들러 (추가)
+ const handleOpenComparisonDialog = () => {
+ // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인
+ const hasSubmittedQuotations = details.some(detail =>
+ detail.hasQuotation && detail.quotationStatus === "Submitted"
+ );
+
+ if (!hasSubmittedQuotations) {
+ toast.warning("제출된 견적이 없습니다.");
+ return;
+ }
+
+ setComparisonDialogOpen(true);
+ }
+
+ // 읽지 않은 메시지 로드
+ const loadUnreadMessages = async () => {
+ if (!selectedRfq || !selectedRfq.id) return;
+
+ try {
+ setIsUnreadLoading(true);
+
+ // 읽지 않은 메시지 수 가져오기
+ const unreadData = await fetchUnreadMessages(selectedRfq.id);
+ setUnreadMessages(unreadData);
+ } catch (error) {
+ console.error("읽지 않은 메시지 로드 오류:", error);
+ // 조용히 실패 - 사용자에게 알림 표시하지 않음
+ } finally {
+ setIsUnreadLoading(false);
+ }
+ };
+
+ // 칼럼 정의 - unreadMessages 상태 전달
+ const columns = React.useMemo(() =>
+ getRfqDetailColumns({
+ setRowAction,
+ unreadMessages
+ }), [unreadMessages])
+
+ // 필터 필드 정의 (필터 사용 시)
+ const advancedFilterFields = React.useMemo(
+ () => [
+ {
+ id: "vendorName",
+ label: "벤더명",
+ type: "text",
+ },
+ {
+ id: "vendorCode",
+ label: "벤더 코드",
+ type: "text",
+ },
+ {
+ id: "currency",
+ label: "통화",
+ type: "text",
+ },
+ ],
+ []
+ )
+
+ // RFQ ID가 변경될 때 데이터 로드
+ useEffect(() => {
+ async function loadRfqDetails() {
+ if (!selectedRfq || !selectedRfq.id) {
+ setDetails([])
+ return
+ }
+
+ try {
+ setIsLoading(true)
+ const transformRfqDetails = (data: any[]): RfqDetailView[] => {
+ return data.map(item => ({
+ ...item,
+ // Convert vendorId from string|null to number|undefined
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ // Transform any other fields that need type conversion
+ }));
+ };
+
+ // Then in your useEffect:
+ const result = await fetchRfqDetails(selectedRfq.id);
+ setDetails(transformRfqDetails(result.data));
+
+ // 읽지 않은 메시지 개수 로드
+ await loadUnreadMessages();
+ } catch (error) {
+ console.error("RFQ 디테일 로드 오류:", error)
+ setDetails([])
+ toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadRfqDetails()
+ }, [selectedRfq])
+
+ // 주기적으로 읽지 않은 메시지 갱신 (60초마다)
+ useEffect(() => {
+ if (!selectedRfq || !selectedRfq.id) return;
+
+ const intervalId = setInterval(() => {
+ loadUnreadMessages();
+ }, 60000); // 60초마다 갱신
+
+ return () => clearInterval(intervalId);
+ }, [selectedRfq]);
+
+ // rowAction 처리
+ useEffect(() => {
+ if (!rowAction) return
+
+ const handleRowAction = async () => {
+ try {
+ // 통신 액션인 경우 드로어 열기
+ if (rowAction.type === "communicate") {
+ setSelectedVendor(rowAction.row.original);
+ setCommunicationDrawerOpen(true);
+
+ // 해당 벤더의 읽지 않은 메시지를 0으로 설정 (메시지를 읽은 것으로 간주)
+ const vendorId = rowAction.row.original.vendorId;
+ if (vendorId) {
+ setUnreadMessages(prev => ({
+ ...prev,
+ [vendorId]: 0
+ }));
+ }
+
+ // rowAction 초기화
+ setRowAction(null);
+ return;
+ }
+
+ // 다른 액션들은 기존과 동일하게 처리
+ setIsAdddialogLoading(true);
+
+ // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈)
+ const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([
+ fetchVendors(),
+ fetchCurrencies(),
+ fetchPaymentTerms(),
+ fetchIncoterms()
+ ]);
+
+ setVendors(vendorsData.data || []);
+ setCurrencies(currenciesData.data || []);
+ setPaymentTerms(paymentTermsData.data || []);
+ setIncoterms(incotermsData.data || []);
+
+ // 이제 데이터가 로드되었으므로 필요한 작업 수행
+ if (rowAction.type === "update") {
+ setSelectedDetail(rowAction.row.original);
+ setUpdateSheetOpen(true);
+ } else if (rowAction.type === "delete") {
+ setSelectedDetail(rowAction.row.original);
+ setDeleteDialogOpen(true);
+ }
+ } catch (error) {
+ console.error("데이터 로드 오류:", error);
+ toast.error("데이터를 불러오는 중 오류가 발생했습니다");
+ } finally {
+ // communicate 타입이 아닌 경우에만 로딩 상태 변경
+ if (rowAction && rowAction.type !== "communicate") {
+ setIsAdddialogLoading(false);
+ }
+ }
+ };
+
+ handleRowAction();
+ }, [rowAction])
+
+ // RFQ가 선택되지 않은 경우
+ if (!selectedRfq) {
+ return (
+ <div className="flex h-full items-center justify-center text-muted-foreground">
+ RFQ를 선택하세요
+ </div>
+ )
+ }
+
+ // 로딩 중인 경우
+ if (isLoading) {
+ return (
+ <div className="p-4 space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-24 w-full" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ )
+ }
+
+ const handleRefreshData = async () => {
+ if (!selectedRfq || !selectedRfq.id) return
+
+ try {
+ setIsRefreshing(true)
+
+ const transformRfqDetails = (data: any[]): RfqDetailView[] => {
+ return data.map(item => ({
+ ...item,
+ // Convert vendorId from string|null to number|undefined
+ vendorId: item.vendorId ? Number(item.vendorId) : undefined,
+ // Transform any other fields that need type conversion
+ }));
+ };
+
+ // Then in your useEffect:
+ const result = await fetchRfqDetails(selectedRfq.id);
+ setDetails(transformRfqDetails(result.data));
+
+ // 읽지 않은 메시지 개수 업데이트
+ await loadUnreadMessages();
+
+ toast.success("데이터가 새로고침되었습니다")
+ } catch (error) {
+ console.error("RFQ 디테일 로드 오류:", error)
+ toast.error("데이터 새로고침 중 오류가 발생했습니다")
+ } finally {
+ setIsRefreshing(false)
+ }
+ }
+
+ // 전체 읽지 않은 메시지 수 계산
+ const totalUnreadMessages = Object.values(unreadMessages).reduce((sum, count) => sum + count, 0);
+
+ // 견적이 있는 벤더 수 계산
+ const vendorsWithQuotations = details.filter(detail => detail.hasQuotation && detail.quotationStatus === "Submitted").length;
+
+ return (
+ <div className="h-full overflow-hidden pt-4">
+
+ {/* 메시지 및 새로고침 영역 */}
+
+
+ {/* 테이블 또는 빈 상태 표시 */}
+ {details.length > 0 ? (
+
+ <ClientDataTable
+ columns={columns}
+ data={details}
+ advancedFilterFields={advancedFilterFields}
+ maxHeight={maxHeight}
+ >
+
+ <div className="flex justify-between items-center">
+ <div className="flex items-center gap-2 mr-2">
+ {totalUnreadMessages > 0 && (
+ <Badge variant="destructive" className="h-6">
+ 읽지 않은 메시지: {totalUnreadMessages}건
+ </Badge>
+ )}
+ {vendorsWithQuotations > 0 && (
+ <Badge variant="outline" className="h-6">
+ 견적 제출: {vendorsWithQuotations}개 벤더
+ </Badge>
+ )}
+ </div>
+ <div className="flex gap-2">
+ {/* 견적 비교 버튼 추가 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleOpenComparisonDialog}
+ className="gap-2"
+ disabled={
+ !selectedRfq ||
+ details.length === 0 ||
+ (!!selectedRfq.rfqSealedYn && selectedRfq.dueDate && new Date() < new Date(selectedRfq.dueDate))
+ }
+ >
+ <BarChart2 className="size-4" aria-hidden="true" />
+ <span>견적 비교</span>
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleRefreshData}
+ disabled={isRefreshing}
+ >
+ {isRefreshing ? (
+ <>
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
+ 새로고침 중...
+ </>
+ ) : (
+ '새로고침'
+ )}
+ </Button>
+ </div>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ className="gap-2"
+ disabled={!selectedRfq || isAdddialogLoading}
+ >
+ {isAdddialogLoading ? (
+ <>
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ <span>로딩 중...</span>
+ </>
+ ) : (
+ <>
+ <UserPlus className="size-4" aria-hidden="true" />
+ <span>벤더 추가</span>
+ </>
+ )}
+ </Button>
+ </ClientDataTable>
+
+ ) : (
+ <div className="flex h-48 items-center justify-center text-muted-foreground border rounded-md p-4">
+ <div className="flex flex-col items-center gap-4">
+ <p>해당 RFQ에 대한 협력업체가 정해지지 않았습니다. 아래 버튼을 이용하여 추가하시기 바랍니다.</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleAddVendor}
+ className="gap-2"
+ disabled={!selectedRfq || isAdddialogLoading}
+ >
+ {isAdddialogLoading ? (
+ <>
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ <span>로딩 중...</span>
+ </>
+ ) : (
+ <>
+ <UserPlus className="size-4" aria-hidden="true" />
+ <span>협력업체 추가</span>
+ </>
+ )}
+ </Button>
+ </div>
+ </div>
+ )}
+
+ {/* 벤더 추가 다이얼로그 */}
+ <AddVendorDialog
+ open={vendorDialogOpen}
+ onOpenChange={(open) => {
+ setVendorDialogOpen(open);
+ if (!open) setIsAdddialogLoading(false);
+ }}
+ selectedRfq={selectedRfq}
+ vendors={vendors}
+ currencies={currencies}
+ paymentTerms={paymentTerms}
+ incoterms={incoterms}
+ onSuccess={handleRefreshData}
+ existingVendorIds={existingVendorIds}
+ />
+
+ {/* 벤더 정보 수정 시트 */}
+ <UpdateRfqDetailSheet
+ open={updateSheetOpen}
+ onOpenChange={setUpdateSheetOpen}
+ detail={selectedDetail}
+ vendors={vendors}
+ currencies={currencies}
+ paymentTerms={paymentTerms}
+ incoterms={incoterms}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 벤더 정보 삭제 다이얼로그 */}
+ <DeleteRfqDetailDialog
+ open={deleteDialogOpen}
+ onOpenChange={setDeleteDialogOpen}
+ detail={selectedDetail}
+ showTrigger={false}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 벤더 커뮤니케이션 드로어 */}
+ <VendorCommunicationDrawer
+ open={communicationDrawerOpen}
+ onOpenChange={(open) => {
+ setCommunicationDrawerOpen(open);
+ // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신
+ if (!open) loadUnreadMessages();
+ }}
+ selectedRfq={selectedRfq}
+ selectedVendor={selectedVendor}
+ onSuccess={handleRefreshData}
+ />
+
+ {/* 견적 비교 다이얼로그 추가 */}
+ <VendorQuotationComparisonDialog
+ open={comparisonDialogOpen}
+ onOpenChange={setComparisonDialogOpen}
+ selectedRfq={selectedRfq}
+ />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx b/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx
new file mode 100644
index 00000000..45e4a602
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx
@@ -0,0 +1,449 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from "@/components/ui/command"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Checkbox } from "@/components/ui/checkbox"
+import { ScrollArea } from "@/components/ui/scroll-area"
+
+import { RfqDetailView } from "./rfq-detail-column"
+import { updateRfqDetail } from "@/lib/procurement-rfqs/services"
+
+// 폼 유효성 검증 스키마
+const updateRfqDetailSchema = z.object({
+ vendorId: z.string().min(1, "벤더를 선택해주세요"),
+ currency: z.string().min(1, "통화를 선택해주세요"),
+ paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"),
+ incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"),
+ incotermsDetail: z.string().optional(),
+ deliveryDate: z.string().optional(),
+ taxCode: z.string().optional(),
+ placeOfShipping: z.string().optional(),
+ placeOfDestination: z.string().optional(),
+ materialPriceRelatedYn: z.boolean().default(false),
+})
+
+type UpdateRfqDetailFormValues = z.infer<typeof updateRfqDetailSchema>
+
+// 데이터 타입 정의
+interface Vendor {
+ id: number;
+ vendorName: string;
+ vendorCode: string;
+}
+
+interface Currency {
+ code: string;
+ name: string;
+}
+
+interface PaymentTerm {
+ code: string;
+ description: string;
+}
+
+interface Incoterm {
+ code: string;
+ description: string;
+}
+
+interface UpdateRfqDetailSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ detail: RfqDetailView | null;
+ vendors: Vendor[];
+ currencies: Currency[];
+ paymentTerms: PaymentTerm[];
+ incoterms: Incoterm[];
+ onSuccess?: () => void;
+}
+
+export function UpdateRfqDetailSheet({
+ detail,
+ vendors,
+ currencies,
+ paymentTerms,
+ incoterms,
+ onSuccess,
+ ...props
+}: UpdateRfqDetailSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const [vendorOpen, setVendorOpen] = React.useState(false)
+
+ const form = useForm<UpdateRfqDetailFormValues>({
+ resolver: zodResolver(updateRfqDetailSchema),
+ defaultValues: {
+ vendorId: detail?.vendorName ? String(vendors.find(v => v.vendorName === detail.vendorName)?.id || "") : "",
+ currency: detail?.currency || "",
+ paymentTermsCode: detail?.paymentTermsCode || "",
+ incotermsCode: detail?.incotermsCode || "",
+ incotermsDetail: detail?.incotermsDetail || "",
+ deliveryDate: detail?.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "",
+ taxCode: detail?.taxCode || "",
+ placeOfShipping: detail?.placeOfShipping || "",
+ placeOfDestination: detail?.placeOfDestination || "",
+ materialPriceRelatedYn: detail?.materialPriceRelatedYn || false,
+ },
+ })
+
+ // detail이 변경될 때 form 값 업데이트
+ React.useEffect(() => {
+ if (detail) {
+ const vendorId = vendors.find(v => v.vendorName === detail.vendorName)?.id
+
+ form.reset({
+ vendorId: vendorId ? String(vendorId) : "",
+ currency: detail.currency || "",
+ paymentTermsCode: detail.paymentTermsCode || "",
+ incotermsCode: detail.incotermsCode || "",
+ incotermsDetail: detail.incotermsDetail || "",
+ deliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "",
+ taxCode: detail.taxCode || "",
+ placeOfShipping: detail.placeOfShipping || "",
+ placeOfDestination: detail.placeOfDestination || "",
+ materialPriceRelatedYn: detail.materialPriceRelatedYn || false,
+ })
+ }
+ }, [detail, form, vendors])
+
+ function onSubmit(values: UpdateRfqDetailFormValues) {
+ if (!detail) return
+
+ startUpdateTransition(async () => {
+ try {
+ const result = await updateRfqDetail(detail.detailId, values)
+
+ if (!result.success) {
+ toast.error(result.message || "수정 중 오류가 발생했습니다")
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("RFQ 벤더 정보가 수정되었습니다")
+ onSuccess?.()
+ } catch (error) {
+ console.error("RFQ 벤더 수정 오류:", error)
+ toast.error("수정 중 오류가 발생했습니다")
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex w-full flex-col gap-6 sm:max-w-xl">
+ <SheetHeader className="text-left">
+ <SheetTitle>RFQ 벤더 정보 수정</SheetTitle>
+ <SheetDescription>
+ 벤더 정보를 수정하고 저장하세요
+ </SheetDescription>
+ </SheetHeader>
+ <ScrollArea className="flex-1 pr-4">
+ <Form {...form}>
+ <form
+ id="update-rfq-detail-form"
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* 검색 가능한 벤더 선택 필드 */}
+ <FormField
+ control={form.control}
+ name="vendorId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>벤더 <span className="text-red-500">*</span></FormLabel>
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value
+ ? vendors.find((vendor) => String(vendor.id) === field.value)
+ ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})`
+ : "벤더를 선택하세요"
+ : "벤더를 선택하세요"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="벤더 검색..." />
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <ScrollArea className="h-60">
+ <CommandGroup>
+ {vendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode}`}
+ onSelect={() => {
+ form.setValue("vendorId", String(vendor.id), {
+ shouldValidate: true,
+ })
+ setVendorOpen(false)
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ String(vendor.id) === field.value
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {vendor.vendorName} ({vendor.vendorCode})
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </ScrollArea>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="currency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>통화 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="통화를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {currencies.map((currency) => (
+ <SelectItem key={currency.code} value={currency.code}>
+ {currency.name} ({currency.code})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="paymentTermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>지불 조건 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="지불 조건 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {paymentTerms.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="incotermsCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>인코텀즈 <span className="text-red-500">*</span></FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="인코텀즈 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {incoterms.map((incoterm) => (
+ <SelectItem key={incoterm.code} value={incoterm.code}>
+ {incoterm.description}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="incotermsDetail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>인코텀즈 세부사항</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="인코텀즈 세부사항" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="deliveryDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>납품 예정일</FormLabel>
+ <FormControl>
+ <Input {...field} type="date" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="taxCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>세금 코드</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="세금 코드" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="placeOfShipping"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>선적지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="선적지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="placeOfDestination"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>도착지</FormLabel>
+ <FormControl>
+ <Input {...field} placeholder="도착지" />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <FormField
+ control={form.control}
+ name="materialPriceRelatedYn"
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel>자재 가격 관련 여부</FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ </form>
+ </Form>
+ </ScrollArea>
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button
+ type="submit"
+ form="update-rfq-detail-form"
+ disabled={isUpdatePending}
+ >
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx b/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx
new file mode 100644
index 00000000..34efdfc2
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx
@@ -0,0 +1,518 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect, useRef } from "react"
+import { ProcurementRfqsView } from "@/db/schema"
+import { RfqDetailView } from "./rfq-detail-column"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Avatar, AvatarFallback } from "@/components/ui/avatar"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import {
+ Send,
+ Paperclip,
+ DownloadCloud,
+ File,
+ FileText,
+ Image as ImageIcon,
+ AlertCircle,
+ X
+} from "lucide-react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { formatDateTime } from "@/lib/utils"
+import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트
+import { fetchVendorComments, markMessagesAsRead } from "@/lib/procurement-rfqs/services"
+
+// 타입 정의
+interface Comment {
+ id: number;
+ rfqId: number;
+ vendorId: number | null // null 허용으로 변경
+ userId?: number | null // null 허용으로 변경
+ content: string;
+ isVendorComment: boolean | null; // null 허용으로 변경
+ createdAt: Date;
+ updatedAt: Date;
+ userName?: string | null // null 허용으로 변경
+ vendorName?: string | null // null 허용으로 변경
+ attachments: Attachment[];
+ isRead: boolean | null // null 허용으로 변경
+}
+
+interface Attachment {
+ id: number;
+ fileName: string;
+ fileSize: number;
+ fileType: string;
+ filePath: string;
+ uploadedAt: Date;
+}
+
+// 프롭스 정의
+interface VendorCommunicationDrawerProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ selectedRfq: ProcurementRfqsView | null;
+ selectedVendor: RfqDetailView | null;
+ onSuccess?: () => void;
+}
+
+async function sendComment(params: {
+ rfqId: number;
+ vendorId: number;
+ content: string;
+ attachments?: File[];
+}): Promise<Comment> {
+ try {
+ // 폼 데이터 생성 (파일 첨부를 위해)
+ const formData = new FormData();
+ formData.append('rfqId', params.rfqId.toString());
+ formData.append('vendorId', params.vendorId.toString());
+ formData.append('content', params.content);
+ formData.append('isVendorComment', 'false');
+
+ // 첨부파일 추가
+ if (params.attachments && params.attachments.length > 0) {
+ params.attachments.forEach((file) => {
+ formData.append(`attachments`, file);
+ });
+ }
+
+ // API 엔드포인트 구성
+ const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`;
+
+ // API 호출
+ const response = await fetch(url, {
+ method: 'POST',
+ body: formData, // multipart/form-data 형식 사용
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(`API 요청 실패: ${response.status} ${errorText}`);
+ }
+
+ // 응답 데이터 파싱
+ const result = await response.json();
+
+ if (!result.success || !result.data) {
+ throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다');
+ }
+
+ return result.data.comment;
+ } catch (error) {
+ console.error('코멘트 전송 오류:', error);
+ throw error;
+ }
+}
+
+export function VendorCommunicationDrawer({
+ open,
+ onOpenChange,
+ selectedRfq,
+ selectedVendor,
+ onSuccess
+}: VendorCommunicationDrawerProps) {
+ // 상태 관리
+ const [comments, setComments] = useState<Comment[]>([]);
+ const [newComment, setNewComment] = useState("");
+ const [attachments, setAttachments] = useState<File[]>([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const fileInputRef = useRef<HTMLInputElement>(null);
+ const messagesEndRef = useRef<HTMLDivElement>(null);
+
+ // 첨부파일 관련 상태
+ const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
+ const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null);
+
+ // 드로어가 열릴 때 데이터 로드
+ useEffect(() => {
+ if (open && selectedRfq && selectedVendor) {
+ loadComments();
+ }
+ }, [open, selectedRfq, selectedVendor]);
+
+ // 스크롤 최하단으로 이동
+ useEffect(() => {
+ if (messagesEndRef.current) {
+ messagesEndRef.current.scrollIntoView({ behavior: "smooth" });
+ }
+ }, [comments]);
+
+ // 코멘트 로드 함수
+ const loadComments = async () => {
+ if (!selectedRfq || !selectedVendor) return;
+
+ try {
+ setIsLoading(true);
+
+ // Server Action을 사용하여 코멘트 데이터 가져오기
+ const commentsData = await fetchVendorComments(selectedRfq.id, selectedVendor.vendorId);
+ setComments(commentsData);
+
+ // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경
+ await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId);
+ } catch (error) {
+ console.error("코멘트 로드 오류:", error);
+ toast.error("메시지를 불러오는 중 오류가 발생했습니다");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ // 파일 선택 핸들러
+ const handleFileSelect = () => {
+ fileInputRef.current?.click();
+ };
+
+ // 파일 변경 핸들러
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ if (e.target.files && e.target.files.length > 0) {
+ const newFiles = Array.from(e.target.files);
+ setAttachments(prev => [...prev, ...newFiles]);
+ }
+ };
+
+ // 파일 제거 핸들러
+ const handleRemoveFile = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index));
+ };
+
+ console.log(newComment)
+
+ // 코멘트 전송 핸들러
+ const handleSubmitComment = async () => {
+ console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId )
+ console.log(!newComment.trim() && attachments.length === 0)
+
+ if (!newComment.trim() && attachments.length === 0) return;
+ if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return;
+
+ console.log("버튼 클릭")
+
+ try {
+ setIsSubmitting(true);
+
+ // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용)
+ const newCommentObj = await sendComment({
+ rfqId: selectedRfq.id,
+ vendorId: selectedVendor.vendorId,
+ content: newComment,
+ attachments: attachments
+ });
+
+ // 상태 업데이트
+ setComments(prev => [...prev, newCommentObj]);
+ setNewComment("");
+ setAttachments([]);
+
+ toast.success("메시지가 전송되었습니다");
+
+ // 데이터 새로고침
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("코멘트 전송 오류:", error);
+ toast.error("메시지 전송 중 오류가 발생했습니다");
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 첨부파일 미리보기
+ const handleAttachmentPreview = (attachment: Attachment) => {
+ setSelectedAttachment(attachment);
+ setPreviewDialogOpen(true);
+ };
+
+ // 첨부파일 다운로드
+ const handleAttachmentDownload = (attachment: Attachment) => {
+ // TODO: 실제 다운로드 구현
+ window.open(attachment.filePath, '_blank');
+ };
+
+ // 파일 아이콘 선택
+ const getFileIcon = (fileType: string) => {
+ if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />;
+ if (fileType.includes("spreadsheet") || fileType.includes("excel"))
+ return <FileText className="h-5 w-5 text-green-500" />;
+ if (fileType.includes("document") || fileType.includes("word"))
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 첨부파일 미리보기 다이얼로그
+ const renderAttachmentPreviewDialog = () => {
+ if (!selectedAttachment) return null;
+
+ const isImage = selectedAttachment.fileType.startsWith("image/");
+ const isPdf = selectedAttachment.fileType.includes("pdf");
+
+ return (
+ <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}>
+ <DialogContent className="max-w-3xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ {getFileIcon(selectedAttachment.fileType)}
+ {selectedAttachment.fileName}
+ </DialogTitle>
+ <DialogDescription>
+ {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)}
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="min-h-[300px] flex items-center justify-center p-4">
+ {isImage ? (
+ <img
+ src={selectedAttachment.filePath}
+ alt={selectedAttachment.fileName}
+ className="max-h-[500px] max-w-full object-contain"
+ />
+ ) : isPdf ? (
+ <iframe
+ src={`${selectedAttachment.filePath}#toolbar=0`}
+ className="w-full h-[500px]"
+ title={selectedAttachment.fileName}
+ />
+ ) : (
+ <div className="flex flex-col items-center gap-4 p-8">
+ {getFileIcon(selectedAttachment.fileType)}
+ <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p>
+ <Button
+ variant="outline"
+ onClick={() => handleAttachmentDownload(selectedAttachment)}
+ >
+ <DownloadCloud className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ );
+ };
+
+ if (!selectedRfq || !selectedVendor) {
+ return null;
+ }
+
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[85vh]">
+ <DrawerHeader className="border-b">
+ <DrawerTitle className="flex items-center gap-2">
+ <Avatar className="h-8 w-8">
+ <AvatarFallback className="bg-primary/10">
+ {selectedVendor.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ <div>
+ <span>{selectedVendor.vendorName}</span>
+ <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge>
+ </div>
+ </DrawerTitle>
+ <DrawerDescription>
+ RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName}
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="p-0 flex flex-col h-[60vh]">
+ {/* 메시지 목록 */}
+ <ScrollArea className="flex-1 p-4">
+ {isLoading ? (
+ <div className="flex h-full items-center justify-center">
+ <p className="text-muted-foreground">메시지 로딩 중...</p>
+ </div>
+ ) : comments.length === 0 ? (
+ <div className="flex h-full items-center justify-center">
+ <div className="flex flex-col items-center gap-2">
+ <AlertCircle className="h-6 w-6 text-muted-foreground" />
+ <p className="text-muted-foreground">아직 메시지가 없습니다</p>
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {comments.map(comment => (
+ <div
+ key={comment.id}
+ className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`}
+ >
+ {comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/10">
+ {comment.vendorName?.[0] || 'V'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+
+ <div className={`rounded-lg p-3 max-w-[80%] ${
+ comment.isVendorComment
+ ? 'bg-muted'
+ : 'bg-primary text-primary-foreground'
+ }`}>
+ <div className="text-sm font-medium mb-1">
+ {comment.isVendorComment ? comment.vendorName : comment.userName}
+ </div>
+
+ {comment.content && (
+ <div className="text-sm whitespace-pre-wrap break-words">
+ {comment.content}
+ </div>
+ )}
+
+ {/* 첨부파일 표시 */}
+ {comment.attachments.length > 0 && (
+ <div className={`mt-2 pt-2 ${
+ comment.isVendorComment
+ ? 'border-t border-t-border/30'
+ : 'border-t border-t-primary-foreground/20'
+ }`}>
+ {comment.attachments.map(attachment => (
+ <div
+ key={attachment.id}
+ className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer"
+ onClick={() => handleAttachmentPreview(attachment)}
+ >
+ {getFileIcon(attachment.fileType)}
+ <span className="flex-1 truncate">{attachment.fileName}</span>
+ <span className="text-xs opacity-70">
+ {formatFileSize(attachment.fileSize)}
+ </span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-6 w-6 rounded-full"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleAttachmentDownload(attachment);
+ }}
+ >
+ <DownloadCloud className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end">
+ {formatDateTime(comment.createdAt)}
+ </div>
+ </div>
+
+ {!comment.isVendorComment && (
+ <Avatar className="h-8 w-8 mt-1">
+ <AvatarFallback className="bg-primary/20">
+ {comment.userName?.[0] || 'U'}
+ </AvatarFallback>
+ </Avatar>
+ )}
+ </div>
+ ))}
+ <div ref={messagesEndRef} />
+ </div>
+ )}
+ </ScrollArea>
+
+ {/* 선택된 첨부파일 표시 */}
+ {attachments.length > 0 && (
+ <div className="p-2 bg-muted mx-4 rounded-md mb-2">
+ <div className="text-xs font-medium mb-1">첨부파일</div>
+ <div className="flex flex-wrap gap-2">
+ {attachments.map((file, index) => (
+ <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs">
+ {file.type.startsWith("image/") ? (
+ <ImageIcon className="h-4 w-4 mr-1 text-blue-500" />
+ ) : (
+ <File className="h-4 w-4 mr-1 text-gray-500" />
+ )}
+ <span className="truncate max-w-[100px]">{file.name}</span>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 ml-1 p-0"
+ onClick={() => handleRemoveFile(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 메시지 입력 영역 */}
+ <div className="p-4 border-t">
+ <div className="flex gap-2 items-end">
+ <div className="flex-1">
+ <Textarea
+ placeholder="메시지를 입력하세요..."
+ className="min-h-[80px] resize-none"
+ value={newComment}
+ onChange={(e) => setNewComment(e.target.value)}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="hidden"
+ multiple
+ onChange={handleFileChange}
+ />
+ <Button
+ variant="outline"
+ size="icon"
+ onClick={handleFileSelect}
+ title="파일 첨부"
+ >
+ <Paperclip className="h-4 w-4" />
+ </Button>
+ <Button
+ onClick={handleSubmitComment}
+ disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting}
+ >
+ <Send className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <DrawerFooter className="border-t">
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => loadComments()}>
+ 새로고침
+ </Button>
+ <DrawerClose asChild>
+ <Button variant="outline">닫기</Button>
+ </DrawerClose>
+ </div>
+ </DrawerFooter>
+ </DrawerContent>
+
+ {renderAttachmentPreviewDialog()}
+ </Drawer>
+ );
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx
new file mode 100644
index 00000000..72cf187c
--- /dev/null
+++ b/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx
@@ -0,0 +1,665 @@
+"use client"
+
+import * as React from "react"
+import { useEffect, useState } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Skeleton } from "@/components/ui/skeleton"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import { toast } from "sonner"
+
+// Lucide 아이콘
+import { Plus, Minus } from "lucide-react"
+
+import { ProcurementRfqsView } from "@/db/schema"
+import { fetchVendorQuotations, fetchQuotationItems } from "@/lib/procurement-rfqs/services"
+import { formatCurrency, formatDate } from "@/lib/utils"
+
+// 견적 정보 타입
+interface VendorQuotation {
+ id: number
+ rfqId: number
+ vendorId: number
+ vendorName?: string | null
+ quotationCode: string
+ quotationVersion: number
+ totalItemsCount: number
+ subTotal: string
+ taxTotal: string
+ discountTotal: string
+ totalPrice: string
+ currency: string
+ validUntil: string | Date // 수정: string | Date 허용
+ estimatedDeliveryDate: string | Date // 수정: string | Date 허용
+ paymentTermsCode: string
+ paymentTermsDescription?: string | null
+ incotermsCode: string
+ incotermsDescription?: string | null
+ incotermsDetail: string
+ status: string
+ remark: string
+ rejectionReason: string
+ submittedAt: string | Date // 수정: string | Date 허용
+ acceptedAt: string | Date // 수정: string | Date 허용
+ createdAt: string | Date // 수정: string | Date 허용
+ updatedAt: string | Date // 수정: string | Date 허용
+}
+
+// 견적 아이템 타입
+interface QuotationItem {
+ id: number
+ quotationId: number
+ prItemId: number
+ materialCode: string | null // Changed from string to string | null
+ materialDescription: string | null // Changed from string to string | null
+ quantity: string
+ uom: string | null // Changed assuming this might be null
+ unitPrice: string
+ totalPrice: string
+ currency: string | null // Changed from string to string | null
+ vendorMaterialCode: string | null // Changed from string to string | null
+ vendorMaterialDescription: string | null // Changed from string to string | null
+ deliveryDate: Date | null // Changed from string to string | null
+ leadTimeInDays: number | null // Changed from number to number | null
+ taxRate: string | null // Changed from string to string | null
+ taxAmount: string | null // Changed from string to string | null
+ discountRate: string | null // Changed from string to string | null
+ discountAmount: string | null // Changed from string to string | null
+ remark: string | null // Changed from string to string | null
+ isAlternative: boolean | null // Changed from boolean to boolean | null
+ isRecommended: boolean | null // Changed from boolean to boolean | null
+}
+
+interface VendorQuotationComparisonDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ selectedRfq: ProcurementRfqsView | null
+}
+
+export function VendorQuotationComparisonDialog({
+ open,
+ onOpenChange,
+ selectedRfq,
+}: VendorQuotationComparisonDialogProps) {
+ const [isLoading, setIsLoading] = useState(false)
+ const [quotations, setQuotations] = useState<VendorQuotation[]>([])
+ const [quotationItems, setQuotationItems] = useState<Record<number, QuotationItem[]>>({})
+ const [activeTab, setActiveTab] = useState("summary")
+
+ // 벤더별 접힘 상태 (true=접힘, false=펼침), 기본값: 접힘
+ const [collapsedVendors, setCollapsedVendors] = useState<Record<number, boolean>>({})
+
+ useEffect(() => {
+ async function loadQuotationData() {
+ if (!open || !selectedRfq?.id) return
+
+ try {
+ setIsLoading(true)
+ // 1) 견적 목록
+ const quotationsResult = await fetchVendorQuotations(selectedRfq.id)
+ const rawQuotationsData = quotationsResult.data || []
+
+ const quotationsData = rawQuotationsData.map((rawData): VendorQuotation => ({
+ id: rawData.id,
+ rfqId: rawData.rfqId,
+ vendorId: rawData.vendorId,
+ vendorName: rawData.vendorName || null,
+ quotationCode: rawData.quotationCode || '',
+ quotationVersion: rawData.quotationVersion || 0,
+ totalItemsCount: rawData.totalItemsCount || 0,
+ subTotal: rawData.subTotal || '0',
+ taxTotal: rawData.taxTotal || '0',
+ discountTotal: rawData.discountTotal || '0',
+ totalPrice: rawData.totalPrice || '0',
+ currency: rawData.currency || 'KRW',
+ validUntil: rawData.validUntil || '',
+ estimatedDeliveryDate: rawData.estimatedDeliveryDate || '',
+ paymentTermsCode: rawData.paymentTermsCode || '',
+ paymentTermsDescription: rawData.paymentTermsDescription || null,
+ incotermsCode: rawData.incotermsCode || '',
+ incotermsDescription: rawData.incotermsDescription || null,
+ incotermsDetail: rawData.incotermsDetail || '',
+ status: rawData.status || '',
+ remark: rawData.remark || '',
+ rejectionReason: rawData.rejectionReason || '',
+ submittedAt: rawData.submittedAt || '',
+ acceptedAt: rawData.acceptedAt || '',
+ createdAt: rawData.createdAt || '',
+ updatedAt: rawData.updatedAt || '',
+ }));
+
+ setQuotations(quotationsData);
+
+ // 벤더별로 접힘 상태 기본값(true) 설정
+ const collapsedInit: Record<number, boolean> = {}
+ quotationsData.forEach((q) => {
+ collapsedInit[q.id] = true
+ })
+ setCollapsedVendors(collapsedInit)
+
+ // 2) 견적 아이템
+ const qIds = quotationsData.map((q) => q.id)
+ if (qIds.length > 0) {
+ const itemsResult = await fetchQuotationItems(qIds)
+ const itemsData = itemsResult.data || []
+
+ const itemsByQuotation: Record<number, QuotationItem[]> = {}
+ itemsData.forEach((item) => {
+ if (!itemsByQuotation[item.quotationId]) {
+ itemsByQuotation[item.quotationId] = []
+ }
+ itemsByQuotation[item.quotationId].push(item)
+ })
+ setQuotationItems(itemsByQuotation)
+ }
+ } catch (error) {
+ console.error("견적 데이터 로드 오류:", error)
+ toast.error("견적 데이터를 불러오는 데 실패했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ loadQuotationData()
+ }, [open, selectedRfq])
+
+ // 견적 상태 -> 뱃지 색
+ const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "Submitted":
+ return "default"
+ case "Accepted":
+ return "default"
+ case "Rejected":
+ return "destructive"
+ case "Revised":
+ return "destructive"
+ default:
+ return "secondary"
+ }
+ }
+
+ // 모든 prItemId 모음
+ const allItemIds = React.useMemo(() => {
+ const itemSet = new Set<number>()
+ Object.values(quotationItems).forEach((items) => {
+ items.forEach((it) => itemSet.add(it.prItemId))
+ })
+ return Array.from(itemSet)
+ }, [quotationItems])
+
+ // 아이템 찾는 함수
+ const findItemByQuotationId = (prItemId: number, qid: number) => {
+ const items = quotationItems[qid] || []
+ return items.find((i) => i.prItemId === prItemId)
+ }
+
+ // 접힘 상태 토글
+ const toggleVendor = (qid: number) => {
+ setCollapsedVendors((prev) => ({
+ ...prev,
+ [qid]: !prev[qid],
+ }))
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ {/* 다이얼로그 자체는 max-h, max-w 설정, 내부에 스크롤 컨테이너를 둠 */}
+ <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]" style={{ maxWidth: '90vw', maxHeight: '90vh' }}>
+ <DialogHeader>
+ <DialogTitle>벤더 견적 비교</DialogTitle>
+ <DialogDescription>
+ {selectedRfq
+ ? `RFQ ${selectedRfq.rfqCode} - ${selectedRfq.itemName || ""}`
+ : ""}
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="space-y-4">
+ <Skeleton className="h-8 w-1/2" />
+ <Skeleton className="h-48 w-full" />
+ </div>
+ ) : quotations.length === 0 ? (
+ <div className="py-8 text-center text-muted-foreground">
+ 제출된(Submitted) 견적이 없습니다
+ </div>
+ ) : (
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="summary">견적 요약 비교</TabsTrigger>
+ <TabsTrigger value="items">아이템별 비교</TabsTrigger>
+ </TabsList>
+
+ {/* ======================== 요약 비교 탭 ======================== */}
+ <TabsContent value="summary" className="mt-4">
+ {/*
+ table-fixed + 가로 너비를 크게 잡아줌 (예: w-[1200px])
+ -> 컨테이너보다 넓으면 수평 스크롤 발생.
+ */}
+ <div className="border rounded-md max-h-[60vh] overflow-auto">
+ <table className="table-fixed w-full border-collapse">
+ <thead className="sticky top-0 bg-background z-10">
+ <TableRow>
+ <TableHead
+ className="sticky left-0 top-0 z-20 bg-background p-2"
+ >
+ 항목
+ </TableHead>
+ {quotations.map((q) => (
+ <TableHead key={q.id} className="p-2 text-center whitespace-nowrap">
+ {q.vendorName || `벤더 ID: ${q.vendorId}`}
+ </TableHead>
+ ))}
+ </TableRow>
+ </thead>
+ <tbody>
+ {/* 견적 상태 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 견적 상태
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`status-${q.id}`} className="p-2">
+ <Badge variant={getStatusBadgeVariant(q.status)}>
+ {q.status}
+ </Badge>
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 견적 버전 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 견적 버전
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`version-${q.id}`} className="p-2">
+ v{q.quotationVersion}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 총 금액 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 총 금액
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`total-${q.id}`} className="p-2 font-semibold">
+ {formatCurrency(Number(q.totalPrice), q.currency)}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 소계 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 소계
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`subtotal-${q.id}`} className="p-2">
+ {formatCurrency(Number(q.subTotal), q.currency)}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 세금 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 세금
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`tax-${q.id}`} className="p-2">
+ {formatCurrency(Number(q.taxTotal), q.currency)}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 할인 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 할인
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`discount-${q.id}`} className="p-2">
+ {formatCurrency(Number(q.discountTotal), q.currency)}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 통화 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 통화
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`currency-${q.id}`} className="p-2">
+ {q.currency}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 유효기간 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 유효 기간
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`valid-${q.id}`} className="p-2">
+ {formatDate(q.validUntil, "KR")}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 예상 배송일 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 예상 배송일
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`delivery-${q.id}`} className="p-2">
+ {formatDate(q.estimatedDeliveryDate, "KR")}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 지불 조건 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 지불 조건
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`payment-${q.id}`} className="p-2">
+ {q.paymentTermsDescription || q.paymentTermsCode}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 인코텀즈 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 인코텀즈
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`incoterms-${q.id}`} className="p-2">
+ {q.incotermsDescription || q.incotermsCode}
+ {q.incotermsDetail && (
+ <div className="text-xs text-muted-foreground mt-1">
+ {q.incotermsDetail}
+ </div>
+ )}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 제출일 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 제출일
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell key={`submitted-${q.id}`} className="p-2">
+ {formatDate(q.submittedAt, "KR")}
+ </TableCell>
+ ))}
+ </TableRow>
+
+ {/* 비고 */}
+ <TableRow>
+ <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium">
+ 비고
+ </TableCell>
+ {quotations.map((q) => (
+ <TableCell
+ key={`remark-${q.id}`}
+ className="p-2 whitespace-pre-wrap"
+ >
+ {q.remark || "-"}
+ </TableCell>
+ ))}
+ </TableRow>
+ </tbody>
+ </table>
+ </div>
+ </TabsContent>
+
+ {/* ====================== 아이템별 비교 탭 ====================== */}
+ <TabsContent value="items" className="mt-4">
+ {/* 컨테이너에 테이블 관련 클래스 직접 적용 */}
+ <div className="border rounded-md max-h-[60vh] overflow-y-auto overflow-x-auto" >
+ <div className="min-w-full w-max" style={{ maxWidth: '70vw' }}>
+ <table className="w-full border-collapse">
+ <thead className="sticky top-0 bg-background z-10">
+ {/* 첫 번째 헤더 행 */}
+ <tr>
+ {/* 첫 행: 자재(코드) 컬럼 */}
+ <th
+ rowSpan={2}
+ className="sticky left-0 top-0 z-20 p-2 border border-gray-200 text-left"
+ style={{
+ width: '250px',
+ minWidth: '250px',
+ backgroundColor: 'white',
+ }}
+ >
+ 자재 (코드)
+ </th>
+
+ {/* 벤더 헤더 (접힘/펼침) */}
+ {quotations.map((q, index) => {
+ const collapsed = collapsedVendors[q.id]
+ // 접힌 상태면 1칸, 펼친 상태면 6칸
+ return (
+ <th
+ key={q.id}
+ className="p-2 text-center whitespace-nowrap border border-gray-200"
+ colSpan={collapsed ? 1 : 6}
+ style={{
+ borderRight: index < quotations.length - 1 ? '1px solid #e5e7eb' : '',
+ backgroundColor: 'white',
+ }}
+ >
+ {/* + / - 버튼 */}
+ <div className="flex items-center gap-2 justify-center">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="h-7 w-7 p-1"
+ onClick={() => toggleVendor(q.id)}
+ >
+ {collapsed ? <Plus size={16} /> : <Minus size={16} />}
+ </Button>
+ <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span>
+ </div>
+ </th>
+ )
+ })}
+ </tr>
+
+ {/* 두 번째 헤더 행 - 하위 컬럼들 */}
+ <tr className="border-b border-b-gray-200">
+ {/* 펼쳐진 벤더의 하위 컬럼들만 표시 */}
+ {quotations.flatMap((q, qIndex) => {
+ // 접힌 상태면 추가 헤더 없음
+ if (collapsedVendors[q.id]) {
+ return [
+ <th
+ key={`${q.id}-collapsed`}
+ className="p-2 text-center whitespace-nowrap border border-gray-200"
+ style={{ backgroundColor: 'white' }}
+ >
+ 총액
+ </th>
+ ];
+ }
+
+ // 펼친 상태면 6개 컬럼 표시
+ const columns = [
+ { key: 'unitprice', label: '단가' },
+ { key: 'totalprice', label: '총액' },
+ { key: 'tax', label: '세금' },
+ { key: 'discount', label: '할인' },
+ { key: 'leadtime', label: '리드타임' },
+ { key: 'alternative', label: '대체품' },
+ ];
+
+ return columns.map((col, colIndex) => {
+ const isFirstInGroup = colIndex === 0;
+ const isLastInGroup = colIndex === columns.length - 1;
+
+ return (
+ <th
+ key={`${q.id}-${col.key}`}
+ className={`p-2 text-center whitespace-nowrap border border-gray-200 ${
+ isFirstInGroup ? 'border-l border-l-gray-200' : ''
+ } ${
+ isLastInGroup ? 'border-r border-r-gray-200' : ''
+ }`}
+ style={{ backgroundColor: 'white' }}
+ >
+ {col.label}
+ </th>
+ );
+ });
+ })}
+ </tr>
+ </thead>
+
+ {/* 테이블 바디 */}
+ <tbody>
+ {allItemIds.map((itemId) => {
+ // 자재 기본 정보는 첫 번째 벤더 아이템 기준
+ const firstQid = quotations[0]?.id
+ const sampleItem = firstQid
+ ? findItemByQuotationId(itemId, firstQid)
+ : undefined
+
+ return (
+ <tr key={itemId} className="border-b border-gray-100">
+ {/* 자재 (코드) 셀 */}
+ <td
+ className="sticky left-0 z-10 p-2 align-top border-r border-gray-100"
+ style={{
+ width: '250px',
+ minWidth: '250px',
+ backgroundColor: 'white',
+ }}
+ >
+ {sampleItem?.materialDescription || sampleItem?.materialCode || ""}
+ {sampleItem && (
+ <div className="text-xs text-muted-foreground mt-1">
+ 코드: {sampleItem.materialCode} | 수량:{" "}
+ {sampleItem.quantity} {sampleItem.uom}
+ </div>
+ )}
+ </td>
+
+ {/* 벤더별 아이템 데이터 */}
+ {quotations.flatMap((q, qIndex) => {
+ const collapsed = collapsedVendors[q.id]
+ const itemData = findItemByQuotationId(itemId, q.id)
+
+ // 접힌 상태면 총액만 표시
+ if (collapsed) {
+ return [
+ <td
+ key={`${q.id}-collapsed`}
+ className="p-2 text-center text-sm font-medium whitespace-nowrap border-r border-gray-100"
+ >
+ {itemData
+ ? formatCurrency(Number(itemData.totalPrice), itemData.currency)
+ : "N/A"}
+ </td>
+ ];
+ }
+
+ // 펼친 상태 - 아이템 없음
+ if (!itemData) {
+ return [
+ <td
+ key={`${q.id}-empty`}
+ colSpan={6}
+ className="p-2 text-center text-sm border-r border-gray-100"
+ >
+ 없음
+ </td>
+ ];
+ }
+
+ // 펼친 상태 - 모든 컬럼 표시
+ const columns = [
+ { key: 'unitprice', render: () => formatCurrency(Number(itemData.unitPrice), itemData.currency), align: 'right' },
+ { key: 'totalprice', render: () => formatCurrency(Number(itemData.totalPrice), itemData.currency), align: 'right', bold: true },
+ { key: 'tax', render: () => itemData.taxRate ? `${itemData.taxRate}% (${formatCurrency(Number(itemData.taxAmount), itemData.currency)})` : "-", align: 'right' },
+ { key: 'discount', render: () => itemData.discountRate ? `${itemData.discountRate}% (${formatCurrency(Number(itemData.discountAmount), itemData.currency)})` : "-", align: 'right' },
+ { key: 'leadtime', render: () => itemData.leadTimeInDays ? `${itemData.leadTimeInDays}일` : "-", align: 'center' },
+ { key: 'alternative', render: () => itemData.isAlternative ? "대체품" : "표준품", align: 'center' },
+ ];
+
+ return columns.map((col, colIndex) => {
+ const isFirstInGroup = colIndex === 0;
+ const isLastInGroup = colIndex === columns.length - 1;
+
+ return (
+ <td
+ key={`${q.id}-${col.key}`}
+ className={`p-2 text-${col.align} ${col.bold ? 'font-semibold' : ''} ${
+ isFirstInGroup ? 'border-l border-l-gray-100' : ''
+ } ${
+ isLastInGroup ? 'border-r border-r-gray-100' : 'border-r border-gray-100'
+ }`}
+ >
+ {col.render()}
+ </td>
+ );
+ });
+ })}
+ </tr>
+ );
+ })}
+
+ {/* 아이템이 전혀 없는 경우 */}
+ {allItemIds.length === 0 && (
+ <tr>
+ <td
+ colSpan={100} // 충분히 큰 수로 설정해 모든 컬럼을 커버
+ className="text-center p-4 border border-gray-100"
+ >
+ 아이템 정보가 없습니다
+ </td>
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </TabsContent>
+ </Tabs>
+ )}
+
+ <DialogFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 닫기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/procurement-rfqs/table/rfq-filter-sheet.tsx b/lib/procurement-rfqs/table/rfq-filter-sheet.tsx
new file mode 100644
index 00000000..a746603b
--- /dev/null
+++ b/lib/procurement-rfqs/table/rfq-filter-sheet.tsx
@@ -0,0 +1,686 @@
+"use client"
+
+import { useEffect, useTransition, useState, useRef } from "react"
+import { useRouter, useParams } from "next/navigation"
+import { z } from "zod"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { CalendarIcon, ChevronRight, Search, X } from "lucide-react"
+import { customAlphabet } from "nanoid"
+import { parseAsStringEnum, useQueryState } from "nuqs"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Badge } from "@/components/ui/badge"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { cn } from "@/lib/utils"
+import { useTranslation } from '@/i18n/client'
+import { getFiltersStateParser } from "@/lib/parsers"
+import { DateRangePicker } from "@/components/date-range-picker"
+
+// nanoid 생성기
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6)
+
+// 필터 스키마 정의 (RFQ 관련 항목 유지)
+const filterSchema = z.object({
+ picCode: z.string().optional(),
+ projectCode: z.string().optional(),
+ rfqCode: z.string().optional(),
+ itemCode: z.string().optional(),
+ majorItemMaterialCode: z.string().optional(),
+ status: z.string().optional(),
+ dateRange: z.object({
+ from: z.date().optional(),
+ to: z.date().optional(),
+ }).optional(),
+})
+
+// 상태 옵션 정의
+const statusOptions = [
+ { value: "RFQ Created", label: "RFQ Created" },
+ { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" },
+ { value: "RFQ Sent", label: "RFQ Sent" },
+ { value: "Quotation Analysis", label: "Quotation Analysis" },
+ { value: "PO Transfer", label: "PO Transfer" },
+ { value: "PO Create", label: "PO Create" },
+]
+
+type FilterFormValues = z.infer<typeof filterSchema>
+
+interface RFQFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSearch?: () => void;
+ isLoading?: boolean;
+}
+
+// Updated component for inline use (not a sheet anymore)
+export function RFQFilterSheet({
+ isOpen,
+ onClose,
+ onSearch,
+ isLoading = false
+}: RFQFilterSheetProps) {
+ const router = useRouter()
+ const params = useParams();
+ const lng = params ? (params.lng as string) : 'ko';
+ const { t } = useTranslation(lng);
+
+ const [isPending, startTransition] = useTransition()
+
+ // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지
+ const [isInitializing, setIsInitializing] = useState(false)
+ // 마지막으로 적용된 필터를 추적하기 위한 ref
+ const lastAppliedFilters = useRef<string>("")
+
+ // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경
+ const [filters, setFilters] = useQueryState(
+ "basicFilters",
+ getFiltersStateParser().withDefault([])
+ )
+
+ // joinOperator 설정
+ const [joinOperator, setJoinOperator] = useQueryState(
+ "basicJoinOperator",
+ parseAsStringEnum(["and", "or"]).withDefault("and")
+ )
+
+ // 현재 URL의 페이지 파라미터도 가져옴
+ const [page, setPage] = useQueryState("page", { defaultValue: "1" })
+
+ // 폼 상태 초기화
+ const form = useForm<FilterFormValues>({
+ resolver: zodResolver(filterSchema),
+ defaultValues: {
+ picCode: "",
+ projectCode: "",
+ rfqCode: "",
+ itemCode: "",
+ majorItemMaterialCode: "",
+ status: "",
+ dateRange: {
+ from: undefined,
+ to: undefined,
+ },
+ },
+ })
+
+ // URL 필터에서 초기 폼 상태 설정 - 개선된 버전
+ useEffect(() => {
+ // 현재 필터를 문자열로 직렬화
+ const currentFiltersString = JSON.stringify(filters);
+
+ // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트
+ if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) {
+ setIsInitializing(true);
+
+ const formValues = { ...form.getValues() };
+ let formUpdated = false;
+
+ filters.forEach(filter => {
+ if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) {
+ formValues.dateRange = {
+ from: filter.value[0] ? new Date(filter.value[0]) : undefined,
+ to: filter.value[1] ? new Date(filter.value[1]) : undefined,
+ };
+ formUpdated = true;
+ } else if (filter.id in formValues) {
+ // @ts-ignore - 동적 필드 접근
+ formValues[filter.id] = filter.value;
+ formUpdated = true;
+ }
+ });
+
+ // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트
+ if (formUpdated) {
+ form.reset(formValues);
+ lastAppliedFilters.current = currentFiltersString;
+ }
+
+ setIsInitializing(false);
+ }
+ }, [filters, isOpen]) // form 의존성 제거
+
+ // 현재 적용된 필터 카운트
+ const getActiveFilterCount = () => {
+ return filters?.length || 0
+ }
+
+ // 폼 제출 핸들러 - PQ 방식으로 수정 (수동 URL 업데이트 버전)
+ async function onSubmit(data: FilterFormValues) {
+ // 초기화 중이면 제출 방지
+ if (isInitializing) return;
+
+ startTransition(async () => {
+ try {
+ // 필터 배열 생성
+ const newFilters = []
+
+ if (data.picCode?.trim()) {
+ newFilters.push({
+ id: "picCode",
+ value: data.picCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.projectCode?.trim()) {
+ newFilters.push({
+ id: "projectCode",
+ value: data.projectCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.rfqCode?.trim()) {
+ newFilters.push({
+ id: "rfqCode",
+ value: data.rfqCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.itemCode?.trim()) {
+ newFilters.push({
+ id: "itemCode",
+ value: data.itemCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.majorItemMaterialCode?.trim()) {
+ newFilters.push({
+ id: "majorItemMaterialCode",
+ value: data.majorItemMaterialCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.status?.trim()) {
+ newFilters.push({
+ id: "status",
+ value: data.status.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ })
+ }
+
+ // Add date range to params if it exists
+ if (data.dateRange?.from) {
+ newFilters.push({
+ id: "rfqSendDate",
+ value: [
+ data.dateRange.from.toISOString().split('T')[0],
+ data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined
+ ].filter(Boolean),
+ type: "date",
+ operator: "isBetween",
+ rowId: generateId()
+ })
+ }
+
+ console.log("=== RFQ Filter Submit Debug ===");
+ console.log("Generated filters:", newFilters);
+ console.log("Join operator:", joinOperator);
+
+ // 🔑 PQ 방식: 수동으로 URL 업데이트 (nuqs 대신)
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ // 기존 필터 관련 파라미터 제거
+ params.delete('basicFilters');
+ params.delete('basicJoinOperator');
+ params.delete('page');
+
+ // 새로운 필터 추가
+ if (newFilters.length > 0) {
+ params.set('basicFilters', JSON.stringify(newFilters));
+ params.set('basicJoinOperator', joinOperator);
+ }
+
+ // 페이지를 1로 설정
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+ console.log("New URL:", newUrl);
+
+ // 🔑 PQ 방식: 페이지 완전 새로고침으로 서버 렌더링 강제
+ window.location.href = newUrl;
+
+ // 마지막 적용된 필터 업데이트
+ lastAppliedFilters.current = JSON.stringify(newFilters);
+
+ // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우)
+ if (onSearch) {
+ console.log("Calling onSearch...");
+ onSearch();
+ }
+
+ console.log("=== RFQ Filter Submit Complete ===");
+ } catch (error) {
+ console.error("RFQ 필터 적용 오류:", error);
+ }
+ })
+ }
+
+ // 필터 초기화 핸들러 - PQ 방식으로 수정
+ async function handleReset() {
+ try {
+ setIsInitializing(true);
+
+ form.reset({
+ picCode: "",
+ projectCode: "",
+ rfqCode: "",
+ itemCode: "",
+ majorItemMaterialCode: "",
+ status: "",
+ dateRange: { from: undefined, to: undefined },
+ });
+
+ console.log("=== RFQ Filter Reset Debug ===");
+ console.log("Current URL before reset:", window.location.href);
+
+ // 🔑 PQ 방식: 수동으로 URL 초기화
+ const currentUrl = new URL(window.location.href);
+ const params = new URLSearchParams(currentUrl.search);
+
+ // 필터 관련 파라미터 제거
+ params.delete('basicFilters');
+ params.delete('basicJoinOperator');
+ params.set('page', '1');
+
+ const newUrl = `${currentUrl.pathname}?${params.toString()}`;
+ console.log("Reset URL:", newUrl);
+
+ // 🔑 PQ 방식: 페이지 완전 새로고침
+ window.location.href = newUrl;
+
+ // 마지막 적용된 필터 초기화
+ lastAppliedFilters.current = "";
+
+ console.log("RFQ 필터 초기화 완료");
+ setIsInitializing(false);
+ } catch (error) {
+ console.error("RFQ 필터 초기화 오류:", error);
+ setIsInitializing(false);
+ }
+ }
+
+ // Don't render if not open (for side panel use)
+ if (!isOpen) {
+ return null;
+ }
+
+ return (
+ <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}>
+ {/* Filter Panel Header */}
+ <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0">
+ <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3>
+ <div className="flex items-center gap-2">
+ {getActiveFilterCount() > 0 && (
+ <Badge variant="secondary" className="px-2 py-1">
+ {getActiveFilterCount()}개 필터 적용됨
+ </Badge>
+ )}
+ </div>
+ </div>
+
+ {/* Join Operator Selection */}
+ <div className="px-6 shrink-0">
+ <label className="text-sm font-medium">조건 결합 방식</label>
+ <Select
+ value={joinOperator}
+ onValueChange={(value: "and" | "or") => setJoinOperator(value)}
+ disabled={isInitializing}
+ >
+ <SelectTrigger className="h-8 w-[180px] mt-2 bg-white">
+ <SelectValue placeholder="조건 결합 방식" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
+ <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0">
+ {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */}
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
+ <div className="space-y-4 pt-2">
+ {/* 발주 담당 */}
+ <FormField
+ control={form.control}
+ name="picCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("발주담당")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("발주담당 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("picCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 코드 */}
+ <FormField
+ control={form.control}
+ name="projectCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("프로젝트 코드")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("프로젝트 코드 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("projectCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* RFQ NO. */}
+ <FormField
+ control={form.control}
+ name="rfqCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("RFQ NO.")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("RFQ 번호 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("rfqCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재그룹 */}
+ <FormField
+ control={form.control}
+ name="itemCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("자재그룹")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("자재그룹 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("itemCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 자재코드 */}
+ <FormField
+ control={form.control}
+ name="majorItemMaterialCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("자재코드")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder={t("자재코드 입력")}
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("majorItemMaterialCode", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Status */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("Status")}</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isInitializing}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex justify-between w-full">
+ <SelectValue placeholder={t("Select status")} />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="h-4 w-4 -mr-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("status", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {statusOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* RFQ 전송일 */}
+ <FormField
+ control={form.control}
+ name="dateRange"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>{t("RFQ 전송일")}</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <DateRangePicker
+ triggerSize="default"
+ triggerClassName="w-full bg-white"
+ align="start"
+ showClearButton={true}
+ placeholder={t("RFQ 전송일 범위를 고르세요")}
+ value={field.value || undefined}
+ onChange={field.onChange}
+ disabled={isInitializing}
+ />
+ {(field.value?.from || field.value?.to) && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-10 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("dateRange", { from: undefined, to: undefined });
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ <span className="sr-only">Clear</span>
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* Fixed buttons at bottom */}
+ <div className="p-4 shrink-0">
+ <div className="flex gap-2 justify-end">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleReset}
+ disabled={isPending || getActiveFilterCount() === 0 || isInitializing}
+ className="px-4"
+ >
+ {t("초기화")}
+ </Button>
+ <Button
+ type="submit"
+ variant="samsung"
+ disabled={isPending || isLoading || isInitializing}
+ className="px-4"
+ >
+ <Search className="size-4 mr-2" />
+ {isPending || isLoading ? t("조회 중...") : t("조회")}
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-table copy.tsx b/lib/procurement-rfqs/table/rfq-table copy.tsx
deleted file mode 100644
index 510f474d..00000000
--- a/lib/procurement-rfqs/table/rfq-table copy.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-"use client"
-
-import * as React from "react"
-import type {
- DataTableAdvancedFilterField,
- DataTableFilterField,
- DataTableRowAction,
-} from "@/types/table"
-import { useDataTable } from "@/hooks/use-data-table"
-import { DataTable } from "@/components/data-table/data-table"
-import { getColumns, EditingCellState } from "./rfq-table-column"
-import { useEffect } from "react"
-import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
-import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
-import { ProcurementRfqsView } from "@/db/schema"
-import { getPORfqs } from "../services"
-import { toast } from "sonner"
-import { updateRfqRemark } from "@/lib/procurement-rfqs/services" // 구현 필요
-
-interface RFQListTableProps {
- data?: Awaited<ReturnType<typeof getPORfqs>>;
- onSelectRFQ?: (rfq: ProcurementRfqsView | null) => void;
- // 데이터 새로고침을 위한 콜백 추가
- onDataRefresh?: () => void;
- maxHeight?: string | number; // Add this prop
-}
-
-// 보다 유연한 타입 정의
-type LocalDataType = Awaited<ReturnType<typeof getPORfqs>>;
-
-export function RFQListTable({
- data,
- onSelectRFQ,
- onDataRefresh,
- maxHeight
-}: RFQListTableProps) {
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<ProcurementRfqsView> | null>(null)
- // 인라인 에디팅을 위한 상태 추가
- const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null)
- // 로컬 데이터를 관리하기 위한 상태 추가
- const [localData, setLocalData] = React.useState<LocalDataType>(data || { data: [], pageCount: 0, total: 0 });
-
- // 데이터가 변경될 때 로컬 데이터도 업데이트
- useEffect(() => {
- setLocalData(data || { data: [], pageCount: 0, total: 0 })
- }, [data])
-
-
- // 비고 업데이트 함수
- const updateRemark = async (rfqId: number, remark: string) => {
- try {
- // 낙관적 UI 업데이트 (로컬 데이터 먼저 갱신)
- if (localData && localData.data) {
- // 로컬 데이터에서 해당 행 찾기
- const rowIndex = localData.data.findIndex(row => row.id === rfqId);
- if (rowIndex >= 0) {
- // 불변성을 유지하면서 로컬 데이터 업데이트
- const newData = [...localData.data];
- newData[rowIndex] = { ...newData[rowIndex], remark };
-
- // 전체 데이터 구조 복사하여 업데이트
- setLocalData({ ...localData, data: newData } as typeof localData);
- }
- }
-
- const result = await updateRfqRemark(rfqId, remark);
-
- if (result.success) {
- toast.success("비고가 업데이트되었습니다");
-
- // 서버 데이터 리프레시 호출
- if (onDataRefresh) {
- onDataRefresh();
- }
- } else {
- toast.error(result.message || "업데이트 중 오류가 발생했습니다");
- }
- } catch (error) {
- console.error("비고 업데이트 오류:", error);
- toast.error("업데이트 중 오류가 발생했습니다");
- }
- }
-
- // 행 액션 처리
- useEffect(() => {
- if (rowAction) {
- // 액션 유형에 따라 처리
- switch (rowAction.type) {
- case "select":
- // 선택된 문서 처리
- if (onSelectRFQ) {
- onSelectRFQ(rowAction.row.original)
- }
- break;
- case "update":
- // 업데이트 처리 로직
- console.log("Update rfq:", rowAction.row.original)
- break;
- case "delete":
- // 삭제 처리 로직
- console.log("Delete rfq:", rowAction.row.original)
- break;
- }
-
- // 액션 처리 후 rowAction 초기화
- setRowAction(null)
- }
- }, [rowAction, onSelectRFQ])
-
- const columns = React.useMemo(
- () => getColumns({
- setRowAction,
- editingCell,
- setEditingCell,
- updateRemark
- }),
- [setRowAction, editingCell, setEditingCell, updateRemark]
- )
-
-
- // Filter fields
- const filterFields: DataTableFilterField<ProcurementRfqsView>[] = []
-
- const advancedFilterFields: DataTableAdvancedFilterField<ProcurementRfqsView>[] = [
- {
- id: "rfqCode",
- label: "RFQ No.",
- type: "text",
- },
- {
- id: "projectCode",
- label: "프로젝트",
- type: "text",
- },
- {
- id: "itemCode",
- label: "자재그룹",
- type: "text",
- },
- {
- id: "itemName",
- label: "자재명",
- type: "text",
- },
-
- {
- id: "rfqSealedYn",
- label: "RFQ 밀봉여부",
- type: "text",
- },
- {
- id: "majorItemMaterialCode",
- label: "자재코드",
- type: "text",
- },
- {
- id: "rfqSendDate",
- label: "RFQ 전송일",
- type: "date",
- },
- {
- id: "dueDate",
- label: "RFQ 마감일",
- type: "date",
- },
- {
- id: "createdByUserName",
- label: "요청자",
- type: "text",
- },
- ]
-
- // useDataTable 훅으로 react-table 구성 - 로컬 데이터 사용하도록 수정
- const { table } = useDataTable({
- data: localData?.data || [],
- columns,
- pageCount: localData?.pageCount || 0,
- rowCount: localData?.total || 0, // 총 레코드 수 추가
- filterFields,
- enablePinning: true,
- enableAdvancedFilter: true,
- initialState: {
- sorting: [{ id: "updatedAt", desc: true }],
- },
- getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
- clearOnDefault: true,
- columnResizeMode: "onEnd",
- })
-
- return (
- <div className="w-full overflow-auto">
- <DataTable table={table} maxHeight={maxHeight}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
- >
- <RFQTableToolbarActions
- table={table}
- localData={localData}
- setLocalData={setLocalData}
- onSuccess={onDataRefresh}
- />
- </DataTableAdvancedToolbar>
- </DataTable>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/procurement-rfqs/table/rfq-table.tsx b/lib/procurement-rfqs/table/rfq-table.tsx
index 23cd66fa..ca976172 100644
--- a/lib/procurement-rfqs/table/rfq-table.tsx
+++ b/lib/procurement-rfqs/table/rfq-table.tsx
@@ -1,44 +1,86 @@
"use client"
import * as React from "react"
+import { useSearchParams } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react"
import type {
DataTableAdvancedFilterField,
DataTableRowAction,
} from "@/types/table"
+import {
+ ResizablePanelGroup,
+ ResizablePanel,
+ ResizableHandle,
+} from "@/components/ui/resizable"
+
import { useDataTable } from "@/hooks/use-data-table"
import { DataTable } from "@/components/data-table/data-table"
import { getColumns, EditingCellState } from "./rfq-table-column"
-import { useEffect, useCallback, useRef, useMemo } from "react"
+import { useEffect, useCallback, useRef, useMemo, useLayoutEffect } from "react"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions"
import { ProcurementRfqsView } from "@/db/schema"
import { getPORfqs } from "../services"
import { toast } from "sonner"
import { updateRfqRemark } from "@/lib/procurement-rfqs/services"
-import { useSearchParams } from "next/navigation"
import { useTablePresets } from "@/components/data-table/use-table-presets"
import { TablePresetManager } from "@/components/data-table/data-table-preset"
import { Loader2 } from "lucide-react"
+import { RFQFilterSheet } from "./rfq-filter-sheet"
+import { RfqDetailTables } from "./detail-table/rfq-detail-table"
+import { cn } from "@/lib/utils"
interface RFQListTableProps {
- data?: Awaited<ReturnType<typeof getPORfqs>>;
- onSelectRFQ?: (rfq: ProcurementRfqsView | null) => void;
- onDataRefresh?: () => void;
- maxHeight?: string | number;
+ promises: Promise<[Awaited<ReturnType<typeof getPORfqs>>]>
+ className?: string;
+ calculatedHeight?: string; // 계산된 높이 추가
}
export function RFQListTable({
- data,
- onSelectRFQ,
- onDataRefresh,
- maxHeight
+ promises,
+ className,
+ calculatedHeight
}: RFQListTableProps) {
const searchParams = useSearchParams()
+
+ // 필터 패널 상태
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
+
+ // 선택된 RFQ 상태
+ const [selectedRfq, setSelectedRfq] = React.useState<ProcurementRfqsView | null>(null)
+
+ // 패널 collapse 상태
+ const [isTopCollapsed, setIsTopCollapsed] = React.useState(false)
+ const [panelHeight, setPanelHeight] = React.useState<number>(55)
+
+ // refs
+ const headerRef = React.useRef<HTMLDivElement>(null)
+
+ // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요)
+ const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이
+ const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값)
+ const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border)
+ const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비
+
+ // 높이 계산
+ // 필터 패널 높이 - Layout Header와 Footer 사이
+ const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)`
+
+ console.log(calculatedHeight)
+
+ // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외
+ const FIXED_TABLE_HEIGHT = calculatedHeight
+ ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)`
+ : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback
+
+ // Suspense 방식으로 데이터 처리
+ const [promiseData] = React.use(promises)
+ const tableData = promiseData
+
const [rowAction, setRowAction] = React.useState<DataTableRowAction<ProcurementRfqsView> | null>(null)
const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null)
- const [localData, setLocalData] = React.useState<typeof data>(data || { data: [], pageCount: 0, total: 0 })
- const [isMounted, setIsMounted] = React.useState(false)
-
+
// 초기 설정 정의
const initialSettings = React.useMemo(() => ({
page: parseInt(searchParams.get('page') || '1'),
@@ -70,39 +112,16 @@ export function RFQListTable({
deletePreset,
setDefaultPreset,
renamePreset,
- updateClientState,
getCurrentSettings,
} = useTablePresets<ProcurementRfqsView>('rfq-list-table', initialSettings)
- // 클라이언트 마운트 체크
- useEffect(() => {
- setIsMounted(true)
- }, [])
-
- // 데이터 변경 감지
- useEffect(() => {
- setLocalData(data || { data: [], pageCount: 0, total: 0 })
- }, [data])
-
// 비고 업데이트 함수
const updateRemark = async (rfqId: number, remark: string) => {
try {
- if (localData && localData.data) {
- const rowIndex = localData.data.findIndex(row => row.id === rfqId);
- if (rowIndex >= 0) {
- const newData = [...localData.data];
- newData[rowIndex] = { ...newData[rowIndex], remark };
- setLocalData({ ...localData, data: newData });
- }
- }
-
const result = await updateRfqRemark(rfqId, remark);
if (result.success) {
toast.success("비고가 업데이트되었습니다");
- if (onDataRefresh) {
- onDataRefresh();
- }
} else {
toast.error(result.message || "업데이트 중 오류가 발생했습니다");
}
@@ -117,9 +136,7 @@ export function RFQListTable({
if (rowAction) {
switch (rowAction.type) {
case "select":
- if (onSelectRFQ) {
- onSelectRFQ(rowAction.row.original)
- }
+ setSelectedRfq(rowAction.row.original)
break;
case "update":
console.log("Update rfq:", rowAction.row.original)
@@ -130,7 +147,7 @@ export function RFQListTable({
}
setRowAction(null)
}
- }, [rowAction, onSelectRFQ])
+ }, [rowAction])
const columns = React.useMemo(
() => getColumns({
@@ -198,7 +215,6 @@ export function RFQListTable({
// useDataTable 초기 상태 설정
const initialState = useMemo(() => {
- console.log('Setting initial state:', currentSettings)
return {
sorting: initialSettings.sort.filter(sortItem => {
const columnExists = columns.some(col => col.accessorKey === sortItem.id)
@@ -209,167 +225,188 @@ export function RFQListTable({
}
}, [currentSettings, initialSettings.sort, columns])
- // useDataTable 훅 설정
+ // useDataTable 훅 설정 (PQ와 동일한 설정)
const { table } = useDataTable({
- data: localData?.data || [],
+ data: tableData?.data || [],
columns,
- pageCount: localData?.pageCount || 0,
- rowCount: localData?.total || 0,
- filterFields: [],
+ pageCount: tableData?.pageCount || 0,
+ rowCount: tableData?.total || 0,
+ filterFields: [], // PQ와 동일하게 빈 배열
enablePinning: true,
enableAdvancedFilter: true,
initialState,
getRowId: (originalRow) => String(originalRow.id),
- shallow: false,
+ shallow: false, // PQ와 동일하게 false
clearOnDefault: true,
- columnResizeMode: "onEnd",
})
-
- // 테이블 상태 변경 감지 및 자동 저장
- const lastKnownStateRef = useRef<{
- columnVisibility: string
- columnPinning: string
- columnOrder: string[]
- }>({
- columnVisibility: '{}',
- columnPinning: '{"left":[],"right":[]}',
- columnOrder: []
- })
-
- const checkAndUpdateTableState = useCallback(() => {
- if (!presetsLoading && !activePresetId) return
-
+
+ // 조회 버튼 클릭 핸들러
+ const handleSearch = () => {
+ setIsFilterPanelOpen(false)
+ }
+
+ // Get active basic filter count (PQ와 동일한 방식)
+ const getActiveBasicFilterCount = () => {
try {
- const currentVisibility = table.getState().columnVisibility
- const currentPinning = table.getState().columnPinning
-
- // 컬럼 순서 가져오기
- const allColumns = table.getAllColumns()
- const leftPinned = table.getLeftHeaderGroups()[0]?.headers.map(h => h.column.id) || []
- const rightPinned = table.getRightHeaderGroups()[0]?.headers.map(h => h.column.id) || []
- const center = table.getCenterHeaderGroups()[0]?.headers.map(h => h.column.id) || []
- const currentOrder = [...leftPinned, ...center, ...rightPinned]
-
- const visibilityString = JSON.stringify(currentVisibility)
- const pinningString = JSON.stringify(currentPinning)
- const orderString = JSON.stringify(currentOrder)
-
- // 실제 변경이 있을 때만 업데이트
- if (
- visibilityString !== lastKnownStateRef.current.columnVisibility ||
- pinningString !== lastKnownStateRef.current.columnPinning ||
- orderString !== JSON.stringify(lastKnownStateRef.current.columnOrder)
- ) {
- console.log('Table state changed, updating preset...')
-
- const newClientState = {
- columnVisibility: currentVisibility,
- columnOrder: currentOrder,
- pinnedColumns: currentPinning,
- }
-
- // 상태 업데이트 전에 기록
- lastKnownStateRef.current = {
- columnVisibility: visibilityString,
- columnPinning: pinningString,
- columnOrder: currentOrder
- }
-
- updateClientState(newClientState)
- }
- } catch (error) {
- console.error('Error checking table state:', error)
- }
- }, [activePresetId, table, updateClientState, presetsLoading ])
-
- // 주기적으로 테이블 상태 체크
- useEffect(() => {
- if (!isMounted || !activePresetId) return
-
- console.log('Starting table state polling')
- const intervalId = setInterval(checkAndUpdateTableState, 500)
-
- return () => {
- clearInterval(intervalId)
- console.log('Stopped table state polling')
+ const basicFilters = searchParams.get('basicFilters')
+ return basicFilters ? JSON.parse(basicFilters).length : 0
+ } catch (e) {
+ return 0
}
- }, [isMounted, activePresetId, checkAndUpdateTableState])
-
- // 프리셋 적용 시 테이블 상태 업데이트
- useEffect(() => {
- if (isMounted && activePresetId && currentSettings) {
- const settings = currentSettings
- console.log('Applying preset settings to table:', settings)
-
- const currentVisibility = table.getState().columnVisibility
- const currentPinning = table.getState().columnPinning
-
- if (
- JSON.stringify(currentVisibility) !== JSON.stringify(settings.columnVisibility) ||
- JSON.stringify(currentPinning) !== JSON.stringify(settings.pinnedColumns)
- ) {
- console.log('Updating table state to match preset...')
-
- // 테이블 상태 업데이트
- table.setColumnVisibility(settings.columnVisibility)
- table.setColumnPinning(settings.pinnedColumns)
-
- // 상태 저장소 업데이트
- lastKnownStateRef.current = {
- columnVisibility: JSON.stringify(settings.columnVisibility),
- columnPinning: JSON.stringify(settings.pinnedColumns),
- columnOrder: settings.columnOrder || []
- }
- }
- }
- }, [isMounted, activePresetId, currentSettings, table])
+ }
- // 로딩 중일 때는 스켈레톤 표시
- if (!isMounted) {
- return (
- <div className="w-full h-96 flex items-center justify-center">
- <div className="flex flex-col items-center gap-2">
- <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
- <span className="text-sm text-muted-foreground">테이블 설정을 로드하는 중...</span>
+ console.log(panelHeight)
+
+ return (
+ <div
+ className={cn("flex flex-col relative", className)}
+ style={{ height: calculatedHeight }}
+ >
+ {/* Filter Panel - 계산된 높이 적용 */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
+ isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
+ )}
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ top: `${LAYOUT_HEADER_HEIGHT*2}px`,
+ height: FIXED_FILTER_HEIGHT
+ }}
+ >
+ {/* Filter Content */}
+ <div className="h-full">
+ <RFQFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={handleSearch}
+ isLoading={false}
+ />
</div>
</div>
- )
- }
-
- return (
- <div className="w-full overflow-auto">
- <DataTable table={table} maxHeight={maxHeight}>
- <DataTableAdvancedToolbar
- table={table}
- filterFields={advancedFilterFields}
- shallow={false}
+
+ {/* Main Content */}
+ <div
+ className="flex flex-col transition-all duration-300 ease-in-out"
+ style={{
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
+ height: '100%'
+ }}
+ >
+ {/* Header Bar - 고정 높이 */}
+ <div
+ ref={headerRef}
+ className="flex items-center justify-between p-4 bg-background border-b"
+ style={{
+ height: `${LOCAL_HEADER_HEIGHT}px`,
+ flexShrink: 0
+ }}
>
- <div className="flex items-center gap-2">
- {/* DB 기반 테이블 프리셋 매니저 */}
- <TablePresetManager<ProcurementRfqsView>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- />
-
- {/* 기존 툴바 액션들 */}
- <RFQTableToolbarActions
- table={table}
- localData={localData}
- setLocalData={setLocalData}
- onSuccess={onDataRefresh}
- />
+ <div className="flex items-center gap-3">
+ <Button
+ variant="outline"
+ size="sm"
+ type='button'
+ onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
+ className="flex items-center shadow-sm"
+ >
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
+ {getActiveBasicFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveBasicFilterCount()}
+ </span>
+ )}
+ </Button>
+ </div>
+
+ {/* Right side info */}
+ <div className="text-sm text-muted-foreground">
+ {tableData && (
+ <span>총 {tableData.total || 0}건</span>
+ )}
</div>
- </DataTableAdvancedToolbar>
- </DataTable>
+ </div>
+
+ {/* Table Content Area - 계산된 높이 사용 */}
+ <div
+ className="relative bg-background"
+ style={{
+ height: FIXED_TABLE_HEIGHT,
+ display: 'grid',
+ gridTemplateRows: '1fr',
+ gridTemplateColumns: '1fr'
+ }}
+ >
+ <ResizablePanelGroup
+ direction="vertical"
+ className="w-full h-full"
+ >
+ <ResizablePanel
+ defaultSize={60}
+ minSize={25}
+ maxSize={75}
+ collapsible={false}
+ onResize={(size) => {
+ setPanelHeight(size)
+ }}
+ className="flex flex-col overflow-hidden"
+ >
+ {/* 상단 테이블 영역 */}
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <DataTable
+ table={table}
+ // className="h-full"
+ maxHeight={`${panelHeight*0.5}vh`}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ <TablePresetManager<ProcurementRfqsView>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ />
+
+ <RFQTableToolbarActions
+ table={table}
+ localData={tableData}
+ setLocalData={() => {}}
+ onSuccess={() => {}}
+ />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </ResizablePanel>
+
+ <ResizableHandle withHandle />
+
+ <ResizablePanel
+ minSize={25}
+ defaultSize={40}
+ collapsible={false}
+ className="flex flex-col overflow-hidden"
+ >
+ {/* 하단 상세 테이블 영역 */}
+ <div className="flex-1 min-h-0 overflow-hidden bg-background">
+ <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/>
+ </div>
+ </ResizablePanel>
+ </ResizablePanelGroup>
+ </div>
+ </div>
</div>
)
} \ No newline at end of file