260 lines
8.0 KiB
TypeScript
260 lines
8.0 KiB
TypeScript
"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<string, any>;
|
|
enabled?: boolean; // false면 fetch 안 함 (디자인 모드)
|
|
/** D.2 — enabled 일 때만 entityJoinApi 에 전달 */
|
|
dataFilter?: DataFilterConfig;
|
|
/** D.2 — enabled 일 때만 entityJoinApi 에 전달 */
|
|
excludeFilter?: ExcludeFilterPayload;
|
|
}
|
|
|
|
export interface UseTableDataResult {
|
|
data: Record<string, any>[];
|
|
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<string, any>) => void;
|
|
refresh: () => void;
|
|
/**
|
|
* Phase D.9 (2026-05-20) — DataReceivable.receiveData() 가 local data 를 override.
|
|
* append/replace/merge 결과를 통째 적용. fetch refresh 전까지 유지. totalOverride 미지정 시 length 사용.
|
|
*/
|
|
setLocalData: (next: Record<string, any>[], 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<Record<string, any>[]>([]);
|
|
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<Record<string, any>>(externalSearch || {});
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(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<string, any>) => {
|
|
setSearch(s);
|
|
setPage(1);
|
|
}, []);
|
|
|
|
const setLocalData = useCallback((next: Record<string, any>[], 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,
|
|
};
|
|
}
|