"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; // 이미 배치된 컬럼명 집합 // 테이블 선택 관련 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", // V2 컴포넌트로 대체됨 "image-widget", // → V2Media (image) "file-upload", // → input (type='file') 로 대체 "entity-search-input", // → V2Select (entity 모드) "autocomplete-search-input", // → V2Select (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는 별도 컴포넌트로 유지 // v2-media로 통합됨 "image-display", // → v2-media (image) // 공통코드관리로 통합 예정 "category-manager", // → 공통코드관리 기능으로 통합 예정 // 분할 패널 정리 "screen-split-panel", // 화면 임베딩 방식은 사용하지 않음 // 미완성/미사용 컴포넌트 (기존 화면 호환성 유지, 새 추가만 막음) "accordion-basic", // 아코디언 컴포넌트 "conditional-container", // 조건부 컨테이너 "universal-form-modal", // 범용 폼 모달 // 통합 미디어 (테이블 컬럼 입력 타입으로 사용) "v2-media", // → 테이블 컬럼의 image/file 입력 타입으로 사용 // 플로우 위젯 숨김 처리 "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-input", // → input (type='text'/'number') "v2-select", // → input (type='select') "v2-category-manager", // → input (type='select', 추후 category 특화) "v2-file-upload", // → input (type='file') "v2-media", // → input (type='file') // 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-select", // → v2-select (아래 v2Components에서 별도 처리) "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 = { table: , "v2-table-list": , stats: , title: , divider: , container: , input: , button: , search: , "v2-repeater": , "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": , }; if (icons[id]) return icons[id]; // 카테고리 폴백 switch (category) { case "data": return ; case "display": return ; case "action": return ; case "layout": return ; default: return ; } }; // 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, 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) => (
{ 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} >
{getComponentIcon(component.id, component.category)}
{component.name}
); // 빈 상태 렌더링 const renderEmptyState = () => (
검색 결과 없음
); // ── 기본/고급 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 (
{/* 검색 */}
{ const value = e.target.value; setSearchQuery(value); if (onSearchChange) onSearchChange(value); }} className="inv-search-input" />
{/* 단일 스크롤 영역 */}
{/* ── 테이블 컬럼 (선택된 테이블이 있을 때만) ── */} {selectedTableName && (
{selectedTableName} {tableColumns.length}
{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 (
{ if (isPlaced || !selectedTable) return; onTableDragStart?.(e, selectedTable as any, col); }} className={`inv-col-row ${isPlaced ? "placed" : ""}`} data-type={tag} > {tag} {colLabel} {isPlaced && 배치됨}
); }) ) : (
컬럼 없음
)}
{/* 테이블 변경 */} {showTableSelector && tables && tables.length > 1 && ( )}
)} {/* ── 컴포넌트 ── */}
컴포넌트
{allFiltered.basic.length > 0 ? allFiltered.basic.map(renderComponentCard) : renderEmptyState()}
{/* ── 더보기 ── */} {allFiltered.advanced.length > 0 && (
더보기 {allFiltered.advanced.length}
{allFiltered.advanced.map(renderComponentCard)}
)}
); }