diff options
Diffstat (limited to 'lib/rfq-last/table/rfq-filter-sheet.tsx')
| -rw-r--r-- | lib/rfq-last/table/rfq-filter-sheet.tsx | 780 |
1 files changed, 780 insertions, 0 deletions
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 |
