ERP-node/frontend/hooks/pop/useDataSource.ts

384 lines
11 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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<string, unknown>[];
/** 단일 집계 값 (aggregation 시) 또는 전체 행 수 */
value: number;
/** 전체 행 수 (페이징용) */
total: number;
}
/** CRUD 작업 결과 */
export interface MutationResult {
success: boolean;
data?: unknown;
error?: string;
}
/** refetch 시 전달할 오버라이드 필터 */
interface OverrideOptions {
filters?: Record<string, unknown>;
}
// ===== 내부: 집계/조인 조회 =====
/**
* DataSourceConfig를 SQL로
* dataFetcher.ts의 fetchAggregatedData와
*/
async function fetchWithSqlBuilder(
config: DataSourceConfig
): Promise<DataSourceResult> {
const sql = buildAggregationSQL(config);
// API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백
let queryResult: { columns: string[]; rows: Record<string, unknown>[] };
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<string, unknown> = { ...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<string, unknown>
): Promise<DataSourceResult> {
// config.filters를 Record<string, unknown> 형태로 변환
const baseFilters: Record<string, unknown> = {};
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<string, unknown>
): 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<DataSourceResult>({
rows: [],
value: 0,
total: 0,
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// config를 ref로 저장 (콜백 안정성)
const configRef = useRef(config);
configRef.current = config;
// 자동 새로고침 타이머
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// ===== 조회 (READ) =====
const refetch = useCallback(
async (options?: OverrideOptions): Promise<void> => {
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<string, unknown>): Promise<MutationResult> => {
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<string, unknown>
): Promise<MutationResult> => {
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<string, unknown>
): Promise<MutationResult> => {
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;
}