Files
DDD1542 2f398ae0b3 chore: 제어모드 IDE 작업 + v2/legacy 레지스트리 컴포넌트 폐기
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control
- 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합
- InvLegacyButtonConfigPanel cp 마이그레이션
- canonical data view cleanup 후속 노트
2026-05-19 21:31:03 +09:00

623 lines
22 KiB
TypeScript

'use client';
import { useState, useEffect, useRef, useMemo } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
/**
* 노드 설정 팝오버 — Phase 2: schema-driven dropdown
*
* 핵심: 노드와 연결된 테이블의 컬럼/enum 메타를 dropdown 으로 자동 매핑.
* - 영어 자유 입력 폐기 (실사용 불가)
* - 한글 라벨 우선 + 영문 컬럼 sub
* - enum 컬럼이면 값도 dropdown
* - multi-table 시 optgroup 으로 namespace 구분
* - 저장은 fully qualified { table, column } 객체 (Phase 3 준비)
*/
export function NodeConfigPopover() {
const { configNodeId, ruleNodes, ruleConnections, setConfigNodeId, updateRuleNode } = useControlMode();
const popRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const node = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null;
const def = node ? CTRL_NODE_TYPES[node.type] : null;
// 현재 노드와 연결된 테이블 노드들 (양방향 — from/to 어느 쪽이든)
const connectedTables = useMemo<Record<string, any>[]>(() => {
if (!configNodeId) return [];
const tableNodeIds = new Set<string>();
ruleConnections.forEach((c) => {
if (c.from_node_id === configNodeId) tableNodeIds.add(c.to_node_id);
if (c.to_node_id === configNodeId) tableNodeIds.add(c.from_node_id);
});
return ruleNodes.filter((n) => n.type === 'table' && tableNodeIds.has(n.id));
}, [configNodeId, ruleNodes, ruleConnections]);
useEffect(() => {
if (configNodeId && node) {
requestAnimationFrame(() => setOpen(true));
} else {
setOpen(false);
}
}, [configNodeId, node]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!configNodeId) return;
const t = e.target as HTMLElement;
if (t.closest('.ctrl-cfg-pop')) return;
if (t.closest('.v3-rule-node')) return;
if (t.closest('.tbl-node')) return;
if (t.closest('.ctrl-an-body')) return;
setConfigNodeId(null);
};
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}, [configNodeId, setConfigNodeId]);
if (!node || !def) return null;
const handleSave = (summary: string, config: Record<string, any>) => {
updateRuleNode(node.id, { config: { ...node.config, ...config, summary } });
setConfigNodeId(null);
};
return (
<div
ref={popRef}
className={`ctrl-cfg-pop${open ? ' open' : ''}`}
style={{ left: node.x + 172, top: node.y }}
>
<div className="cfg-hd">{def.icon} {def.label} </div>
<ConfigForm
type={node.type}
config={node.config ?? {}}
connectedTables={connectedTables}
onSave={handleSave}
onClose={() => setConfigNodeId(null)}
/>
</div>
);
}
/* ─── Helpers ─── */
interface ColumnMeta {
tableName: string;
tableLabel: string;
column: string;
label: string;
type: string;
options?: Array<{ value: string; label: string }>;
pk?: boolean;
}
/** 연결된 테이블들의 모든 컬럼을 flat 으로 + 표시 정보 포함 */
function flattenColumns(tables: Record<string, any>[]): ColumnMeta[] {
const out: ColumnMeta[] = [];
tables.forEach((t) => {
const tName = t.table_name ?? t.tableName ?? '';
const tLabel = t.label ?? tName;
(t.columns ?? []).forEach((c: Record<string, any>) => {
const colName = c.column ?? c.name ?? c.COLUMN_NAME ?? '';
if (!colName) return;
out.push({
tableName: tName,
tableLabel: tLabel,
column: colName,
label: c.label ?? c.dname ?? colName,
type: c.type ?? c.dtype ?? 'text',
options: c.options,
pk: !!c.pk,
});
});
});
return out;
}
/** fully qualified id ↔ 객체 변환 */
function serializeField(field: any): string {
if (!field) return '';
if (typeof field === 'string') return field; // legacy
if (field.table && field.column) return `${field.table}|${field.column}`;
return '';
}
function deserializeField(s: string): { table: string; column: string } | null {
if (!s || !s.includes('|')) return null;
const [table, column] = s.split('|');
return { table, column };
}
/** field value (string or {table,column}) 으로 ColumnMeta 찾기 */
function findColumn(cols: ColumnMeta[], field: any): ColumnMeta | null {
if (!field) return null;
if (typeof field === 'string') return cols.find((c) => c.column === field) ?? null;
if (field.table && field.column) {
return cols.find((c) => c.tableName === field.table && c.column === field.column) ?? null;
}
return null;
}
/** 한글 라벨 표시 (field) */
function displayField(field: any, cols: ColumnMeta[]): string {
const col = findColumn(cols, field);
if (col) return col.label;
if (typeof field === 'string') return field;
if (field?.column) return field.column;
return '?';
}
/* ─── Reusable pickers ─── */
function FieldPicker({
tables, value, onChange, placeholder,
}: {
tables: Record<string, any>[];
value: any;
onChange: (field: { table: string; column: string }) => void;
placeholder?: string;
}) {
const cols = useMemo(() => flattenColumns(tables), [tables]);
if (tables.length === 0) {
return <div className="cfg-empty"> </div>;
}
const currentId = serializeField(value);
return (
<select
className="cfg-sel"
value={currentId}
onChange={(e) => {
const f = deserializeField(e.target.value);
if (f) onChange(f);
}}
>
<option value="">{placeholder ?? '컬럼 선택...'}</option>
{tables.map((tbl) => {
const tName = tbl.table_name ?? tbl.tableName ?? '';
const tLabel = tbl.label ?? tName;
const tableCols = cols.filter((c) => c.tableName === tName);
if (tableCols.length === 0) return null;
const groupLabel = tLabel !== tName ? `${tLabel} · ${tName}` : tName;
return (
<optgroup key={tName} label={groupLabel}>
{tableCols.map((c) => {
const id = `${c.tableName}|${c.column}`;
const dispLabel = c.label !== c.column ? `${c.label} (${c.column})` : c.column;
return (
<option key={id} value={id}>
{dispLabel}{c.pk ? ' · PK' : ''}{c.type === 'select' ? ' · enum' : ''}
</option>
);
})}
</optgroup>
);
})}
</select>
);
}
function TablePicker({
tables, value, onChange, placeholder,
}: {
tables: Record<string, any>[];
value: any;
onChange: (tableName: string) => void;
placeholder?: string;
}) {
// 자동 채움 — Strict 모드 안전 useEffect (committed lifecycle 에서만 실행)
const single = tables.length === 1
? (tables[0].table_name ?? tables[0].tableName ?? '')
: null;
useEffect(() => {
if (single && value !== single) onChange(single);
}, [single, value, onChange]);
if (tables.length === 0) {
return <div className="cfg-empty"> </div>;
}
// 1개면 자동 readonly
if (tables.length === 1) {
const t = tables[0];
const tName = t.table_name ?? t.tableName ?? '';
const tLabel = t.label ?? tName;
return (
<div className="cfg-static">
<span className="cfg-static-main">{tLabel}</span>
{tLabel !== tName && <span className="cfg-static-sub">{tName}</span>}
<span className="cfg-static-hint">()</span>
</div>
);
}
// 2개+ 면 dropdown
const current = typeof value === 'string' ? value : (value?.table ?? '');
return (
<select className="cfg-sel" value={current} onChange={(e) => onChange(e.target.value)}>
<option value="">{placeholder ?? '테이블 선택...'}</option>
{tables.map((t) => {
const tName = t.table_name ?? t.tableName ?? '';
const tLabel = t.label ?? tName;
return (
<option key={tName} value={tName}>
{tLabel}{tLabel !== tName ? ` (${tName})` : ''}
</option>
);
})}
</select>
);
}
function ValuePicker({
tables, fieldRef, value, onChange, placeholder,
}: {
tables: Record<string, any>[];
fieldRef: any; // 어느 컬럼의 값인지
value: any;
onChange: (v: string) => void;
placeholder?: string;
}) {
const cols = useMemo(() => flattenColumns(tables), [tables]);
const col = findColumn(cols, fieldRef);
// enum 컬럼이면 dropdown
if (col?.type === 'select' && col.options && col.options.length > 0) {
return (
<select className="cfg-sel" value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
<option value="">{placeholder ?? '값 선택...'}</option>
{col.options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}{opt.label !== opt.value ? ` (${opt.value})` : ''}
</option>
))}
</select>
);
}
// 기본 typed input
return (
<input
className="cfg-inp"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? (col ? `${col.label}` : '값 입력')}
/>
);
}
/* ─── ConfigForm ─── */
function ConfigForm({
type, config, connectedTables, onSave, onClose,
}: {
type: string;
config: Record<string, any>;
connectedTables: Record<string, any>[];
onSave: (summary: string, config: Record<string, any>) => void;
onClose: () => void;
}) {
const [vals, setVals] = useState<Record<string, any>>(config);
const set = (k: string, v: any) => setVals((p) => ({ ...p, [k]: v }));
const cols = useMemo(() => flattenColumns(connectedTables), [connectedTables]);
const handleSave = () => {
let summary = '';
const fLabel = (f: any) => displayField(f, cols);
const tLabel = (tName: string) => {
const t = connectedTables.find((x) => (x.table_name ?? x.tableName) === tName);
return t?.label ?? tName ?? '?';
};
switch (type) {
case 'condition':
summary = `${fLabel(vals.field)} ${vals.op || '='} "${vals.value || '?'}"`;
break;
case 'status-change':
summary = `${tLabel(vals.table)}.${fLabel(vals.field)} → "${vals.value || '?'}"`;
break;
case 'auto-insert':
summary = `${tLabel(vals.table)} INSERT`;
break;
case 'timer':
summary = `${fLabel(vals.field)} +${vals.amount || 0}${vals.unit || '일'} 경과`;
break;
case 'notification':
summary = `${vals.channel || '이메일'}${vals.target || '담당자'}`;
break;
case 'approval':
summary = `${vals.approver || '팀장'} 승인${vals.condition ? ` (${vals.condition})` : ''}`;
break;
case 'calculation':
summary = `${tLabel(vals.table)}.${fLabel(vals.field)} = ${vals.formula || '?'}`;
break;
case 'webhook':
summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`;
break;
case 'validation':
summary = `${fLabel(vals.field)} ${vals.rule || '필수값'}`;
break;
case 'log':
summary = `로그: ${vals.content || '?'}`;
break;
case 'delete':
summary = `${tLabel(vals.table)} ${vals.mode === 'soft' ? 'soft delete' : 'hard delete'}`;
break;
case 'document':
summary = `${vals.template || '?'}${vals.format || 'pdf'}`;
break;
case 'delay':
summary = `${vals.amount || 0}${vals.unit || '분'} 대기`;
break;
case 'loop':
summary = vals.iterField ? `for each ${vals.iterField}` : `${vals.count || 1}회 반복`;
break;
case 'parallel':
summary = `${vals.branches || 2}개 병렬 실행`;
break;
case 'merge':
summary = vals.strategy === 'all' ? '모든 분기 대기 (all)' : '먼저 도착 (any)';
break;
default:
summary = vals.summary || '설정됨';
}
onSave(summary, vals);
};
return (
<>
{renderFields(type, vals, set, connectedTables)}
<div className="cfg-ft">
<button className="cfg-btn save" onClick={handleSave}></button>
<button className="cfg-btn" onClick={onClose}></button>
</div>
</>
);
}
function renderFields(
type: string,
vals: Record<string, any>,
set: (k: string, v: any) => void,
tables: Record<string, any>[],
) {
switch (type) {
/* ─── Phase 2 schema-driven 4종 ─── */
case 'condition':
return (
<>
<CfgSec label="필드">
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="비교할 컬럼 선택..." />
</CfgSec>
<CfgSec label="연산자">
<CfgSelect value={vals.op ?? '='} onChange={(v) => set('op', v)}
options={['=', '≠', '>', '<', '≥', '≤', '포함', '기한 경과']} />
</CfgSec>
<CfgSec label="값">
<ValuePicker tables={tables} fieldRef={vals.field} value={vals.value}
onChange={(v) => set('value', v)} />
</CfgSec>
</>
);
case 'status-change':
return (
<>
<CfgSec label="대상 테이블">
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec>
<CfgSec label="변경 필드">
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="변경할 컬럼 선택..." />
</CfgSec>
<CfgSec label="변경값">
<ValuePicker tables={tables} fieldRef={vals.field} value={vals.value}
onChange={(v) => set('value', v)} placeholder="새 값" />
</CfgSec>
</>
);
case 'calculation':
return (
<>
<CfgSec label="대상 테이블">
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec>
<CfgSec label="결과 필드">
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="저장할 컬럼 선택..." />
</CfgSec>
<CfgSec label="수식">
<CfgInput value={vals.formula ?? ''} onChange={(v) => set('formula', v)}
placeholder="QTY * UNIT_PRICE" />
</CfgSec>
</>
);
case 'validation':
return (
<>
<CfgSec label="대상 필드">
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="검증할 컬럼 선택..." />
</CfgSec>
<CfgSec label="검증 규칙">
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)}
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
</CfgSec>
</>
);
/* ─── 기존 케이스 유지 (테이블 컬럼 의존성 없는 노드들) ─── */
case 'auto-insert':
return (
<CfgSec label="대상 테이블">
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec>
);
case 'timer':
return (
<>
<CfgSec label="기준 필드">
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
placeholder="시간 기준 컬럼..." />
</CfgSec>
<CfgSec label="경과 기준">
<div style={{ display: 'flex', gap: '.3rem' }}>
<CfgInput value={vals.amount ?? '0'} onChange={(v) => set('amount', v)} placeholder="0" />
<CfgSelect value={vals.unit ?? '일'} onChange={(v) => set('unit', v)} options={['일', '시간', '주']} />
</div>
</CfgSec>
</>
);
case 'notification':
return (
<>
<CfgSec label="채널">
<CfgSelect value={vals.channel ?? '이메일'} onChange={(v) => set('channel', v)}
options={['이메일', 'SMS', '푸시', 'Slack']} />
</CfgSec>
<CfgSec label="수신자">
<CfgSelect value={vals.target ?? '담당자'} onChange={(v) => set('target', v)}
options={['담당자', '관리자', '전체']} />
</CfgSec>
<CfgSec label="메시지">
<textarea className="cfg-ta" rows={2} value={vals.message ?? ''}
onChange={(e) => set('message', e.target.value)} />
</CfgSec>
</>
);
case 'approval':
return (
<>
<CfgSec label="승인자">
<CfgSelect value={vals.approver ?? '팀장'} onChange={(v) => set('approver', v)}
options={['팀장', '부서장', '관리자', '지정 사용자']} />
</CfgSec>
<CfgSec label="승인 조건">
<CfgInput value={vals.condition ?? ''} onChange={(v) => set('condition', v)} placeholder="조건식" />
</CfgSec>
</>
);
case 'webhook':
return (
<>
<CfgSec label="URL">
<CfgInput value={vals.url ?? ''} onChange={(v) => set('url', v)} placeholder="https://..." />
</CfgSec>
<CfgSec label="메서드">
<CfgSelect value={vals.method ?? 'POST'} onChange={(v) => set('method', v)}
options={['POST', 'GET', 'PUT', 'DELETE']} />
</CfgSec>
</>
);
case 'log':
return (
<>
<CfgSec label="로그 레벨">
<CfgSelect value={vals.level ?? 'info'} onChange={(v) => set('level', v)}
options={['info', 'warn', 'error', 'debug']} />
</CfgSec>
<CfgSec label="내용">
<CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" />
</CfgSec>
</>
);
case 'delete':
return (
<>
<CfgSec label="대상 테이블">
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
</CfgSec>
<CfgSec label="삭제 방식">
<CfgSelect value={vals.mode ?? 'soft'} onChange={(v) => set('mode', v)}
options={['soft', 'hard']} />
</CfgSec>
<CfgSec label="조건 (WHERE)">
<CfgInput value={vals.where ?? ''} onChange={(v) => set('where', v)} placeholder="id = ?" />
</CfgSec>
</>
);
case 'document':
return (
<>
<CfgSec label="템플릿">
<CfgInput value={vals.template ?? ''} onChange={(v) => set('template', v)} placeholder="출고확인서.docx" />
</CfgSec>
<CfgSec label="출력 경로">
<CfgInput value={vals.output ?? ''} onChange={(v) => set('output', v)} placeholder="/docs/{id}.pdf" />
</CfgSec>
<CfgSec label="포맷">
<CfgSelect value={vals.format ?? 'pdf'} onChange={(v) => set('format', v)}
options={['pdf', 'docx', 'xlsx', 'html']} />
</CfgSec>
</>
);
case 'delay':
return (
<CfgSec label="지연 시간">
<div style={{ display: 'flex', gap: '.3rem' }}>
<CfgInput value={vals.amount ?? '0'} onChange={(v) => set('amount', v)} placeholder="0" />
<CfgSelect value={vals.unit ?? '분'} onChange={(v) => set('unit', v)}
options={['초', '분', '시간', '일']} />
</div>
</CfgSec>
);
case 'loop':
return (
<>
<CfgSec label="반복 방식">
<CfgSelect value={vals.mode ?? 'count'} onChange={(v) => set('mode', v)}
options={['count', 'forEach', 'while']} />
</CfgSec>
{vals.mode === 'forEach' ? (
<CfgSec label="반복 대상 필드">
<FieldPicker tables={tables} value={vals.iterField} onChange={(f) => set('iterField', f)} />
</CfgSec>
) : vals.mode === 'while' ? (
<CfgSec label="조건식">
<CfgInput value={vals.condition ?? ''} onChange={(v) => set('condition', v)} placeholder="x < 10" />
</CfgSec>
) : (
<CfgSec label="횟수">
<CfgInput value={vals.count ?? '1'} onChange={(v) => set('count', v)} placeholder="1" />
</CfgSec>
)}
</>
);
case 'parallel':
return (
<CfgSec label="병렬 분기 수">
<CfgInput value={vals.branches ?? '2'} onChange={(v) => set('branches', v)} placeholder="2" />
</CfgSec>
);
case 'merge':
return (
<CfgSec label="합류 전략">
<CfgSelect value={vals.strategy ?? 'any'} onChange={(v) => set('strategy', v)}
options={['any', 'all']} />
</CfgSec>
);
default:
return <div className="cfg-sec" style={{ color: 'var(--v5-text-muted)', fontSize: '.55rem' }}> </div>;
}
}
/* ─── 공통 atoms ─── */
function CfgSec({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="cfg-sec">
<label className="cfg-lb">{label}</label>
{children}
</div>
);
}
function CfgInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
return (
<input className="cfg-inp" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
);
}
function CfgSelect({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: string[] }) {
return (
<select className="cfg-sel" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
);
}