Files
invyone/frontend/components/screen/panels/ComponentsPanel.tsx
T
DDD1542 4a8413000b
Build & Deploy to K8s / build-and-deploy (push) Failing after 11m17s
Consolidate canonical input migration
Remove legacy v2 input/select and file/media runtimes, add canonical option/file loaders, and document Codex handoff.
2026-05-12 18:36:43 +09:00

486 lines
21 KiB
TypeScript

"use client";
import React, { useState, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { ComponentDefinition, ComponentCategory } from "@/types/component";
import {
Search, Package, Layers, Palette, Zap, Database, GripVertical,
Table2, BarChart3, Type, Minus, LayoutGrid, TextCursorInput, SlidersHorizontal,
ListFilter, Workflow, Map, CalendarRange, ClipboardCheck, Warehouse, GitBranch,
Truck, CircleDot, Boxes,
} from "lucide-react";
import { TableInfo, ColumnInfo } from "@/types/screen";
interface ComponentsPanelProps {
className?: string;
// 테이블 관련 props
tables?: TableInfo[];
searchTerm?: string;
onSearchChange?: (value: string) => void;
onTableDragStart?: (e: React.DragEvent, table: TableInfo, column?: ColumnInfo) => void;
selectedTableName?: string;
placedColumns?: Set<string>; // 이미 배치된 컬럼명 집합
// 테이블 선택 관련 props
onTableSelect?: (tableName: string) => void; // 테이블 선택 콜백
showTableSelector?: boolean; // 테이블 선택 UI 표시 여부 (기본: 테이블 없으면 표시)
}
export function ComponentsPanel({
className,
tables = [],
searchTerm = "",
onSearchChange,
onTableDragStart,
selectedTableName,
placedColumns,
onTableSelect,
showTableSelector = true,
}: ComponentsPanelProps) {
const [searchQuery, setSearchQuery] = useState("");
// 레지스트리에서 모든 컴포넌트 조회
const allComponents = useMemo(() => {
const components = ComponentRegistry.getAllComponents();
// v2-table-list가 자동 등록되므로 수동 추가 불필요
return components;
}, []);
// ── 기본 컴포넌트 (v2 하드코딩) ──
const basicV2Components = useMemo(
() =>
[
{
id: "v2-repeater",
name: "데이터 조회/선택",
description: "다른 테이블에서 데이터를 조회하고 선택하여 전달",
category: "data" as ComponentCategory,
tags: ["조회", "선택", "전달", "모달", "repeater"],
default_size: { width: 600, height: 300 },
},
] as unknown as ComponentDefinition[],
[],
);
// ── 고급 컴포넌트 (도메인 특화) ──
const advancedComponents = useMemo(
() =>
[
{
id: "v2-bom-tree",
name: "BOM 트리",
description: "자재 구성을 계층 트리로 조회",
category: "data" as ComponentCategory,
tags: ["bom", "tree", "계층", "제조"],
default_size: { width: 900, height: 600 },
},
{
id: "v2-bom-item-editor",
name: "BOM 편집",
description: "하위 자재를 트리 구조로 추가/편집/삭제",
category: "data" as ComponentCategory,
tags: ["bom", "tree", "편집", "제조"],
default_size: { width: 900, height: 400 },
},
] as unknown as ComponentDefinition[],
[],
);
// 레거시 호환 (기존 코드에서 v2Components 참조하는 곳용)
const v2Components = useMemo(
() => [...basicV2Components, ...advancedComponents],
[basicV2Components, advancedComponents],
);
// 카테고리별 컴포넌트 그룹화
const componentsByCategory = useMemo(() => {
// 숨길 컴포넌트 ID 목록
const hiddenComponents = [
// 기본 입력 컴포넌트 (테이블 컬럼 드래그 시 자동 생성)
"text-input",
"number-input",
"date-input",
"textarea-basic",
// canonical input 으로 대체됨 (Phase D.4)
"image-widget", // → canonical input (type='file', format='image')
"file-upload", // → canonical input (type='file', format='file')
"entity-search-input", // → canonical input (entity 모드)
"autocomplete-search-input", // → canonical input (autocomplete 모드)
// DataFlow 전용 (일반 화면에서 불필요)
"mail-recipient-selector",
// 현재 사용 안함
"repeater-field-group",
// v2-repeater로 통합됨
"simple-repeater-table", // → v2-repeater (inline 모드)
"modal-repeater-table", // → v2-repeater (modal 모드)
// 특수 업무용 컴포넌트 (일반 화면에서 불필요)
"tax-invoice-list", // 세금계산서 전용
"customer-item-mapping", // 고객-품목 매핑 전용
// card-display는 별도 컴포넌트로 유지
// canonical input 으로 통합됨 (Phase D.4)
"image-display", // → canonical input (type='file', format='image')
// 공통코드관리로 통합 예정
"category-manager", // → 공통코드관리 기능으로 통합 예정
// 분할 패널 정리
"screen-split-panel", // 화면 임베딩 방식은 사용하지 않음
// 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음)
"accordion-basic", // 아코디언 컴포넌트
"conditional-container", // 조건부 컨테이너
"universal-form-modal", // 범용 폼 모달
// 옛 v2-media — Phase D.4 에서 canonical input 으로 흡수. hidden 유지 (생성 차단)
"v2-media",
// 플로우 위젯 숨김 처리
"flow-widget",
// 선택 항목 상세입력 - 거래처 품목 추가 등에서 사용
"selected-items-detail-input", // → 데이터 조회/선택 으로 대체
// 연관 데이터 버튼 - v2-repeater로 대체 가능
"related-data-buttons",
// ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) =====
"button-primary", // → v2-button-primary
"split-panel-layout", // → v2-split-panel-layout
"aggregation-widget", // → v2-aggregation-widget
"table-list", // → v2-table-list
"text-display", // → v2-text-display
"divider-line", // → v2-divider-line
// ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider`
"v2-divider-line", // → divider
"v2-split-line", // → divider (drag-resize 기능은 다음 Phase)
// ★ 2026-04-11 통합 컴포넌트(Phase A-2): 제목/텍스트 2종 → `title`
"v2-text-display", // → title
// text-display 는 아래 기존 항목에서 이미 숨김 처리됨
// ★ 2026-04-11 통합 컴포넌트(Phase A-3): 버튼 → `button`
"v2-button-primary", // → button
// button-primary 는 아래 기존 항목에서 이미 숨김 처리됨
// related-data-buttons 는 기존에 이미 숨김
// ★ 2026-04-11 통합 컴포넌트(Phase A-4): 검색 필터 → `search`
"v2-table-search-widget", // → search
// table-search-widget, autocomplete-search-input 은 기존에 이미 숨김
// ★ 2026-04-11 통합 컴포넌트(Phase B-1): 필드 입력 20+종 → `input`
// (V2 입력/선택은 Phase D.2 에서 완전 폐기 — 등록/생성 경로 자체 삭제, hidden 목록에 둘 필요 없음)
"v2-category-manager", // → input (type='select', 추후 category 특화)
"v2-file-upload", // → canonical input (type='file', Phase D.4)
// v2-media 는 이미 위에서 hidden 처리됨
// v2-numbering-rule: 폐기 (2026-05-11). admin 페이지 /admin/systemMng/numberingRuleList 로 대체
"v2-location-swap-selector", // → input (type='entity')
// 아래 legacy 들은 이미 상단 "기본 입력 컴포넌트" 섹션에서 hidden:
// text-input, number-input, date-input, textarea-basic, image-widget,
// entity-search-input, autocomplete-search-input, file-upload (일부)
// 이미 리스트에 없는 것만 추가:
"select-basic", // → input (type='select')
"checkbox-basic", // → input (type='checkbox')
"radio-basic", // → input (type='select', radio 렌더)
"toggle-switch", // → input (type='checkbox', toggle 렌더)
"slider-basic", // → input (type='number', slider 렌더)
// ★ 2026-04-11 통합 컴포넌트(Phase B-2): 통계/KPI → `stats`
"v2-aggregation-widget", // → stats
"v2-status-count", // → stats
// aggregation-widget, card-display 는 기존 상단에서 이미 숨김
// form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정.
"field-example-1", // legacy form-layout 의 실제 id (숨김 유지)
// ★ 2026-04-11 통합 컴포넌트(Phase C-1): 데이터 테이블 → `table`
"v2-table-list", // → table (displayMode='table')
"v2-split-panel-layout", // → table (displayMode='split')
// table-list, split-panel-layout, split-panel-layout2, modal-repeater-table,
// simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김
// ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container`
"v2-tabs-widget", // → container (containerType='tabs')
"v2-section-card", // → container (containerType='section', sectionVariant='card')
"v2-section-paper", // → container (containerType='section', sectionVariant='paper')
"v2-repeat-container", // → container (containerType='repeater')
"v2-repeater", // → container (containerType='repeater')
// accordion-basic, conditional-container, section-card, section-paper,
// tabs, repeat-container, repeat-screen-modal, repeater-field-group,
// screen-split-panel 는 기존 상단에서 이미 숨김
// numbering-rule: 폐기 (2026-05-11)
"split-panel-layout2", // → table (displayMode='split') Phase E 통합
"section-paper", // → v2-section-paper
"section-card", // → v2-section-card
"location-swap-selector", // → v2-location-swap-selector
"rack-structure", // → v2-rack-structure
"v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리)
"repeat-container", // → v2-repeat-container
"repeat-screen-modal", // → v2-repeat-screen-modal
"table-search-widget", // → v2-table-search-widget
"tabs", // → v2-tabs
"tabs-widget", // → v2-tabs-widget
];
return {
input: allComponents.filter((c) => c.category === ComponentCategory.INPUT && !hiddenComponents.includes(c.id)),
action: allComponents.filter((c) => c.category === ComponentCategory.ACTION && !hiddenComponents.includes(c.id)),
display: allComponents.filter(
(c) => c.category === ComponentCategory.DISPLAY && !hiddenComponents.includes(c.id),
),
data: allComponents.filter((c) => c.category === ComponentCategory.DATA && !hiddenComponents.includes(c.id)),
layout: allComponents.filter((c) => c.category === ComponentCategory.LAYOUT && !hiddenComponents.includes(c.id)),
utility: allComponents.filter(
(c) => c.category === ComponentCategory.UTILITY && !hiddenComponents.includes(c.id),
),
v2: v2Components,
};
}, [allComponents, v2Components]);
// 카테고리별 검색 필터링
const getFilteredComponents = (category: keyof typeof componentsByCategory) => {
let components = componentsByCategory[category];
if (searchQuery) {
const query = searchQuery.toLowerCase();
components = components.filter(
(component: ComponentDefinition) =>
component.name.toLowerCase().includes(query) ||
component.description.toLowerCase().includes(query) ||
component.tags?.some((tag: string) => tag.toLowerCase().includes(query)),
);
}
return components;
};
// ── INVYONE 컴포넌트별 아이콘 매핑 ──
const getComponentIcon = (id: string, category: string) => {
const s = 14;
const icons: Record<string, React.ReactNode> = {
table: <Table2 size={s} />,
"v2-table-list": <Table2 size={s} />,
stats: <BarChart3 size={s} />,
title: <Type size={s} />,
divider: <Minus size={s} />,
container: <LayoutGrid size={s} />,
input: <TextCursorInput size={s} />,
button: <Zap size={s} />,
search: <ListFilter size={s} />,
"v2-repeater": <SlidersHorizontal size={s} />,
"v2-bom-tree": <Boxes size={s} />,
"v2-bom-item-editor": <Boxes size={s} />,
"v2-approval-step": <ClipboardCheck size={s} />,
map: <Map size={s} />,
"v2-shipping-plan-editor": <Truck size={s} />,
"v2-timeline-scheduler": <CalendarRange size={s} />,
"v2-rack-structure": <Warehouse size={s} />,
"v2-process-work-standard": <Workflow size={s} />,
"v2-item-routing": <GitBranch size={s} />,
};
if (icons[id]) return icons[id];
// 카테고리 폴백
switch (category) {
case "data": return <Database size={s} />;
case "display": return <Palette size={s} />;
case "action": return <Zap size={s} />;
case "layout": return <Layers size={s} />;
default: return <CircleDot size={s} />;
}
};
// getCategoryIcon 제거됨 — getComponentIcon 으로 대체
// 드래그 시작 핸들러
//
// ★ 2026-04-11 버그 픽스:
// handleComponentDrop (ScreenDesigner.tsx) 이 component.defaultSize,
// component.defaultConfig, component.webType (camelCase) 로 접근하지만
// ComponentDefinition 은 default_size / default_config / web_type
// (snake_case) 로 저장한다. 격자 스냅이 켜진 상태에서 이 불일치 때문에
// drop 함수가 TypeError 로 중단되어 컴포넌트 배치가 전혀 안 됐음.
// 여기서 camelCase 별칭을 함께 주입해서 호환.
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, component: ComponentDefinition) => {
const dragData = {
type: "component",
component: {
...component,
// snake_case → camelCase 별칭 (handleComponentDrop 호환)
defaultSize: component.default_size,
defaultConfig: component.default_config,
webType: component.web_type,
},
};
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
e.dataTransfer.effectAllowed = "copy";
};
// ── 카테고리별 컬러 클래스 ──
const getCategoryAccent = (cat: string) => {
switch (cat) {
case "data": return "inv-cat-data";
case "display": return "inv-cat-display";
case "action": return "inv-cat-action";
case "layout": return "inv-cat-layout";
case "input": return "inv-cat-input";
case "utility": return "inv-cat-utility";
default: return "inv-cat-default";
}
};
// ── INVYONE 컴포넌트 카드 ──
const renderComponentCard = (component: ComponentDefinition) => (
<div
key={component.id}
draggable
onDragStart={(e) => {
handleDragStart(e, component);
e.currentTarget.style.opacity = "0.5";
}}
onDragEnd={(e) => {
e.currentTarget.style.opacity = "1";
}}
className={`inv-comp-card ${getCategoryAccent(component.category)}`}
title={component.description}
>
<div className="inv-comp-icon">
{getComponentIcon(component.id, component.category)}
</div>
<span className="inv-comp-name">{component.name}</span>
<GripVertical size={10} className="inv-comp-grip" />
</div>
);
// 빈 상태 렌더링
const renderEmptyState = () => (
<div className="text-muted-foreground flex h-20 items-center justify-center text-center text-[0.6rem]">
</div>
);
// ── 기본/고급 ID 분류 ──
const BASIC_IDS = new Set([
"table", "search", "input", "button", "stats", "title", "divider", "container",
"v2-repeater",
]);
const ADVANCED_IDS = new Set([
"v2-bom-tree", "v2-bom-item-editor", "v2-approval-step",
"map", "v2-shipping-plan-editor", "v2-timeline-scheduler",
"v2-rack-structure", "v2-process-work-standard", "v2-item-routing",
]);
const allFiltered = useMemo(() => {
const all = [
...getFilteredComponents("v2"),
...getFilteredComponents("action"),
...getFilteredComponents("display"),
...getFilteredComponents("data"),
...getFilteredComponents("layout"),
...getFilteredComponents("input"),
...getFilteredComponents("utility"),
];
return {
basic: all.filter((c) => BASIC_IDS.has(c.id)),
advanced: all.filter((c) => ADVANCED_IDS.has(c.id)),
};
}, [searchQuery, componentsByCategory]);
// 테이블 컬럼을 드래그 가능한 칩으로 렌더
const selectedTable = tables?.find(
(t: any) => (t.tableName || t.table_name) === selectedTableName,
);
const tableColumns = (selectedTable as any)?.columns || [];
return (
<div className={`inv-panel-root ${className || ""}`}>
{/* 검색 */}
<div className="inv-search-wrap">
<Search size={12} className="inv-search-icon" />
<Input
placeholder="검색..."
value={searchQuery}
onChange={(e) => {
const value = e.target.value;
setSearchQuery(value);
if (onSearchChange) onSearchChange(value);
}}
className="inv-search-input"
/>
</div>
{/* 단일 스크롤 영역 */}
<div className="inv-panel-scroll">
{/* ── 테이블 컬럼 (선택된 테이블이 있을 때만) ── */}
{selectedTableName && (
<details open className="inv-section">
<summary className="inv-section-header">
<Database size={11} />
<span className="flex-1">{selectedTableName}</span>
<span className="inv-section-count">{tableColumns.length}</span>
</summary>
<div className="inv-column-list">
{tableColumns.length > 0 ? (
tableColumns.map((col: any) => {
const colName = col.columnName || col.column_name;
const colLabel = col.columnLabel || col.column_label || col.displayName || colName;
const isPlaced = placedColumns?.has(colName);
const dataType = (col.dataType || col.data_type || "").toLowerCase();
let tag = "TXT";
if (dataType.includes("int") || dataType.includes("numeric") || dataType.includes("decimal")) tag = "NUM";
else if (dataType.includes("date") || dataType.includes("time")) tag = "DATE";
else if (dataType.includes("bool")) tag = "BOOL";
return (
<div
key={colName}
draggable={!isPlaced}
onDragStart={(e) => {
if (isPlaced || !selectedTable) return;
onTableDragStart?.(e, selectedTable as any, col);
}}
className={`inv-col-row ${isPlaced ? "placed" : ""}`}
data-type={tag}
>
<span className="inv-col-tag">{tag}</span>
<span className="inv-col-name" title={colName}>{colLabel}</span>
{isPlaced && <span className="inv-col-placed"></span>}
</div>
);
})
) : (
<div className="text-muted-foreground py-2 text-center text-[0.6rem]">
</div>
)}
</div>
{/* 테이블 변경 */}
{showTableSelector && tables && tables.length > 1 && (
<select
value={selectedTableName || ""}
onChange={(e) => onTableSelect?.(e.target.value)}
className="inv-table-select"
>
{tables.map((t: any) => (
<option key={t.tableName || t.table_name} value={t.tableName || t.table_name}>
{t.tableLabel || t.table_label || t.tableName || t.table_name}
</option>
))}
</select>
)}
</details>
)}
{/* ── 컴포넌트 ── */}
<div className="inv-section">
<div className="inv-section-header inv-section-static">
<Package size={11} />
<span></span>
</div>
<div className="inv-comp-list">
{allFiltered.basic.length > 0
? allFiltered.basic.map(renderComponentCard)
: renderEmptyState()}
</div>
</div>
{/* ── 더보기 ── */}
{allFiltered.advanced.length > 0 && (
<details className="inv-section inv-more-section">
<summary className="inv-more-toggle">
<span></span>
<span className="inv-more-count">{allFiltered.advanced.length}</span>
</summary>
<div className="inv-comp-list">
{allFiltered.advanced.map(renderComponentCard)}
</div>
</details>
)}
</div>
</div>
);
}