summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-02 09:52:21 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-02 09:52:21 +0000
commitfccb00d15466cd0b2d861163663a5070c768ff77 (patch)
tree4b14b27417ebeb873a9d4b4d7b5c64f6e1d78135 /lib
parent72f212f717f136e875e7623404a5ddd4c5268901 (diff)
(대표님) OCR 박진석프로 요청 대응, rfq 변경된 요구사항 구현
Diffstat (limited to 'lib')
-rw-r--r--lib/rfq-last/service.ts141
-rw-r--r--lib/rfq-last/table/rfq-filter-sheet.tsx780
-rw-r--r--lib/rfq-last/table/rfq-table-columns.tsx445
-rw-r--r--lib/rfq-last/table/rfq-table-toolbar-actions.tsx163
-rw-r--r--lib/rfq-last/table/rfq-table.tsx457
-rw-r--r--lib/rfq-last/validations.ts65
-rw-r--r--lib/welding/table/ocr-table-toolbar-actions.tsx55
7 files changed, 2085 insertions, 21 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
new file mode 100644
index 00000000..f2710f02
--- /dev/null
+++ b/lib/rfq-last/service.ts
@@ -0,0 +1,141 @@
+// lib/rfq/service.ts
+'use server'
+
+import { unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { rfqsLastView } from "@/db/schema";
+import { and, desc, asc, ilike, or, eq, SQL, count, gte, lte,isNotNull,ne } from "drizzle-orm";
+import { filterColumns } from "@/lib/filter-columns";
+import { GetRfqsSchema } from "./validations";
+
+export async function getRfqs(input: GetRfqsSchema) {
+ unstable_noStore();
+
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 1. RFQ 타입별 필터링
+ let typeFilter: SQL<unknown> | undefined = undefined;
+ if (input.rfqCategory) {
+ switch (input.rfqCategory) {
+ case "general":
+ // 일반견적: rfqType이 있는 경우
+ typeFilter = and(
+ isNotNull(rfqsLastView.rfqType),
+ ne(rfqsLastView.rfqType, '')
+ );
+ break;
+ case "itb":
+ // ITB: projectCompany가 있는 경우
+ typeFilter = and(
+ isNotNull(rfqsLastView.projectCompany),
+ ne(rfqsLastView.projectCompany, '')
+ );
+ break;
+ case "rfq":
+ // RFQ: prNumber가 있는 경우
+ typeFilter = and(
+ isNotNull(rfqsLastView.prNumber),
+ ne(rfqsLastView.prNumber, '')
+ );
+ break;
+ }
+ }
+
+ // 2. 고급 필터 처리
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+
+ if (input.filters && Array.isArray(input.filters) && input.filters.length > 0) {
+ console.log("필터 적용:", input.filters.map(f => `${f.id} ${f.operator} ${f.value}`));
+
+ try {
+ advancedWhere = filterColumns({
+ table: rfqsLastView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+
+ console.log("필터 조건 생성 완료");
+ } catch (error) {
+ console.error("필터 조건 생성 오류:", error);
+ advancedWhere = undefined;
+ }
+ }
+
+ // 3. 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+
+ const searchConditions: SQL<unknown>[] = [
+ ilike(rfqsLastView.rfqCode, s),
+ ilike(rfqsLastView.itemCode, s),
+ ilike(rfqsLastView.itemName, s),
+ ilike(rfqsLastView.packageNo, s),
+ ilike(rfqsLastView.packageName, s),
+ ilike(rfqsLastView.picName, s),
+ ilike(rfqsLastView.engPicName, s),
+ ilike(rfqsLastView.projectCode, s),
+ ilike(rfqsLastView.projectName, s),
+ ilike(rfqsLastView.rfqTitle, s),
+ ilike(rfqsLastView.prNumber, s),
+ ].filter(Boolean);
+
+ if (searchConditions.length > 0) {
+ globalWhere = or(...searchConditions);
+ }
+ }
+
+ // 4. 최종 WHERE 조건 결합
+ const whereConditions: SQL<unknown>[] = [];
+ if (typeFilter) whereConditions.push(typeFilter);
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // 5. 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(rfqsLastView)
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ console.log("총 데이터 수:", total);
+
+ // 6. 정렬 및 페이징 처리
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof rfqsLastView.$inferSelect;
+ return sort.desc
+ ? desc(rfqsLastView[column])
+ : asc(rfqsLastView[column]);
+ });
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(rfqsLastView.createdAt));
+ }
+
+ const rfqData = await db
+ .select()
+ .from(rfqsLastView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ console.log("반환 데이터 수:", rfqData.length);
+
+ return { data: rfqData, pageCount, total };
+ } catch (err) {
+ console.error("getRfqs 오류:", err);
+ return { data: [], pageCount: 0, total: 0 };
+ }
+}
+
diff --git a/lib/rfq-last/table/rfq-filter-sheet.tsx b/lib/rfq-last/table/rfq-filter-sheet.tsx
new file mode 100644
index 00000000..b88c5d2a
--- /dev/null
+++ b/lib/rfq-last/table/rfq-filter-sheet.tsx
@@ -0,0 +1,780 @@
+"use client";
+
+import { useEffect, useTransition, useState, useRef } from "react";
+import { useRouter } from "next/navigation";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Search, X } from "lucide-react";
+import { customAlphabet } from "nanoid";
+
+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 { DatePicker } from "@/components/ui/date-picker";
+import { cn } from "@/lib/utils";
+import { RFQ_STATUS_OPTIONS, SERIES_OPTIONS } from "@/lib/rfq-last/validations";
+
+const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6);
+
+const rfqFilterSchema = z.object({
+ rfqCode: z.string().optional(),
+ status: z.string().optional(),
+ series: z.string().optional(),
+ projectCode: z.string().optional(),
+ projectName: z.string().optional(),
+ itemCode: z.string().optional(),
+ itemName: z.string().optional(),
+ packageNo: z.string().optional(),
+ picName: z.string().optional(),
+ vendorCountMin: z.string().optional(),
+ vendorCountMax: z.string().optional(),
+ dueDateFrom: z.date().optional(),
+ dueDateTo: z.date().optional(),
+ rfqSendDateFrom: z.date().optional(),
+ rfqSendDateTo: z.date().optional(),
+ // 일반견적 필드
+ rfqType: z.string().optional(),
+ rfqTitle: z.string().optional(),
+ // ITB 필드
+ projectCompany: z.string().optional(),
+ projectSite: z.string().optional(),
+ smCode: z.string().optional(),
+ // RFQ 필드
+ prNumber: z.string().optional(),
+});
+
+export type RfqFilterFormValues = z.infer<typeof rfqFilterSchema>;
+
+interface RfqFilterSheetProps {
+ isOpen: boolean;
+ onClose: () => void;
+ isLoading?: boolean;
+ onFiltersApply: (filters: any[], joinOperator: "and" | "or") => void;
+ rfqCategory?: "all" | "general" | "itb" | "rfq";
+}
+
+export function RfqFilterSheet({
+ isOpen,
+ onClose,
+ isLoading = false,
+ onFiltersApply,
+ rfqCategory = "all",
+}: RfqFilterSheetProps) {
+ const router = useRouter();
+ const [isPending, startTransition] = useTransition();
+ const [joinOperator, setJoinOperator] = useState<"and" | "or">("and");
+
+ const form = useForm<RfqFilterFormValues>({
+ resolver: zodResolver(rfqFilterSchema),
+ defaultValues: {
+ rfqCode: "",
+ status: "",
+ series: "",
+ projectCode: "",
+ projectName: "",
+ itemCode: "",
+ itemName: "",
+ packageNo: "",
+ picName: "",
+ vendorCountMin: "",
+ vendorCountMax: "",
+ dueDateFrom: undefined,
+ dueDateTo: undefined,
+ rfqSendDateFrom: undefined,
+ rfqSendDateTo: undefined,
+ rfqType: "",
+ rfqTitle: "",
+ projectCompany: "",
+ projectSite: "",
+ smCode: "",
+ prNumber: "",
+ },
+ });
+
+ async function onSubmit(data: RfqFilterFormValues) {
+ startTransition(() => {
+ try {
+ const newFilters = [];
+
+ // 기본 필드
+ if (data.rfqCode?.trim()) {
+ newFilters.push({
+ id: "rfqCode",
+ value: data.rfqCode.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()
+ });
+ }
+
+ if (data.series?.trim()) {
+ newFilters.push({
+ id: "series",
+ value: data.series.trim(),
+ type: "select",
+ operator: "eq",
+ rowId: generateId()
+ });
+ }
+
+ if (data.projectCode?.trim()) {
+ newFilters.push({
+ id: "projectCode",
+ value: data.projectCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ });
+ }
+
+ if (data.projectName?.trim()) {
+ newFilters.push({
+ id: "projectName",
+ value: data.projectName.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.itemName?.trim()) {
+ newFilters.push({
+ id: "itemName",
+ value: data.itemName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ });
+ }
+
+ if (data.packageNo?.trim()) {
+ newFilters.push({
+ id: "packageNo",
+ value: data.packageNo.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ });
+ }
+
+ if (data.picName?.trim()) {
+ newFilters.push({
+ id: "picUserName",
+ value: data.picName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ });
+ }
+
+ // 벤더 수 범위
+ if (data.vendorCountMin?.trim()) {
+ newFilters.push({
+ id: "vendorCount",
+ value: parseInt(data.vendorCountMin.trim()),
+ type: "number",
+ operator: "gte",
+ rowId: generateId()
+ });
+ }
+
+ if (data.vendorCountMax?.trim()) {
+ newFilters.push({
+ id: "vendorCount",
+ value: parseInt(data.vendorCountMax.trim()),
+ type: "number",
+ operator: "lte",
+ rowId: generateId()
+ });
+ }
+
+ // 날짜 필터
+ if (data.dueDateFrom) {
+ newFilters.push({
+ id: "dueDate",
+ value: data.dueDateFrom.toISOString(),
+ type: "date",
+ operator: "gte",
+ rowId: generateId()
+ });
+ }
+
+ if (data.dueDateTo) {
+ newFilters.push({
+ id: "dueDate",
+ value: data.dueDateTo.toISOString(),
+ type: "date",
+ operator: "lte",
+ rowId: generateId()
+ });
+ }
+
+ if (data.rfqSendDateFrom) {
+ newFilters.push({
+ id: "rfqSendDate",
+ value: data.rfqSendDateFrom.toISOString(),
+ type: "date",
+ operator: "gte",
+ rowId: generateId()
+ });
+ }
+
+ if (data.rfqSendDateTo) {
+ newFilters.push({
+ id: "rfqSendDate",
+ value: data.rfqSendDateTo.toISOString(),
+ type: "date",
+ operator: "lte",
+ rowId: generateId()
+ });
+ }
+
+ // 일반견적 필드
+ if ((rfqCategory === "general" || rfqCategory === "all") && data.rfqType?.trim()) {
+ newFilters.push({
+ id: "rfqType",
+ value: data.rfqType.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ });
+ }
+
+ if ((rfqCategory === "general" || rfqCategory === "all") && data.rfqTitle?.trim()) {
+ newFilters.push({
+ id: "rfqTitle",
+ value: data.rfqTitle.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ });
+ }
+
+ // ITB 필드
+ if ((rfqCategory === "itb" || rfqCategory === "all") && data.projectCompany?.trim()) {
+ newFilters.push({
+ id: "projectCompany",
+ value: data.projectCompany.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ });
+ }
+
+ if ((rfqCategory === "itb" || rfqCategory === "all") && data.projectSite?.trim()) {
+ newFilters.push({
+ id: "projectSite",
+ value: data.projectSite.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ });
+ }
+
+ if ((rfqCategory === "itb" || rfqCategory === "all") && data.smCode?.trim()) {
+ newFilters.push({
+ id: "smCode",
+ value: data.smCode.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ });
+ }
+
+ // RFQ 필드
+ if ((rfqCategory === "rfq" || rfqCategory === "all") && data.prNumber?.trim()) {
+ newFilters.push({
+ id: "prNumber",
+ value: data.prNumber.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ });
+ }
+
+ console.log("=== 생성된 필터들 ===", newFilters);
+ console.log("=== 조인 연산자 ===", joinOperator);
+
+ onFiltersApply(newFilters, joinOperator);
+ onClose();
+
+ console.log("=== 필터 적용 완료 ===");
+ } catch (error) {
+ console.error("RFQ 필터 적용 오류:", error);
+ }
+ });
+ }
+
+ function handleReset() {
+ form.reset();
+ setJoinOperator("and");
+
+ const currentUrl = new URL(window.location.href);
+ const newSearchParams = new URLSearchParams(currentUrl.search);
+
+ newSearchParams.set("filters", JSON.stringify([]));
+ newSearchParams.set("joinOperator", "and");
+ newSearchParams.set("page", "1");
+ newSearchParams.delete("search");
+
+ router.replace(`${currentUrl.pathname}?${newSearchParams.toString()}`);
+
+ onFiltersApply([], "and");
+ console.log("=== 필터 완전 초기화 완료 ===");
+ }
+
+ if (!isOpen) return null;
+
+ return (
+ <div
+ className="flex h-full max-h-full flex-col px-6 sm:px-8"
+ style={{ backgroundColor: "#F5F7FB", paddingLeft: "2rem", paddingRight: "2rem" }}
+ >
+ {/* Header */}
+ <div className="flex shrink-0 min-h-[60px] items-center justify-between px-6">
+ <h3 className="whitespace-nowrap text-lg font-semibold">RFQ 검색 필터</h3>
+ <div className="flex items-center gap-2">
+ <Button variant="ghost" size="icon" onClick={onClose} className="h-8 w-8">
+ <X className="size-4" />
+ </Button>
+ </div>
+ </div>
+
+ {/* Join operator selector */}
+ <div className="shrink-0 px-6">
+ <label className="text-sm font-medium">조건 결합 방식</label>
+ <Select
+ value={joinOperator}
+ onValueChange={(v: "and" | "or") => setJoinOperator(v)}
+ >
+ <SelectTrigger className="mt-2 h-8 w-[180px] bg-white">
+ <SelectValue placeholder="조건 결합 방식" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="and">모든 조건 충족 (AND)</SelectItem>
+ <SelectItem value="or">하나라도 충족 (OR)</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ {/* Form */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-col h-full">
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4">
+ <div className="space-y-4 pt-2">
+
+ {/* RFQ 코드 */}
+ <FormField
+ control={form.control}
+ name="rfqCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ 코드</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="RFQ 코드 입력"
+ {...field}
+ disabled={isPending}
+ className={cn(field.value && "pr-8", "bg-white")}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={() => form.setValue("rfqCode", "")}
+ disabled={isPending}
+ className="absolute right-0 top-0 h-full px-2"
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 상태 */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>상태</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isPending}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex w-full justify-between">
+ <SelectValue placeholder="상태 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="-mr-2 h-4 w-4"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("status", "");
+ }}
+ disabled={isPending}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {RFQ_STATUS_OPTIONS.map((opt) => (
+ <SelectItem key={opt.value} value={opt.value}>
+ {opt.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 시리즈 (RFQ 카테고리일 때만) */}
+ {(rfqCategory === "rfq" || rfqCategory === "all") && (
+ <FormField
+ control={form.control}
+ name="series"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>시리즈</FormLabel>
+ <Select
+ value={field.value}
+ onValueChange={field.onChange}
+ disabled={isPending}
+ >
+ <FormControl>
+ <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}>
+ <div className="flex w-full justify-between">
+ <SelectValue placeholder="시리즈 선택" />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="-mr-2 h-4 w-4"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("series", "");
+ }}
+ disabled={isPending}
+ >
+ <X className="size-3" />
+ </Button>
+ )}
+ </div>
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {SERIES_OPTIONS.map((opt) => (
+ <SelectItem key={opt.value || "none"} value={opt.value || "none"}>
+ {opt.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 프로젝트 정보 */}
+ <FormField
+ control={form.control}
+ name="projectCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>프로젝트 코드</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="프로젝트 코드 입력"
+ {...field}
+ disabled={isPending}
+ className={cn(field.value && "pr-8", "bg-white")}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={() => form.setValue("projectCode", "")}
+ disabled={isPending}
+ className="absolute right-0 top-0 h-full px-2"
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 일반견적 필드 */}
+ {(rfqCategory === "general" || rfqCategory === "all") && (
+ <>
+ <FormField
+ control={form.control}
+ name="rfqType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>견적 유형</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="견적 유형 입력"
+ {...field}
+ disabled={isPending}
+ className={cn(field.value && "pr-8", "bg-white")}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={() => form.setValue("rfqType", "")}
+ disabled={isPending}
+ className="absolute right-0 top-0 h-full px-2"
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="rfqTitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>견적 제목</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="견적 제목 입력"
+ {...field}
+ disabled={isPending}
+ className={cn(field.value && "pr-8", "bg-white")}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={() => form.setValue("rfqTitle", "")}
+ disabled={isPending}
+ className="absolute right-0 top-0 h-full px-2"
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </>
+ )}
+
+ {/* ITB 필드 */}
+ {(rfqCategory === "itb" || rfqCategory === "all") && (
+ <>
+ <FormField
+ control={form.control}
+ name="projectCompany"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>프로젝트 회사</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="프로젝트 회사 입력"
+ {...field}
+ disabled={isPending}
+ className={cn(field.value && "pr-8", "bg-white")}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={() => form.setValue("projectCompany", "")}
+ disabled={isPending}
+ className="absolute right-0 top-0 h-full px-2"
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </>
+ )}
+
+ {/* RFQ 필드 */}
+ {(rfqCategory === "rfq" || rfqCategory === "all") && (
+ <FormField
+ control={form.control}
+ name="prNumber"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PR 번호</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="PR 번호 입력"
+ {...field}
+ disabled={isPending}
+ className={cn(field.value && "pr-8", "bg-white")}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={() => form.setValue("prNumber", "")}
+ disabled={isPending}
+ className="absolute right-0 top-0 h-full px-2"
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 날짜 범위 필터 */}
+ <div className="space-y-2">
+ <FormLabel>마감일 범위</FormLabel>
+ <div className="grid grid-cols-2 gap-2">
+ <FormField
+ control={form.control}
+ name="dueDateFrom"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <DatePicker
+ date={field.value}
+ onDateChange={field.onChange}
+ placeholder="시작일"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="dueDateTo"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <DatePicker
+ date={field.value}
+ onDateChange={field.onChange}
+ placeholder="종료일"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ </div>
+ </div>
+
+ {/* Footer buttons */}
+ <div className="shrink-0 p-4">
+ <div className="flex justify-end gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleReset}
+ disabled={isPending}
+ className="px-4"
+ >
+ 초기화
+ </Button>
+ <Button
+ type="submit"
+ variant="samsung"
+ disabled={isPending || isLoading}
+ className="px-4"
+ >
+ <Search className="mr-2 size-4" />
+ {isPending || isLoading ? "조회 중..." : "조회"}
+ </Button>
+ </div>
+ </div>
+ </form>
+ </Form>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx
new file mode 100644
index 00000000..3fac8881
--- /dev/null
+++ b/lib/rfq-last/table/rfq-table-columns.tsx
@@ -0,0 +1,445 @@
+"use client";
+
+import * as React from "react";
+import { type ColumnDef } from "@tanstack/react-table";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Eye, FileText, Send, Package, Users, ChevronRight } from "lucide-react";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+import { RfqsLastView } from "@/db/schema";
+import { DataTableRowAction } from "@/types/table";
+import { format } from "date-fns";
+import { ko } from "date-fns/locale";
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqsLastView> | null>>;
+ rfqCategory?: "all" | "general" | "itb" | "rfq";
+}
+
+// RFQ 상태별 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case "RFQ 생성": return "outline";
+ case "구매담당지정": return "secondary";
+ case "견적요청문서 확정": return "default";
+ case "Short List 확정": return "default";
+ case "TBE 완료": return "default";
+ case "RFQ 발송": return "default";
+ case "견적접수": return "default";
+ case "최종업체선정": return "default";
+ default: return "outline";
+ }
+};
+
+// 시리즈 배지
+const getSeriesBadge = (series: string | null) => {
+ if (!series) return null;
+
+ const label = series === "SS" ? "시리즈 통합" : series === "II" ? "품목 통합" : series;
+ const variant = series === "SS" ? "default" : series === "II" ? "secondary" : "outline";
+
+ return <Badge variant={variant} className="text-xs">{label}</Badge>;
+};
+
+export function getRfqColumns({
+ setRowAction,
+ rfqCategory = "all"
+}: GetColumnsProps): ColumnDef<RfqsLastView>[] {
+
+ const baseColumns: ColumnDef<RfqsLastView>[] = [
+ // ═══════════════════════════════════════════════════════════════
+ // 선택 및 기본 정보
+ // ═══════════════════════════════════════════════════════════════
+
+ // Checkbox
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // RFQ 코드
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="RFQ 코드" />,
+ cell: ({ row }) => {
+ const rfqSealed = row.original.rfqSealedYn;
+ return (
+ <div className="flex items-center gap-1">
+ <span className="font-mono font-medium">{row.original.rfqCode}</span>
+ {rfqSealed && (
+ <Badge variant="destructive" className="text-xs">봉인</Badge>
+ )}
+ </div>
+ );
+ },
+ size: 140,
+ },
+
+ // 상태
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {row.original.status}
+ </Badge>
+ ),
+ size: 120,
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 일반견적 필드 (rfqCategory가 'general' 또는 'all'일 때만)
+ // ═══════════════════════════════════════════════════════════════
+ ...(rfqCategory === "general" || rfqCategory === "all" ? [
+ {
+ id: "rfqType",
+ accessorKey: "rfqType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 유형" />,
+ cell: ({ row }) => row.original.rfqType || "-",
+ size: 100,
+ },
+ {
+ id: "rfqTitle",
+ accessorKey: "rfqTitle",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적 제목" />,
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate" title={row.original.rfqTitle || ""}>
+ {row.original.rfqTitle || "-"}
+ </div>
+ ),
+ size: 200,
+ },
+ ] as ColumnDef<RfqsLastView>[] : []),
+
+ // ═══════════════════════════════════════════════════════════════
+ // ITB 필드 (rfqCategory가 'itb' 또는 'all'일 때만)
+ // ═══════════════════════════════════════════════════════════════
+ ...(rfqCategory === "itb" || rfqCategory === "all" ? [
+ {
+ id: "projectCompany",
+ accessorKey: "projectCompany",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 회사" />,
+ cell: ({ row }) => row.original.projectCompany || "-",
+ size: 120,
+ },
+ {
+ id: "projectFlag",
+ accessorKey: "projectFlag",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 플래그" />,
+ cell: ({ row }) => row.original.projectFlag || "-",
+ size: 100,
+ },
+ {
+ id: "projectSite",
+ accessorKey: "projectSite",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 사이트" />,
+ cell: ({ row }) => row.original.projectSite || "-",
+ size: 120,
+ },
+ {
+ id: "smCode",
+ accessorKey: "smCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="SM 코드" />,
+ cell: ({ row }) => row.original.smCode || "-",
+ size: 80,
+ },
+ ] as ColumnDef<RfqsLastView>[] : []),
+
+ // ═══════════════════════════════════════════════════════════════
+ // RFQ(PR) 필드 (rfqCategory가 'rfq' 또는 'all'일 때만)
+ // ═══════════════════════════════════════════════════════════════
+ ...(rfqCategory === "rfq" || rfqCategory === "all" ? [
+ {
+ id: "prNumber",
+ accessorKey: "prNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.prNumber || "-"}</span>
+ ),
+ size: 120,
+ },
+ {
+ id: "prIssueDate",
+ accessorKey: "prIssueDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR 발행일" />,
+ cell: ({ row }) => {
+ const date = row.original.prIssueDate;
+ return date ? format(new Date(date), "yyyy-MM-dd") : "-";
+ },
+ size: 100,
+ },
+ {
+ id: "series",
+ accessorKey: "series",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리즈" />,
+ cell: ({ row }) => getSeriesBadge(row.original.series),
+ size: 100,
+ },
+ ] as ColumnDef<RfqsLastView>[] : []),
+
+ // ═══════════════════════════════════════════════════════════════
+ // 공통 프로젝트 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "프로젝트 정보",
+ columns: [
+ {
+ accessorKey: "projectCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트 코드" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.projectCode || "-"}</span>
+ ),
+ size: 120,
+ },
+ {
+ accessorKey: "projectName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />,
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate" title={row.original.projectName || ""}>
+ {row.original.projectName || "-"}
+ </div>
+ ),
+ size: 200,
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 품목 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "품목 정보",
+ columns: [
+ {
+ accessorKey: "itemCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재코드" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.itemCode || "-"}</span>
+ ),
+ size: 100,
+ },
+ {
+ accessorKey: "itemName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재명" />,
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate" title={row.original.itemName || ""}>
+ {row.original.itemName || "-"}
+ </div>
+ ),
+ size: 200,
+ },
+ {
+ accessorKey: "packageNo",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="패키지 번호" />,
+ cell: ({ row }) => row.original.packageNo || "-",
+ size: 100,
+ },
+ {
+ accessorKey: "packageName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="패키지명" />,
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate" title={row.original.packageName || ""}>
+ {row.original.packageName || "-"}
+ </div>
+ ),
+ size: 200,
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 담당자 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "담당자",
+ columns: [
+ {
+ accessorKey: "picUserName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />,
+ cell: ({ row }) => row.original.picUserName || row.original.picName || "-",
+ size: 100,
+ },
+ {
+ accessorKey: "engPicName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="엔지니어링 담당" />,
+ cell: ({ row }) => row.original.engPicName || "-",
+ size: 120,
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 일정 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "일정",
+ columns: [
+ {
+ accessorKey: "rfqSendDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="발송일" />,
+ cell: ({ row }) => {
+ const date = row.original.rfqSendDate;
+ return date ? (
+ <div className="flex items-center gap-1">
+ <Send className="h-3 w-3 text-muted-foreground" />
+ <span className="text-sm">
+ {format(new Date(date), "MM-dd", { locale: ko })}
+ </span>
+ </div>
+ ) : "-";
+ },
+ size: 90,
+ },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="마감일" />,
+ cell: ({ row }) => {
+ const date = row.original.dueDate;
+ if (!date) return "-";
+
+ const now = new Date();
+ const dueDate = new Date(date);
+ const isOverdue = now > dueDate;
+
+ return (
+ <span className={`text-sm ${isOverdue ? "text-red-600 font-medium" : ""}`}>
+ {format(dueDate, "MM-dd", { locale: ko })}
+ </span>
+ );
+ },
+ size: 90,
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 벤더 및 견적 현황
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "견적 현황",
+ columns: [
+ {
+ accessorKey: "vendorCount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업체수" />,
+ cell: ({ row }) => (
+ <div className="flex items-center gap-1">
+ <Users className="h-3 w-3 text-muted-foreground" />
+ <span className="font-medium">{row.original.vendorCount || 0}</span>
+ </div>
+ ),
+ size: 80,
+ },
+ {
+ accessorKey: "shortListedVendorCount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="Short List" />,
+ cell: ({ row }) => {
+ const count = row.original.shortListedVendorCount || 0;
+ return count > 0 ? (
+ <Badge variant="default" className="font-mono">
+ {count}
+ </Badge>
+ ) : "-";
+ },
+ size: 90,
+ },
+ {
+ accessorKey: "quotationReceivedCount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="견적접수" />,
+ cell: ({ row }) => {
+ const received = row.original.quotationReceivedCount || 0;
+ const total = row.original.vendorCount || 0;
+
+ return (
+ <div className="flex items-center gap-1">
+ <FileText className="h-3 w-3 text-muted-foreground" />
+ <span className={`text-sm ${received === total && total > 0 ? "text-green-600 font-medium" : ""}`}>
+ {received}/{total}
+ </span>
+ </div>
+ );
+ },
+ size: 90,
+ },
+ ]
+ },
+
+ // PR Items 정보
+ {
+ accessorKey: "prItemsCount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="PR Items" />,
+ cell: ({ row }) => {
+ const prItems = row.original.prItemsCount || 0;
+ const majorItems = row.original.majorItemsCount || 0;
+
+ return (
+ <div className="flex flex-col gap-0.5">
+ <span className="text-sm font-medium">{prItems}개</span>
+ {majorItems > 0 && (
+ <Badge variant="secondary" className="text-xs">
+ 주요 {majorItems}
+ </Badge>
+ )}
+ </div>
+ );
+ },
+ size: 90,
+ },
+
+ // 액션
+ {
+ id: "actions",
+ header: "액션",
+ enableHiding: false,
+ size: 80,
+ minSize: 80,
+ cell: ({ row }) => {
+ return (
+ <div className="flex items-center gap-1">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ <Eye className="h-4 w-4" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>상세보기</TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ },
+ ];
+
+ return baseColumns;
+} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
new file mode 100644
index 00000000..1f60da36
--- /dev/null
+++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import * as React from "react";
+import { type Table } from "@tanstack/react-table";
+import { Download, RefreshCw, Plus } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { RfqsLastView } from "@/db/schema";
+
+interface RfqTableToolbarActionsProps {
+ table: Table<RfqsLastView>;
+ onRefresh?: () => void;
+}
+
+export function RfqTableToolbarActions({
+ table,
+ onRefresh,
+}: RfqTableToolbarActionsProps) {
+ const [isExporting, setIsExporting] = React.useState(false);
+
+ const handleExportCSV = React.useCallback(async () => {
+ setIsExporting(true);
+ try {
+ const data = table.getFilteredRowModel().rows.map((row) => {
+ const original = row.original;
+ return {
+ "RFQ 코드": original.rfqCode || "",
+ "상태": original.status || "",
+ "프로젝트 코드": original.projectCode || "",
+ "프로젝트명": original.projectName || "",
+ "자재코드": original.itemCode || "",
+ "자재명": original.itemName || "",
+ "패키지 번호": original.packageNo || "",
+ "패키지명": original.packageName || "",
+ "구매담당자": original.picUserName || original.picName || "",
+ "엔지니어링 담당": original.engPicName || "",
+ "발송일": original.rfqSendDate ? new Date(original.rfqSendDate).toLocaleDateString("ko-KR") : "",
+ "마감일": original.dueDate ? new Date(original.dueDate).toLocaleDateString("ko-KR") : "",
+ "업체수": original.vendorCount || 0,
+ "Short List": original.shortListedVendorCount || 0,
+ "견적접수": original.quotationReceivedCount || 0,
+ "PR Items": original.prItemsCount || 0,
+ "주요 Items": original.majorItemsCount || 0,
+ "시리즈": original.series || "",
+ "견적 유형": original.rfqType || "",
+ "견적 제목": original.rfqTitle || "",
+ "프로젝트 회사": original.projectCompany || "",
+ "프로젝트 사이트": original.projectSite || "",
+ "SM 코드": original.smCode || "",
+ "PR 번호": original.prNumber || "",
+ "PR 발행일": original.prIssueDate ? new Date(original.prIssueDate).toLocaleDateString("ko-KR") : "",
+ "생성자": original.createdByUserName || "",
+ "생성일": original.createdAt ? new Date(original.createdAt).toLocaleDateString("ko-KR") : "",
+ "수정자": original.updatedByUserName || "",
+ "수정일": original.updatedAt ? new Date(original.updatedAt).toLocaleDateString("ko-KR") : "",
+ };
+ });
+
+ const fileName = `RFQ_목록_${new Date().toISOString().split("T")[0]}.csv`;
+ exportTableToCSV({ data, filename: fileName });
+ } catch (error) {
+ console.error("Export failed:", error);
+ } finally {
+ setIsExporting(false);
+ }
+ }, [table]);
+
+ const handleExportSelected = React.useCallback(async () => {
+ setIsExporting(true);
+ try {
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ if (selectedRows.length === 0) {
+ alert("선택된 항목이 없습니다.");
+ return;
+ }
+
+ const data = selectedRows.map((row) => {
+ const original = row.original;
+ return {
+ "RFQ 코드": original.rfqCode || "",
+ "상태": original.status || "",
+ "프로젝트 코드": original.projectCode || "",
+ "프로젝트명": original.projectName || "",
+ "자재코드": original.itemCode || "",
+ "자재명": original.itemName || "",
+ "패키지 번호": original.packageNo || "",
+ "패키지명": original.packageName || "",
+ "구매담당자": original.picUserName || original.picName || "",
+ "엔지니어링 담당": original.engPicName || "",
+ "발송일": original.rfqSendDate ? new Date(original.rfqSendDate).toLocaleDateString("ko-KR") : "",
+ "마감일": original.dueDate ? new Date(original.dueDate).toLocaleDateString("ko-KR") : "",
+ "업체수": original.vendorCount || 0,
+ "Short List": original.shortListedVendorCount || 0,
+ "견적접수": original.quotationReceivedCount || 0,
+ };
+ });
+
+ const fileName = `RFQ_선택항목_${new Date().toISOString().split("T")[0]}.csv`;
+ exportTableToCSV({ data, filename: fileName });
+ } catch (error) {
+ console.error("Export failed:", error);
+ } finally {
+ setIsExporting(false);
+ }
+ }, [table]);
+
+ return (
+ <div className="flex items-center gap-2">
+ {onRefresh && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onRefresh}
+ className="h-8 px-2 lg:px-3"
+ >
+ <RefreshCw className="mr-2 h-4 w-4" />
+ 새로고침
+ </Button>
+ )}
+
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="h-8 px-2 lg:px-3"
+ disabled={isExporting}
+ >
+ <Download className="mr-2 h-4 w-4" />
+ {isExporting ? "내보내는 중..." : "내보내기"}
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={handleExportCSV}>
+ 전체 데이터 내보내기
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={handleExportSelected}
+ disabled={table.getFilteredSelectedRowModel().rows.length === 0}
+ >
+ 선택한 항목 내보내기 ({table.getFilteredSelectedRowModel().rows.length}개)
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ <Button
+ variant="samsung"
+ size="sm"
+ className="h-8 px-2 lg:px-3"
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ RFQ 생성
+ </Button>
+ </div>
+ );
+} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-table.tsx b/lib/rfq-last/table/rfq-table.tsx
new file mode 100644
index 00000000..199695a0
--- /dev/null
+++ b/lib/rfq-last/table/rfq-table.tsx
@@ -0,0 +1,457 @@
+"use client";
+
+import * as React from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table";
+
+import { useDataTable } from "@/hooks/use-data-table";
+import { DataTable } from "@/components/data-table/data-table";
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
+import { cn } from "@/lib/utils";
+import { useTablePresets } from "@/components/data-table/use-table-presets";
+import { TablePresetManager } from "@/components/data-table/data-table-preset";
+import { RfqFilterSheet } from "./rfq-filter-sheet";
+import { getRfqColumns } from "./rfq-table-columns";
+import { RfqsLastView } from "@/db/schema";
+import { getRfqs } from "../service";
+import { RfqTableToolbarActions } from "./rfq-table-toolbar-actions";
+
+interface RfqTableProps {
+ data: Awaited<ReturnType<typeof getRfqs>>;
+ rfqCategory?: "general" | "itb" | "rfq";
+ className?: string;
+}
+
+export function RfqTable({
+ data,
+ rfqCategory = "itb",
+ className
+}: RfqTableProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqsLastView> | null>(null);
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false);
+
+ // 외부 필터 상태
+ const [externalFilters, setExternalFilters] = React.useState<any[]>([]);
+ const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and");
+
+ // 필터 적용 핸들러
+ const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => {
+ console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator);
+ setExternalFilters(filters);
+ setExternalJoinOperator(joinOperator);
+ setIsFilterPanelOpen(false);
+ }, []);
+
+ const searchString = React.useMemo(
+ () => searchParams.toString(),
+ [searchParams]
+ );
+
+ const getSearchParam = React.useCallback(
+ (key: string, def = "") =>
+ new URLSearchParams(searchString).get(key) ?? def,
+ [searchString]
+ );
+
+ // 초기 데이터 설정
+// const [initialPromiseData] = React.use(promises);
+ const [tableData, setTableData] = React.useState(data);
+ const [isDataLoading, setIsDataLoading] = React.useState(false);
+
+ // URL 필터 변경 감지 및 데이터 새로고침
+ React.useEffect(() => {
+ const refetchData = async () => {
+ try {
+ setIsDataLoading(true);
+
+ const currentFilters = getSearchParam("filters");
+ const currentJoinOperator = getSearchParam("joinOperator", "and");
+ const currentPage = parseInt(getSearchParam("page", "1"));
+ const currentPerPage = parseInt(getSearchParam("perPage", "10"));
+ const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
+ const currentSearch = getSearchParam("search", "");
+
+ const searchParams = {
+ filters: currentFilters ? JSON.parse(currentFilters) : [],
+ joinOperator: currentJoinOperator as "and" | "or",
+ page: currentPage,
+ perPage: currentPerPage,
+ sort: currentSort,
+ search: currentSearch,
+ rfqCategory: rfqCategory,
+ };
+
+ console.log("=== 새 데이터 요청 ===", searchParams);
+
+ const newData = await getRfqs(searchParams);
+ setTableData(newData);
+
+ console.log("=== 데이터 업데이트 완료 ===", newData.data.length, "건");
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error);
+ } finally {
+ setIsDataLoading(false);
+ }
+ };
+
+ const timeoutId = setTimeout(() => {
+ const hasChanges = getSearchParam("filters") ||
+ getSearchParam("search") ||
+ getSearchParam("page") !== "1" ||
+ getSearchParam("perPage") !== "10" ||
+ getSearchParam("sort");
+
+ if (hasChanges) {
+ refetchData();
+ }
+ }, 300);
+
+ return () => clearTimeout(timeoutId);
+ }, [searchString, rfqCategory, getSearchParam]);
+
+ const refreshData = React.useCallback(async () => {
+ try {
+ setIsDataLoading(true);
+
+ const currentFilters = getSearchParam("filters");
+ const currentJoinOperator = getSearchParam("joinOperator", "and");
+ const currentPage = parseInt(getSearchParam("page", "1"));
+ const currentPerPage = parseInt(getSearchParam("perPage", "10"));
+ const currentSort = getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }];
+ const currentSearch = getSearchParam("search", "");
+
+ const searchParams = {
+ filters: currentFilters ? JSON.parse(currentFilters) : [],
+ joinOperator: currentJoinOperator as "and" | "or",
+ page: currentPage,
+ perPage: currentPerPage,
+ sort: currentSort,
+ search: currentSearch,
+ rfqCategory: rfqCategory,
+ };
+
+ const newData = await getRfqs(searchParams);
+ setTableData(newData);
+
+ console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건");
+ } catch (error) {
+ console.error("데이터 새로고침 오류:", error);
+ } finally {
+ setIsDataLoading(false);
+ }
+ }, [rfqCategory, getSearchParam]);
+
+ // 컨테이너 위치 추적
+ const containerRef = React.useRef<HTMLDivElement>(null);
+ const [containerTop, setContainerTop] = React.useState(0);
+
+ const updateContainerBounds = React.useCallback(() => {
+ if (containerRef.current) {
+ const rect = containerRef.current.getBoundingClientRect();
+ const newTop = rect.top;
+ setContainerTop(prevTop => {
+ if (Math.abs(prevTop - newTop) > 1) {
+ return newTop;
+ }
+ return prevTop;
+ });
+ }
+ }, []);
+
+ React.useEffect(() => {
+ updateContainerBounds();
+
+ const handleResize = () => {
+ updateContainerBounds();
+ };
+
+ window.addEventListener('resize', handleResize);
+ window.addEventListener('scroll', updateContainerBounds);
+
+ return () => {
+ window.removeEventListener('resize', handleResize);
+ window.removeEventListener('scroll', updateContainerBounds);
+ };
+ }, [updateContainerBounds]);
+
+ const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
+ try {
+ const value = getSearchParam(key);
+ return value ? JSON.parse(value) : defaultValue;
+ } catch {
+ return defaultValue;
+ }
+ }, [getSearchParam]);
+
+ const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
+ return parseSearchParamHelper(key, defaultValue);
+ };
+
+ // 테이블 설정
+ const initialSettings = React.useMemo(() => ({
+ page: parseInt(getSearchParam("page", "1")),
+ perPage: parseInt(getSearchParam("perPage", "10")),
+ sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }],
+ filters: parseSearchParam("filters", []),
+ joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and",
+ search: getSearchParam("search", ""),
+ columnVisibility: {},
+ columnOrder: [],
+ pinnedColumns: { left: [], right: ["actions"] },
+ groupBy: [],
+ expandedRows: []
+ }), [getSearchParam, parseSearchParam]);
+
+ const {
+ presets,
+ activePresetId,
+ hasUnsavedChanges,
+ isLoading: presetsLoading,
+ createPreset,
+ applyPreset,
+ updatePreset,
+ deletePreset,
+ setDefaultPreset,
+ renamePreset,
+ getCurrentSettings,
+ } = useTablePresets<RfqsLastView>('rfq-table', initialSettings);
+
+ // 컬럼 정의
+ const columns = React.useMemo(() => {
+ return getRfqColumns({
+ setRowAction,
+ rfqCategory
+ });
+ }, [rfqCategory, setRowAction]);
+
+ const filterFields: DataTableFilterField<RfqsLastView>[] = [
+ { id: "rfqCode", label: "RFQ 코드" },
+ { id: "projectName", label: "프로젝트명" },
+ { id: "itemName", label: "자재명" },
+ { id: "status", label: "상태" },
+ ];
+
+ const advancedFilterFields: DataTableAdvancedFilterField<RfqsLastView>[] = [
+ { id: "rfqCode", label: "RFQ 코드", type: "text" },
+ {
+ id: "status",
+ label: "상태",
+ type: "select",
+ options: [
+ { label: "RFQ 생성", value: "RFQ 생성" },
+ { label: "구매담당지정", value: "구매담당지정" },
+ { label: "견적요청문서 확정", value: "견적요청문서 확정" },
+ { label: "Short List 확정", value: "Short List 확정" },
+ { label: "TBE 완료", value: "TBE 완료" },
+ { label: "RFQ 발송", value: "RFQ 발송" },
+ { label: "견적접수", value: "견적접수" },
+ { label: "최종업체선정", value: "최종업체선정" },
+ ]
+ },
+ { id: "projectCode", label: "프로젝트 코드", type: "text" },
+ { id: "projectName", label: "프로젝트명", type: "text" },
+ { id: "itemCode", label: "자재코드", type: "text" },
+ { id: "itemName", label: "자재명", type: "text" },
+ { id: "packageNo", label: "패키지 번호", type: "text" },
+ { id: "picUserName", label: "구매담당자", type: "text" },
+ { id: "vendorCount", label: "업체수", type: "number" },
+ { id: "dueDate", label: "마감일", type: "date" },
+ { id: "rfqSendDate", label: "발송일", type: "date" },
+ ...(rfqCategory === "general" || rfqCategory === "all" ? [
+ { id: "rfqType", label: "견적 유형", type: "text" },
+ { id: "rfqTitle", label: "견적 제목", type: "text" },
+ ] as DataTableAdvancedFilterField<RfqsLastView>[] : []),
+ ...(rfqCategory === "itb" || rfqCategory === "all" ? [
+ { id: "projectCompany", label: "프로젝트 회사", type: "text" },
+ { id: "projectSite", label: "프로젝트 사이트", type: "text" },
+ { id: "smCode", label: "SM 코드", type: "text" },
+ ] as DataTableAdvancedFilterField<RfqsLastView>[] : []),
+ ...(rfqCategory === "rfq" || rfqCategory === "all" ? [
+ { id: "prNumber", label: "PR 번호", type: "text" },
+ { id: "prIssueDate", label: "PR 발행일", type: "date" },
+ {
+ id: "series",
+ label: "시리즈",
+ type: "select",
+ options: [
+ { label: "시리즈 통합", value: "SS" },
+ { label: "품목 통합", value: "II" },
+ { label: "통합 없음", value: "" },
+ ]
+ },
+ ] as DataTableAdvancedFilterField<RfqsLastView>[] : []),
+ ];
+
+ const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]);
+
+ const initialState = React.useMemo(() => ({
+ sorting: initialSettings.sort.filter((s: any) => columns.some((c: any) => ("accessorKey" in c ? c.accessorKey : c.id) === s.id)),
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }), [columns, currentSettings, initialSettings.sort]);
+
+ const { table } = useDataTable({
+ data: tableData.data,
+ columns,
+ pageCount: tableData.pageCount,
+ rowCount: tableData.total || tableData.data.length,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState,
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+ const getActiveFilterCount = React.useCallback(() => {
+ try {
+ const filtersParam = getSearchParam("filters");
+ if (filtersParam) {
+ const filters = JSON.parse(filtersParam);
+ return Array.isArray(filters) ? filters.length : 0;
+ }
+ return 0;
+ } catch {
+ return 0;
+ }
+ }, [getSearchParam]);
+
+ const FILTER_PANEL_WIDTH = 400;
+
+ return (
+ <>
+ {/* Filter Panel */}
+ <div
+ className={cn(
+ "fixed left-0 bg-background border-r z-50 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: `${containerTop}px`,
+ height: `calc(100vh - ${containerTop}px)`
+ }}
+ >
+ <RfqFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onFiltersApply={handleFiltersApply}
+ rfqCategory={rfqCategory}
+ isLoading={false}
+ />
+ </div>
+
+ {/* Main Content Container */}
+ <div
+ ref={containerRef}
+ className={cn("relative w-full overflow-hidden", className)}
+ >
+ <div className="flex w-full h-full">
+ <div
+ className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out"
+ style={{
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px'
+ }}
+ >
+ {/* Header Bar */}
+ <div className="flex items-center justify-between p-4 bg-background shrink-0">
+ <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" />}
+ {getActiveFilterCount() > 0 && (
+ <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
+ {getActiveFilterCount()}
+ </span>
+ )}
+ </Button>
+
+ {rfqCategory !== "all" && (
+ <Badge variant="outline" className="text-sm">
+ {rfqCategory === "general" ? "일반견적" :
+ rfqCategory === "itb" ? "ITB" : "RFQ"}
+ </Badge>
+ )}
+ </div>
+
+ <div className="flex items-center gap-4">
+ <div className="text-sm text-muted-foreground">
+ {tableData && (
+ <span>총 {tableData.total || tableData.data.length}건</span>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* Table Content Area */}
+ <div className="flex-1 overflow-hidden relative" style={{ height: 'calc(100vh - 200px)' }}>
+ {isDataLoading && (
+ <div className="absolute inset-0 bg-background/50 backdrop-blur-sm z-10 flex items-center justify-center">
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
+ 필터링 중...
+ </div>
+ </div>
+ )}
+ <div className="h-full w-full">
+ <DataTable table={table} className="h-full">
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ debounceMs={300}
+ shallow={false}
+ externalFilters={externalFilters}
+ externalJoinOperator={externalJoinOperator}
+ onFiltersChange={(filters, joinOperator) => {
+ console.log("=== 필터 변경 감지 ===", filters, joinOperator);
+ }}
+ >
+ <div className="flex items-center gap-2">
+ <TablePresetManager<RfqsLastView>
+ 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}
+ onRefresh={refreshData}
+ />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </>
+ );
+} \ No newline at end of file
diff --git a/lib/rfq-last/validations.ts b/lib/rfq-last/validations.ts
new file mode 100644
index 00000000..09fd2f6f
--- /dev/null
+++ b/lib/rfq-last/validations.ts
@@ -0,0 +1,65 @@
+// lib/rfq/validation.ts
+
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+ } from "nuqs/server";
+ import * as z from "zod";
+
+ import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
+
+ // RFQ 상태 옵션
+ export const RFQ_STATUS_OPTIONS = [
+ { value: "RFQ 생성", label: "RFQ 생성" },
+ { value: "구매담당지정", label: "구매담당지정" },
+ { value: "견적요청문서 확정", label: "견적요청문서 확정" },
+ { value: "Short List 확정", label: "Short List 확정" },
+ { value: "TBE 완료", label: "TBE 완료" },
+ { value: "RFQ 발송", label: "RFQ 발송" },
+ { value: "견적접수", label: "견적접수" },
+ { value: "최종업체선정", label: "최종업체선정" },
+ ];
+
+ // 시리즈 옵션
+ export const SERIES_OPTIONS = [
+ { value: "SS", label: "시리즈 통합" },
+ { value: "II", label: "품목 통합" },
+ { value: "", label: "통합 없음" },
+ ];
+
+ // RFQ 카테고리 (탭 구분용)
+ export const RFQ_CATEGORY_OPTIONS = [
+ { value: "all", label: "전체" },
+ { value: "general", label: "일반견적" },
+ { value: "itb", label: "ITB" },
+ { value: "rfq", label: "RFQ" },
+ ];
+
+ // ============= 메인 검색 파라미터 스키마 =============
+
+ export const searchParamsRfqCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<any>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // 검색
+ search: parseAsString.withDefault(""),
+
+ // RFQ 카테고리 (전체/일반견적/ITB/RFQ)
+ rfqCategory: parseAsStringEnum(["all", "general", "itb", "rfq"]).withDefault("all"),
+ });
+
+ // ============= 타입 정의 =============
+
+ export type GetRfqsSchema = Awaited<
+ ReturnType<typeof searchParamsRfqCache.parse>
+ >; \ No newline at end of file
diff --git a/lib/welding/table/ocr-table-toolbar-actions.tsx b/lib/welding/table/ocr-table-toolbar-actions.tsx
index a6a38adc..35171c29 100644
--- a/lib/welding/table/ocr-table-toolbar-actions.tsx
+++ b/lib/welding/table/ocr-table-toolbar-actions.tsx
@@ -497,34 +497,47 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) {
}
// 전체 데이터 내보내기
- const exportAllData = async () => {
- if (isExporting) return
- setIsExporting(true)
+const exportAllData = async () => {
+ if (isExporting) return
- try {
- toast.loading("전체 데이터를 가져오는 중...", {
- description: "잠시만 기다려주세요."
- })
-
- const allData = await getOcrAllRows()
- toast.dismiss()
+ setIsExporting(true)
- if (allData.length === 0) {
- toast.warning("내보낼 데이터가 없습니다.")
- return
- }
+ try {
+ const loadingToast = toast.loading("엑셀 파일을 생성 중입니다...", {
+ description: "대량 데이터 처리 중... 잠시만 기다려주세요."
+ })
- await exportOcrDataToExcel(allData, `OCR 결과 (전체 데이터 - ${allData.length}개 행)`)
- toast.success(`전체 데이터 ${allData.length}개 행이 성공적으로 내보내졌습니다.`)
+ // 서버에서 직접 엑셀 파일 받기
+ const response = await fetch('/api/ocr/export', {
+ method: 'GET',
+ })
- } catch (error) {
- console.error('전체 데이터 내보내기 오류:', error)
- toast.error('전체 데이터 내보내기 중 오류가 발생했습니다.')
- } finally {
- setIsExporting(false)
+ if (!response.ok) {
+ throw new Error('Export failed')
}
+
+ // Blob으로 변환 후 다운로드
+ const blob = await response.blob()
+ const url = window.URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `OCR_Export_${new Date().toISOString()}.xlsx`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ window.URL.revokeObjectURL(url)
+
+ toast.dismiss(loadingToast)
+ toast.success("엑셀 파일이 성공적으로 다운로드되었습니다.")
+
+ } catch (error) {
+ console.error('Export error:', error)
+ toast.error('데이터 내보내기 중 오류가 발생했습니다.')
+ } finally {
+ setIsExporting(false)
}
+}
// 삭제 후 콜백 - 테이블 새로고침
const handleDeleteSuccess = () => {