Files
invyone/frontend/components/control/ControlPalette.tsx
T
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

222 lines
7.9 KiB
TypeScript

'use client';
import { useEffect, useMemo, useState } from 'react';
import { Search, Star } from 'lucide-react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
import { getMetaTableList } from '@/lib/api/meta';
interface ControlPaletteProps {
onDropTable: (tableName: string, x: number, y: number) => void;
onDropControl: (type: string, x: number, y: number) => void;
}
/**
* 제어 모드 팔레트 — 사이드바 교체
* - 검색박스
* - ⭐ 시연용 추천 (화이트리스트)
* - DB 테이블 max-height + 내부 스크롤
* - 영어/한국어 동시 표시
* - 제어 노드 16종 카테고리별 그룹
*/
// 시연용 추천 화이트리스트 (있을 만한 ERP 표준 테이블 + 메뉴 캡쳐에서 확인된 것)
const RECOMMENDED_TABLES = [
'user_info',
'department',
'role_info',
'menu_master',
'authority_master',
'approval_definitions',
'approval_requests',
'approval_lines',
'audit_log',
'attach_file_info',
];
// 도메인 아이콘 매핑 (prefix 기준)
function pickIcon(name: string): string {
const n = name.toLowerCase();
if (n.startsWith('user') || n === 'user_info') return '👤';
if (n.startsWith('department') || n.startsWith('dept')) return '🏢';
if (n.startsWith('role') || n.startsWith('authority')) return '🛡';
if (n.startsWith('menu')) return '📂';
if (n.startsWith('approval')) return '✋';
if (n.startsWith('audit') || n.startsWith('log')) return '📜';
if (n.startsWith('attach') || n.startsWith('file')) return '📎';
if (n.startsWith('mail')) return '📨';
if (n.startsWith('ai_')) return '🤖';
if (n.startsWith('order')) return '📦';
if (n.startsWith('project')) return '📋';
if (n.startsWith('barcode') || n.startsWith('label')) return '🏷';
if (n.startsWith('batch')) return '⚙';
if (n.startsWith('config') || n.startsWith('setting')) return '⚙';
return '🗂';
}
export function ControlPalette(_props: ControlPaletteProps) {
const [tables, setTables] = useState<Record<string, any>[]>([]);
const [search, setSearch] = useState('');
const mode = useControlMode((s) => s.mode);
const isEditMode = mode === 'edit';
useEffect(() => {
getMetaTableList().then(setTables).catch(() => {});
}, []);
// 검색 + 추천/일반 분리
const { recommended, others } = useMemo(() => {
const q = search.trim().toLowerCase();
const filtered = q
? tables.filter((t) => {
const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase();
const label = String(t.table_label ?? t.TABLE_LABEL ?? '').toLowerCase();
return name.includes(q) || label.includes(q);
})
: tables;
const rec: Record<string, any>[] = [];
const oth: Record<string, any>[] = [];
filtered.forEach((t) => {
const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase();
if (RECOMMENDED_TABLES.includes(name)) rec.push(t);
else oth.push(t);
});
// 추천은 화이트리스트 순서 유지
rec.sort((a, b) => {
const an = String(a.table_name ?? a.TABLE_NAME ?? '').toLowerCase();
const bn = String(b.table_name ?? b.TABLE_NAME ?? '').toLowerCase();
return RECOMMENDED_TABLES.indexOf(an) - RECOMMENDED_TABLES.indexOf(bn);
});
return { recommended: rec, others: oth };
}, [tables, search]);
const handleDragStart = (e: React.DragEvent, data: Record<string, any>) => {
e.dataTransfer.setData('text/plain', JSON.stringify(data));
e.dataTransfer.effectAllowed = 'copy';
};
const catLabels: Record<string, string> = {
: '트리거',
: '조건 / 분기',
: '액션',
: '흐름 제어',
: '외부 연동',
: '기록',
};
const cats = ['트리거', '조건', '액션', '흐름', '연동', '기록'];
const renderTableItem = (t: Record<string, any>, isRecommended: boolean) => {
const name = t.table_name ?? t.TABLE_NAME;
const rawLabel = t.table_label ?? t.TABLE_LABEL;
const label = rawLabel && rawLabel !== name ? rawLabel : null;
const icon = pickIcon(String(name));
return (
<div
key={name}
className={`ctrl-palette-item${isRecommended ? ' ctrl-palette-item-rec' : ''}`}
draggable
title={`${label ?? name}${label ? ` (${name})` : ''} — 캔버스로 드래그`}
onDragStart={(e) => handleDragStart(e, { kind: 'table', name })}
>
<span className="cp-icon">{icon}</span>
<span className="cp-label">
<span className="cp-label-main">{label ?? name}</span>
{label && <span className="cp-label-sub">{name}</span>}
</span>
{isRecommended && <Star size={9} className="cp-rec-star" />}
</div>
);
};
return (
<div className="ctrl-palette">
{/* 헤더 */}
<div className="ctrl-palette-header">
<span className="ctrl-palette-header-title"> </span>
{!isEditMode && (
<span className="ctrl-palette-header-hint"> </span>
)}
</div>
{/* 검색박스 */}
<div className="ctrl-palette-search-wrap">
<Search size={11} className="ctrl-palette-search-icon" />
<input
type="text"
className="ctrl-palette-search"
placeholder="테이블 / 노드 검색…"
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={!isEditMode}
/>
</div>
<div
className={`ctrl-palette-scroll${!isEditMode ? ' disabled' : ''}`}
style={{ pointerEvents: isEditMode ? 'auto' : 'none' }}
>
{/* 주요 테이블 (자주 쓰는 ERP 표준) */}
{recommended.length > 0 && (
<>
<div className="ctrl-palette-section ctrl-palette-section-rec">
<Star size={9} style={{ marginRight: 3, fill: 'currentColor' }} />
<span className="ctrl-palette-section-count">{recommended.length}</span>
</div>
<div className="ctrl-palette-tables">
{recommended.map((t) => renderTableItem(t, true))}
</div>
</>
)}
{/* 전체 DB 테이블 (max-height + 내부 스크롤) */}
<div className="ctrl-palette-section">
DB
{others.length > 0 && <span className="ctrl-palette-section-count">{others.length}</span>}
</div>
<div className="ctrl-palette-tables ctrl-palette-tables-others">
{others.map((t) => renderTableItem(t, false))}
{others.length === 0 && search && (
<div className="ctrl-palette-empty"> </div>
)}
{others.length === 0 && !search && tables.length === 0 && (
<div className="ctrl-palette-empty"> </div>
)}
</div>
{/* 제어 노드 카테고리별 */}
{cats.map((cat) => {
const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => {
if (d.cat !== cat) return false;
if (!search.trim()) return true;
const q = search.trim().toLowerCase();
return d.label.toLowerCase().includes(q);
});
if (!items.length) return null;
return (
<div key={cat}>
<div className="ctrl-palette-section">{catLabels[cat] ?? cat}</div>
{items.map(([type, def]) => (
<div
key={type}
className="ctrl-palette-item"
draggable
title={`${def.label} — 캔버스로 드래그`}
onDragStart={(e) => handleDragStart(e, { kind: 'control', type })}
>
<span className="cp-icon">{def.icon}</span>
<span className="cp-label">
<span className="cp-label-main">{def.label}</span>
</span>
</div>
))}
</div>
);
})}
</div>
</div>
);
}