2f398ae0b3
- 제어모드 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 후속 노트
230 lines
8.7 KiB
TypeScript
230 lines
8.7 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { Search, LayoutDashboard, Boxes, Database, ChevronRight } from 'lucide-react';
|
|
import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode';
|
|
import { NODE_CATEGORIES, ctrlCatToV3, getNodeIcon } from '../schemas';
|
|
import { getMetaTableList } from '@/lib/api/meta';
|
|
|
|
interface LeftRailProps {
|
|
cards: Record<string, any>[];
|
|
selectedCardId: string;
|
|
}
|
|
|
|
/**
|
|
* LeftRail — v3 V3LeftRail 베이스 + invyone 테이블 팔레트
|
|
*
|
|
* 섹션:
|
|
* 1) 이 대시보드의 카드
|
|
* 2) DB 테이블 — 한글 라벨 가나다순 우선, 영문 name 보조. 이모티콘 / 추천 화이트리스트 없음
|
|
* 3) 노드 팔레트 (edit 모드만)
|
|
*
|
|
* dataTransfer 포맷: text/plain = JSON({ kind: 'table'|'control', name|type })
|
|
*/
|
|
export function LeftRail({ cards, selectedCardId }: LeftRailProps) {
|
|
const mode = useControlMode((s) => s.mode);
|
|
const setSelectedCardId = useControlMode((s) => s.setSelectedCardId);
|
|
const [query, setQuery] = useState('');
|
|
const [tables, setTables] = useState<Record<string, any>[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (mode !== 'edit') return;
|
|
getMetaTableList().then(setTables).catch(() => {});
|
|
}, [mode]);
|
|
|
|
const { sortedTables, nodeEntries } = useMemo(() => {
|
|
const q = query.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;
|
|
|
|
// 정렬: 한글 label 있는 것 가나다순 → label 없는 것 (영문 name) 알파벳순
|
|
const koCollator = new Intl.Collator('ko');
|
|
const sorted = [...filtered].sort((a, b) => {
|
|
const aLabel = String(a.table_label ?? a.TABLE_LABEL ?? '');
|
|
const bLabel = String(b.table_label ?? b.TABLE_LABEL ?? '');
|
|
const aName = String(a.table_name ?? a.TABLE_NAME ?? '');
|
|
const bName = String(b.table_name ?? b.TABLE_NAME ?? '');
|
|
const aHasKo = !!aLabel && aLabel !== aName;
|
|
const bHasKo = !!bLabel && bLabel !== bName;
|
|
if (aHasKo !== bHasKo) return aHasKo ? -1 : 1;
|
|
if (aHasKo) return koCollator.compare(aLabel, bLabel);
|
|
return aName.localeCompare(bName);
|
|
});
|
|
|
|
// 노드 필터
|
|
const filteredNodes = Object.entries(CTRL_NODE_TYPES).filter(([type, def]) => {
|
|
if (!q) return true;
|
|
return def.label.toLowerCase().includes(q) || type.toLowerCase().includes(q);
|
|
});
|
|
|
|
return { sortedTables: sorted, nodeEntries: filteredNodes };
|
|
}, [tables, query]);
|
|
|
|
/** 드래그 시작 — text/plain JSON, EditCanvas.handleCanvasDrop 와 호환 */
|
|
const onDragTable = (e: React.DragEvent, name: string) => {
|
|
e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'table', name }));
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
};
|
|
const onDragNode = (e: React.DragEvent, type: string) => {
|
|
e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'control', type }));
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
};
|
|
|
|
const renderTableItem = (t: Record<string, any>) => {
|
|
const name = t.table_name ?? t.TABLE_NAME;
|
|
const rawLabel = t.table_label ?? t.TABLE_LABEL;
|
|
const hasKoLabel = !!rawLabel && rawLabel !== name;
|
|
return (
|
|
<div
|
|
key={name}
|
|
className="ctrl-rail-tbl"
|
|
draggable
|
|
title={`${rawLabel ?? name}${hasKoLabel ? ` (${name})` : ''} — 캔버스로 드래그`}
|
|
onDragStart={(e) => onDragTable(e, name)}
|
|
>
|
|
<Database size={11} className="ctrl-rail-tbl-ico" />
|
|
<span className="ctrl-rail-tbl-main">
|
|
<span className="ctrl-rail-tbl-label">{hasKoLabel ? rawLabel : name}</span>
|
|
{hasKoLabel && <span className="ctrl-rail-tbl-sub">{name}</span>}
|
|
</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="ctrl-ide-leftrail">
|
|
{/* 검색 */}
|
|
<div className="ctrl-rail-search">
|
|
<Search size={11} />
|
|
<input
|
|
type="text"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="테이블 / 노드 검색…"
|
|
/>
|
|
</div>
|
|
|
|
{/* ① 카드 */}
|
|
<RailSection ic={<LayoutDashboard size={11} />} title="이 대시보드의 카드" count={cards.length}>
|
|
<div className="ctrl-rail-cards">
|
|
{cards.map((c) => {
|
|
const id = c.card_id ?? c.CARD_ID ?? c.id;
|
|
const title = c.title ?? c.TITLE ?? '카드';
|
|
const table = c.primary_table ?? c.PRIMARY_TABLE ?? '';
|
|
const sel = id === selectedCardId;
|
|
return (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
className={`ctrl-rail-card${sel ? ' on' : ''}`}
|
|
onClick={() => setSelectedCardId(id)}
|
|
>
|
|
<div className="ctrl-rail-card-ico">
|
|
<Database size={12} />
|
|
</div>
|
|
<div className="ctrl-rail-card-body">
|
|
<div className="ctrl-rail-card-title">{title}</div>
|
|
{table && <div className="ctrl-rail-card-tbl">{table}</div>}
|
|
</div>
|
|
{sel && <ChevronRight size={10} className="ctrl-rail-card-chev" />}
|
|
</button>
|
|
);
|
|
})}
|
|
{cards.length === 0 && <div className="ctrl-rail-empty">카드 없음</div>}
|
|
</div>
|
|
</RailSection>
|
|
|
|
{/* ② DB 테이블 (edit 모드일 때만) — 한글 라벨 가나다순 우선, 이모티콘 없음 */}
|
|
{mode === 'edit' && (
|
|
<RailSection ic={<Database size={11} />} title="DB 테이블" count={sortedTables.length} expand>
|
|
<div className="ctrl-rail-tbls">
|
|
{sortedTables.map((t) => renderTableItem(t))}
|
|
{sortedTables.length === 0 && query && (
|
|
<div className="ctrl-rail-empty">검색 결과 없음</div>
|
|
)}
|
|
{sortedTables.length === 0 && !query && tables.length === 0 && (
|
|
<div className="ctrl-rail-empty">로딩 중…</div>
|
|
)}
|
|
</div>
|
|
</RailSection>
|
|
)}
|
|
|
|
{/* ③ 노드 팔레트 (edit 모드만) */}
|
|
{mode === 'edit' && (
|
|
<RailSection ic={<Boxes size={11} />} title="노드 팔레트" count={Object.keys(CTRL_NODE_TYPES).length} expand>
|
|
<div className="ctrl-rail-nodes">
|
|
{NODE_CATEGORIES.map((cat) => {
|
|
const items = nodeEntries.filter(([, def]) => ctrlCatToV3(def.cat) === cat.id);
|
|
if (items.length === 0) return null;
|
|
return (
|
|
<div key={cat.id} className="ctrl-rail-nodecat">
|
|
<div className="ctrl-rail-cat-label" style={{ color: `rgb(${cat.rgb})` }}>
|
|
<span className="ctrl-rail-cat-dot" style={{ background: `rgb(${cat.rgb})` }} />
|
|
<span>{cat.label}</span>
|
|
<span className="ctrl-rail-cat-count">{items.length}</span>
|
|
</div>
|
|
<div className="ctrl-rail-nodes-grid">
|
|
{items.map(([type, def]) => {
|
|
const Ic = getNodeIcon(type);
|
|
return (
|
|
<div
|
|
key={type}
|
|
className="ctrl-rail-node"
|
|
draggable
|
|
onDragStart={(e) => onDragNode(e, type)}
|
|
title={`${def.label} (${type}) — 캔버스로 드래그`}
|
|
>
|
|
<Ic size={10} style={{ color: `rgb(${def.rgb})`, flexShrink: 0 }} />
|
|
<span className="ctrl-rail-node-label">{def.label}</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
{nodeEntries.length === 0 && (
|
|
<div className="ctrl-rail-empty">검색 결과 없음</div>
|
|
)}
|
|
</div>
|
|
</RailSection>
|
|
)}
|
|
|
|
{mode !== 'edit' && (
|
|
<div className="ctrl-rail-hint">
|
|
<Boxes size={14} />
|
|
<span>EDIT 모드에서 DB 테이블 / 노드 팔레트가 열립니다</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RailSection({
|
|
ic, title, count, expand, children,
|
|
}: {
|
|
ic: React.ReactNode;
|
|
title: string;
|
|
count: number;
|
|
expand?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className={`ctrl-rail-sec${expand ? ' expand' : ''}`}>
|
|
<div className="ctrl-rail-sec-head">
|
|
{ic}
|
|
<span className="ctrl-rail-sec-title">{title}</span>
|
|
<span className="ctrl-rail-sec-count">{count}</span>
|
|
</div>
|
|
<div className="ctrl-rail-sec-body">{children}</div>
|
|
</div>
|
|
);
|
|
}
|