diff options
Diffstat (limited to 'lib/techsales-rfq/table/rfq-filter-sheet.tsx')
| -rw-r--r-- | lib/techsales-rfq/table/rfq-filter-sheet.tsx | 759 |
1 files changed, 759 insertions, 0 deletions
diff --git a/lib/techsales-rfq/table/rfq-filter-sheet.tsx b/lib/techsales-rfq/table/rfq-filter-sheet.tsx new file mode 100644 index 00000000..6021699f --- /dev/null +++ b/lib/techsales-rfq/table/rfq-filter-sheet.tsx @@ -0,0 +1,759 @@ +"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 { 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 { DateRangePicker } from "@/components/date-range-picker" +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" + +// nanoid 생성기 +const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) + +// 필터 스키마 정의 (TechSales RFQ에 맞게 수정) +const filterSchema = z.object({ + rfqCode: z.string().optional(), + materialCode: z.string().optional(), + itemName: z.string().optional(), + pspid: z.string().optional(), + projNm: z.string().optional(), + ptypeNm: z.string().optional(), + createdByName: z.string().optional(), + status: z.string().optional(), + dateRange: z.object({ + from: z.date().optional(), + to: z.date().optional(), + }).optional(), +}) + +// 상태 옵션 정의 (TechSales RFQ 상태에 맞게 수정) +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: "Closed", label: "Closed" }, +] + +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: { + rfqCode: "", + materialCode: "", + itemName: "", + pspid: "", + projNm: "", + ptypeNm: "", + createdByName: "", + 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) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (formValues as any)[filter.id] = filter.value; + formUpdated = true; + } + }); + + // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트 + if (formUpdated) { + form.reset(formValues); + lastAppliedFilters.current = currentFiltersString; + } + + setIsInitializing(false); + } + }, [filters, isOpen, form]) // form 의존성 추가 + + // 현재 적용된 필터 카운트 + const getActiveFilterCount = () => { + return filters?.length || 0 + } + + // 조회 버튼 클릭 핸들러 + const handleSearch = () => { + // 필터 패널 닫기 로직이 있다면 여기에 추가 + if (onSearch) { + onSearch(); + } + } + + // 폼 제출 핸들러 - 개선된 버전 + async function onSubmit(data: FilterFormValues) { + // 초기화 중이면 제출 방지 + if (isInitializing) return; + + startTransition(async () => { + try { + // 필터 배열 생성 + const newFilters = [] + + if (data.rfqCode?.trim()) { + newFilters.push({ + id: "rfqCode", + value: data.rfqCode.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.materialCode?.trim()) { + newFilters.push({ + id: "materialCode", + value: data.materialCode.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.itemName?.trim()) { + newFilters.push({ + id: "itemName", + value: data.itemName.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.pspid?.trim()) { + newFilters.push({ + id: "pspid", + value: data.pspid.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.projNm?.trim()) { + newFilters.push({ + id: "projNm", + value: data.projNm.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.ptypeNm?.trim()) { + newFilters.push({ + id: "ptypeNm", + value: data.ptypeNm.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.createdByName?.trim()) { + newFilters.push({ + id: "createdByName", + value: data.createdByName.trim(), + type: "text" as const, + operator: "iLike" as const, + rowId: generateId() + }) + } + + if (data.status?.trim()) { + newFilters.push({ + id: "status", + value: data.status.trim(), + type: "select" as const, + operator: "eq" as const, + 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) as string[], + type: "date" as const, + operator: "isBetween" as const, + rowId: generateId() + }) + } + + console.log("기본 필터 적용:", newFilters); + + // 마지막 적용된 필터 업데이트 + lastAppliedFilters.current = JSON.stringify(newFilters); + + // 먼저 필터를 설정 + await setFilters(newFilters.length > 0 ? newFilters : null); + + // 그 다음 페이지를 1로 설정 + await setPage("1"); + + // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) + handleSearch(); + + // 페이지 새로고침으로 서버 데이터 다시 가져오기 + setTimeout(() => { + window.location.reload(); + }, 100); + } catch (error) { + console.error("필터 적용 오류:", error); + } + }) + } + + // 필터 초기화 핸들러 - 개선된 버전 + async function handleReset() { + try { + setIsInitializing(true); + + form.reset({ + rfqCode: "", + materialCode: "", + itemName: "", + pspid: "", + projNm: "", + ptypeNm: "", + createdByName: "", + status: "", + dateRange: { from: undefined, to: undefined }, + }); + + // 필터와 조인 연산자를 초기화 + await setFilters(null); + await setJoinOperator("and"); + await setPage("1"); + + // 마지막 적용된 필터 초기화 + lastAppliedFilters.current = ""; + + console.log("필터 초기화 완료"); + setIsInitializing(false); + + // 페이지 새로고침으로 서버 데이터 다시 가져오기 + setTimeout(() => { + window.location.reload(); + }, 100); + } catch (error) { + console.error("필터 초기화 오류:", 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 p-4"> + {/* 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> + + {/* 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-6 pt-4"> + {/* 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="materialCode" + 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("materialCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재명 */} + <FormField + control={form.control} + name="itemName" + 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("itemName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트 ID */} + <FormField + control={form.control} + name="pspid" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("프로젝트 ID")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("프로젝트 ID 입력")} + {...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("pspid", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트명 */} + <FormField + control={form.control} + name="projNm" + 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("projNm", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선종명 */} + <FormField + control={form.control} + name="ptypeNm" + 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("ptypeNm", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 요청자 */} + <FormField + control={form.control} + name="createdByName" + 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("createdByName", ""); + }} + 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 전송일 범위를 고르세요")} + date={field.value || undefined} + onDateChange={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 |
