Files
invyone/frontend/components/control/NodeConfigPopover.tsx
T
2026-04-10 13:33:37 +09:00

270 lines
9.5 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
/**
* 노드 설정 팝오버 (mockup showNodeConfig/_buildCfgForm 포팅)
* 노드 타입별 설정 폼
*/
export function NodeConfigPopover() {
const { configNodeId, ruleNodes, 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;
useEffect(() => {
if (configNodeId && node) {
requestAnimationFrame(() => setOpen(true));
} else {
setOpen(false);
}
}, [configNodeId, node]);
// 외부 클릭 닫기
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!configNodeId) return;
if ((e.target as HTMLElement).closest('.ctrl-cfg-pop')) return;
if ((e.target as HTMLElement).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 ?? {}} onSave={handleSave} onClose={() => setConfigNodeId(null)} />
</div>
);
}
function ConfigForm({ type, config, onSave, onClose }: {
type: string; config: 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 handleSave = () => {
let summary = '';
switch (type) {
case 'condition':
summary = `${vals.field || '?'} ${vals.op || '='} "${vals.value || '?'}"`;
break;
case 'status-change':
summary = `${vals.table || '?'}.${vals.field || 'STATUS'} → "${vals.value || '?'}"`;
break;
case 'auto-insert':
summary = `${vals.table || '?'} INSERT`;
break;
case 'timer':
summary = `${vals.field || '?'} +${vals.amount || 0}${vals.unit || '일'} 경과`;
break;
case 'notification':
summary = `${vals.channel || '이메일'}${vals.target || '담당자'}`;
break;
case 'approval':
summary = `${vals.approver || '팀장'} 승인 (${vals.condition || ''})`;
break;
case 'calculation':
summary = `${vals.table || '?'}.${vals.field || '?'} = ${vals.formula || '?'}`;
break;
case 'webhook':
summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`;
break;
case 'validation':
summary = `${vals.field || '?'} ${vals.rule || '필수값'}`;
break;
case 'log':
summary = `로그: ${vals.content || '?'}`;
break;
default:
summary = vals.summary || '설정됨';
}
onSave(summary, vals);
};
return (
<>
{renderFields(type, vals, set)}
<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
) {
switch (type) {
case 'condition':
return (
<>
<CfgSec label="필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="STATUS" />
</CfgSec>
<CfgSec label="연산자">
<CfgSelect value={vals.op ?? '='} onChange={(v) => set('op', v)}
options={['=', '≠', '>', '<', '기한 경과', '포함']} />
</CfgSec>
<CfgSec label="값">
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="비교값" />
</CfgSec>
</>
);
case 'status-change':
return (
<>
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
</CfgSec>
<CfgSec label="변경 필드">
<CfgInput value={vals.field ?? 'STATUS'} onChange={(v) => set('field', v)} />
</CfgSec>
<CfgSec label="변경값">
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="새 값" />
</CfgSec>
</>
);
case 'auto-insert':
return (
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
</CfgSec>
);
case 'timer':
return (
<>
<CfgSec label="기준 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="ORDER_DATE" />
</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 'calculation':
return (
<>
<CfgSec label="대상 테이블">
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
</CfgSec>
<CfgSec label="결과 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
</CfgSec>
<CfgSec label="수식">
<CfgInput value={vals.formula ?? ''} onChange={(v) => set('formula', v)} placeholder="QTY * UNIT_PRICE" />
</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 'validation':
return (
<>
<CfgSec label="대상 필드">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
</CfgSec>
<CfgSec label="검증 규칙">
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)}
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
</CfgSec>
</>
);
case 'log':
return (
<CfgSec label="내용">
<CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" />
</CfgSec>
);
default:
return <div className="cfg-sec" style={{ color: 'var(--v5-text-muted)', fontSize: '.55rem' }}> </div>;
}
}
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>
);
}