"use client"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import type { DataFilterConfig } from "@/types/screen-management"; /** * useTableData — 통합 table 컴포넌트 데이터 fetch 훅 * * entityJoinApi.getTableDataWithJoins() 호출. * 페이지네이션, 정렬, 검색 상태를 관리. * * Phase D.2 (2026-05-20) — `dataFilter` / `excludeFilter` 를 entityJoinApi 에 그대로 전달. * 호출자는 객체 ref 가 매 렌더마다 신규 생성되지 않도록 memoize 하는 책임이 있다 — 내부적으로도 * stable JSON string 으로 dep 추적해 ref 변동만으로 fetch 폭주가 나지 않게 한다. */ export interface ExcludeFilterPayload { enabled: boolean; referenceTable: string; referenceColumn: string; sourceColumn: string; filterColumn?: string; filterValue?: any; } export interface UseTableDataParams { tableName?: string; page?: number; pageSize?: number; sortBy?: string; sortOrder?: "asc" | "desc"; search?: Record; enabled?: boolean; // false면 fetch 안 함 (디자인 모드) /** D.2 — enabled 일 때만 entityJoinApi 에 전달 */ dataFilter?: DataFilterConfig; /** D.2 — enabled 일 때만 entityJoinApi 에 전달 */ excludeFilter?: ExcludeFilterPayload; } export interface UseTableDataResult { data: Record[]; total: number; totalPages: number; page: number; pageSize: number; sortBy: string; sortOrder: "asc" | "desc"; loading: boolean; error: string | null; // 액션 setPage: (p: number) => void; setPageSize: (s: number) => void; setSortBy: (col: string) => void; toggleSort: (col: string) => void; setSearch: (s: Record) => void; refresh: () => void; /** * Phase D.9 (2026-05-20) — DataReceivable.receiveData() 가 local data 를 override. * append/replace/merge 결과를 통째 적용. fetch refresh 전까지 유지. totalOverride 미지정 시 length 사용. */ setLocalData: (next: Record[], totalOverride?: number) => void; } export function useTableData(params: UseTableDataParams): UseTableDataResult { const { tableName, page: initialPage = 1, pageSize: initialPageSize = 20, sortBy: initialSortBy = "", sortOrder: initialSortOrder = "desc", search: externalSearch, enabled = true, dataFilter, excludeFilter, } = params; // D.2 — dataFilter / excludeFilter 객체 ref 가 매 렌더마다 신규여도 dep 으로 안 잡히도록 // stable JSON string 으로 변환해 fetchData dep 으로 사용. 호출자 책임 보강. const dataFilterJson = useMemo( () => (dataFilter && (dataFilter as any).enabled ? JSON.stringify(dataFilter) : null), [dataFilter], ); const excludeFilterJson = useMemo( () => (excludeFilter && excludeFilter.enabled ? JSON.stringify(excludeFilter) : null), [excludeFilter], ); const [data, setData] = useState[]>([]); const [total, setTotal] = useState(0); const [totalPages, setTotalPages] = useState(1); const [page, setPage] = useState(initialPage); const [pageSize, setPageSize] = useState(initialPageSize); const [sortBy, setSortBy] = useState(initialSortBy); const [sortOrder, setSortOrder] = useState<"asc" | "desc">(initialSortOrder); const [search, setSearch] = useState>(externalSearch || {}); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const initialStateRef = useRef({ tableName, page: initialPage, pageSize: initialPageSize, sortBy: initialSortBy, sortOrder: initialSortOrder, }); // 외부 검색 조건 동기화. // D.2: 필터를 모두 clear 해서 externalSearch 가 undefined 로 바뀐 경우에도 // 내부 search state 를 비워야 stale 검색 조건이 남지 않는다. useEffect(() => { const nextSearch = externalSearch || {}; setSearch((prev) => { const prevKeys = Object.keys(prev); const nextKeys = Object.keys(nextSearch); const changed = prevKeys.length !== nextKeys.length || nextKeys.some((key) => prev[key] !== nextSearch[key]); if (!changed) return prev; return nextSearch; }); setPage(1); }, [externalSearch]); // 데이터 fetch const fetchData = useCallback(async () => { if (!tableName || !enabled) return; setLoading(true); setError(null); try { // 동적 import — 번들 사이즈 최적화 const { entityJoinApi } = await import("@/lib/api/entityJoin"); const response = await entityJoinApi.getTableDataWithJoins(tableName, { page, size: pageSize, sortBy: sortBy || undefined, sortOrder, search: Object.keys(search).length > 0 ? search : undefined, enableEntityJoin: true, // D.2 — JSON 으로 변환된 stable string 이 dep 이지만 실제 payload 는 원본 객체 사용. dataFilter: dataFilter && (dataFilter as any).enabled ? dataFilter : undefined, excludeFilter: excludeFilter && excludeFilter.enabled ? excludeFilter : undefined, }); setData(response.data || []); setTotal(response.total || 0); setTotalPages(response.totalPages || 1); } catch (err: any) { console.error("[useTableData] fetch 실패:", err); setError(err?.message || "데이터 로드 실패"); setData([]); setTotal(0); setTotalPages(1); } finally { setLoading(false); } // dataFilter / excludeFilter 객체 ref 가 아닌 *Json string 만 dep — fetch 폭주 방지 // eslint-disable-next-line react-hooks/exhaustive-deps }, [ tableName, page, pageSize, sortBy, sortOrder, search, enabled, dataFilterJson, excludeFilterJson, ]); useEffect(() => { fetchData(); }, [fetchData]); // 테이블 / config 초기값 변경 시 런타임 상태 동기화. // 초기 mount 에서는 useState(initial*) 값이 이미 권위이므로 reset 하지 않는다. useEffect(() => { const prev = initialStateRef.current; const changed = prev.tableName !== tableName || prev.page !== initialPage || prev.pageSize !== initialPageSize || prev.sortBy !== initialSortBy || prev.sortOrder !== initialSortOrder; if (!changed) return; initialStateRef.current = { tableName, page: initialPage, pageSize: initialPageSize, sortBy: initialSortBy, sortOrder: initialSortOrder, }; setPage(initialPage); setPageSize(initialPageSize); setSortBy(initialSortBy); setSortOrder(initialSortOrder); setSearch(externalSearch || {}); }, [tableName, initialPage, initialPageSize, initialSortBy, initialSortOrder, externalSearch]); const toggleSort = useCallback((col: string) => { setSortBy((prev) => { if (prev === col) { setSortOrder((o) => (o === "asc" ? "desc" : "asc")); return col; } setSortOrder("asc"); return col; }); }, []); const refresh = useCallback(() => { fetchData(); }, [fetchData]); const setPageSizeAction = useCallback((s: number) => { setPageSize(s); setPage(1); }, []); const setSearchAction = useCallback((s: Record) => { setSearch(s); setPage(1); }, []); const setLocalData = useCallback((next: Record[], totalOverride?: number) => { const arr = Array.isArray(next) ? next : []; setData(arr); const t = typeof totalOverride === "number" && totalOverride >= 0 ? totalOverride : arr.length; setTotal(t); const ps = pageSize > 0 ? pageSize : 20; setTotalPages(Math.max(1, Math.ceil(t / ps))); }, [pageSize]); return { data, total, totalPages, page, pageSize, sortBy, sortOrder, loading, error, setPage, setPageSize: setPageSizeAction, setSortBy, toggleSort, setSearch: setSearchAction, refresh, // Phase D.9 — 외부 receiveData() 가 local override. 다음 fetch 까지 유지. setLocalData, }; }