/** * useDataSource - POP 컴포넌트용 데이터 CRUD 통합 훅 * * DataSourceConfig를 받아서 자동으로 적절한 API를 선택하여 데이터를 조회/생성/수정/삭제한다. * * 조회 분기: * - aggregation 또는 joins가 있으면 → SQL 빌더 + executeQuery (대시보드와 동일) * - 그 외 → dataApi.getTableData (단순 테이블 조회) * * CRUD: * - save: dataApi.createRecord * - update: dataApi.updateRecord * - remove: dataApi.deleteRecord * * 사용 패턴: * ```typescript * // 집계 조회 (대시보드용) * const { data, loading } = useDataSource({ * tableName: "sales_order", * aggregation: { type: "sum", column: "amount", groupBy: ["category"] }, * refreshInterval: 30, * }); * * // 단순 목록 조회 (테이블용) * const { data, refetch } = useDataSource({ * tableName: "purchase_order", * sort: [{ column: "created_at", direction: "desc" }], * limit: 20, * }); * * // 저장/삭제 (버튼용) * const { save, remove } = useDataSource({ tableName: "inbound_record" }); * await save({ supplier_id: "SUP-001", quantity: 50 }); * ``` */ import { useState, useCallback, useEffect, useRef } from "react"; import { apiClient } from "@/lib/api/client"; import { dashboardApi } from "@/lib/api/dashboard"; import { dataApi } from "@/lib/api/data"; import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types"; import { validateDataSourceConfig, buildAggregationSQL } from "./popSqlBuilder"; // ===== 타입 정의 ===== /** 조회 결과 */ export interface DataSourceResult { /** 데이터 행 배열 */ rows: Record[]; /** 단일 집계 값 (aggregation 시) 또는 전체 행 수 */ value: number; /** 전체 행 수 (페이징용) */ total: number; } /** CRUD 작업 결과 */ export interface MutationResult { success: boolean; data?: unknown; error?: string; } /** refetch 시 전달할 오버라이드 필터 */ interface OverrideOptions { filters?: Record; } // ===== 내부: 집계/조인 조회 ===== /** * 집계 또는 조인이 포함된 DataSourceConfig를 SQL로 변환하여 실행 * dataFetcher.ts의 fetchAggregatedData와 동일한 로직 */ async function fetchWithSqlBuilder( config: DataSourceConfig ): Promise { const sql = buildAggregationSQL(config); // API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백 let queryResult: { columns: string[]; rows: Record[] }; try { // 1차: apiClient (axios 기반, 인증/세션 안정적) const response = await apiClient.post("/dashboards/execute-query", { query: sql }); if (response.data?.success && response.data?.data) { queryResult = response.data.data; } else { throw new Error(response.data?.message || "쿼리 실행 실패"); } } catch { // 2차: dashboardApi (fetch 기반, 폴백) queryResult = await dashboardApi.executeQuery(sql); } if (queryResult.rows.length === 0) { return { rows: [], value: 0, total: 0 }; } // PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨 → 숫자 변환 const processedRows = queryResult.rows.map((row) => { const converted: Record = { ...row }; for (const key of Object.keys(converted)) { const val = converted[key]; if (typeof val === "string" && val !== "" && !isNaN(Number(val))) { converted[key] = Number(val); } } return converted; }); // 첫 번째 행의 value 컬럼 추출 const firstRow = processedRows[0]; const numericValue = parseFloat( String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0) ); return { rows: processedRows, value: Number.isFinite(numericValue) ? numericValue : 0, total: processedRows.length, }; } // ===== 내부: 단순 테이블 조회 ===== /** * aggregation/joins 없는 단순 테이블 조회 * dataApi.getTableData 래핑 */ async function fetchSimpleTable( config: DataSourceConfig, overrideFilters?: Record ): Promise { // config.filters를 Record 형태로 변환 const baseFilters: Record = {}; if (config.filters?.length) { for (const f of config.filters) { if (f.column?.trim()) { baseFilters[f.column] = f.value; } } } // overrideFilters가 있으면 병합 (같은 키는 override가 덮어씀) const mergedFilters = overrideFilters ? { ...baseFilters, ...overrideFilters } : baseFilters; const tableResult = await dataApi.getTableData(config.tableName, { page: 1, size: config.limit ?? 100, sortBy: config.sort?.[0]?.column, sortOrder: config.sort?.[0]?.direction, filters: Object.keys(mergedFilters).length > 0 ? mergedFilters : undefined, }); return { rows: tableResult.data, value: tableResult.total ?? tableResult.data.length, total: tableResult.total ?? tableResult.data.length, }; } // ===== 내부: overrideFilters를 DataSourceFilter 배열에 병합 ===== /** * 기존 config에 overrideFilters를 병합한 새 config 생성 * 같은 column이 있으면 override 값으로 대체 */ function mergeFilters( config: DataSourceConfig, overrideFilters?: Record ): DataSourceConfig { if (!overrideFilters || Object.keys(overrideFilters).length === 0) { return config; } // 기존 filters에서 override 대상이 아닌 것만 유지 const overrideColumns = new Set(Object.keys(overrideFilters)); const existingFilters: DataSourceFilter[] = (config.filters ?? []).filter( (f) => !overrideColumns.has(f.column) ); // override를 DataSourceFilter로 변환하여 추가 const newFilters: DataSourceFilter[] = Object.entries(overrideFilters).map( ([column, value]) => ({ column, operator: "=" as const, value, }) ); return { ...config, filters: [...existingFilters, ...newFilters], }; } // ===== 메인 훅 ===== /** * POP 컴포넌트용 데이터 CRUD 통합 훅 * * @param config - DataSourceConfig (tableName 필수) * @returns data, loading, error, refetch, save, update, remove */ export function useDataSource(config: DataSourceConfig) { const [data, setData] = useState({ rows: [], value: 0, total: 0, }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // config를 ref로 저장 (콜백 안정성) const configRef = useRef(config); configRef.current = config; // 자동 새로고침 타이머 const refreshTimerRef = useRef | null>(null); // ===== 조회 (READ) ===== const refetch = useCallback( async (options?: OverrideOptions): Promise => { const currentConfig = configRef.current; // 테이블명 없으면 조회하지 않음 if (!currentConfig.tableName?.trim()) { return; } setLoading(true); setError(null); try { const hasAggregation = !!currentConfig.aggregation; const hasJoins = !!(currentConfig.joins && currentConfig.joins.length > 0); let result: DataSourceResult; if (hasAggregation || hasJoins) { // 집계/조인 → SQL 빌더 경로 // 설정 완료 여부 검증 const merged = mergeFilters(currentConfig, options?.filters); const validationError = validateDataSourceConfig(merged); if (validationError) { setError(validationError); setLoading(false); return; } result = await fetchWithSqlBuilder(merged); } else { // 단순 조회 → dataApi 경로 result = await fetchSimpleTable(currentConfig, options?.filters); } setData(result); } catch (err: unknown) { const message = err instanceof Error ? err.message : "데이터 조회 실패"; setError(message); } finally { setLoading(false); } }, [] // configRef 사용으로 의존성 불필요 ); // ===== 생성 (CREATE) ===== const save = useCallback( async (record: Record): Promise => { const tableName = configRef.current.tableName; if (!tableName?.trim()) { return { success: false, error: "테이블이 설정되지 않았습니다" }; } try { const result = await dataApi.createRecord(tableName, record); return { success: result.success ?? true, data: result.data, error: result.message, }; } catch (err: unknown) { const message = err instanceof Error ? err.message : "레코드 생성 실패"; return { success: false, error: message }; } }, [] ); // ===== 수정 (UPDATE) ===== const update = useCallback( async ( id: string | number, record: Record ): Promise => { const tableName = configRef.current.tableName; if (!tableName?.trim()) { return { success: false, error: "테이블이 설정되지 않았습니다" }; } try { const result = await dataApi.updateRecord(tableName, id, record); return { success: result.success ?? true, data: result.data, error: result.message, }; } catch (err: unknown) { const message = err instanceof Error ? err.message : "레코드 수정 실패"; return { success: false, error: message }; } }, [] ); // ===== 삭제 (DELETE) ===== const remove = useCallback( async ( id: string | number | Record ): Promise => { const tableName = configRef.current.tableName; if (!tableName?.trim()) { return { success: false, error: "테이블이 설정되지 않았습니다" }; } try { const result = await dataApi.deleteRecord(tableName, id); return { success: result.success ?? true, data: result.data, error: result.message, }; } catch (err: unknown) { const message = err instanceof Error ? err.message : "레코드 삭제 실패"; return { success: false, error: message }; } }, [] ); // ===== 자동 조회 + 새로고침 ===== // config.tableName 또는 refreshInterval이 변경되면 재조회 const tableName = config.tableName; const refreshInterval = config.refreshInterval; useEffect(() => { // 테이블명 있으면 초기 조회 if (tableName?.trim()) { refetch(); } // refreshInterval 설정 시 자동 새로고침 if (refreshInterval && refreshInterval > 0) { const sec = Math.max(5, refreshInterval); // 최소 5초 refreshTimerRef.current = setInterval(() => { refetch(); }, sec * 1000); } return () => { if (refreshTimerRef.current) { clearInterval(refreshTimerRef.current); refreshTimerRef.current = null; } }; }, [tableName, refreshInterval, refetch]); return { data, loading, error, refetch, save, update, remove, } as const; }