Files
invyone/frontend/components/control/ide/LeftRail.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

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>
);
}