diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/rfq-last/service.ts | 141 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-filter-sheet.tsx | 780 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table-columns.tsx | 445 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table-toolbar-actions.tsx | 163 | ||||
| -rw-r--r-- | lib/rfq-last/table/rfq-table.tsx | 457 | ||||
| -rw-r--r-- | lib/rfq-last/validations.ts | 65 | ||||
| -rw-r--r-- | lib/welding/table/ocr-table-toolbar-actions.tsx | 55 |
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 = () => { |
