From 20800b214145ee6056f94ca18fa1054f145eb977 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 28 May 2025 00:32:31 +0000 Subject: (대표님) lib 파트 커밋 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cancel-investigation-dialog.tsx | 69 +++ .../pq-review-table-new/feature-flags-provider.tsx | 108 ++++ lib/pq/pq-review-table-new/pq-container.tsx | 151 +++++ lib/pq/pq-review-table-new/pq-filter-sheet.tsx | 651 +++++++++++++++++++++ .../request-investigation-dialog.tsx | 331 +++++++++++ lib/pq/pq-review-table-new/send-results-dialog.tsx | 69 +++ lib/pq/pq-review-table-new/user-combobox.tsx | 122 ++++ .../pq-review-table-new/vendors-table-columns.tsx | 640 ++++++++++++++++++++ .../vendors-table-toolbar-actions.tsx | 351 +++++++++++ lib/pq/pq-review-table-new/vendors-table.tsx | 308 ++++++++++ 10 files changed, 2800 insertions(+) create mode 100644 lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx create mode 100644 lib/pq/pq-review-table-new/feature-flags-provider.tsx create mode 100644 lib/pq/pq-review-table-new/pq-container.tsx create mode 100644 lib/pq/pq-review-table-new/pq-filter-sheet.tsx create mode 100644 lib/pq/pq-review-table-new/request-investigation-dialog.tsx create mode 100644 lib/pq/pq-review-table-new/send-results-dialog.tsx create mode 100644 lib/pq/pq-review-table-new/user-combobox.tsx create mode 100644 lib/pq/pq-review-table-new/vendors-table-columns.tsx create mode 100644 lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx create mode 100644 lib/pq/pq-review-table-new/vendors-table.tsx (limited to 'lib/pq/pq-review-table-new') diff --git a/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx b/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx new file mode 100644 index 00000000..03045537 --- /dev/null +++ b/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx @@ -0,0 +1,69 @@ +"use client" + +import * as React from "react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +interface CancelInvestigationDialogProps { + isOpen: boolean + onClose: () => void + onConfirm: () => Promise + selectedCount: number +} + +export function CancelInvestigationDialog({ + isOpen, + onClose, + onConfirm, + selectedCount, +}: CancelInvestigationDialogProps) { + const [isPending, setIsPending] = React.useState(false) + + async function handleConfirm() { + setIsPending(true) + try { + await onConfirm() + } finally { + setIsPending(false) + } + } + + return ( + !open && onClose()}> + + + 실사 의뢰 취소 + + 선택한 {selectedCount}개 협력업체의 실사 의뢰를 취소하시겠습니까? + 계획 상태인 실사만 취소할 수 있습니다. + + + + + + + + + ) +} \ No newline at end of file diff --git a/lib/pq/pq-review-table-new/feature-flags-provider.tsx b/lib/pq/pq-review-table-new/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/pq/pq-review-table-new/feature-flags-provider.tsx @@ -0,0 +1,108 @@ +"use client" + +import * as React from "react" +import { useQueryState } from "nuqs" + +import { dataTableConfig, type DataTableConfig } from "@/config/data-table" +import { cn } from "@/lib/utils" +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" + +type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] + +interface FeatureFlagsContextProps { + featureFlags: FeatureFlagValue[] + setFeatureFlags: (value: FeatureFlagValue[]) => void +} + +const FeatureFlagsContext = React.createContext({ + featureFlags: [], + setFeatureFlags: () => {}, +}) + +export function useFeatureFlags() { + const context = React.useContext(FeatureFlagsContext) + if (!context) { + throw new Error( + "useFeatureFlags must be used within a FeatureFlagsProvider" + ) + } + return context +} + +interface FeatureFlagsProviderProps { + children: React.ReactNode +} + +export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { + const [featureFlags, setFeatureFlags] = useQueryState( + "flags", + { + defaultValue: [], + parse: (value) => value.split(",") as FeatureFlagValue[], + serialize: (value) => value.join(","), + eq: (a, b) => + a.length === b.length && a.every((value, index) => value === b[index]), + clearOnDefault: true, + shallow: false, + } + ) + + return ( + void setFeatureFlags(value), + }} + > +
+ setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + + + + + + +
{flag.tooltipTitle}
+
+ {flag.tooltipDescription} +
+
+
+ ))} +
+
+ {children} +
+ ) +} diff --git a/lib/pq/pq-review-table-new/pq-container.tsx b/lib/pq/pq-review-table-new/pq-container.tsx new file mode 100644 index 00000000..ebe46809 --- /dev/null +++ b/lib/pq/pq-review-table-new/pq-container.tsx @@ -0,0 +1,151 @@ +"use client" + +import { useState, useEffect, useCallback, useRef } from "react" +import { useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { PanelLeftClose, PanelLeftOpen } from "lucide-react" + +import { cn } from "@/lib/utils" +import { getPQSubmissions } from "../service" +import { PQSubmissionsTable } from "./vendors-table" +import { PQFilterSheet } from "./pq-filter-sheet" + +interface PQContainerProps { + // Promise.all로 감싼 promises를 받음 + promises: Promise<[Awaited>]> + // 컨테이너 클래스명 (옵션) + className?: string +} + +export default function PQContainer({ + promises, + className +}: PQContainerProps) { + const searchParams = useSearchParams() + + // Whether the filter panel is open + const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false) + + // Container wrapper의 위치를 측정하기 위한 ref + const containerRef = useRef(null) + const [containerTop, setContainerTop] = useState(0) + + // Container 위치 측정 함수 - top만 측정 + const updateContainerBounds = useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setContainerTop(rect.top) + } + }, []) + + // 컴포넌트 마운트 시와 윈도우 리사이즈 시 위치 업데이트 + useEffect(() => { + updateContainerBounds() + + const handleResize = () => { + updateContainerBounds() + } + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', updateContainerBounds) + + return () => { + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', updateContainerBounds) + } + }, [updateContainerBounds]) + + // 조회 버튼 클릭 핸들러 - PQFilterSheet에 전달 + const handleSearch = () => { + // Close the panel after search + setIsFilterPanelOpen(false) + } + + // Get active filter count for UI display (서버 사이드 필터만 계산) + const getActiveFilterCount = () => { + try { + // 새로운 이름 우선, 기존 이름도 지원 + const basicFilters = searchParams.get('basicFilters') || searchParams.get('pqBasicFilters') + return basicFilters ? JSON.parse(basicFilters).length : 0 + } catch (e) { + return 0 + } + } + + // Filter panel width + const FILTER_PANEL_WIDTH = 400; + + return ( + <> + {/* Filter Panel - fixed positioning으로 화면 최대 좌측에서 시작 */} +
+ {/* Filter Content */} +
+ setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} // 로딩 상태 제거 + /> +
+
+ + {/* Main Content Container */} +
+
+ {/* Main Content - 너비 조정으로 필터 패널 공간 확보 */} +
+ {/* Header Bar */} +
+
+ +
+
+ + {/* Table Content Area */} +
+
+ {/* Promise를 직접 전달 - Items와 동일한 패턴 */} + +
+
+
+
+
+ + ) +} \ No newline at end of file diff --git a/lib/pq/pq-review-table-new/pq-filter-sheet.tsx b/lib/pq/pq-review-table-new/pq-filter-sheet.tsx new file mode 100644 index 00000000..979f25a2 --- /dev/null +++ b/lib/pq/pq-review-table-new/pq-filter-sheet.tsx @@ -0,0 +1,651 @@ +"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) + +// PQ 필터 스키마 정의 +const pqFilterSchema = z.object({ + requesterName: z.string().optional(), + pqNumber: z.string().optional(), + vendorName: z.string().optional(), + status: z.string().optional(), + evaluationResult: z.string().optional(), + createdAtRange: z.object({ + from: z.date().optional(), + to: z.date().optional(), + }).optional(), +}) + +// PQ 상태 옵션 정의 +const pqStatusOptions = [ + { value: "REQUESTED", label: "요청됨" }, + { value: "IN_PROGRESS", label: "진행 중" }, + { value: "SUBMITTED", label: "제출됨" }, + { value: "APPROVED", label: "승인됨" }, + { value: "REJECTED", label: "거부됨" }, +] + +// 평가 결과 옵션 정의 +const evaluationResultOptions = [ + { value: "APPROVED", label: "승인" }, + { value: "SUPPLEMENT", label: "보완" }, + { value: "REJECTED", label: "불가" }, +] + +type PQFilterFormValues = z.infer + +interface PQFilterSheetProps { + isOpen: boolean; + onClose: () => void; + onSearch?: () => void; + isLoading?: boolean; +} + +export function PQFilterSheet({ + isOpen, + onClose, + onSearch, + isLoading = false +}: PQFilterSheetProps) { + 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("") + + // nuqs로 URL 상태 관리 - 파라미터명을 'pqBasicFilters'로 변경 + 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({ + resolver: zodResolver(pqFilterSchema), + defaultValues: { + requesterName: "", + pqNumber: "", + vendorName: "", + status: "", + evaluationResult: "", + createdAtRange: { + 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 === "createdAt" && Array.isArray(filter.value) && filter.value.length > 0) { + formValues.createdAtRange = { + 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]) + + // 현재 적용된 필터 카운트 + const getActiveFilterCount = () => { + return filters?.length || 0 + } + +// 폼 제출 핸들러 - 수동 URL 업데이트 버전 +async function onSubmit(data: PQFilterFormValues) { + // 초기화 중이면 제출 방지 + if (isInitializing) return; + + startTransition(async () => { + try { + // 필터 배열 생성 + const newFilters = [] + + if (data.requesterName?.trim()) { + newFilters.push({ + id: "requesterName", + value: data.requesterName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.pqNumber?.trim()) { + newFilters.push({ + id: "pqNumber", + value: data.pqNumber.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.vendorName?.trim()) { + newFilters.push({ + id: "vendorName", + value: data.vendorName.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.evaluationResult?.trim()) { + newFilters.push({ + id: "evaluationResult", + value: data.evaluationResult.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + // 생성일 범위 추가 + if (data.createdAtRange?.from) { + newFilters.push({ + id: "createdAt", + value: [ + data.createdAtRange.from.toISOString().split('T')[0], + data.createdAtRange.to ? data.createdAtRange.to.toISOString().split('T')[0] : undefined + ].filter(Boolean), + type: "date", + operator: "isBetween", + rowId: generateId() + }) + } + + // 수동으로 URL 업데이트 (nuqs 대신) + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 기존 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('pqBasicFilters'); + params.delete('basicJoinOperator'); + params.delete('pqBasicJoinOperator'); + 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); + + // 페이지 완전 새로고침으로 서버 렌더링 강제 + window.location.href = newUrl; + + // 마지막 적용된 필터 업데이트 + lastAppliedFilters.current = JSON.stringify(newFilters); + + // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) + if (onSearch) { + console.log("Calling onSearch..."); + onSearch(); + } + + console.log("=== PQ Filter Submit Complete ==="); + } catch (error) { + console.error("PQ 필터 적용 오류:", error); + } + }) +} + + // 필터 초기화 핸들러 + // 필터 초기화 핸들러 + async function handleReset() { + try { + setIsInitializing(true); + + form.reset({ + requesterName: "", + pqNumber: "", + vendorName: "", + status: "", + evaluationResult: "", + createdAtRange: { from: undefined, to: undefined }, + }); + + console.log("=== PQ Filter Reset Debug ==="); + console.log("Current URL before reset:", window.location.href); + + // 수동으로 URL 초기화 + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('pqBasicFilters'); + params.delete('basicJoinOperator'); + params.delete('pqBasicJoinOperator'); + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("Reset URL:", newUrl); + + // 페이지 완전 새로고침 + window.location.href = newUrl; + + // 마지막 적용된 필터 초기화 + lastAppliedFilters.current = ""; + + console.log("PQ 필터 초기화 완료"); + setIsInitializing(false); + } catch (error) { + console.error("PQ 필터 초기화 오류:", error); + setIsInitializing(false); + } + } + + // Don't render if not open (for side panel use) + if (!isOpen) { + return null; + } + + return ( +
+ {/* Filter Panel Header */} +
+

PQ 검색 필터

+
+ {getActiveFilterCount() > 0 && ( + + {getActiveFilterCount()}개 필터 적용됨 + + )} +
+
+ + {/* Join Operator Selection */} +
+ + +
+ +
+ + {/* Scrollable content area */} +
+
+ {/* 요청자명 */} + ( + + 요청자명 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* PQ 번호 */} + ( + + PQ 번호 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* 협력업체명 */} + ( + + 협력업체명 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* PQ 상태 */} + ( + + PQ 상태 + + + + )} + /> + + {/* 평가 결과 */} + ( + + 평가 결과 + + + + )} + /> + + {/* PQ 생성일 */} + ( + + PQ 생성일 + +
+ + {(field.value?.from || field.value?.to) && ( + + )} +
+
+ +
+ )} + /> +
+
+ + {/* Fixed buttons at bottom */} +
+
+ + +
+
+
+ +
+ ) +} \ No newline at end of file diff --git a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx new file mode 100644 index 00000000..d5588be4 --- /dev/null +++ b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx @@ -0,0 +1,331 @@ +"use client" + +import * as React from "react" +import { CalendarIcon } from "lucide-react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { format } from "date-fns" +import { z } from "zod" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { UserCombobox } from "./user-combobox" +import { getQMManagers } from "@/lib/pq/service" + +// QM 사용자 타입 +interface QMUser { + id: number + name: string + email: string + department?: string +} + +const requestInvestigationFormSchema = z.object({ + evaluationType: z.enum(["SITE_AUDIT", "QM_SELF_AUDIT"], { + required_error: "평가 유형을 선택해주세요.", + }), + qmManagerId: z.number({ + required_error: "QM 담당자를 선택해주세요.", + }), + forecastedAt: z.date({ + required_error: "실사 예정일을 선택해주세요.", + }), + investigationAddress: z.string().min(1, "실사 장소를 입력해주세요."), + investigationMethod: z.string().optional(), + investigationNotes: z.string().optional(), +}) + +type RequestInvestigationFormValues = z.infer + +interface RequestInvestigationDialogProps { + isOpen: boolean + onClose: () => void + onSubmit: (data: { + evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT", + qmManagerId: number, + forecastedAt: Date, + investigationAddress: string, + investigationMethod?: string, + investigationNotes?: string + }) => Promise + selectedCount: number + // 선택된 행에서 가져온 초기값 + initialData?: { + evaluationType?: "SITE_AUDIT" | "QM_SELF_AUDIT", + qmManagerId?: number, + forecastedAt?: Date, + investigationAddress?: string, + investigationMethod?: string, + investigationNotes?: string + } +} + +export function RequestInvestigationDialog({ + isOpen, + onClose, + onSubmit, + selectedCount, + initialData, +}: RequestInvestigationDialogProps) { + const [isPending, setIsPending] = React.useState(false) + const [qmManagers, setQMManagers] = React.useState([]) + const [isLoadingManagers, setIsLoadingManagers] = React.useState(false) + + // form 객체 생성 시 initialData 활용 + const form = useForm({ + resolver: zodResolver(requestInvestigationFormSchema), + defaultValues: { + evaluationType: initialData?.evaluationType || "SITE_AUDIT", + qmManagerId: initialData?.qmManagerId || undefined, + forecastedAt: initialData?.forecastedAt || undefined, + investigationAddress: initialData?.investigationAddress || "", + investigationMethod: initialData?.investigationMethod || "", + investigationNotes: initialData?.investigationNotes || "", + }, + }) + + // Dialog가 열릴 때마다 초기값으로 폼 재설정 + React.useEffect(() => { + if (isOpen) { + form.reset({ + evaluationType: initialData?.evaluationType || "SITE_AUDIT", + qmManagerId: initialData?.qmManagerId || undefined, + forecastedAt: initialData?.forecastedAt || undefined, + investigationAddress: initialData?.investigationAddress || "", + investigationMethod: initialData?.investigationMethod || "", + investigationNotes: initialData?.investigationNotes || "", + }); + } + }, [isOpen, initialData, form]); + + // Dialog가 열릴 때 QM 담당자 목록 로드 + React.useEffect(() => { + if (isOpen && qmManagers.length === 0) { + const loadQMManagers = async () => { + setIsLoadingManagers(true) + try { + const result = await getQMManagers() + if (result.success && result.data) { + setQMManagers(result.data) + } + } catch (error) { + console.error("QM 담당자 로드 오류:", error) + } finally { + setIsLoadingManagers(false) + } + } + + loadQMManagers() + } + }, [isOpen, qmManagers.length]) + + async function handleSubmit(data: RequestInvestigationFormValues) { + setIsPending(true) + try { + await onSubmit(data) + } finally { + setIsPending(false) + form.reset() + } + } + + return ( + !open && onClose()}> + + + 실사 의뢰 + + {selectedCount}개 협력업체에 대한 실사를 의뢰합니다. 실사 관련 정보를 입력해주세요. + + +
+ + ( + + 평가 유형 + + + + )} + /> + + ( + + QM 담당자 + + + + + + )} + /> + + ( + + 실사 예정일 + + + + + + + + date < new Date()} + initialFocus + /> + + + + + )} + /> + + ( + + 실사 장소 + +