diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-06 04:23:40 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-06 04:23:40 +0000 |
| commit | de2ac5a2860bc25180971e7a11f852d9d44675b7 (patch) | |
| tree | b931c363f2cb19e177a0a7b17190d5de2a82d709 /lib/evaluation/table | |
| parent | 6c549b0f264e9be4d60af38f9efc05b189d6849f (diff) | |
(대표님) 정기평가, 법적검토, 정책, 가입관련 처리 및 관련 컴포넌트 추가, 메뉴 변경
Diffstat (limited to 'lib/evaluation/table')
| -rw-r--r-- | lib/evaluation/table/evaluation-filter-sheet.tsx | 234 | ||||
| -rw-r--r-- | lib/evaluation/table/evaluation-table.tsx | 223 |
2 files changed, 339 insertions, 118 deletions
diff --git a/lib/evaluation/table/evaluation-filter-sheet.tsx b/lib/evaluation/table/evaluation-filter-sheet.tsx index b0bf9139..c2dd9734 100644 --- a/lib/evaluation/table/evaluation-filter-sheet.tsx +++ b/lib/evaluation/table/evaluation-filter-sheet.tsx @@ -7,7 +7,6 @@ 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 { @@ -28,7 +27,6 @@ import { SelectValue, } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -import { getFiltersStateParser } from "@/lib/parsers"; import { EVALUATION_TARGET_FILTER_OPTIONS } from "@/lib/evaluation-target-list/validation"; /***************************************************************************************** @@ -49,7 +47,6 @@ const statusOptions = [ { value: "FINALIZED", label: "결과확정" }, ]; - const documentsSubmittedOptions = [ { value: "true", label: "제출완료" }, { value: "false", label: "미제출" }, @@ -104,14 +101,13 @@ export function PeriodicEvaluationFilterSheet({ isLoading = false, onFiltersApply, }: PeriodicEvaluationFilterSheetProps) { - /** Router (needed only for pathname) */ + /** Router (needed for URL updates) */ const router = useRouter(); /** Track pending state while we update URL */ const [isPending, startTransition] = useTransition(); const [joinOperator, setJoinOperator] = useState<"and" | "or">("and") - /** React‑Hook‑Form */ const form = useForm<PeriodicEvaluationFilterFormValues>({ resolver: zodResolver(periodicEvaluationFilterSchema), @@ -131,82 +127,156 @@ export function PeriodicEvaluationFilterSheet({ }, }); - /***************************************************************************************** - * 3️⃣ Submit → build filter array → push to URL (and reset page=1) + * 3️⃣ Submit → build filter array → callback + URL (동기적 처리) *****************************************************************************************/ async function onSubmit(data: PeriodicEvaluationFilterFormValues) { startTransition(() => { try { - const newFilters: any[] = []; - - const pushFilter = ( - id: string, - value: any, - type: "text" | "select" | "number" | "boolean", - operator: "eq" | "iLike" | "gte" | "lte" - ) => { - newFilters.push({ id, value, type, operator, rowId: generateId() }); - }; - - if (data.evaluationYear?.trim()) - pushFilter("evaluationYear", Number(data.evaluationYear), "number", "eq"); - - if (data.division?.trim()) - pushFilter("division", data.division.trim(), "select", "eq"); - - if (data.status?.trim()) - pushFilter("status", data.status.trim(), "select", "eq"); - - if (data.domesticForeign?.trim()) - pushFilter("domesticForeign", data.domesticForeign.trim(), "select", "eq"); - - if (data.materialType?.trim()) - pushFilter("materialType", data.materialType.trim(), "select", "eq"); - - if (data.vendorCode?.trim()) - pushFilter("vendorCode", data.vendorCode.trim(), "text", "iLike"); - - if (data.vendorName?.trim()) - pushFilter("vendorName", data.vendorName.trim(), "text", "iLike"); - - if (data.documentsSubmitted?.trim()) - pushFilter( - "documentsSubmitted", - data.documentsSubmitted.trim() === "true", - "boolean", - "eq" - ); - - if (data.evaluationGrade?.trim()) - pushFilter("evaluationGrade", data.evaluationGrade.trim(), "select", "eq"); - - if (data.finalGrade?.trim()) - pushFilter("finalGrade", data.finalGrade.trim(), "select", "eq"); - - if (data.minTotalScore?.trim()) - pushFilter("totalScore", Number(data.minTotalScore), "number", "gte"); - - if (data.maxTotalScore?.trim()) - pushFilter("totalScore", Number(data.maxTotalScore), "number", "lte"); - - setJoinOperator(joinOperator); - - + const newFilters = [] + + // 필터 생성 로직 + if (data.evaluationYear?.trim()) { + newFilters.push({ + id: "evaluationYear", + value: parseInt(data.evaluationYear.trim()), + type: "number", + operator: "eq", + rowId: generateId() + }) + } + + if (data.division?.trim()) { + newFilters.push({ + id: "division", + value: data.division.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.status?.trim()) { + newFilters.push({ + id: "status", + value: data.status.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.domesticForeign?.trim()) { + newFilters.push({ + id: "domesticForeign", + value: data.domesticForeign.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.materialType?.trim()) { + newFilters.push({ + id: "materialType", + value: data.materialType.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.vendorCode?.trim()) { + newFilters.push({ + id: "vendorCode", + value: data.vendorCode.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.documentsSubmitted?.trim()) { + newFilters.push({ + id: "documentsSubmitted", + value: data.documentsSubmitted.trim() === "true", + type: "boolean", + operator: "eq", + rowId: generateId() + }) + } + + if (data.evaluationGrade?.trim()) { + newFilters.push({ + id: "evaluationGrade", + value: data.evaluationGrade.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.finalGrade?.trim()) { + newFilters.push({ + id: "finalGrade", + value: data.finalGrade.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.minTotalScore?.trim()) { + newFilters.push({ + id: "totalScore", + value: parseFloat(data.minTotalScore.trim()), + type: "number", + operator: "gte", + rowId: generateId() + }) + } + + if (data.maxTotalScore?.trim()) { + newFilters.push({ + id: "totalScore", + value: parseFloat(data.maxTotalScore.trim()), + type: "number", + operator: "lte", + rowId: generateId() + }) + } + + console.log("=== 생성된 필터들 ===", newFilters); + console.log("=== 조인 연산자 ===", joinOperator); + + + // ✅ 부모 컴포넌트에 필터 전달 (동기적으로 즉시 호출) onFiltersApply(newFilters, joinOperator); - } catch (err) { - // eslint-disable-next-line no-console - console.error("정기평가 필터 적용 오류:", err); + + console.log("=== 필터 적용 완료 ==="); + } catch (error) { + console.error("정기평가 필터 적용 오류:", error); } }); } /***************************************************************************************** - * 4️⃣ Reset → clear form & URL + * 4️⃣ Reset → clear form & URL (동기적 처리) *****************************************************************************************/ - async function handleReset() { + function handleReset() { + // 1. 폼 초기화 form.reset({ - evaluationYear: new Date().getFullYear().toString(), + evaluationYear: "", division: "", status: "", domesticForeign: "", @@ -220,9 +290,26 @@ export function PeriodicEvaluationFilterSheet({ maxTotalScore: "", }); - onFiltersApply([], "and"); + // 2. 조인 연산자 초기화 setJoinOperator("and"); + // 3. URL 파라미터 초기화 (필터를 빈 배열로 설정) + 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"); // 검색어 제거 + + // URL 업데이트 + router.replace(`${currentUrl.pathname}?${newSearchParams.toString()}`); + + // 4. 빈 필터 배열 전달 (즉시 UI 업데이트를 위해) + onFiltersApply([], "and"); + + console.log("=== 필터 완전 초기화 완료 ==="); } /***************************************************************************************** @@ -306,7 +393,6 @@ export function PeriodicEvaluationFilterSheet({ )} /> - {/* 구분 */} <FormField control={form.control} @@ -472,7 +558,7 @@ export function PeriodicEvaluationFilterSheet({ className="-mr-2 h-4 w-4" onClick={(e) => { e.stopPropagation(); - form.setValue("materialType", ""); + form.setValue("materialType", ""); }} disabled={isPending} > @@ -798,7 +884,7 @@ export function PeriodicEvaluationFilterSheet({ type="button" variant="outline" onClick={handleReset} - disabled={isPending } + disabled={isPending} className="px-4" > 초기화 @@ -806,7 +892,7 @@ export function PeriodicEvaluationFilterSheet({ <Button type="submit" variant="samsung" - disabled={isPending || isLoading } + disabled={isPending || isLoading} className="px-4" > <Search className="mr-2 size-4" /> @@ -818,4 +904,4 @@ export function PeriodicEvaluationFilterSheet({ </Form> </div> ); -} +}
\ No newline at end of file diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx index 257225c8..4404967a 100644 --- a/lib/evaluation/table/evaluation-table.tsx +++ b/lib/evaluation/table/evaluation-table.tsx @@ -28,7 +28,7 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv import { cn } from "@/lib/utils" import { useTablePresets } from "@/components/data-table/use-table-presets" import { TablePresetManager } from "@/components/data-table/data-table-preset" -import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet" +import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet" // ✅ 올바른 컴포넌트 이름 import { getPeriodicEvaluationsColumns } from "./evaluation-columns" import { PeriodicEvaluationView, PeriodicEvaluationAggregatedView } from "@/db/schema" import { @@ -300,9 +300,20 @@ export function PeriodicEvaluationsTable({ const [detailedCount, setDetailedCount] = React.useState<number | undefined>(undefined) const [aggregatedCount, setAggregatedCount] = React.useState<number | undefined>(undefined) + // ✅ 외부 필터 상태 (폼에서 전달받은 필터) - EvaluationTargetsTable 패턴과 동일 const [externalFilters, setExternalFilters] = React.useState<any[]>([]); const [externalJoinOperator, setExternalJoinOperator] = React.useState<"and" | "or">("and"); + + // ✅ 폼에서 전달받은 필터를 처리하는 핸들러 - EvaluationTargetsTable 패턴과 동일 + const handleFiltersApply = React.useCallback((filters: any[], joinOperator: "and" | "or") => { + console.log("=== 폼에서 필터 전달받음 ===", filters, joinOperator); + setExternalFilters(filters); + setExternalJoinOperator(joinOperator); + // 필터 적용 후 패널 닫기 + setIsFilterPanelOpen(false); + }, []); + // ✅ 뷰 모드 변경 시 URL 업데이트 const handleViewModeChange = React.useCallback((newMode: "detailed" | "aggregated") => { setViewMode(newMode); @@ -321,14 +332,116 @@ export function PeriodicEvaluationsTable({ router.push(`?${newSearchParams.toString()}`, { scroll: false }) }, [router, searchParams]) - 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] + ) + + // ✅ 초기 데이터 설정 - EvaluationTargetsTable 패턴과 동일 + const [initialPromiseData] = React.use(promises) + const [tableData, setTableData] = React.useState(initialPromiseData) + const [isDataLoading, setIsDataLoading] = React.useState(false) + + // ✅ URL 필터 변경 감지 및 데이터 새로고침 - EvaluationTargetsTable 패턴과 동일 + React.useEffect(() => { + const refetchData = async () => { + try { + setIsDataLoading(true) + + // 현재 URL 파라미터 기반으로 새 검색 파라미터 생성 + 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 currentAggregated = getSearchParam("aggregated") === "true" + + const searchParams = { + filters: currentFilters ? JSON.parse(currentFilters) : [], + joinOperator: currentJoinOperator as "and" | "or", + page: currentPage, + perPage: currentPerPage, + sort: currentSort, + search: currentSearch, + evaluationYear: evaluationYear, + aggregated: currentAggregated + } + + console.log("=== 새 데이터 요청 ===", searchParams) + + // 서버 액션 직접 호출 + const newData = await getPeriodicEvaluationsWithAggregation(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") || + getSearchParam("aggregated") + + if (hasChanges) { + refetchData() + } + }, 300) // 디바운스 시간 단축 + + return () => clearTimeout(timeoutId) + }, [searchString, evaluationYear, getSearchParam]) - // 컨테이너 위치 추적 + const refreshData = React.useCallback(async () => { + try { + setIsDataLoading(true) + + // 현재 URL 파라미터로 데이터 새로고침 + 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 currentAggregated = getSearchParam("aggregated") === "true" + + const searchParams = { + filters: currentFilters ? JSON.parse(currentFilters) : [], + joinOperator: currentJoinOperator as "and" | "or", + page: currentPage, + perPage: currentPerPage, + sort: currentSort, + search: currentSearch, + evaluationYear: evaluationYear, + aggregated: currentAggregated + } + + const newData = await getPeriodicEvaluationsWithAggregation(searchParams) + setTableData(newData) + + console.log("=== 데이터 새로고침 완료 ===", newData.data.length, "건") + } catch (error) { + console.error("데이터 새로고침 오류:", error) + } finally { + setIsDataLoading(false) + } + }, [evaluationYear, getSearchParam]) + + // 컨테이너 위치 추적 - EvaluationTargetsTable 패턴과 동일 const containerRef = React.useRef<HTMLDivElement>(null) const [containerTop, setContainerTop] = React.useState(0) @@ -347,42 +460,47 @@ export function PeriodicEvaluationsTable({ React.useEffect(() => { updateContainerBounds() - const throttledHandler = () => { - let timeoutId: NodeJS.Timeout - return () => { - clearTimeout(timeoutId) - timeoutId = setTimeout(updateContainerBounds, 16) - } + + const handleResize = () => { + updateContainerBounds() } - - const handler = throttledHandler() - window.addEventListener('resize', updateContainerBounds) - window.addEventListener('scroll', handler) - + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', updateContainerBounds) + return () => { - window.removeEventListener('resize', updateContainerBounds) - window.removeEventListener('scroll', handler) + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', updateContainerBounds) } }, [updateContainerBounds]) - // 데이터 로드 - const [promiseData] = React.use(promises) - const tableData = promiseData + 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: currentParams.page || 1, - perPage: currentParams.perPage || 10, - sort: currentParams.sort || [{ id: "createdAt", desc: true }], - filters: currentParams.filters || [], - joinOperator: currentParams.joinOperator || "and", - search: "", + 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: [] - }), [currentParams]) + }), [getSearchParam, parseSearchParam]) const { presets, @@ -469,11 +587,17 @@ export function PeriodicEvaluationsTable({ const getActiveFilterCount = React.useCallback(() => { try { - return currentParams.filters?.length || 0; + // URL에서 현재 필터 수 확인 + const filtersParam = getSearchParam("filters") + if (filtersParam) { + const filters = JSON.parse(filtersParam) + return Array.isArray(filters) ? filters.length : 0 + } + return 0 } catch { - return 0; + return 0 } - }, [currentParams.filters]); + }, [getSearchParam]) const FILTER_PANEL_WIDTH = 400; @@ -491,14 +615,13 @@ export function PeriodicEvaluationsTable({ height: `calc(100vh - ${containerTop}px)` }} > - <div className="h-full"> - <PeriodicEvaluationFilterSheet - isOpen={isFilterPanelOpen} - onClose={() => setIsFilterPanelOpen(false)} - onFiltersApply={handleFiltersApply} - isLoading={false} - /> - </div> + {/* ✅ 올바른 컴포넌트 사용 */} + <PeriodicEvaluationFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onFiltersApply={handleFiltersApply} // ✅ 필터 적용 콜백 전달 + isLoading={false} + /> </div> {/* Main Content Container */} @@ -567,9 +690,18 @@ export function PeriodicEvaluationsTable({ </div> {/* Table Content Area */} - <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 500px)' }}> + <div className="flex-1 overflow-hidden relative" style={{ height: 'calc(100vh - 500px)' }}> + {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"> + {/* ✅ EvaluationTargetsTable 패턴과 동일하게 수정 */} <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} @@ -596,7 +728,10 @@ export function PeriodicEvaluationsTable({ onRenamePreset={renamePreset} /> - <PeriodicEvaluationsTableToolbarActions table={table} /> + <PeriodicEvaluationsTableToolbarActions + table={table} + onRefresh={refreshData} + /> </div> </DataTableAdvancedToolbar> </DataTable> |
