270 lines
9.5 KiB
TypeScript
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>
|
|
);
|
|
}
|