diff options
Diffstat (limited to 'lib/procurement-rfqs/table/rfq-filter-sheet.tsx')
| -rw-r--r-- | lib/procurement-rfqs/table/rfq-filter-sheet.tsx | 686 |
1 files changed, 686 insertions, 0 deletions
diff --git a/lib/procurement-rfqs/table/rfq-filter-sheet.tsx b/lib/procurement-rfqs/table/rfq-filter-sheet.tsx new file mode 100644 index 00000000..a746603b --- /dev/null +++ b/lib/procurement-rfqs/table/rfq-filter-sheet.tsx @@ -0,0 +1,686 @@ +"use client" + +import { useEffect, useTransition, useState, useRef } from "react" +import { useRouter, useParams } from "next/navigation" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { CalendarIcon, ChevronRight, Search, X } from "lucide-react" +import { customAlphabet } from "nanoid" +import { parseAsStringEnum, useQueryState } from "nuqs" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { useTranslation } from '@/i18n/client' +import { getFiltersStateParser } from "@/lib/parsers" +import { DateRangePicker } from "@/components/date-range-picker" + +// nanoid 생성기 +const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) + +// 필터 스키마 정의 (RFQ 관련 항목 유지) +const filterSchema = z.object({ + picCode: z.string().optional(), + projectCode: z.string().optional(), + rfqCode: z.string().optional(), + itemCode: z.string().optional(), + majorItemMaterialCode: z.string().optional(), + status: z.string().optional(), + dateRange: z.object({ + from: z.date().optional(), + to: z.date().optional(), + }).optional(), +}) + +// 상태 옵션 정의 +const statusOptions = [ + { value: "RFQ Created", label: "RFQ Created" }, + { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" }, + { value: "RFQ Sent", label: "RFQ Sent" }, + { value: "Quotation Analysis", label: "Quotation Analysis" }, + { value: "PO Transfer", label: "PO Transfer" }, + { value: "PO Create", label: "PO Create" }, +] + +type FilterFormValues = z.infer<typeof filterSchema> + +interface RFQFilterSheetProps { + isOpen: boolean; + onClose: () => void; + onSearch?: () => void; + isLoading?: boolean; +} + +// Updated component for inline use (not a sheet anymore) +export function RFQFilterSheet({ + isOpen, + onClose, + onSearch, + isLoading = false +}: RFQFilterSheetProps) { + const router = useRouter() + const params = useParams(); + const lng = params ? (params.lng as string) : 'ko'; + const { t } = useTranslation(lng); + + const [isPending, startTransition] = useTransition() + + // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지 + const [isInitializing, setIsInitializing] = useState(false) + // 마지막으로 적용된 필터를 추적하기 위한 ref + const lastAppliedFilters = useRef<string>("") + + // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경 + const [filters, setFilters] = useQueryState( + "basicFilters", + getFiltersStateParser().withDefault([]) + ) + + // joinOperator 설정 + const [joinOperator, setJoinOperator] = useQueryState( + "basicJoinOperator", + parseAsStringEnum(["and", "or"]).withDefault("and") + ) + + // 현재 URL의 페이지 파라미터도 가져옴 + const [page, setPage] = useQueryState("page", { defaultValue: "1" }) + + // 폼 상태 초기화 + const form = useForm<FilterFormValues>({ + resolver: zodResolver(filterSchema), + defaultValues: { + picCode: "", + projectCode: "", + rfqCode: "", + itemCode: "", + majorItemMaterialCode: "", + status: "", + dateRange: { + from: undefined, + to: undefined, + }, + }, + }) + + // URL 필터에서 초기 폼 상태 설정 - 개선된 버전 + useEffect(() => { + // 현재 필터를 문자열로 직렬화 + const currentFiltersString = JSON.stringify(filters); + + // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트 + if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { + setIsInitializing(true); + + const formValues = { ...form.getValues() }; + let formUpdated = false; + + filters.forEach(filter => { + if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) { + formValues.dateRange = { + from: filter.value[0] ? new Date(filter.value[0]) : undefined, + to: filter.value[1] ? new Date(filter.value[1]) : undefined, + }; + formUpdated = true; + } else if (filter.id in formValues) { + // @ts-ignore - 동적 필드 접근 + formValues[filter.id] = filter.value; + formUpdated = true; + } + }); + + // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트 + if (formUpdated) { + form.reset(formValues); + lastAppliedFilters.current = currentFiltersString; + } + + setIsInitializing(false); + } + }, [filters, isOpen]) // form 의존성 제거 + + // 현재 적용된 필터 카운트 + const getActiveFilterCount = () => { + return filters?.length || 0 + } + + // 폼 제출 핸들러 - PQ 방식으로 수정 (수동 URL 업데이트 버전) + async function onSubmit(data: FilterFormValues) { + // 초기화 중이면 제출 방지 + if (isInitializing) return; + + startTransition(async () => { + try { + // 필터 배열 생성 + const newFilters = [] + + if (data.picCode?.trim()) { + newFilters.push({ + id: "picCode", + value: data.picCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.projectCode?.trim()) { + newFilters.push({ + id: "projectCode", + value: data.projectCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.rfqCode?.trim()) { + newFilters.push({ + id: "rfqCode", + value: data.rfqCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.itemCode?.trim()) { + newFilters.push({ + id: "itemCode", + value: data.itemCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.majorItemMaterialCode?.trim()) { + newFilters.push({ + id: "majorItemMaterialCode", + value: data.majorItemMaterialCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.status?.trim()) { + newFilters.push({ + id: "status", + value: data.status.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + // Add date range to params if it exists + if (data.dateRange?.from) { + newFilters.push({ + id: "rfqSendDate", + value: [ + data.dateRange.from.toISOString().split('T')[0], + data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined + ].filter(Boolean), + type: "date", + operator: "isBetween", + rowId: generateId() + }) + } + + console.log("=== RFQ Filter Submit Debug ==="); + console.log("Generated filters:", newFilters); + console.log("Join operator:", joinOperator); + + // 🔑 PQ 방식: 수동으로 URL 업데이트 (nuqs 대신) + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 기존 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('basicJoinOperator'); + params.delete('page'); + + // 새로운 필터 추가 + if (newFilters.length > 0) { + params.set('basicFilters', JSON.stringify(newFilters)); + params.set('basicJoinOperator', joinOperator); + } + + // 페이지를 1로 설정 + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("New URL:", newUrl); + + // 🔑 PQ 방식: 페이지 완전 새로고침으로 서버 렌더링 강제 + window.location.href = newUrl; + + // 마지막 적용된 필터 업데이트 + lastAppliedFilters.current = JSON.stringify(newFilters); + + // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) + if (onSearch) { + console.log("Calling onSearch..."); + onSearch(); + } + + console.log("=== RFQ Filter Submit Complete ==="); + } catch (error) { + console.error("RFQ 필터 적용 오류:", error); + } + }) + } + + // 필터 초기화 핸들러 - PQ 방식으로 수정 + async function handleReset() { + try { + setIsInitializing(true); + + form.reset({ + picCode: "", + projectCode: "", + rfqCode: "", + itemCode: "", + majorItemMaterialCode: "", + status: "", + dateRange: { from: undefined, to: undefined }, + }); + + console.log("=== RFQ Filter Reset Debug ==="); + console.log("Current URL before reset:", window.location.href); + + // 🔑 PQ 방식: 수동으로 URL 초기화 + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('basicJoinOperator'); + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("Reset URL:", newUrl); + + // 🔑 PQ 방식: 페이지 완전 새로고침 + window.location.href = newUrl; + + // 마지막 적용된 필터 초기화 + lastAppliedFilters.current = ""; + + console.log("RFQ 필터 초기화 완료"); + setIsInitializing(false); + } catch (error) { + console.error("RFQ 필터 초기화 오류:", error); + setIsInitializing(false); + } + } + + // Don't render if not open (for side panel use) + if (!isOpen) { + return null; + } + + return ( + <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> + {/* Filter Panel Header */} + <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> + <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3> + <div className="flex items-center gap-2"> + {getActiveFilterCount() > 0 && ( + <Badge variant="secondary" className="px-2 py-1"> + {getActiveFilterCount()}개 필터 적용됨 + </Badge> + )} + </div> + </div> + + {/* Join Operator Selection */} + <div className="px-6 shrink-0"> + <label className="text-sm font-medium">조건 결합 방식</label> + <Select + value={joinOperator} + onValueChange={(value: "and" | "or") => setJoinOperator(value)} + disabled={isInitializing} + > + <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> + <SelectValue placeholder="조건 결합 방식" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> + <SelectItem value="or">하나라도 충족 (OR)</SelectItem> + </SelectContent> + </Select> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> + {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */} + <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> + <div className="space-y-4 pt-2"> + {/* 발주 담당 */} + <FormField + control={form.control} + name="picCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("발주담당")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("발주담당 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("picCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트 코드 */} + <FormField + control={form.control} + name="projectCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("프로젝트 코드")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("프로젝트 코드 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("projectCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* RFQ NO. */} + <FormField + control={form.control} + name="rfqCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("RFQ NO.")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("RFQ 번호 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("rfqCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재그룹 */} + <FormField + control={form.control} + name="itemCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("자재그룹")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("자재그룹 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("itemCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재코드 */} + <FormField + control={form.control} + name="majorItemMaterialCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("자재코드")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("자재코드 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("majorItemMaterialCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Status */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("Status")}</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder={t("Select status")} /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("status", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {statusOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* RFQ 전송일 */} + <FormField + control={form.control} + name="dateRange" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("RFQ 전송일")}</FormLabel> + <FormControl> + <div className="relative"> + <DateRangePicker + triggerSize="default" + triggerClassName="w-full bg-white" + align="start" + showClearButton={true} + placeholder={t("RFQ 전송일 범위를 고르세요")} + value={field.value || undefined} + onChange={field.onChange} + disabled={isInitializing} + /> + {(field.value?.from || field.value?.to) && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-10 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("dateRange", { from: undefined, to: undefined }); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + {/* Fixed buttons at bottom */} + <div className="p-4 shrink-0"> + <div className="flex gap-2 justify-end"> + <Button + type="button" + variant="outline" + onClick={handleReset} + disabled={isPending || getActiveFilterCount() === 0 || isInitializing} + className="px-4" + > + {t("초기화")} + </Button> + <Button + type="submit" + variant="samsung" + disabled={isPending || isLoading || isInitializing} + className="px-4" + > + <Search className="size-4 mr-2" /> + {isPending || isLoading ? t("조회 중...") : t("조회")} + </Button> + </div> + </div> + </form> + </Form> + </div> + ) +}
\ No newline at end of file |
