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