feat(pop): usePopEvent, useDataSource 공통 훅 구현
- usePopEvent: screenId 기반 이벤트 버스 (publish/subscribe/sharedData) - useDataSource: DataSourceConfig 기반 데이터 CRUD 통합 훅 - 집계/조인 → SQL 빌더 경로 (대시보드 로직 재사용) - 단순 조회 → dataApi 경로 - save/update/remove CRUD 래핑 - popSqlBuilder: dataFetcher.ts에서 SQL 빌더 로직 추출 (순수 유틸) - index.ts: 배럴 파일 (재export) 기존 대시보드(dataFetcher.ts) 미수정, 향후 통합 예정 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
0ef0332e08
commit
300542d922
|
|
@ -0,0 +1,15 @@
|
||||||
|
/**
|
||||||
|
* POP 공통 훅 배럴 파일
|
||||||
|
*
|
||||||
|
* 사용법: import { usePopEvent, useDataSource } from "@/hooks/pop";
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 이벤트 통신 훅
|
||||||
|
export { usePopEvent, cleanupScreen } from "./usePopEvent";
|
||||||
|
|
||||||
|
// 데이터 CRUD 훅
|
||||||
|
export { useDataSource } from "./useDataSource";
|
||||||
|
export type { MutationResult, DataSourceResult } from "./useDataSource";
|
||||||
|
|
||||||
|
// SQL 빌더 유틸 (고급 사용 시)
|
||||||
|
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
/**
|
||||||
|
* POP 공통 SQL 빌더
|
||||||
|
*
|
||||||
|
* DataSourceConfig를 SQL 문자열로 변환하는 순수 유틸리티.
|
||||||
|
* 원본: pop-dashboard/utils/dataFetcher.ts에서 추출 (로직 동일).
|
||||||
|
*
|
||||||
|
* 대시보드(dataFetcher.ts)는 기존 코드를 그대로 유지하고,
|
||||||
|
* 새 컴포넌트(useDataSource 등)는 이 파일을 사용한다.
|
||||||
|
* 향후 대시보드 교체 시 dataFetcher.ts가 이 파일을 import하도록 변경 예정.
|
||||||
|
*
|
||||||
|
* 보안:
|
||||||
|
* - escapeSQL: SQL 인젝션 방지 (문자열 이스케이프)
|
||||||
|
* - sanitizeIdentifier: 테이블명/컬럼명에서 위험 문자 제거
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types";
|
||||||
|
|
||||||
|
// ===== SQL 값 이스케이프 =====
|
||||||
|
|
||||||
|
/** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */
|
||||||
|
function escapeSQL(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "NULL";
|
||||||
|
if (typeof value === "number") return String(value);
|
||||||
|
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
||||||
|
// 문자열: 작은따옴표 이스케이프
|
||||||
|
const str = String(value).replace(/'/g, "''");
|
||||||
|
return `'${str}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 식별자 검증 (테이블명, 컬럼명) =====
|
||||||
|
|
||||||
|
/** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */
|
||||||
|
function sanitizeIdentifier(name: string): string {
|
||||||
|
// 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용
|
||||||
|
return name.replace(/[^a-zA-Z0-9_.]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 설정 완료 여부 검증 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSourceConfig의 필수값이 모두 채워졌는지 검증
|
||||||
|
* 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는
|
||||||
|
* SQL을 생성하지 않도록 사전 차단
|
||||||
|
*
|
||||||
|
* @returns null이면 유효, 문자열이면 미완료 사유
|
||||||
|
*/
|
||||||
|
export function validateDataSourceConfig(config: DataSourceConfig): string | null {
|
||||||
|
// 테이블명 필수
|
||||||
|
if (!config.tableName || !config.tableName.trim()) {
|
||||||
|
return "테이블이 선택되지 않았습니다";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 집계 함수가 설정되었으면 대상 컬럼도 필수
|
||||||
|
// (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능)
|
||||||
|
if (config.aggregation) {
|
||||||
|
const aggType = config.aggregation.type?.toLowerCase();
|
||||||
|
const aggCol = config.aggregation.column?.trim();
|
||||||
|
if (aggType !== "count" && !aggCol) {
|
||||||
|
return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 조인이 있으면 조인 조건 필수
|
||||||
|
if (config.joins?.length) {
|
||||||
|
for (const join of config.joins) {
|
||||||
|
if (!join.targetTable?.trim()) {
|
||||||
|
return "조인 대상 테이블이 선택되지 않았습니다";
|
||||||
|
}
|
||||||
|
if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
|
||||||
|
return "조인 조건 컬럼이 설정되지 않았습니다";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 필터 조건 SQL 생성 =====
|
||||||
|
|
||||||
|
/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */
|
||||||
|
function buildWhereClause(filters: DataSourceFilter[]): string {
|
||||||
|
// 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어)
|
||||||
|
const validFilters = filters.filter((f) => f.column?.trim());
|
||||||
|
if (!validFilters.length) return "";
|
||||||
|
|
||||||
|
const conditions = validFilters.map((f) => {
|
||||||
|
const col = sanitizeIdentifier(f.column);
|
||||||
|
|
||||||
|
switch (f.operator) {
|
||||||
|
case "between": {
|
||||||
|
const arr = Array.isArray(f.value) ? f.value : [f.value, f.value];
|
||||||
|
return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`;
|
||||||
|
}
|
||||||
|
case "in": {
|
||||||
|
const arr = Array.isArray(f.value) ? f.value : [f.value];
|
||||||
|
const vals = arr.map(escapeSQL).join(", ");
|
||||||
|
return `${col} IN (${vals})`;
|
||||||
|
}
|
||||||
|
case "like":
|
||||||
|
return `${col} LIKE ${escapeSQL(f.value)}`;
|
||||||
|
default:
|
||||||
|
return `${col} ${f.operator} ${escapeSQL(f.value)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return `WHERE ${conditions.join(" AND ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 집계 SQL 빌더 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataSourceConfig를 SELECT SQL로 변환
|
||||||
|
*
|
||||||
|
* @param config - 데이터 소스 설정
|
||||||
|
* @returns SQL 문자열
|
||||||
|
*/
|
||||||
|
export function buildAggregationSQL(config: DataSourceConfig): string {
|
||||||
|
const tableName = sanitizeIdentifier(config.tableName);
|
||||||
|
|
||||||
|
// SELECT 절
|
||||||
|
let selectClause: string;
|
||||||
|
if (config.aggregation) {
|
||||||
|
const aggType = config.aggregation.type.toUpperCase();
|
||||||
|
const aggCol = config.aggregation.column?.trim()
|
||||||
|
? sanitizeIdentifier(config.aggregation.column)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수
|
||||||
|
if (!aggCol) {
|
||||||
|
selectClause = aggType === "COUNT"
|
||||||
|
? "COUNT(*) as value"
|
||||||
|
: `${aggType}(${tableName}.*) as value`;
|
||||||
|
} else {
|
||||||
|
selectClause = `${aggType}(${aggCol}) as value`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GROUP BY가 있으면 해당 컬럼도 SELECT에 포함
|
||||||
|
if (config.aggregation.groupBy?.length) {
|
||||||
|
const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", ");
|
||||||
|
selectClause = `${groupCols}, ${selectClause}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectClause = "*";
|
||||||
|
}
|
||||||
|
|
||||||
|
// FROM 절 (조인 포함 - 조건이 완전한 조인만 적용)
|
||||||
|
let fromClause = tableName;
|
||||||
|
if (config.joins?.length) {
|
||||||
|
for (const join of config.joins) {
|
||||||
|
// 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어)
|
||||||
|
if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const joinTable = sanitizeIdentifier(join.targetTable);
|
||||||
|
const joinType = join.joinType.toUpperCase();
|
||||||
|
const srcCol = sanitizeIdentifier(join.on.sourceColumn);
|
||||||
|
const tgtCol = sanitizeIdentifier(join.on.targetColumn);
|
||||||
|
fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHERE 절
|
||||||
|
const whereClause = config.filters?.length
|
||||||
|
? buildWhereClause(config.filters)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// GROUP BY 절
|
||||||
|
let groupByClause = "";
|
||||||
|
if (config.aggregation?.groupBy?.length) {
|
||||||
|
groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ORDER BY 절
|
||||||
|
let orderByClause = "";
|
||||||
|
if (config.sort?.length) {
|
||||||
|
const sortCols = config.sort
|
||||||
|
.map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`)
|
||||||
|
.join(", ");
|
||||||
|
orderByClause = `ORDER BY ${sortCols}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIMIT 절
|
||||||
|
const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : "";
|
||||||
|
|
||||||
|
return [
|
||||||
|
`SELECT ${selectClause}`,
|
||||||
|
`FROM ${fromClause}`,
|
||||||
|
whereClause,
|
||||||
|
groupByClause,
|
||||||
|
orderByClause,
|
||||||
|
limitClause,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,383 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
/**
|
||||||
|
* usePopEvent - POP 컴포넌트 간 이벤트 통신 훅
|
||||||
|
*
|
||||||
|
* 같은 화면(screenId) 안에서만 동작하는 이벤트 버스.
|
||||||
|
* 다른 screenId 간에는 완전히 격리됨.
|
||||||
|
*
|
||||||
|
* 주요 기능:
|
||||||
|
* - publish/subscribe: 일회성 이벤트 (거래처 선택됨, 저장 완료 등)
|
||||||
|
* - getSharedData/setSharedData: 지속성 상태 (버튼 클릭 시 다른 컴포넌트 값 수집용)
|
||||||
|
*
|
||||||
|
* 사용 패턴:
|
||||||
|
* ```typescript
|
||||||
|
* const { publish, subscribe, getSharedData, setSharedData } = usePopEvent("S001");
|
||||||
|
*
|
||||||
|
* // 이벤트 구독 (반드시 useEffect 안에서, cleanup 필수)
|
||||||
|
* useEffect(() => {
|
||||||
|
* const unsub = subscribe("supplier-selected", (payload) => {
|
||||||
|
* console.log(payload.supplierId);
|
||||||
|
* });
|
||||||
|
* return unsub;
|
||||||
|
* }, []);
|
||||||
|
*
|
||||||
|
* // 이벤트 발행
|
||||||
|
* publish("supplier-selected", { supplierId: "SUP-001" });
|
||||||
|
*
|
||||||
|
* // 공유 데이터 저장/조회
|
||||||
|
* setSharedData("selectedSupplier", { id: "SUP-001" });
|
||||||
|
* const supplier = getSharedData("selectedSupplier");
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
// ===== 타입 정의 =====
|
||||||
|
|
||||||
|
/** 이벤트 콜백 함수 타입 */
|
||||||
|
type EventCallback = (payload: unknown) => void;
|
||||||
|
|
||||||
|
/** 화면별 이벤트 리스너 맵: eventName -> Set<callback> */
|
||||||
|
type ListenerMap = Map<string, Set<EventCallback>>;
|
||||||
|
|
||||||
|
/** 화면별 공유 데이터 맵: key -> value */
|
||||||
|
type SharedDataMap = Map<string, unknown>;
|
||||||
|
|
||||||
|
// ===== 전역 저장소 (React 외부, 모듈 스코프) =====
|
||||||
|
// SSR 환경에서 서버/클라이언트 간 공유 방지
|
||||||
|
|
||||||
|
/** screenId별 이벤트 리스너 저장소 */
|
||||||
|
const screenBuses: Map<string, ListenerMap> =
|
||||||
|
typeof window !== "undefined" ? new Map() : new Map();
|
||||||
|
|
||||||
|
/** screenId별 공유 데이터 저장소 */
|
||||||
|
const sharedDataStore: Map<string, SharedDataMap> =
|
||||||
|
typeof window !== "undefined" ? new Map() : new Map();
|
||||||
|
|
||||||
|
// ===== 내부 헬퍼 =====
|
||||||
|
|
||||||
|
/** 해당 screenId의 리스너 맵 가져오기 (없으면 생성) */
|
||||||
|
function getListenerMap(screenId: string): ListenerMap {
|
||||||
|
let map = screenBuses.get(screenId);
|
||||||
|
if (!map) {
|
||||||
|
map = new Map();
|
||||||
|
screenBuses.set(screenId, map);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 해당 screenId의 공유 데이터 맵 가져오기 (없으면 생성) */
|
||||||
|
function getSharedMap(screenId: string): SharedDataMap {
|
||||||
|
let map = sharedDataStore.get(screenId);
|
||||||
|
if (!map) {
|
||||||
|
map = new Map();
|
||||||
|
sharedDataStore.set(screenId, map);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 외부 API: 화면 정리 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 화면 언마운트 시 해당 screenId의 모든 리스너 + 공유 데이터 정리
|
||||||
|
* 메모리 누수 방지용. 뷰어 또는 PopRenderer에서 화면 전환 시 호출.
|
||||||
|
*/
|
||||||
|
export function cleanupScreen(screenId: string): void {
|
||||||
|
screenBuses.delete(screenId);
|
||||||
|
sharedDataStore.delete(screenId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 메인 훅 =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POP 컴포넌트 간 이벤트 통신 훅
|
||||||
|
*
|
||||||
|
* @param screenId - 화면 ID (같은 screenId 안에서만 통신)
|
||||||
|
* @returns publish, subscribe, getSharedData, setSharedData
|
||||||
|
*/
|
||||||
|
export function usePopEvent(screenId: string) {
|
||||||
|
// screenId를 ref로 저장 (콜백 안정성)
|
||||||
|
const screenIdRef = useRef(screenId);
|
||||||
|
screenIdRef.current = screenId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 발행
|
||||||
|
* 해당 screenId + eventName에 등록된 모든 콜백에 payload 전달
|
||||||
|
*/
|
||||||
|
const publish = useCallback(
|
||||||
|
(eventName: string, payload?: unknown): void => {
|
||||||
|
const listeners = getListenerMap(screenIdRef.current);
|
||||||
|
const callbacks = listeners.get(eventName);
|
||||||
|
if (!callbacks || callbacks.size === 0) return;
|
||||||
|
|
||||||
|
// Set을 배열로 복사 후 순회 (순회 중 unsubscribe 안전)
|
||||||
|
const callbackArray = Array.from(callbacks);
|
||||||
|
for (const cb of callbackArray) {
|
||||||
|
try {
|
||||||
|
cb(payload);
|
||||||
|
} catch (err) {
|
||||||
|
// 개별 콜백 에러가 다른 콜백 실행을 막지 않음
|
||||||
|
console.error(
|
||||||
|
`[usePopEvent] 콜백 에러 (screen: ${screenIdRef.current}, event: ${eventName}):`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이벤트 구독
|
||||||
|
*
|
||||||
|
* 주의: 반드시 useEffect 안에서 호출하고, 반환값(unsubscribe)을 cleanup에서 호출할 것.
|
||||||
|
*
|
||||||
|
* @returns unsubscribe 함수
|
||||||
|
*/
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(eventName: string, callback: EventCallback): (() => void) => {
|
||||||
|
const listeners = getListenerMap(screenIdRef.current);
|
||||||
|
|
||||||
|
let callbacks = listeners.get(eventName);
|
||||||
|
if (!callbacks) {
|
||||||
|
callbacks = new Set();
|
||||||
|
listeners.set(eventName, callbacks);
|
||||||
|
}
|
||||||
|
callbacks.add(callback);
|
||||||
|
|
||||||
|
// unsubscribe 함수 반환
|
||||||
|
const capturedScreenId = screenIdRef.current;
|
||||||
|
return () => {
|
||||||
|
const map = screenBuses.get(capturedScreenId);
|
||||||
|
if (!map) return;
|
||||||
|
const cbs = map.get(eventName);
|
||||||
|
if (!cbs) return;
|
||||||
|
cbs.delete(callback);
|
||||||
|
// 빈 Set 정리
|
||||||
|
if (cbs.size === 0) {
|
||||||
|
map.delete(eventName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 데이터 조회
|
||||||
|
* 다른 컴포넌트가 setSharedData로 저장한 값을 가져옴
|
||||||
|
*/
|
||||||
|
const getSharedData = useCallback(
|
||||||
|
<T = unknown>(key: string): T | undefined => {
|
||||||
|
const shared = sharedDataStore.get(screenIdRef.current);
|
||||||
|
if (!shared) return undefined;
|
||||||
|
return shared.get(key) as T | undefined;
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 공유 데이터 저장
|
||||||
|
* 같은 screenId의 다른 컴포넌트가 getSharedData로 읽을 수 있음
|
||||||
|
*/
|
||||||
|
const setSharedData = useCallback(
|
||||||
|
(key: string, value: unknown): void => {
|
||||||
|
const shared = getSharedMap(screenIdRef.current);
|
||||||
|
shared.set(key, value);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { publish, subscribe, getSharedData, setSharedData } as const;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue