From 4db5d738178921d5c0d7f324a4c7b13b96d17aa4 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 14:54:45 +0900 Subject: [PATCH 01/30] [agent-pipeline] pipe-20260317054958-cypk round-1 --- .../components/screen/panels/V2PropertiesPanel.tsx | 6 +++--- .../v2/config-panels/V2FieldConfigPanel.tsx | 11 +++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index ef739b27..047a10cb 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -211,12 +211,11 @@ export const V2PropertiesPanel: React.FC = ({ // 현재 화면의 테이블명 가져오기 const currentTableName = tables?.[0]?.tableName; - // DB input_type 가져오기 (columnMetaCache에서 최신값 조회) + // DB input_type만 조회 (saved config와 분리하여 전달) const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName; const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined; const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined; - const inputType = dbInputType || currentConfig.inputType || currentConfig.webType || (selectedComponent as any).inputType; // 컴포넌트별 추가 props const extraProps: Record = {}; @@ -224,7 +223,8 @@ export const V2PropertiesPanel: React.FC = ({ const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; if (componentId === "v2-input" || componentId === "v2-select") { - extraProps.inputType = inputType; + extraProps.componentType = componentId; + extraProps.inputType = dbInputType; extraProps.tableName = resolvedTableName; extraProps.columnName = resolvedColumnName; extraProps.screenTableName = resolvedTableName; diff --git a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx index 2f2b8011..3ce85266 100644 --- a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx @@ -77,9 +77,9 @@ interface CategoryValueOption { valueLabel: string; } -// ─── 하위 호환: 기존 config에서 fieldType 추론 ─── +// ─── 하위 호환: 기존 config에서 fieldType 추론 (우선순위: DB값 > 사용자 fieldType > 컴포넌트구조 > saved config > 기본값) ─── function resolveFieldType(config: Record, componentType?: string, metaInputType?: string): FieldType { - // DB input_type이 전달된 경우 (데이터타입관리에서 변경 시) 우선 적용 + // (a) metaInputType: DB 전용 (undefined면 스킵, V2PropertiesPanel에서 dbInputType만 전달) if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") { const dbType = metaInputType as FieldType; if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) { @@ -87,9 +87,10 @@ function resolveFieldType(config: Record, componentType?: string, m } } + // (b) 사용자가 설정 패널에서 직접 선택한 fieldType if (config.fieldType) return config.fieldType as FieldType; - // v2-select 계열 + // (c) v2-select 계열: componentType 또는 config.source 기반 if (componentType === "v2-select" || config.source) { const source = config.source === "code" ? "category" : config.source; if (source === "entity") return "entity"; @@ -97,11 +98,13 @@ function resolveFieldType(config: Record, componentType?: string, m return "select"; } - // v2-input 계열 + // (d) saved config fallback (config.inputType / config.type) const it = config.inputType || config.type; if (it === "number") return "number"; if (it === "textarea") return "textarea"; if (it === "numbering") return "numbering"; + + // (e) 최종 기본값 return "text"; } From 2b4500a999ea1a0595de7affc81cc066380c1889 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 15:00:41 +0900 Subject: [PATCH 02/30] [agent-pipeline] pipe-20260317054958-cypk round-2 --- .../screen/panels/V2PropertiesPanel.tsx | 49 +++++++++---------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index 047a10cb..65f55230 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -17,7 +17,6 @@ import { GroupComponent, DataTableComponent, TableInfo, - LayoutComponent, FileComponent, AreaComponent, } from "@/types/screen"; @@ -212,15 +211,15 @@ export const V2PropertiesPanel: React.FC = ({ const currentTableName = tables?.[0]?.tableName; // DB input_type만 조회 (saved config와 분리하여 전달) - const colName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; - const tblName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + const colName = (selectedComponent as any).columnName || currentConfig.fieldKey || currentConfig.columnName; + const tblName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName; const dbMeta = colName && tblName && !colName.includes(".") ? columnMetaCache[tblName]?.[colName] : undefined; const dbInputType = dbMeta ? (() => { const raw = dbMeta.input_type || dbMeta.inputType; return raw === "direct" || raw === "auto" ? undefined : raw; })() : undefined; // 컴포넌트별 추가 props const extraProps: Record = {}; - const resolvedTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; - const resolvedColumnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; + const resolvedTableName = (selectedComponent as any).tableName || currentTable?.tableName || currentTableName; + const resolvedColumnName = (selectedComponent as any).columnName || currentConfig.fieldKey || currentConfig.columnName; if (componentId === "v2-input" || componentId === "v2-select") { extraProps.componentType = componentId; @@ -256,7 +255,7 @@ export const V2PropertiesPanel: React.FC = ({ const currentConfig = selectedComponent.componentConfig || {}; // 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지) - const config = currentConfig || definition.defaultProps?.componentConfig || {}; + const config = currentConfig || (definition as any).defaultProps?.componentConfig || {}; const handlePanelConfigChange = (newConfig: any) => { // 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합 @@ -282,14 +281,14 @@ export const V2PropertiesPanel: React.FC = ({ onConfigChange={handlePanelConfigChange} tables={tables} allTables={allTables} - screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} - tableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} + screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName} + tableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName} columnName={ (selectedComponent as any).columnName || currentConfig?.columnName || currentConfig?.fieldName } inputType={(selectedComponent as any).inputType || currentConfig?.inputType} componentType={componentType} - tableColumns={currentTable?.columns || []} + tableColumns={(currentTable as any)?.columns || []} allComponents={allComponents} currentComponent={selectedComponent} menuObjid={menuObjid} @@ -323,8 +322,8 @@ export const V2PropertiesPanel: React.FC = ({ componentType={componentType} config={selectedComponent.componentConfig || {}} onChange={handleDynamicConfigChange} - screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} - tableColumns={currentTable?.columns || []} + screenTableName={(selectedComponent as any).tableName || currentTable?.tableName || currentTableName} + tableColumns={(currentTable as any)?.columns || []} tables={tables} menuObjid={menuObjid} allComponents={allComponents} @@ -491,7 +490,7 @@ export const V2PropertiesPanel: React.FC = ({ 제목
handleUpdate("title", e.target.value)} placeholder="제목" className="h-7 text-xs" @@ -503,7 +502,7 @@ export const V2PropertiesPanel: React.FC = ({ 설명
handleUpdate("description", e.target.value)} placeholder="설명" className="h-7 text-xs" @@ -519,9 +518,9 @@ export const V2PropertiesPanel: React.FC = ({

OPTIONS

{(isInputField || widget.required !== undefined) && (() => { - const colName = widget.columnName || selectedComponent?.columnName; + const colName = widget.columnName || (selectedComponent as any)?.columnName; const colMeta = colName - ? currentTable?.columns?.find( + ? (currentTable as any)?.columns?.find( (c: any) => (c.columnName || c.column_name || "").toLowerCase() === colName.toLowerCase(), ) : null; @@ -568,7 +567,7 @@ export const V2PropertiesPanel: React.FC = ({
숨김 { handleUpdate("hidden", checked); handleUpdate("componentConfig.hidden", checked); @@ -689,7 +688,7 @@ export const V2PropertiesPanel: React.FC = ({
표시 { const boolValue = checked === true; handleUpdate("style.labelDisplay", boolValue); @@ -785,7 +784,7 @@ export const V2PropertiesPanel: React.FC = ({ const webType = selectedComponent.componentConfig?.webType; // 테이블 패널에서 드래그한 컴포넌트인지 확인 - const isFromTablePanel = !!(selectedComponent.tableName && selectedComponent.columnName); + const isFromTablePanel = !!((selectedComponent as any).tableName && (selectedComponent as any).columnName); if (!componentId) { return ( @@ -845,8 +844,8 @@ export const V2PropertiesPanel: React.FC = ({ = ({ = ({ return (
{/* WebType 선택 (있는 경우만) */} - {widget.webType && ( + {(widget as any).webType && (
- handleUpdate("webType", value)}> {webTypes.map((wt) => ( - {wt.web_type_name_kor || wt.web_type} + {(wt as any).web_type_name_kor || wt.web_type} ))} From 87a7431e53209bde36d53e9435f07e298b143057 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 15:04:26 +0900 Subject: [PATCH 03/30] [agent-pipeline] pipe-20260317054958-cypk round-3 --- .../v2/config-panels/V2FieldConfigPanel.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx index 3ce85266..4760eae1 100644 --- a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx @@ -77,9 +77,17 @@ interface CategoryValueOption { valueLabel: string; } -// ─── 하위 호환: 기존 config에서 fieldType 추론 (우선순위: DB값 > 사용자 fieldType > 컴포넌트구조 > saved config > 기본값) ─── +// ─── 하위 호환: 기존 config에서 fieldType 추론 (우선순위: 컴포넌트구조 > DB값 > 사용자 fieldType > saved config > 기본값) ─── function resolveFieldType(config: Record, componentType?: string, metaInputType?: string): FieldType { - // (a) metaInputType: DB 전용 (undefined면 스킵, V2PropertiesPanel에서 dbInputType만 전달) + // (a) v2-select 계열: componentType 또는 config.source 우선 (metaInputType보다 먼저 체크) + if (componentType === "v2-select" || config.source) { + const source = config.source === "code" ? "category" : config.source; + if (source === "entity") return "entity"; + if (source === "category") return "category"; + return "select"; + } + + // (b) metaInputType: DB 전용 (undefined면 스킵, V2PropertiesPanel에서 dbInputType만 전달) if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") { const dbType = metaInputType as FieldType; if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) { @@ -87,17 +95,9 @@ function resolveFieldType(config: Record, componentType?: string, m } } - // (b) 사용자가 설정 패널에서 직접 선택한 fieldType + // (c) 사용자가 설정 패널에서 직접 선택한 fieldType if (config.fieldType) return config.fieldType as FieldType; - // (c) v2-select 계열: componentType 또는 config.source 기반 - if (componentType === "v2-select" || config.source) { - const source = config.source === "code" ? "category" : config.source; - if (source === "entity") return "entity"; - if (source === "category") return "category"; - return "select"; - } - // (d) saved config fallback (config.inputType / config.type) const it = config.inputType || config.type; if (it === "number") return "number"; From cc51ad71da79dc7ac5c255431410ad3e83830121 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 15:04:26 +0900 Subject: [PATCH 04/30] [agent-pipeline] rollback to 2b4500a9 --- .../v2/config-panels/V2FieldConfigPanel.tsx | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx index 4760eae1..3ce85266 100644 --- a/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2FieldConfigPanel.tsx @@ -77,17 +77,9 @@ interface CategoryValueOption { valueLabel: string; } -// ─── 하위 호환: 기존 config에서 fieldType 추론 (우선순위: 컴포넌트구조 > DB값 > 사용자 fieldType > saved config > 기본값) ─── +// ─── 하위 호환: 기존 config에서 fieldType 추론 (우선순위: DB값 > 사용자 fieldType > 컴포넌트구조 > saved config > 기본값) ─── function resolveFieldType(config: Record, componentType?: string, metaInputType?: string): FieldType { - // (a) v2-select 계열: componentType 또는 config.source 우선 (metaInputType보다 먼저 체크) - if (componentType === "v2-select" || config.source) { - const source = config.source === "code" ? "category" : config.source; - if (source === "entity") return "entity"; - if (source === "category") return "category"; - return "select"; - } - - // (b) metaInputType: DB 전용 (undefined면 스킵, V2PropertiesPanel에서 dbInputType만 전달) + // (a) metaInputType: DB 전용 (undefined면 스킵, V2PropertiesPanel에서 dbInputType만 전달) if (metaInputType && metaInputType !== "direct" && metaInputType !== "auto") { const dbType = metaInputType as FieldType; if (["text", "number", "textarea", "numbering", "select", "category", "entity"].includes(dbType)) { @@ -95,9 +87,17 @@ function resolveFieldType(config: Record, componentType?: string, m } } - // (c) 사용자가 설정 패널에서 직접 선택한 fieldType + // (b) 사용자가 설정 패널에서 직접 선택한 fieldType if (config.fieldType) return config.fieldType as FieldType; + // (c) v2-select 계열: componentType 또는 config.source 기반 + if (componentType === "v2-select" || config.source) { + const source = config.source === "code" ? "category" : config.source; + if (source === "entity") return "entity"; + if (source === "category") return "category"; + return "select"; + } + // (d) saved config fallback (config.inputType / config.type) const it = config.inputType || config.type; if (it === "number") return "number"; From c3fae741ae09aa199eb379fb007946a06af99b8a Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 15:09:53 +0900 Subject: [PATCH 05/30] [agent-pipeline] pipe-20260317054958-cypk round-4 --- .../screen/panels/V2PropertiesPanel.tsx | 20 ++++++++++++++++++- .../lib/registry/DynamicComponentRenderer.tsx | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index 65f55230..a18f3bda 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -46,7 +46,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent // ComponentRegistry import (동적 ConfigPanel 가져오기용) import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; -import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer"; +import { columnMetaCache, loadColumnMeta } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; import StyleEditor from "../StyleEditor"; import { Slider } from "@/components/ui/slider"; @@ -97,6 +97,24 @@ export const V2PropertiesPanel: React.FC = ({ // 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용) const [allTables, setAllTables] = useState>([]); + // 🆕 선택된 컴포넌트의 테이블에 대한 columnMeta 캐시가 비어 있으면 로드 후 재렌더 + const [columnMetaVersion, setColumnMetaVersion] = useState(0); + useEffect(() => { + if (!selectedComponent) return; + const tblName = + (selectedComponent as any).tableName || + currentTable?.tableName || + tables?.[0]?.tableName; + if (!tblName) return; + if (columnMetaCache[tblName]) return; + loadColumnMeta(tblName).then(() => setColumnMetaVersion((v) => v + 1)); + }, [ + selectedComponent?.id, + (selectedComponent as any)?.tableName, + currentTable?.tableName, + tables?.[0]?.tableName, + ]); + // 🆕 전체 테이블 목록 로드 useEffect(() => { const loadAllTables = async () => { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 46b17fe2..32cc7de1 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -30,7 +30,7 @@ export function invalidateColumnMetaCache(tableName?: string): void { } } -async function loadColumnMeta(tableName: string, forceReload = false): Promise { +export async function loadColumnMeta(tableName: string, forceReload = false): Promise { const now = Date.now(); const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS); From c55520f01c0d84cdba1e6ef01e945799ee456d97 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 15:09:53 +0900 Subject: [PATCH 06/30] [agent-pipeline] rollback to 87a7431e --- .../screen/panels/V2PropertiesPanel.tsx | 20 +------------------ .../lib/registry/DynamicComponentRenderer.tsx | 2 +- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index a18f3bda..65f55230 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -46,7 +46,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent // ComponentRegistry import (동적 ConfigPanel 가져오기용) import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; -import { columnMetaCache, loadColumnMeta } from "@/lib/registry/DynamicComponentRenderer"; +import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; import StyleEditor from "../StyleEditor"; import { Slider } from "@/components/ui/slider"; @@ -97,24 +97,6 @@ export const V2PropertiesPanel: React.FC = ({ // 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용) const [allTables, setAllTables] = useState>([]); - // 🆕 선택된 컴포넌트의 테이블에 대한 columnMeta 캐시가 비어 있으면 로드 후 재렌더 - const [columnMetaVersion, setColumnMetaVersion] = useState(0); - useEffect(() => { - if (!selectedComponent) return; - const tblName = - (selectedComponent as any).tableName || - currentTable?.tableName || - tables?.[0]?.tableName; - if (!tblName) return; - if (columnMetaCache[tblName]) return; - loadColumnMeta(tblName).then(() => setColumnMetaVersion((v) => v + 1)); - }, [ - selectedComponent?.id, - (selectedComponent as any)?.tableName, - currentTable?.tableName, - tables?.[0]?.tableName, - ]); - // 🆕 전체 테이블 목록 로드 useEffect(() => { const loadAllTables = async () => { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 32cc7de1..46b17fe2 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -30,7 +30,7 @@ export function invalidateColumnMetaCache(tableName?: string): void { } } -export async function loadColumnMeta(tableName: string, forceReload = false): Promise { +async function loadColumnMeta(tableName: string, forceReload = false): Promise { const now = Date.now(); const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS); From d8a542b25375a7f3cb17bf0b36a4a20329ae0ef7 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 15:13:42 +0900 Subject: [PATCH 07/30] [agent-pipeline] pipe-20260317054958-cypk round-5 --- .../screen/panels/V2PropertiesPanel.tsx | 20 ++++++++++++++++++- .../lib/registry/DynamicComponentRenderer.tsx | 6 ++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index 65f55230..a18f3bda 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -46,7 +46,7 @@ import { ColorPickerWithTransparent } from "../common/ColorPickerWithTransparent // ComponentRegistry import (동적 ConfigPanel 가져오기용) import { ComponentRegistry } from "@/lib/registry/ComponentRegistry"; -import { columnMetaCache } from "@/lib/registry/DynamicComponentRenderer"; +import { columnMetaCache, loadColumnMeta } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentConfigPanel, hasComponentConfigPanel } from "@/lib/utils/getComponentConfigPanel"; import StyleEditor from "../StyleEditor"; import { Slider } from "@/components/ui/slider"; @@ -97,6 +97,24 @@ export const V2PropertiesPanel: React.FC = ({ // 🆕 전체 테이블 목록 (selected-items-detail-input 등에서 사용) const [allTables, setAllTables] = useState>([]); + // 🆕 선택된 컴포넌트의 테이블에 대한 columnMeta 캐시가 비어 있으면 로드 후 재렌더 + const [columnMetaVersion, setColumnMetaVersion] = useState(0); + useEffect(() => { + if (!selectedComponent) return; + const tblName = + (selectedComponent as any).tableName || + currentTable?.tableName || + tables?.[0]?.tableName; + if (!tblName) return; + if (columnMetaCache[tblName]) return; + loadColumnMeta(tblName).then(() => setColumnMetaVersion((v) => v + 1)); + }, [ + selectedComponent?.id, + (selectedComponent as any)?.tableName, + currentTable?.tableName, + tables?.[0]?.tableName, + ]); + // 🆕 전체 테이블 목록 로드 useEffect(() => { const loadAllTables = async () => { diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 46b17fe2..70c74a1e 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -30,9 +30,11 @@ export function invalidateColumnMetaCache(tableName?: string): void { } } -async function loadColumnMeta(tableName: string, forceReload = false): Promise { +export async function loadColumnMeta(tableName: string, forceReload = false): Promise { const now = Date.now(); - const isStale = columnMetaTimestamp[tableName] && (now - columnMetaTimestamp[tableName] > CACHE_TTL_MS); + const cachedAt = columnMetaTimestamp[tableName]; + const isStale = + typeof cachedAt === "number" && now - cachedAt > CACHE_TTL_MS; if (!forceReload && !isStale && columnMetaCache[tableName]) return; From 80cd95e68398fec3ce28c5091d7ea9f73ef35a8d Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 15:18:41 +0900 Subject: [PATCH 08/30] [agent-pipeline] pipe-20260317054958-cypk round-6 --- .../lib/registry/DynamicComponentRenderer.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 70c74a1e..8a1873f7 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -36,14 +36,14 @@ export async function loadColumnMeta(tableName: string, forceReload = false): Pr const isStale = typeof cachedAt === "number" && now - cachedAt > CACHE_TTL_MS; - if (!forceReload && !isStale && columnMetaCache[tableName]) return; + if (!forceReload && !isStale && tableName in columnMetaCache && columnMetaCache[tableName]) return; if (forceReload || isStale) { delete columnMetaCache[tableName]; delete columnMetaLoading[tableName]; } - if (columnMetaLoading[tableName]) { + if (tableName in columnMetaLoading) { await columnMetaLoading[tableName]; return; } @@ -665,7 +665,8 @@ export const DynamicComponentRenderer: React.FC = } // 1. 새 컴포넌트 시스템에서 먼저 조회 - const newComponent = ComponentRegistry.getComponent(componentType); + const newComponent = + componentType != null ? ComponentRegistry.getComponent(componentType) : null; if (newComponent) { // 새 컴포넌트 시스템으로 렌더링 @@ -777,7 +778,7 @@ export const DynamicComponentRenderer: React.FC = // 렌더러 props 구성 // 숨김 값 추출 - const hiddenValue = component.hidden || component.componentConfig?.hidden; + const hiddenValue = (component as any).hidden || component.componentConfig?.hidden; // 숨김 처리: 인터랙티브 모드(실제 뷰)에서만 숨김, 디자인 모드에서는 표시 if (hiddenValue && isInteractive) { @@ -898,7 +899,7 @@ export const DynamicComponentRenderer: React.FC = // 새로운 기능들 전달 // 🆕 webTypeConfig.numberingRuleId가 있으면 autoGeneration으로 변환 autoGeneration: - component.autoGeneration || + (component as any).autoGeneration || component.componentConfig?.autoGeneration || ((component as any).webTypeConfig?.numberingRuleId ? { @@ -998,7 +999,8 @@ export const DynamicComponentRenderer: React.FC = let renderedElement: React.ReactElement; if (isClass) { - const rendererInstance = new NewComponentRenderer(rendererProps); + const RendererClass = NewComponentRenderer as new (props: any) => { render: () => React.ReactElement }; + const rendererInstance = new RendererClass(rendererProps); renderedElement = rendererInstance.render(); } else { renderedElement = ; @@ -1010,7 +1012,9 @@ export const DynamicComponentRenderer: React.FC = const labelFontSize = component.style?.labelFontSize || "14px"; const labelColor = getAdaptiveLabelColor(component.style?.labelColor); const labelFontWeight = component.style?.labelFontWeight || "500"; - const isRequired = effectiveComponent.required || isColumnRequiredByMeta(screenTableName, baseColumnName); + const isRequired = + effectiveComponent.required || + isColumnRequiredByMeta(screenTableName ?? "", baseColumnName ?? ""); const isLeft = labelPosition === "left"; return ( @@ -1044,7 +1048,8 @@ export const DynamicComponentRenderer: React.FC = } // 2. 레거시 시스템에서 조회 - const renderer = legacyComponentRegistry.get(componentType); + const renderer = + componentType != null ? legacyComponentRegistry.get(componentType) : undefined; if (!renderer) { console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, { From 128872b7664fd1024527eb99d686a57c2048f93c Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 16:20:24 +0900 Subject: [PATCH 09/30] [agent-pipeline] pipe-20260317063830-0nfs round-1 --- .../numbering-rule/NumberingRuleDesigner.tsx | 600 +++++++++--------- .../numbering-rule/NumberingRulePreview.tsx | 208 ++++-- .../table-category/CategoryColumnList.tsx | 101 ++- .../CategoryValueManagerTree.tsx | 39 +- .../V2CategoryManagerComponent.tsx | 191 ++++-- .../SplitPanelLayoutComponent.tsx | 272 ++++++-- .../v2-table-list/SingleTableWithSticky.tsx | 107 +++- .../v2-table-list/TableListComponent.tsx | 13 +- 8 files changed, 1013 insertions(+), 518 deletions(-) diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 406cd009..d85c2a83 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -5,28 +5,16 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Plus, Save, Edit2, FolderTree } from "lucide-react"; +import { Plus, Save, ListOrdered } from "lucide-react"; import { toast } from "sonner"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { NumberingRuleConfig, NumberingRulePart, SEPARATOR_OPTIONS, SeparatorType } from "@/types/numbering-rule"; +import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule"; import { NumberingRuleCard } from "./NumberingRuleCard"; -import { NumberingRulePreview } from "./NumberingRulePreview"; -import { saveNumberingRuleToTest } from "@/lib/api/numberingRule"; -import { apiClient } from "@/lib/api/client"; +import { NumberingRulePreview, computePartDisplayItems, getPartTypeColorClass } from "./NumberingRulePreview"; +import { getNumberingRules, saveNumberingRuleToTest } from "@/lib/api/numberingRule"; import { cn } from "@/lib/utils"; -interface NumberingColumn { - tableName: string; - tableLabel: string; - columnName: string; - columnLabel: string; -} - -interface GroupedColumns { - tableLabel: string; - columns: NumberingColumn[]; -} - interface NumberingRuleDesignerProps { initialConfig?: NumberingRuleConfig; onSave?: (config: NumberingRuleConfig) => void; @@ -34,8 +22,8 @@ interface NumberingRuleDesignerProps { maxRules?: number; isPreview?: boolean; className?: string; - currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용) - menuObjid?: number; // 현재 메뉴 OBJID (메뉴 스코프) + currentTableName?: string; + menuObjid?: number; } export const NumberingRuleDesigner: React.FC = ({ @@ -48,124 +36,84 @@ export const NumberingRuleDesigner: React.FC = ({ currentTableName, menuObjid, }) => { - const [numberingColumns, setNumberingColumns] = useState([]); - const [selectedColumn, setSelectedColumn] = useState<{ tableName: string; columnName: string } | null>(null); + const [rulesList, setRulesList] = useState([]); + const [selectedRuleId, setSelectedRuleId] = useState(null); const [currentRule, setCurrentRule] = useState(null); + const [selectedPartOrder, setSelectedPartOrder] = useState(null); const [loading, setLoading] = useState(false); - const [columnSearch, setColumnSearch] = useState(""); - const [rightTitle, setRightTitle] = useState("규칙 편집"); - const [editingRightTitle, setEditingRightTitle] = useState(false); - - // 구분자 관련 상태 (개별 파트 사이 구분자) const [separatorTypes, setSeparatorTypes] = useState>({}); const [customSeparators, setCustomSeparators] = useState>({}); - // 좌측: 채번 타입 컬럼 목록 로드 + const selectedRule = rulesList.find((r) => r.ruleId === selectedRuleId) ?? currentRule; + + // 좌측: 규칙 목록 로드 useEffect(() => { - loadNumberingColumns(); + loadRules(); }, []); - const loadNumberingColumns = async () => { + const loadRules = async () => { setLoading(true); try { - const response = await apiClient.get("/table-management/numbering-columns"); - if (response.data.success && response.data.data) { - setNumberingColumns(response.data.data); + const response = await getNumberingRules(); + if (response.success && response.data) { + setRulesList(response.data); + if (response.data.length > 0 && !selectedRuleId) { + const first = response.data[0]; + setSelectedRuleId(first.ruleId); + setCurrentRule(JSON.parse(JSON.stringify(first))); + } } - } catch (error: any) { - console.error("채번 컬럼 목록 로드 실패:", error); + } catch (e) { + console.error("채번 규칙 목록 로드 실패:", e); } finally { setLoading(false); } }; - // 컬럼 선택 시 해당 컬럼의 채번 규칙 로드 - const handleSelectColumn = async (tableName: string, columnName: string) => { - setSelectedColumn({ tableName, columnName }); - setLoading(true); - try { - const response = await apiClient.get(`/numbering-rules/by-column/${tableName}/${columnName}`); - if (response.data.success && response.data.data) { - const rule = response.data.data as NumberingRuleConfig; - setCurrentRule(JSON.parse(JSON.stringify(rule))); - } else { - // 규칙 없으면 신규 생성 모드 - const newRule: NumberingRuleConfig = { - ruleId: `rule-${Date.now()}`, - ruleName: `${columnName} 채번`, - parts: [], - separator: "-", - resetPeriod: "none", - currentSequence: 1, - scopeType: "table", - tableName, - columnName, - }; - setCurrentRule(newRule); - } - } catch { - const newRule: NumberingRuleConfig = { - ruleId: `rule-${Date.now()}`, - ruleName: `${columnName} 채번`, - parts: [], - separator: "-", - resetPeriod: "none", - currentSequence: 1, - scopeType: "table", - tableName, - columnName, - }; - setCurrentRule(newRule); - } finally { - setLoading(false); - } + const handleSelectRule = (rule: NumberingRuleConfig) => { + setSelectedRuleId(rule.ruleId); + setCurrentRule(JSON.parse(JSON.stringify(rule))); + setSelectedPartOrder(null); }; - // 테이블별로 그룹화 - const groupedColumns = numberingColumns.reduce>((acc, col) => { - if (!acc[col.tableName]) { - acc[col.tableName] = { tableLabel: col.tableLabel, columns: [] }; - } - acc[col.tableName].columns.push(col); - return acc; - }, {}); - - // 검색 필터 적용 - const filteredGroups = Object.entries(groupedColumns).filter(([tableName, group]) => { - if (!columnSearch) return true; - const search = columnSearch.toLowerCase(); - return ( - tableName.toLowerCase().includes(search) || - group.tableLabel.toLowerCase().includes(search) || - group.columns.some( - (c) => c.columnName.toLowerCase().includes(search) || c.columnLabel.toLowerCase().includes(search) - ) - ); - }); + const handleAddNewRule = () => { + const newRule: NumberingRuleConfig = { + ruleId: `rule-${Date.now()}`, + ruleName: "새 규칙", + parts: [], + separator: "-", + resetPeriod: "none", + currentSequence: 1, + scopeType: "global", + tableName: currentTableName ?? "", + columnName: "", + }; + setRulesList((prev) => [...prev, newRule]); + setSelectedRuleId(newRule.ruleId); + setCurrentRule(JSON.parse(JSON.stringify(newRule))); + setSelectedPartOrder(null); + toast.success("새 규칙이 추가되었습니다"); + }; useEffect(() => { - if (currentRule) { - onChange?.(currentRule); - } + if (currentRule) onChange?.(currentRule); }, [currentRule, onChange]); - // currentRule이 변경될 때 파트별 구분자 상태 동기화 useEffect(() => { if (currentRule && currentRule.parts.length > 0) { const newSepTypes: Record = {}; const newCustomSeps: Record = {}; - currentRule.parts.forEach((part) => { const sep = part.separatorAfter ?? currentRule.separator ?? "-"; if (sep === "") { newSepTypes[part.order] = "none"; newCustomSeps[part.order] = ""; } else { - const predefinedOption = SEPARATOR_OPTIONS.find( - opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep + const opt = SEPARATOR_OPTIONS.find( + (o) => o.value !== "custom" && o.value !== "none" && o.displayValue === sep ); - if (predefinedOption) { - newSepTypes[part.order] = predefinedOption.value; + if (opt) { + newSepTypes[part.order] = opt.value; newCustomSeps[part.order] = ""; } else { newSepTypes[part.order] = "custom"; @@ -173,54 +121,45 @@ export const NumberingRuleDesigner: React.FC = ({ } } }); - setSeparatorTypes(newSepTypes); setCustomSeparators(newCustomSeps); } }, [currentRule?.ruleId]); - // 개별 파트 구분자 변경 핸들러 const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => { - setSeparatorTypes(prev => ({ ...prev, [partOrder]: type })); + setSeparatorTypes((prev) => ({ ...prev, [partOrder]: type })); if (type !== "custom") { - const option = SEPARATOR_OPTIONS.find(opt => opt.value === type); + const option = SEPARATOR_OPTIONS.find((opt) => opt.value === type); const newSeparator = option?.displayValue ?? ""; - setCustomSeparators(prev => ({ ...prev, [partOrder]: "" })); + setCustomSeparators((prev) => ({ ...prev, [partOrder]: "" })); setCurrentRule((prev) => { if (!prev) return null; return { ...prev, - parts: prev.parts.map((part) => - part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part - ), + parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: newSeparator } : p)), }; }); } }, []); - // 개별 파트 직접 입력 구분자 변경 핸들러 const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => { const trimmedValue = value.slice(0, 2); - setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue })); + setCustomSeparators((prev) => ({ ...prev, [partOrder]: trimmedValue })); setCurrentRule((prev) => { if (!prev) return null; return { ...prev, - parts: prev.parts.map((part) => - part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part - ), + parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, separatorAfter: trimmedValue } : p)), }; }); }, []); const handleAddPart = useCallback(() => { if (!currentRule) return; - if (currentRule.parts.length >= maxRules) { toast.error(`최대 ${maxRules}개까지 추가할 수 있습니다`); return; } - const newPart: NumberingRulePart = { id: `part-${Date.now()}`, order: currentRule.parts.length + 1, @@ -229,40 +168,33 @@ export const NumberingRuleDesigner: React.FC = ({ autoConfig: { textValue: "CODE" }, separatorAfter: "-", }; - - setCurrentRule((prev) => { - if (!prev) return null; - return { ...prev, parts: [...prev.parts, newPart] }; - }); - - // 새 파트의 구분자 상태 초기화 - setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" })); - setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" })); - + setCurrentRule((prev) => (prev ? { ...prev, parts: [...prev.parts, newPart] } : null)); + setSeparatorTypes((prev) => ({ ...prev, [newPart.order]: "-" })); + setCustomSeparators((prev) => ({ ...prev, [newPart.order]: "" })); toast.success(`규칙 ${newPart.order}가 추가되었습니다`); }, [currentRule, maxRules]); - // partOrder 기반으로 파트 업데이트 (id가 null일 수 있으므로 order 사용) const handleUpdatePart = useCallback((partOrder: number, updates: Partial) => { setCurrentRule((prev) => { if (!prev) return null; return { ...prev, - parts: prev.parts.map((part) => (part.order === partOrder ? { ...part, ...updates } : part)), + parts: prev.parts.map((p) => (p.order === partOrder ? { ...p, ...updates } : p)), }; }); }, []); - // partOrder 기반으로 파트 삭제 (id가 null일 수 있으므로 order 사용) const handleDeletePart = useCallback((partOrder: number) => { setCurrentRule((prev) => { if (!prev) return null; return { ...prev, - parts: prev.parts.filter((part) => part.order !== partOrder).map((part, index) => ({ ...part, order: index + 1 })), + parts: prev.parts + .filter((p) => p.order !== partOrder) + .map((p, i) => ({ ...p, order: i + 1 })), }; }); - + setSelectedPartOrder(null); toast.success("규칙이 삭제되었습니다"); }, []); @@ -271,246 +203,282 @@ export const NumberingRuleDesigner: React.FC = ({ toast.error("저장할 규칙이 없습니다"); return; } - if (currentRule.parts.length === 0) { toast.error("최소 1개 이상의 규칙을 추가해주세요"); return; } - setLoading(true); try { - // 파트별 기본 autoConfig 정의 const defaultAutoConfigs: Record = { sequence: { sequenceLength: 3, startFrom: 1 }, number: { numberLength: 4, numberValue: 1 }, date: { dateFormat: "YYYYMMDD" }, text: { textValue: "" }, }; - - // 저장 전에 각 파트의 autoConfig에 기본값 채우기 const partsWithDefaults = currentRule.parts.map((part) => { if (part.generationMethod === "auto") { const defaults = defaultAutoConfigs[part.partType] || {}; - return { - ...part, - autoConfig: { ...defaults, ...part.autoConfig }, - }; + return { ...part, autoConfig: { ...defaults, ...part.autoConfig } }; } return part; }); - const ruleToSave = { ...currentRule, parts: partsWithDefaults, - scopeType: "table" as const, - tableName: selectedColumn?.tableName || currentRule.tableName || "", - columnName: selectedColumn?.columnName || currentRule.columnName || "", + scopeType: "global" as const, + tableName: currentRule.tableName || currentTableName || "", + columnName: currentRule.columnName || "", }; - - // 테스트 테이블에 저장 (numbering_rules) const response = await saveNumberingRuleToTest(ruleToSave); - if (response.success && response.data) { - const currentData = JSON.parse(JSON.stringify(response.data)) as NumberingRuleConfig; - setCurrentRule(currentData); + const saved: NumberingRuleConfig = JSON.parse(JSON.stringify(response.data)); + setCurrentRule(saved); + setRulesList((prev) => { + const idx = prev.findIndex((r) => r.ruleId === currentRule.ruleId); + if (idx >= 0) { + const next = [...prev]; + next[idx] = saved; + return next; + } + return [...prev, saved]; + }); + setSelectedRuleId(saved.ruleId); await onSave?.(response.data); toast.success("채번 규칙이 저장되었습니다"); } else { - showErrorToast("채번 규칙 저장에 실패했습니다", response.error, { guidance: "설정을 확인하고 다시 시도해 주세요." }); + showErrorToast("채번 규칙 저장에 실패했습니다", response.error, { + guidance: "설정을 확인하고 다시 시도해 주세요.", + }); } - } catch (error: any) { - showErrorToast("채번 규칙 저장에 실패했습니다", error, { guidance: "설정을 확인하고 다시 시도해 주세요." }); + } catch (error: unknown) { + showErrorToast("채번 규칙 저장에 실패했습니다", error, { + guidance: "설정을 확인하고 다시 시도해 주세요.", + }); } finally { setLoading(false); } - }, [currentRule, onSave, selectedColumn]); + }, [currentRule, onSave, currentTableName]); + + const selectedPart = currentRule?.parts.find((p) => p.order === selectedPartOrder) ?? null; + const globalSep = currentRule?.separator ?? "-"; + const partItems = currentRule ? computePartDisplayItems(currentRule) : []; return ( -
- {/* 좌측: 채번 컬럼 목록 (카테고리 패턴) */} -
-

채번 컬럼

- - setColumnSearch(e.target.value)} - placeholder="검색..." - className="h-8 text-xs" - /> - -
- {loading && numberingColumns.length === 0 ? ( -
-

로딩 중...

+
+ {/* 좌측: 규칙 리스트 (code-nav, 220px) */} +
+
+
+ + 채번 규칙 ({rulesList.length}) +
+ +
+
+ {loading && rulesList.length === 0 ? ( +
+ 로딩 중...
- ) : filteredGroups.length === 0 ? ( -
-

- {numberingColumns.length === 0 - ? "채번 타입 컬럼이 없습니다" - : "검색 결과가 없습니다"} -

+ ) : rulesList.length === 0 ? ( +
+ 규칙이 없습니다
) : ( - filteredGroups.map(([tableName, group]) => ( -
-
- - {group.tableLabel} - ({group.columns.length}) -
- {group.columns.map((col) => { - const isSelected = - selectedColumn?.tableName === col.tableName && - selectedColumn?.columnName === col.columnName; - return ( -
handleSelectColumn(col.tableName, col.columnName)} - > - {col.columnLabel} -
- ); - })} -
- )) + rulesList.map((rule) => { + const isSelected = selectedRuleId === rule.ruleId; + return ( + + ); + }) )}
- {/* 구분선 */} -
+
- {/* 우측: 편집 영역 */} -
+ {/* 우측: 미리보기 + 파이프라인 + 설정 + 저장 바 */} +
{!currentRule ? ( -
-
- -

컬럼을 선택해주세요

-

좌측에서 채번 컬럼을 선택하면 규칙을 편집할 수 있습니다

-
+
+ +

규칙을 선택하세요

+

+ 좌측에서 채번 규칙을 선택하거나 "추가"로 새 규칙을 만드세요 +

) : ( <> -
- {editingRightTitle ? ( - setRightTitle(e.target.value)} - onBlur={() => setEditingRightTitle(false)} - onKeyDown={(e) => e.key === "Enter" && setEditingRightTitle(false)} - className="h-8 text-sm font-semibold" - autoFocus - /> - ) : ( -

{rightTitle}

- )} - +
+ + setCurrentRule((prev) => (prev ? { ...prev, ruleName: e.target.value } : null))} + placeholder="예: 프로젝트 코드" + className="h-9 text-sm" + />
-
- {/* 첫 번째 줄: 규칙명 + 미리보기 */} -
-
- - setCurrentRule((prev) => ({ ...prev!, ruleName: e.target.value }))} - className="h-9" - placeholder="예: 프로젝트 코드" - /> -
-
- - -
-
- - + {/* 큰 미리보기 스트립 (code-preview-strip) */} +
+
-
-
-

코드 구성

- + {/* 파이프라인 영역 (code-pipeline-area) */} +
+
+ 코드 구성 + {currentRule.parts.length}/{maxRules}
- - {currentRule.parts.length === 0 ? ( -
-

규칙을 추가하여 코드를 구성하세요

-
- ) : ( -
- {currentRule.parts.map((part, index) => ( - -
- handleUpdatePart(part.order, updates)} - onDelete={() => handleDeletePart(part.order)} - isPreview={isPreview} - tableName={selectedColumn?.tableName} - /> - {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} - {index < currentRule.parts.length - 1 && ( -
- 뒤 구분자 - - {separatorTypes[part.order] === "custom" && ( - handlePartCustomSeparatorChange(part.order, e.target.value)} - className="h-6 w-14 text-center text-[10px]" - placeholder="2자" - maxLength={2} - /> +
+ {currentRule.parts.length === 0 ? ( +
+ 규칙을 추가하여 코드를 구성하세요 +
+ ) : ( + <> + {currentRule.parts.map((part, index) => { + const item = partItems.find((i) => i.order === part.order); + const sep = part.separatorAfter ?? globalSep; + const isSelected = selectedPartOrder === part.order; + const typeLabel = CODE_PART_TYPE_OPTIONS.find((o) => o.value === part.partType)?.label ?? part.partType; + return ( + +
- - ))} -
- )} + onClick={() => setSelectedPartOrder(part.order)} + > +
{typeLabel}
+
+ {item?.displayValue ?? "-"} +
+ + {index < currentRule.parts.length - 1 && ( + + + + {sep || "(없음)"} + + + )} + + ); + })} + + + )} +
-
+ {/* 설정 패널 (선택된 세그먼트 상세, code-config-panel) */} + {selectedPart && ( +
+
+ handleUpdatePart(selectedPart.order, updates)} + onDelete={() => handleDeletePart(selectedPart.order)} + isPreview={isPreview} + tableName={currentRule.tableName ?? currentTableName} + /> +
+ {currentRule.parts.some((p) => p.order === selectedPart.order) && ( +
+ 뒤 구분자 + + {separatorTypes[selectedPart.order] === "custom" && ( + handlePartCustomSeparatorChange(selectedPart.order, e.target.value)} + className="h-7 w-14 text-center text-[10px]" + placeholder="2자" + maxLength={2} + /> + )} +
+ )} +
+ )} + + {/* 저장 바 (code-save-bar) */} +
+
+ {currentRule.tableName && ( + 테이블: {currentRule.tableName} + )} + {currentRule.columnName && ( + 컬럼: {currentRule.columnName} + )} + 구분자: {globalSep || "-"} + {currentRule.resetPeriod && currentRule.resetPeriod !== "none" && ( + 리셋: {currentRule.resetPeriod} + )} +
-
diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx index eff551a1..eacdb906 100644 --- a/frontend/components/numbering-rule/NumberingRulePreview.tsx +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -1,88 +1,162 @@ "use client"; import React, { useMemo } from "react"; -import { NumberingRuleConfig } from "@/types/numbering-rule"; +import { NumberingRuleConfig, NumberingRulePart, CodePartType } from "@/types/numbering-rule"; +import { CODE_PART_TYPE_OPTIONS } from "@/types/numbering-rule"; + +/** 파트별 표시값 + 타입 (미리보기 스트립/세그먼트용) */ +export interface PartDisplayItem { + partType: CodePartType; + displayValue: string; + order: number; +} + +/** config에서 파트별 표시값 배열 계산 (정렬된 parts 기준) */ +export function computePartDisplayItems(config: NumberingRuleConfig): PartDisplayItem[] { + if (!config.parts || config.parts.length === 0) return []; + const sorted = [...config.parts].sort((a, b) => a.order - b.order); + const globalSep = config.separator ?? "-"; + return sorted.map((part) => ({ + order: part.order, + partType: part.partType, + displayValue: getPartDisplayValue(part), + })); +} + +function getPartDisplayValue(part: NumberingRulePart): string { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || "XXX"; + } + const c = part.autoConfig || {}; + switch (part.partType) { + case "sequence": + return String(c.startFrom ?? 1).padStart(c.sequenceLength ?? 3, "0"); + case "number": + return String(c.numberValue ?? 0).padStart(c.numberLength ?? 4, "0"); + case "date": { + const format = c.dateFormat || "YYYYMMDD"; + if (c.useColumnValue && c.sourceColumnName) { + return format === "YYYY" ? "[YYYY]" : format === "YY" ? "[YY]" : format === "YYYYMM" ? "[YYYYMM]" : format === "YYMM" ? "[YYMM]" : format === "YYMMDD" ? "[YYMMDD]" : "[DATE]"; + } + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + if (format === "YYYY") return String(y); + if (format === "YY") return String(y).slice(-2); + if (format === "YYYYMM") return `${y}${m}`; + if (format === "YYMM") return `${String(y).slice(-2)}${m}`; + if (format === "YYYYMMDD") return `${y}${m}${d}`; + if (format === "YYMMDD") return `${String(y).slice(-2)}${m}${d}`; + return `${y}${m}${d}`; + } + case "text": + return c.textValue || "TEXT"; + default: + return "XXX"; + } +} + +/** 파트 타입별 미리보기용 텍스트 색상 클래스 (CSS 변수 기반) */ +export function getPartTypeColorClass(partType: CodePartType): string { + switch (partType) { + case "date": + return "text-warning"; + case "text": + return "text-primary"; + case "sequence": + return "text-primary"; + case "number": + return "text-muted-foreground"; + case "category": + case "reference": + return "text-muted-foreground"; + default: + return "text-foreground"; + } +} + +/** 파트 타입별 점(dot) 배경 색상 (범례용) */ +export function getPartTypeDotClass(partType: CodePartType): string { + switch (partType) { + case "date": + return "bg-warning"; + case "text": + case "sequence": + return "bg-primary"; + case "number": + case "category": + case "reference": + return "bg-muted-foreground"; + default: + return "bg-foreground"; + } +} interface NumberingRulePreviewProps { config: NumberingRuleConfig; compact?: boolean; + /** 큰 미리보기 스트립: 28px, 파트별 색상, 하단 범례 */ + variant?: "default" | "strip"; } export const NumberingRulePreview: React.FC = ({ config, - compact = false + compact = false, + variant = "default", }) => { + const partItems = useMemo(() => computePartDisplayItems(config), [config]); + const sortedParts = useMemo( + () => (config.parts ? [...config.parts].sort((a, b) => a.order - b.order) : []), + [config.parts] + ); const generatedCode = useMemo(() => { - if (!config.parts || config.parts.length === 0) { - return "규칙을 추가해주세요"; - } - - const sortedParts = config.parts.sort((a, b) => a.order - b.order); - - const partValues = sortedParts.map((part) => { - if (part.generationMethod === "manual") { - return part.manualConfig?.value || "XXX"; - } - - const autoConfig = part.autoConfig || {}; - - switch (part.partType) { - case "sequence": { - const length = autoConfig.sequenceLength || 3; - const startFrom = autoConfig.startFrom || 1; - return String(startFrom).padStart(length, "0"); - } - case "number": { - const length = autoConfig.numberLength || 4; - const value = autoConfig.numberValue || 0; - return String(value).padStart(length, "0"); - } - case "date": { - const format = autoConfig.dateFormat || "YYYYMMDD"; - if (autoConfig.useColumnValue && autoConfig.sourceColumnName) { - switch (format) { - case "YYYY": return "[YYYY]"; - case "YY": return "[YY]"; - case "YYYYMM": return "[YYYYMM]"; - case "YYMM": return "[YYMM]"; - case "YYYYMMDD": return "[YYYYMMDD]"; - case "YYMMDD": return "[YYMMDD]"; - default: return "[DATE]"; - } - } - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - switch (format) { - case "YYYY": return String(year); - case "YY": return String(year).slice(-2); - case "YYYYMM": return `${year}${month}`; - case "YYMM": return `${String(year).slice(-2)}${month}`; - case "YYYYMMDD": return `${year}${month}${day}`; - case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; - default: return `${year}${month}${day}`; - } - } - case "text": - return autoConfig.textValue || "TEXT"; - default: - return "XXX"; - } - }); - - // 파트별 개별 구분자로 결합 + if (partItems.length === 0) return "규칙을 추가해주세요"; const globalSep = config.separator ?? "-"; let result = ""; - partValues.forEach((val, idx) => { - result += val; - if (idx < partValues.length - 1) { - const sep = sortedParts[idx].separatorAfter ?? globalSep; - result += sep; + partItems.forEach((item, idx) => { + result += item.displayValue; + if (idx < partItems.length - 1) { + const part = sortedParts.find((p) => p.order === item.order); + result += part?.separatorAfter ?? globalSep; } }); return result; - }, [config]); + }, [config.separator, partItems, sortedParts]); + + if (variant === "strip") { + const globalSep = config.separator ?? "-"; + return ( +
+
+ {partItems.length === 0 ? ( + 규칙을 추가해주세요 + ) : ( + partItems.map((item, idx) => ( + + {item.displayValue} + {idx < partItems.length - 1 && ( + + {sortedParts.find((p) => p.order === item.order)?.separatorAfter ?? globalSep} + + )} + + )) + )} +
+ {partItems.length > 0 && ( +
+ {CODE_PART_TYPE_OPTIONS.filter((opt) => partItems.some((p) => p.partType === opt.value)).map((opt) => ( + + + {opt.label} + + ))} +
+ )} +
+ ); + } if (compact) { return ( diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index 872e7d57..2aed73fd 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -6,9 +6,9 @@ import { getCategoryValues } from "@/lib/api/tableCategoryValue"; import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; -interface CategoryColumn { +export interface CategoryColumn { tableName: string; - tableLabel?: string; // 테이블 라벨 추가 + tableLabel?: string; columnName: string; columnLabel: string; inputType: string; @@ -16,17 +16,30 @@ interface CategoryColumn { } interface CategoryColumnListProps { - tableName: string; // 현재 화면의 테이블 (사용하지 않음 - 형제 메뉴 전체 표시) + tableName: string; selectedColumn: string | null; - onColumnSelect: (columnName: string, columnLabel: string, tableName: string) => void; - menuObjid?: number; // 현재 메뉴 OBJID (필수) + onColumnSelect: (uniqueKeyOrColumnName: string, columnLabel: string, tableName: string) => void; + menuObjid?: number; + /** 대시보드 모드: 테이블 단위 네비만 표시, 선택 시 onTableSelect 호출 */ + selectedTable?: string | null; + onTableSelect?: (tableName: string) => void; + /** 컬럼 로드 완료 시 부모에 전달 (Stat Strip 등 계산용) */ + onColumnsLoaded?: (columns: CategoryColumn[]) => void; } /** * 카테고리 컬럼 목록 (좌측 패널) * - 형제 메뉴들의 모든 카테고리 타입 컬럼을 표시 (메뉴 스코프) */ -export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, menuObjid }: CategoryColumnListProps) { +export function CategoryColumnList({ + tableName, + selectedColumn, + onColumnSelect, + menuObjid, + selectedTable = null, + onTableSelect, + onColumnsLoaded, +}: CategoryColumnListProps) { const [columns, setColumns] = useState([]); const [isLoading, setIsLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); @@ -151,8 +164,8 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, ); setColumns(columnsWithCount); + onColumnsLoaded?.(columnsWithCount); - // 첫 번째 컬럼 자동 선택 if (columnsWithCount.length > 0 && !selectedColumn) { const firstCol = columnsWithCount[0]; onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName); @@ -160,6 +173,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, } catch (error) { console.error("❌ 테이블 기반 카테고리 컬럼 조회 실패:", error); setColumns([]); + onColumnsLoaded?.([]); } finally { setIsLoading(false); } @@ -248,21 +262,20 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, } setColumns(columnsWithCount); + onColumnsLoaded?.(columnsWithCount); - // 첫 번째 컬럼 자동 선택 if (columnsWithCount.length > 0 && !selectedColumn) { const firstCol = columnsWithCount[0]; onColumnSelect(`${firstCol.tableName}.${firstCol.columnName}`, firstCol.columnLabel, firstCol.tableName); } } catch (error) { console.error("❌ 카테고리 컬럼 조회 실패:", error); - // 에러 시에도 tableName 기반으로 fallback if (tableName) { - console.log("⚠️ menuObjid API 에러, tableName 기반으로 fallback:", tableName); await loadCategoryColumnsByTable(); return; } else { setColumns([]); + onColumnsLoaded?.([]); } } setIsLoading(false); @@ -291,6 +304,72 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, ); } + // 대시보드 모드: 테이블 단위 네비만 표시 + if (onTableSelect != null) { + return ( +
+
+
+ + setSearchQuery(e.target.value)} + className="h-8 border-0 bg-transparent pl-8 pr-8 text-xs shadow-none focus-visible:ring-0" + /> + {searchQuery && ( + + )} +
+
+
+ {filteredColumns.length === 0 && searchQuery ? ( +
+ '{searchQuery}'에 대한 검색 결과가 없습니다 +
+ ) : null} + {groupedColumns.map((group) => { + const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0); + const isActive = selectedTable === group.tableName; + return ( + + ); + })} +
+
+ ); + } + return (
@@ -298,7 +377,6 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect,

관리할 카테고리 컬럼을 선택하세요

- {/* 검색 입력 필드 */}
{searchQuery && ( - {/* 아이콘 */} {getIcon()} - {/* 라벨 */} -
- {node.valueLabel} - {getDepthLabel()} +
+ + {node.valueLabel} + + + {getDepthLabel()} +
- {/* 비활성 표시 */} {!node.isActive && ( - 비활성 + + 비활성 + )} - {/* 액션 버튼 */} -
+
{canAddChild && (
)} - - {/* 카테고리 값 관리 */}
{selectedColumn ? ( viewMode === "tree" ? ( @@ -130,7 +138,9 @@ export function V2CategoryManagerComponent({

- {config.showColumnList ? "좌측에서 관리할 카테고리 컬럼을 선택하세요" : "카테고리 컬럼이 설정되지 않았습니다"} + {config.showColumnList + ? "칩에서 카테고리 컬럼을 선택하세요" + : "카테고리 컬럼이 설정되지 않았습니다"}

@@ -148,24 +158,107 @@ export function V2CategoryManagerComponent({ } return ( - - } - right={rightContent} - leftTitle="카테고리 컬럼" - leftWidth={config.leftPanelWidth} - minLeftWidth={10} - maxLeftWidth={40} - height={config.height} - /> +
+ {/* Stat Strip */} +
+
+
+ {stats.columnCount} +
+
+ 카테고리 컬럼 +
+
+
+
+ {stats.totalValues} +
+
+ 전체 값 +
+
+
+
+ {stats.tableCount} +
+
+ 테이블 +
+
+
+
+ {stats.inactiveCount} +
+
+ 비활성 +
+
+
+ +
+ {/* 좌측 테이블 nav: 240px */} +
+ +
+ + {/* 우측: 칩 바 + 편집기 */} +
+ {/* 칩 바 */} +
+ {columnsForSelectedTable.map((col) => { + const uniqueKey = `${col.tableName}.${col.columnName}`; + const isActive = selectedColumn?.uniqueKey === uniqueKey; + return ( + + ); + })} + {selectedTable && columnsForSelectedTable.length === 0 && ( + 이 테이블에 카테고리 컬럼이 없습니다 + )} +
+ + {/* 편집기 영역 */} +
+ {rightContent} +
+
+
+
); } export default V2CategoryManagerComponent; - diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index c89fb1d3..6c2345a4 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -10,7 +10,6 @@ import { Badge } from "@/components/ui/badge"; import { Plus, Search, - GripVertical, Loader2, ChevronDown, ChevronUp, @@ -21,6 +20,8 @@ import { Settings, Move, FileSpreadsheet, + List, + LayoutPanelRight, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; @@ -325,6 +326,10 @@ export const SplitPanelLayoutComponent: React.FC const [rightFilters, setRightFilters] = useState([]); const [rightGrouping, setRightGrouping] = useState([]); const [rightColumnVisibility, setRightColumnVisibility] = useState([]); + // 우측 패널 컬럼 헤더 드래그 (디자인 모드에서 순서 변경) + const [rightDraggedColumnIndex, setRightDraggedColumnIndex] = useState(null); + const [rightDropTargetColumnIndex, setRightDropTargetColumnIndex] = useState(null); + const [rightDragSource, setRightDragSource] = useState<"main" | number | null>(null); // 데이터 상태 const [leftData, setLeftData] = useState([]); @@ -2631,6 +2636,95 @@ export const SplitPanelLayoutComponent: React.FC } }, [selectedLeftItem, customLeftSelectedData, componentConfig, companyCode, toast, loadLeftData]); + // 우측 패널 컬럼 헤더 드래그 (디자인 모드에서 컬럼 순서 변경) + const handleRightColumnDragStart = useCallback( + (columnIndex: number, source: "main" | number) => { + setRightDraggedColumnIndex(columnIndex); + setRightDragSource(source); + }, + [], + ); + const handleRightColumnDragOver = useCallback((e: React.DragEvent, columnIndex: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setRightDropTargetColumnIndex(columnIndex); + }, []); + const handleRightColumnDragEnd = useCallback(() => { + setRightDraggedColumnIndex(null); + setRightDropTargetColumnIndex(null); + setRightDragSource(null); + }, []); + const handleRightColumnDrop = useCallback( + (e: React.DragEvent, targetIndex: number, source: "main" | number) => { + e.preventDefault(); + const fromIdx = rightDraggedColumnIndex; + if (fromIdx === null || rightDragSource !== source || fromIdx === targetIndex) { + handleRightColumnDragEnd(); + return; + } + if (!onUpdateComponent) { + handleRightColumnDragEnd(); + return; + } + const rightPanel = componentConfig.rightPanel || {}; + if (source === "main") { + const allColumns = rightPanel.columns || []; + const visibleColumns = allColumns.filter((c: any) => c.showInSummary !== false); + const hiddenColumns = allColumns.filter((c: any) => c.showInSummary === false); + if (fromIdx < 0 || fromIdx >= visibleColumns.length || targetIndex < 0 || targetIndex >= visibleColumns.length) { + handleRightColumnDragEnd(); + return; + } + const reordered = [...visibleColumns]; + const [removed] = reordered.splice(fromIdx, 1); + reordered.splice(targetIndex, 0, removed); + const columns = [...reordered, ...hiddenColumns]; + onUpdateComponent({ + ...component, + componentConfig: { + ...componentConfig, + rightPanel: { ...rightPanel, columns }, + }, + }); + } else { + const tabs = [...(rightPanel.additionalTabs || [])]; + const tabConfig = tabs[source]; + if (!tabConfig || !Array.isArray(tabConfig.columns)) { + handleRightColumnDragEnd(); + return; + } + const allTabCols = tabConfig.columns; + const visibleTabCols = allTabCols.filter((c: any) => c.showInSummary !== false); + const hiddenTabCols = allTabCols.filter((c: any) => c.showInSummary === false); + if (fromIdx < 0 || fromIdx >= visibleTabCols.length || targetIndex < 0 || targetIndex >= visibleTabCols.length) { + handleRightColumnDragEnd(); + return; + } + const reordered = [...visibleTabCols]; + const [removed] = reordered.splice(fromIdx, 1); + reordered.splice(targetIndex, 0, removed); + const columns = [...reordered, ...hiddenTabCols]; + const newTabs = tabs.map((t, i) => (i === source ? { ...t, columns } : t)); + onUpdateComponent({ + ...component, + componentConfig: { + ...componentConfig, + rightPanel: { ...rightPanel, additionalTabs: newTabs }, + }, + }); + } + handleRightColumnDragEnd(); + }, + [ + rightDraggedColumnIndex, + rightDragSource, + componentConfig, + component, + onUpdateComponent, + handleRightColumnDragEnd, + ], + ); + // 수정 모달 저장 const handleEditModalSave = useCallback(async () => { const tableName = @@ -3212,10 +3306,18 @@ export const SplitPanelLayoutComponent: React.FC alignItems: "center", }} > -
- - {componentConfig.leftPanel?.title || "좌측 패널"} - +
+
+ + + {componentConfig.leftPanel?.title || "좌측 패널"} + + {!isDesignMode && ( + + {summedLeftData.length} + + )} +
{!isDesignMode && (componentConfig.leftPanel as any)?.showBomExcelUpload && (
- {/* 리사이저 */} + {/* 리사이저: 6px 너비, 그립 핸들(2x28px bar), hover 시 primary 하이라이트 */} {resizable && (
- +
+
)} @@ -4037,9 +4141,10 @@ export const SplitPanelLayoutComponent: React.FC alignItems: "center", }} > -
-
- {/* 탭이 없으면 제목만, 있으면 탭으로 전환 */} +
+
+ + {/* 탭이 없으면 제목만, 있으면 탭으로 전환 (2px primary 밑줄 인디케이터) */} {(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
) : ( - + {componentConfig.rightPanel?.title || "우측 패널"} )} + {!isDesignMode && ( + + {activeTabIndex === 0 + ? Array.isArray(rightData) + ? rightData.length + : rightData ? 1 : 0 + : (tabsData[activeTabIndex]?.length ?? 0)} + + )}
{!isDesignMode && (
@@ -4163,16 +4277,35 @@ export const SplitPanelLayoutComponent: React.FC const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete; // showInSummary가 false가 아닌 것만 메인 테이블에 표시 const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); + const tabIndex = activeTabIndex - 1; + const canDragTabColumns = isDesignMode && tabSummaryColumns.length > 0 && !!onUpdateComponent; return (
- {tabSummaryColumns.map((col: any) => ( - - ))} + {tabSummaryColumns.map((col: any, idx: number) => { + const isDropTarget = rightDragSource === tabIndex && rightDropTargetColumnIndex === idx; + const isDragging = rightDragSource === tabIndex && rightDraggedColumnIndex === idx; + return ( + + ); + })} {hasTabActions && ( )} @@ -4280,16 +4413,35 @@ export const SplitPanelLayoutComponent: React.FC const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete; // showInSummary가 false가 아닌 것만 메인 테이블에 표시 const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); + const listTabIndex = activeTabIndex - 1; + const canDragListTabColumns = isDesignMode && listSummaryColumns.length > 0 && !!onUpdateComponent; return (
- {col.label || col.name} - canDragTabColumns && handleRightColumnDragStart(idx, tabIndex)} + onDragOver={(e) => canDragTabColumns && handleRightColumnDragOver(e, idx)} + onDragEnd={handleRightColumnDragEnd} + onDrop={(e) => canDragTabColumns && handleRightColumnDrop(e, idx, tabIndex)} + > + {col.label || col.name} + 작업
- {listSummaryColumns.map((col: any) => ( - - ))} + {listSummaryColumns.map((col: any, idx: number) => { + const isDropTarget = rightDragSource === listTabIndex && rightDropTargetColumnIndex === idx; + const isDragging = rightDragSource === listTabIndex && rightDraggedColumnIndex === idx; + return ( + + ); + })} {hasTabActions && ( )} @@ -4672,24 +4824,43 @@ export const SplitPanelLayoutComponent: React.FC return sum + w; }, 0); + const rightConfigColumnStart = columnsToShow.filter((c: any) => c._isKeyColumn).length; + const canDragRightColumns = isDesignMode && displayColumns.length > 0 && !!onUpdateComponent; + return (
- {col.label || col.name} - canDragListTabColumns && handleRightColumnDragStart(idx, listTabIndex)} + onDragOver={(e) => canDragListTabColumns && handleRightColumnDragOver(e, idx)} + onDragEnd={handleRightColumnDragEnd} + onDrop={(e) => canDragListTabColumns && handleRightColumnDrop(e, idx, listTabIndex)} + > + {col.label || col.name} + 작업
100 ? `${rightTotalColWidth}%` : '100%' }}> - {columnsToShow.map((col, idx) => ( - - ))} + {columnsToShow.map((col, idx) => { + const configColIndex = idx - rightConfigColumnStart; + const isDraggable = canDragRightColumns && !col._isKeyColumn; + const isDropTarget = rightDragSource === "main" && rightDropTargetColumnIndex === configColIndex; + const isDragging = rightDragSource === "main" && rightDraggedColumnIndex === configColIndex; + return ( + + ); + })} {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */} {!isDesignMode && ((componentConfig.rightPanel?.editButton?.enabled ?? true) || @@ -4705,7 +4876,7 @@ export const SplitPanelLayoutComponent: React.FC const itemId = item.id || item.ID || idx; return ( - + {columnsToShow.map((col, colIdx) => ( toggleRightItemExpansion(itemId)} @@ -4867,7 +5038,7 @@ export const SplitPanelLayoutComponent: React.FC ))} {hasActions && ( ))} {hasGroupedLeftActions && ( - )} @@ -3621,7 +3621,7 @@ export const SplitPanelLayoutComponent: React.FC {columnsToShow.map((col, idx) => ( ))} {hasLeftTableActions && ( - )} @@ -3972,17 +3972,17 @@ export const SplitPanelLayoutComponent: React.FC >
- + {/* 탭이 없으면 제목만, 있으면 탭으로 전환 (2px primary 밑줄 인디케이터) */} {(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
+ )} @@ -4157,13 +4157,13 @@ export const SplitPanelLayoutComponent: React.FC toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} > {tabSummaryColumns.map((col: any) => ( - + )} @@ -4292,13 +4292,13 @@ export const SplitPanelLayoutComponent: React.FC toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} > {listSummaryColumns.map((col: any) => ( - ); })} - {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 컬럼 표시 */} - {!isDesignMode && - ((componentConfig.rightPanel?.editButton?.enabled ?? true) || - (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( - - )} + ) : null; + })()} {filteredData.map((item, idx) => { const itemId = item.id || item.ID || idx; + const rightEditVisible = (componentConfig.rightPanel?.showEdit ?? componentConfig.rightPanel?.editButton?.enabled) !== false; + const rightDeleteVisible = (componentConfig.rightPanel?.showDelete ?? componentConfig.rightPanel?.deleteButton?.enabled) !== false; return ( - + {columnsToShow.map((col, colIdx) => ( ))} {/* 수정 또는 삭제 버튼이 하나라도 활성화되어 있을 때만 작업 셀 표시 */} - {!isDesignMode && - ((componentConfig.rightPanel?.editButton?.enabled ?? true) || - (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( - ))} {hasActions && ( - + )} @@ -4849,13 +4850,13 @@ export const SplitPanelLayoutComponent: React.FC toggleRightItemExpansion(itemId)} > {columnsToDisplay.map((col) => ( - ))} {hasActions && ( - )} {visibleColumns.map((column, columnIndex) => { @@ -5856,11 +5891,12 @@ export const TableListComponent: React.FC = ({ key={column.columnName} ref={(el) => (columnRefs.current[column.columnName] = el)} className={cn( - "text-foreground/90 relative h-8 overflow-hidden text-xs font-bold text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-sm", - column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-2", + "group text-muted-foreground relative h-8 overflow-hidden text-[10px] font-bold uppercase tracking-[0.04em] text-ellipsis whitespace-nowrap select-none sm:h-10 sm:text-xs", + column.columnName === "__checkbox__" ? "px-0 py-1" : "px-3 py-2", column.sortable !== false && column.columnName !== "__checkbox__" && - "hover:bg-muted/70 cursor-pointer transition-colors", + "hover:text-foreground hover:bg-muted/70 cursor-pointer transition-colors", + sortColumn === column.columnName && "!text-primary", isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", // 🆕 Column Reordering 스타일 isColumnDragEnabled && @@ -5880,7 +5916,7 @@ export const TableListComponent: React.FC = ({ minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, userSelect: "none", - backgroundColor: "hsl(var(--muted))", + backgroundColor: "hsl(var(--muted) / 0.8)", ...(isFrozen && { left: `${leftPosition}px` }), }} // 🆕 Column Reordering 이벤트 @@ -5900,9 +5936,12 @@ export const TableListComponent: React.FC = ({ renderCheckboxHeader() ) : (
+ {isColumnDragEnabled && ( + + )} {columnLabels[column.columnName] || column.displayName} {column.sortable !== false && sortColumn === column.columnName && ( - {sortDirection === "asc" ? "↑" : "↓"} + {sortDirection === "asc" ? "↑" : "↓"} )} {/* 🆕 헤더 필터 버튼 */} {tableConfig.headerFilter !== false && @@ -6127,7 +6166,8 @@ export const TableListComponent: React.FC = ({
handleRowClick(row, index, e)} > @@ -6158,13 +6198,14 @@ export const TableListComponent: React.FC = ({ = ({ data-row={index} data-col={colIndex} className={cn( - "text-foreground text-xs font-normal sm:text-sm", - // 이미지 컬럼은 overflow/ellipsis 제외 (이미지 잘림 방지) - inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap", - column.columnName === "__checkbox__" ? "px-0 py-1" : "px-2 py-1 sm:px-4 sm:py-1.5", + "text-foreground text-[11px] font-normal", + inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]", + column.columnName === "__checkbox__" ? "px-0 py-[7px]" : "px-3 py-[7px]", isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]", - // 🆕 포커스된 셀 스타일 isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset", - // 🆕 편집 중인 셀 스타일 editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0", - // 🆕 배치 편집: 수정된 셀 스타일 (노란 배경) isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40", - // 🆕 유효성 에러: 빨간 테두리 및 배경 cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40", - // 🆕 검색 하이라이트 스타일 (노란 배경) isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50", - // 🆕 편집 불가 컬럼 스타일 (연한 회색 배경) column.editable === false && "bg-gray-50 dark:bg-gray-900/30", + // 코드 컬럼: mono 폰트 + primary 색상 + (inputType === "code" || inputType === "category") && "font-mono text-[10px] text-primary font-medium", + // 숫자 컬럼: tabular-nums 오른쪽 정렬 + isNumeric && "tabular-nums", )} // 🆕 유효성 에러 툴팁 title={cellValidationError || undefined} @@ -6465,7 +6504,9 @@ export const TableListComponent: React.FC = ({ })() : column.columnName === "__checkbox__" ? renderCheckboxCell(row, index) - : formatCellValue(cellValue, column, row)} + : (cellValue === null || cellValue === undefined || cellValue === "") + ? - + : formatCellValue(cellValue, column, row)} ); })} diff --git a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx index 03de3cc1..039a591c 100644 --- a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx +++ b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx @@ -48,8 +48,8 @@ const TabsDesignEditor: React.FC<{ return cn( "px-4 py-2 text-sm font-medium cursor-pointer transition-colors", isActive - ? "bg-primary/10 border-b-2 border-primary text-primary font-semibold" - : "text-foreground/70 hover:text-foreground hover:bg-muted/50" + ? "bg-primary/20 dark:bg-primary/25 border-b-2 border-primary text-primary font-semibold" + : "text-muted-foreground hover:text-foreground hover:bg-muted/50" ); }; From 13b2ebaf1f4c26dfcd0f25718d17df254475ee35 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 22:02:52 +0900 Subject: [PATCH 18/30] Refactor ColumnDetailPanel and AppLayout for improved loading state handling and UI consistency. Enhance TabBar and TableListComponent styles for better user experience. Update V2SplitPanelLayoutConfigPanel to manage button visibility based on configuration. Introduce filter chips in TableListComponent for better filter management. --- .../screen/RealtimePreviewDynamic.tsx | 21 +-- .../ButtonPrimaryComponent.tsx | 2 +- .../SplitPanelLayoutComponent.tsx | 166 +++++++++++------- .../v2-table-list/SingleTableWithSticky.tsx | 8 +- .../v2-table-list/TableListComponent.tsx | 19 +- 5 files changed, 124 insertions(+), 92 deletions(-) diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index d23337b5..14314a61 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -359,20 +359,10 @@ const RealtimePreviewDynamicComponent: React.FC = ({ return `${actualHeight}px`; } - // 런타임 모드에서 컴포넌트 타입별 높이 처리 + // 런타임 모드: ResponsiveGridRenderer가 ratio 기반으로 래퍼 높이를 설정하므로, + // 안쪽 컴포넌트는 "100%"로 래퍼를 채워야 비율이 정확하게 맞음 if (!isDesignMode) { const compType = (component as any).componentType || component.componentConfig?.type || ""; - // 테이블: 부모 flex 컨테이너가 높이 관리 (flex: 1) - const flexGrowTypes = [ - "table-list", "v2-table-list", - "split-panel-layout", "split-panel-layout2", - "v2-split-panel-layout", "screen-split-panel", - "v2-tab-container", "tab-container", - "tabs-widget", "v2-tabs-widget", - ]; - if (flexGrowTypes.some(t => compType === t)) { - return "100%"; - } const autoHeightTypes = [ "table-search-widget", "v2-table-search-widget", "flow-widget", @@ -380,9 +370,11 @@ const RealtimePreviewDynamicComponent: React.FC = ({ if (autoHeightTypes.some(t => compType === t || compType.includes(t))) { return "auto"; } + // 나머지 모든 타입: 래퍼의 비율 스케일링을 따르도록 100% + return "100%"; } - // 1순위: size.height가 있으면 우선 사용 + // 디자인 모드: 고정 픽셀 사용 (캔버스 내 절대 좌표 배치) if (size?.height && size.height > 0) { if (component.componentConfig?.type === "table-list") { return `${Math.max(size.height, 200)}px`; @@ -390,17 +382,14 @@ const RealtimePreviewDynamicComponent: React.FC = ({ return `${size.height}px`; } - // 2순위: componentStyle.height (컴포넌트 정의에서 온 기본 스타일) if (componentStyle?.height) { return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height; } - // 3순위: 기본값 if (component.componentConfig?.type === "table-list") { return "200px"; } - // 기본 높이 return "10px"; }; diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 26a5d7c4..dc3dccc0 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -1410,7 +1410,7 @@ export const ButtonPrimaryComponent: React.FC = ({ const buttonElementStyle: React.CSSProperties = { width: buttonWidth, height: buttonHeight, - minHeight: "32px", // 🔧 최소 높이를 32px로 줄임 + minHeight: undefined, // 비율 스케일링 시 래퍼 높이를 정확히 따르도록 제거 // 커스텀 테두리 스타일 (StyleEditor 설정 우선, shorthand 사용 안 함) borderWidth: style?.borderWidth || "0", borderStyle: (style?.borderStyle as React.CSSProperties["borderStyle"]) || (style?.borderWidth ? "solid" : "none"), diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 0c585587..40f00e1a 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -22,6 +22,7 @@ import { FileSpreadsheet, List, PanelRight, + GripVertical, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; @@ -313,10 +314,11 @@ export const SplitPanelLayoutComponent: React.FC const [rightFilters, setRightFilters] = useState([]); const [rightGrouping, setRightGrouping] = useState([]); const [rightColumnVisibility, setRightColumnVisibility] = useState([]); - // 우측 패널 컬럼 헤더 드래그 (디자인 모드에서 순서 변경) + // 우측 패널 컬럼 헤더 드래그 (디자인 + 런타임 순서 변경) const [rightDraggedColumnIndex, setRightDraggedColumnIndex] = useState(null); const [rightDropTargetColumnIndex, setRightDropTargetColumnIndex] = useState(null); const [rightDragSource, setRightDragSource] = useState<"main" | number | null>(null); + const [runtimeColumnOrder, setRuntimeColumnOrder] = useState>({}); // 데이터 상태 const [leftData, setLeftData] = useState([]); @@ -2544,55 +2546,67 @@ export const SplitPanelLayoutComponent: React.FC handleRightColumnDragEnd(); return; } - if (!onUpdateComponent) { - handleRightColumnDragEnd(); - return; - } - const rightPanel = componentConfig.rightPanel || {}; - if (source === "main") { - const allColumns = rightPanel.columns || []; - const visibleColumns = allColumns.filter((c: any) => c.showInSummary !== false); - const hiddenColumns = allColumns.filter((c: any) => c.showInSummary === false); - if (fromIdx < 0 || fromIdx >= visibleColumns.length || targetIndex < 0 || targetIndex >= visibleColumns.length) { - handleRightColumnDragEnd(); - return; + + if (onUpdateComponent) { + // 디자인 모드: config에 영구 저장 + const rightPanel = componentConfig.rightPanel || {}; + if (source === "main") { + const allColumns = rightPanel.columns || []; + const visibleColumns = allColumns.filter((c: any) => c.showInSummary !== false); + const hiddenColumns = allColumns.filter((c: any) => c.showInSummary === false); + if (fromIdx < 0 || fromIdx >= visibleColumns.length || targetIndex < 0 || targetIndex >= visibleColumns.length) { + handleRightColumnDragEnd(); + return; + } + const reordered = [...visibleColumns]; + const [removed] = reordered.splice(fromIdx, 1); + reordered.splice(targetIndex, 0, removed); + const columns = [...reordered, ...hiddenColumns]; + onUpdateComponent({ + ...component, + componentConfig: { + ...componentConfig, + rightPanel: { ...rightPanel, columns }, + }, + }); + } else { + const tabs = [...(rightPanel.additionalTabs || [])]; + const tabConfig = tabs[source]; + if (!tabConfig || !Array.isArray(tabConfig.columns)) { + handleRightColumnDragEnd(); + return; + } + const allTabCols = tabConfig.columns; + const visibleTabCols = allTabCols.filter((c: any) => c.showInSummary !== false); + const hiddenTabCols = allTabCols.filter((c: any) => c.showInSummary === false); + if (fromIdx < 0 || fromIdx >= visibleTabCols.length || targetIndex < 0 || targetIndex >= visibleTabCols.length) { + handleRightColumnDragEnd(); + return; + } + const reordered = [...visibleTabCols]; + const [removed] = reordered.splice(fromIdx, 1); + reordered.splice(targetIndex, 0, removed); + const columns = [...reordered, ...hiddenTabCols]; + const newTabs = tabs.map((t, i) => (i === source ? { ...t, columns } : t)); + onUpdateComponent({ + ...component, + componentConfig: { + ...componentConfig, + rightPanel: { ...rightPanel, additionalTabs: newTabs }, + }, + }); } - const reordered = [...visibleColumns]; - const [removed] = reordered.splice(fromIdx, 1); - reordered.splice(targetIndex, 0, removed); - const columns = [...reordered, ...hiddenColumns]; - onUpdateComponent({ - ...component, - componentConfig: { - ...componentConfig, - rightPanel: { ...rightPanel, columns }, - }, - }); } else { - const tabs = [...(rightPanel.additionalTabs || [])]; - const tabConfig = tabs[source]; - if (!tabConfig || !Array.isArray(tabConfig.columns)) { - handleRightColumnDragEnd(); - return; - } - const allTabCols = tabConfig.columns; - const visibleTabCols = allTabCols.filter((c: any) => c.showInSummary !== false); - const hiddenTabCols = allTabCols.filter((c: any) => c.showInSummary === false); - if (fromIdx < 0 || fromIdx >= visibleTabCols.length || targetIndex < 0 || targetIndex >= visibleTabCols.length) { - handleRightColumnDragEnd(); - return; - } - const reordered = [...visibleTabCols]; - const [removed] = reordered.splice(fromIdx, 1); - reordered.splice(targetIndex, 0, removed); - const columns = [...reordered, ...hiddenTabCols]; - const newTabs = tabs.map((t, i) => (i === source ? { ...t, columns } : t)); - onUpdateComponent({ - ...component, - componentConfig: { - ...componentConfig, - rightPanel: { ...rightPanel, additionalTabs: newTabs }, - }, + // 런타임 모드: 로컬 상태로 순서 변경 + const key = String(source); + setRuntimeColumnOrder((prev) => { + const existing = prev[key]; + const maxLen = 100; + const order = existing || Array.from({ length: maxLen }, (_, i) => i); + const reordered = [...order]; + const [removed] = reordered.splice(fromIdx, 1); + reordered.splice(targetIndex, 0, removed); + return { ...prev, [key]: reordered }; }); } handleRightColumnDragEnd(); @@ -2604,9 +2618,29 @@ export const SplitPanelLayoutComponent: React.FC component, onUpdateComponent, handleRightColumnDragEnd, + setRuntimeColumnOrder, ], ); + // 런타임 컬럼 순서 적용 헬퍼 + const applyRuntimeOrder = useCallback( + (columns: T[], source: "main" | number): T[] => { + const key = String(source); + const order = runtimeColumnOrder[key]; + if (!order) return columns; + const result: T[] = []; + for (const idx of order) { + if (idx < columns.length) result.push(columns[idx]); + } + // order에 없는 나머지 컬럼 추가 + for (let i = 0; i < columns.length; i++) { + if (!order.includes(i)) result.push(columns[i]); + } + return result.length > 0 ? result : columns; + }, + [runtimeColumnOrder], + ); + // 수정 모달 저장 const handleEditModalSave = useCallback(async () => { const tableName = @@ -3946,11 +3980,10 @@ export const SplitPanelLayoutComponent: React.FC {resizable && (
-
-
+
)} @@ -4107,7 +4140,7 @@ export const SplitPanelLayoutComponent: React.FC // showInSummary가 false가 아닌 것만 메인 테이블에 표시 const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); const tabIndex = activeTabIndex - 1; - const canDragTabColumns = isDesignMode && tabSummaryColumns.length > 0 && !!onUpdateComponent; + const canDragTabColumns = tabSummaryColumns.length > 0; return (
- {col.label} - isDraggable && handleRightColumnDragStart(configColIndex, "main")} + onDragOver={(e) => isDraggable && handleRightColumnDragOver(e, configColIndex)} + onDragEnd={handleRightColumnDragEnd} + onDrop={(e) => isDraggable && handleRightColumnDrop(e, configColIndex, "main")} + > + {col.label} +
{!isDesignMode && ((componentConfig.rightPanel?.editButton?.enabled ?? true) || (componentConfig.rightPanel?.deleteButton?.enabled ?? true)) && ( - -
+
+
{(componentConfig.rightPanel?.editButton?.enabled ?? true) && (
-
+
{hasEditButton && ( + )} + {hasDetailDeleteButton && ( + + )} +
+ )} {displayEntries.map(([key, value, label]) => (
diff --git a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx index 69bcc9cd..c2307956 100644 --- a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx @@ -35,7 +35,11 @@ interface SingleTableWithStickyProps { editingValue?: string; onEditingValueChange?: (value: string) => void; onEditKeyDown?: (e: React.KeyboardEvent) => void; - editInputRef?: React.RefObject; + onEditSave?: () => void; + editInputRef?: React.RefObject; + // 인라인 편집 타입별 옵션 (select/category/code, number, date 지원) + columnMeta?: Record; + categoryMappings?: Record>; // 검색 하이라이트 관련 props searchHighlights?: Set; currentSearchIndex?: number; @@ -69,7 +73,10 @@ export const SingleTableWithSticky: React.FC = ({ editingValue, onEditingValueChange, onEditKeyDown, + onEditSave, editInputRef, + columnMeta, + categoryMappings, // 검색 하이라이트 관련 props searchHighlights, currentSearchIndex = 0, @@ -350,15 +357,19 @@ export const SingleTableWithSticky: React.FC = ({ {column.columnName === "__checkbox__" ? ( renderCheckboxCell?.(row, index) ) : isEditing ? ( - // 인라인 편집 입력 필드 - onEditingValueChange?.(e.target.value)} - onKeyDown={onEditKeyDown} - onBlur={() => { - // blur 시 저장 (Enter와 동일) + // 인라인 편집: inputType에 따라 select(category/code), number, date, text + (() => { + const meta = columnMeta?.[column.columnName]; + const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType; + const isNumeric = inputType === "number" || inputType === "decimal"; + const isCategoryType = inputType === "category" || inputType === "code"; + const categoryOptions = categoryMappings?.[column.columnName]; + const hasCategoryOptions = + isCategoryType && categoryOptions && Object.keys(categoryOptions).length > 0; + + const commonInputClass = + "border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"; + const handleBlurSave = () => { if (onEditKeyDown) { const fakeEvent = { key: "Enter", @@ -366,10 +377,78 @@ export const SingleTableWithSticky: React.FC = ({ } as React.KeyboardEvent; onEditKeyDown(fakeEvent); } - }} - className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm" - onClick={(e) => e.stopPropagation()} - /> + onEditSave?.(); + }; + + if (hasCategoryOptions) { + const selectOptions = Object.entries(categoryOptions).map(([value, info]) => ({ + value, + label: info.label, + })); + return ( + + ); + } + + if (inputType === "date" || inputType === "datetime") { + try { + const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker"); + return ( + onEditingValueChange?.(v)} + onSave={() => { + handleBlurSave(); + }} + onKeyDown={onEditKeyDown} + inputRef={editInputRef as React.RefObject} + /> + ); + } catch { + return ( + } + type="text" + value={editingValue ?? ""} + onChange={(e) => onEditingValueChange?.(e.target.value)} + onKeyDown={onEditKeyDown} + onBlur={handleBlurSave} + className={commonInputClass} + onClick={(e) => e.stopPropagation()} + /> + ); + } + } + + return ( + } + type={isNumeric ? "number" : "text"} + value={editingValue ?? ""} + onChange={(e) => onEditingValueChange?.(e.target.value)} + onKeyDown={onEditKeyDown} + onBlur={handleBlurSave} + className={commonInputClass} + style={isNumeric ? { textAlign: "right" } : undefined} + onClick={(e) => e.stopPropagation()} + /> + ); + })() ) : ( renderCellContent() )} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 15b7a13b..f1748e9a 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -5463,6 +5463,15 @@ export const TableListComponent: React.FC = ({ }} getColumnWidth={getColumnWidth} containerWidth={calculatedWidth} + onCellDoubleClick={handleCellDoubleClick} + editingCell={editingCell} + editingValue={editingValue} + onEditingValueChange={setEditingValue} + onEditKeyDown={handleEditKeyDown} + onEditSave={saveEditing} + editInputRef={editInputRef} + columnMeta={columnMeta} + categoryMappings={categoryMappings} />
@@ -6410,7 +6419,7 @@ export const TableListComponent: React.FC = ({ onChange={(e) => setEditingValue(e.target.value)} onKeyDown={handleEditKeyDown} onBlur={saveEditing} - className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" + className="border-primary bg-background h-8 w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" autoFocus > @@ -6447,7 +6456,7 @@ export const TableListComponent: React.FC = ({ onChange={(e) => setEditingValue(e.target.value)} onKeyDown={handleEditKeyDown} onBlur={saveEditing} - className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" + className="border-primary bg-background h-8 w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" style={{ textAlign: isNumeric ? "right" : column.align || "left", }} From 265f46f8d4afe11aa1a8dd823ce813678351c991 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 16:47:12 +0900 Subject: [PATCH 10/30] [agent-pipeline] pipe-20260317063830-0nfs round-2 --- .../numbering-rule/AutoConfigPanel.tsx | 2 +- .../numbering-rule/NumberingRuleCard.tsx | 40 +++++----- .../numbering-rule/NumberingRuleDesigner.tsx | 79 ++++++++++--------- .../numbering-rule/NumberingRulePreview.tsx | 9 ++- .../table-category/CategoryValueAddDialog.tsx | 2 +- .../CategoryValueEditDialog.tsx | 2 +- .../table-category/CategoryValueManager.tsx | 1 - .../CategoryValueManagerTree.tsx | 10 ++- .../V2CategoryManagerComponent.tsx | 8 +- .../SplitPanelLayoutComponent.tsx | 10 +-- .../v2-table-list/SingleTableWithSticky.tsx | 3 +- .../v2-table-list/TableListComponent.tsx | 6 +- 12 files changed, 88 insertions(+), 84 deletions(-) diff --git a/frontend/components/numbering-rule/AutoConfigPanel.tsx b/frontend/components/numbering-rule/AutoConfigPanel.tsx index ac4d2ffa..0586006a 100644 --- a/frontend/components/numbering-rule/AutoConfigPanel.tsx +++ b/frontend/components/numbering-rule/AutoConfigPanel.tsx @@ -478,7 +478,7 @@ const DateConfigPanel: React.FC = ({ {sourceTableName && columns.length === 0 && !loadingColumns && ( -

+

이 테이블에 날짜 타입 컬럼이 없습니다

)} diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index e3dbc3ab..568ecbdc 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -1,7 +1,6 @@ "use client"; import React from "react"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -27,25 +26,24 @@ export const NumberingRuleCard: React.FC = ({ tableName, }) => { return ( - - -
- - 규칙 {part.order} - - -
-
+
+
+ + 규칙 {part.order} + + +
- +
= ({
{/* 큰 미리보기 스트립 (code-preview-strip) */} -
+
{/* 파이프라인 영역 (code-pipeline-area) */} -
-
- 코드 구성 - +
+
+ 코드 구성 + {currentRule.parts.length}/{maxRules}
-
+
{currentRule.parts.length === 0 ? (
규칙을 추가하여 코드를 구성하세요 @@ -376,7 +375,7 @@ export const NumberingRuleDesigner: React.FC = ({ {index < currentRule.parts.length - 1 && ( - - - - {sep || "(없음)"} +
+ + + {sep || "-"} - +
)} ); })} + {headerRight != null ?
{headerRight}
: null}
diff --git a/frontend/components/table-category/CategoryValueManagerTree.tsx b/frontend/components/table-category/CategoryValueManagerTree.tsx index a31c054a..3d361ff0 100644 --- a/frontend/components/table-category/CategoryValueManagerTree.tsx +++ b/frontend/components/table-category/CategoryValueManagerTree.tsx @@ -60,6 +60,8 @@ interface CategoryValueManagerTreeProps { columnName: string; columnLabel: string; onValueCountChange?: (count: number) => void; + /** 편집기 헤더 오른쪽에 표시할 내용 (예: 트리/목록 세그먼트) */ + headerRight?: React.ReactNode; } // 트리 노드 컴포넌트 @@ -272,6 +274,7 @@ export const CategoryValueManagerTree: React.FC = columnName, columnLabel, onValueCountChange, + headerRight, }) => { // 상태 const [tree, setTree] = useState([]); @@ -634,7 +637,7 @@ export const CategoryValueManagerTree: React.FC = return (
- {/* 편집기 헤더: 컬럼명 + 값 수 Badge + 선택 Badge + 액션 버튼 */} + {/* 편집기 헤더: 컬럼명 + 값 수 Badge + 비활성/전체펼침/대분류추가 + headerRight(트리·목록 세그먼트 등) */}

{columnLabel} 카테고리

@@ -668,6 +671,7 @@ export const CategoryValueManagerTree: React.FC = 대분류 추가 + {headerRight != null ?
{headerRight}
: null}
diff --git a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx index 3e951102..22485ec9 100644 --- a/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx +++ b/frontend/lib/registry/components/v2-category-manager/V2CategoryManagerComponent.tsx @@ -88,33 +88,33 @@ export function V2CategoryManagerComponent({ [columns, selectedTable], ); + /** 편집기 헤더에 표시할 트리/목록 세그먼트 (보기 방식 토글) */ + const viewModeSegment = + config.showViewModeToggle ? ( +
+ + +
+ ) : null; + const rightContent = ( <> - {config.showViewModeToggle && ( -
- 보기 방식: -
- - -
-
- )}
{selectedColumn ? ( viewMode === "tree" ? ( @@ -123,6 +123,7 @@ export function V2CategoryManagerComponent({ tableName={selectedColumn.tableName} columnName={selectedColumn.columnName} columnLabel={selectedColumn.columnLabel} + headerRight={viewModeSegment} /> ) : ( ) ) : ( diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 19f6f611..ae6c4fe4 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -207,19 +207,6 @@ export const SplitPanelLayoutComponent: React.FC }) => { const componentConfig = (component.componentConfig || {}) as SplitPanelLayoutConfig; - // 🐛 디버깅: 로드 시 rightPanel.components 확인 - const rightComps = componentConfig.rightPanel?.components || []; - const finishedTimeline = rightComps.find((c: any) => c.id === "finished_timeline"); - if (finishedTimeline) { - const fm = finishedTimeline.componentConfig?.fieldMapping; - console.log("🔍 [SplitPanelLayout] finished_timeline fieldMapping:", { - componentId: finishedTimeline.id, - fieldMapping: fm ? JSON.stringify(fm) : "undefined", - fieldMappingKeys: fm ? Object.keys(fm) : [], - fieldMappingId: fm?.id, - fullComponentConfig: JSON.stringify(finishedTimeline.componentConfig || {}, null, 2), - }); - } // 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 사용 가능) const companyCode = (props as any).companyCode as string | undefined; @@ -635,14 +622,6 @@ export const SplitPanelLayoutComponent: React.FC } = splitPanelContext; const splitPanelId = `split-panel-${component.id}`; - // 디버깅: Context 연결 상태 확인 - console.log("🔗 [SplitPanelLayout] Context 연결 상태:", { - componentId: component.id, - splitPanelId, - hasRegisterFunc: typeof ctxRegisterSplitPanel === "function", - splitPanelsSize: splitPanelContext.splitPanels?.size ?? "없음", - }); - // Context에 분할 패널 등록 (좌표 정보 포함) - 마운트 시 1회만 실행 const ctxRegisterRef = useRef(ctxRegisterSplitPanel); const ctxUnregisterRef = useRef(ctxUnregisterSplitPanel); @@ -666,15 +645,9 @@ export const SplitPanelLayoutComponent: React.FC isDragging: false, }; - console.log("📦 [SplitPanelLayout] Context에 분할 패널 등록:", { - splitPanelId, - panelInfo, - }); - ctxRegisterRef.current(splitPanelId, panelInfo); return () => { - console.log("📦 [SplitPanelLayout] Context에서 분할 패널 해제:", splitPanelId); ctxUnregisterRef.current(splitPanelId); }; // 마운트/언마운트 시에만 실행, 위치/크기 변경은 별도 업데이트로 처리 @@ -731,10 +704,6 @@ export const SplitPanelLayoutComponent: React.FC isDragging: false, initialLeftWidthPercent: leftWidth, }); - console.log("🛑 [SplitPanelLayout] 드래그 종료 - 버튼 위치 고정:", { - splitPanelId, - finalLeftWidthPercent: leftWidth, - }); } prevIsDraggingRef.current = isDragging; @@ -742,11 +711,8 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 그룹별 합산된 데이터 계산 const summedLeftData = useMemo(() => { - console.log("🔍 [그룹합산] leftGroupSumConfig:", leftGroupSumConfig); - // 그룹핑이 비활성화되었거나 그룹 기준 컬럼이 없으면 원본 데이터 반환 if (!leftGroupSumConfig?.enabled || !leftGroupSumConfig?.groupByColumn) { - console.log("🔍 [그룹합산] 그룹핑 비활성화 - 원본 데이터 반환"); return leftData; } @@ -759,7 +725,6 @@ export const SplitPanelLayoutComponent: React.FC const [refTable, fieldName] = columnName.split("."); const inferredSourceColumn = refTable.replace("_info", "_code").replace("_mng", "_id"); const exactKey = `${inferredSourceColumn}_${fieldName}`; - console.log("🔍 [그룹합산] 조인 컬럼 키 변환:", { columnName, exactKey, hasKey: item[exactKey] !== undefined }); if (item[exactKey] !== undefined) return exactKey; if (fieldName === "item_name" || fieldName === "name") { const aliasKey = `${inferredSourceColumn}_name`; @@ -812,14 +777,7 @@ export const SplitPanelLayoutComponent: React.FC } }); - const result = Array.from(groupMap.values()); - console.log("🔗 [분할패널] 그룹별 합산 결과:", { - 원본개수: leftData.length, - 그룹개수: result.length, - 그룹기준: groupByColumn, - }); - - return result; + return Array.from(groupMap.values()); }, [leftData, leftGroupSumConfig]); // 컴포넌트 스타일 @@ -1262,8 +1220,6 @@ export const SplitPanelLayoutComponent: React.FC leftTableName, ); - console.log("🔗 [분할패널] 좌측 additionalJoinColumns:", leftJoinColumns); - const result = await entityJoinApi.getTableDataWithJoins(leftTableName, { page: 1, size: 100, @@ -1274,12 +1230,6 @@ export const SplitPanelLayoutComponent: React.FC companyCodeOverride: companyCode, }); - // 🔍 디버깅: API 응답 데이터의 키 확인 - if (result.data && result.data.length > 0) { - console.log("🔗 [분할패널] API 응답 첫 번째 데이터 키:", Object.keys(result.data[0])); - console.log("🔗 [분할패널] API 응답 첫 번째 데이터:", result.data[0]); - } - // 좌측 패널 dataFilter 클라이언트 사이드 적용 let filteredLeftData = result.data || []; const leftDataFilter = componentConfig.leftPanel?.dataFilter; @@ -1453,11 +1403,8 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 그룹 합산된 항목인 경우: 원본 데이터들로 우측 패널 표시 if (leftItem._originalItems && leftItem._originalItems.length > 0) { - console.log("🔗 [분할패널] 그룹 합산 항목 - 원본 개수:", leftItem._originalItems.length); - // 정렬 기준 컬럼 (복합키의 leftColumn들) const sortColumns = keys?.map((k: any) => k.leftColumn).filter(Boolean) || []; - console.log("🔗 [분할패널] 정렬 기준 컬럼:", sortColumns); // 정렬 함수 const sortByKeys = (data: any[]) => { @@ -1476,7 +1423,6 @@ export const SplitPanelLayoutComponent: React.FC // 원본 데이터를 그대로 우측 패널에 표시 (이력 테이블과 동일 테이블인 경우) if (leftTable === rightTableName) { const sortedData = sortByKeys(leftItem._originalItems); - console.log("🔗 [분할패널] 동일 테이블 - 정렬된 원본 데이터:", sortedData.length); setRightData(sortedData); return; } @@ -1514,9 +1460,7 @@ export const SplitPanelLayoutComponent: React.FC } } - // 정렬 적용 const sortedResults = sortByKeys(allResults); - console.log("🔗 [분할패널] 그룹 합산 - 우측 패널 정렬된 데이터:", sortedResults.length); setRightData(sortedResults); return; } @@ -1534,17 +1478,11 @@ export const SplitPanelLayoutComponent: React.FC } }); - console.log("🔗 [분할패널] 복합키 조건:", searchConditions); - // 🆕 우측 패널 config의 Entity 조인 컬럼 추출 const rightJoinColumns = extractAdditionalJoinColumns( componentConfig.rightPanel?.columns, rightTableName, ); - if (rightJoinColumns) { - console.log("🔗 [분할패널] 우측 패널 additionalJoinColumns:", rightJoinColumns); - } - // 엔티티 조인 API로 데이터 조회 const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, @@ -1554,8 +1492,6 @@ export const SplitPanelLayoutComponent: React.FC additionalJoinColumns: rightJoinColumns, }); - console.log("🔗 [분할패널] 복합키 조회 결과:", result); - setRightData(result.data || []); } else { // 단일키 (하위 호환성) → entityJoinApi 사용으로 전환 (entity 조인 컬럼 지원) @@ -1566,8 +1502,6 @@ export const SplitPanelLayoutComponent: React.FC const leftValue = leftItem[leftColumn]; const { entityJoinApi } = await import("@/lib/api/entityJoin"); - console.log("🔗 [분할패널] 단일키 조건:", { leftColumn, rightColumn, leftValue, rightTableName }); - // 단일키를 복합키 형식으로 변환 (entity 컬럼이므로 equals 연산자 필수) const searchConditions: Record = {}; searchConditions[rightColumn] = { value: leftValue, operator: "equals" }; @@ -1577,10 +1511,6 @@ export const SplitPanelLayoutComponent: React.FC componentConfig.rightPanel?.columns, rightTableName, ); - if (rightJoinColumnsLegacy) { - console.log("🔗 [분할패널] 단일키 모드 additionalJoinColumns:", rightJoinColumnsLegacy); - } - const result = await entityJoinApi.getTableDataWithJoins(rightTableName, { search: searchConditions, enableEntityJoin: true, @@ -1631,10 +1561,6 @@ export const SplitPanelLayoutComponent: React.FC // 탭 config의 Entity 조인 컬럼 추출 const tabJoinColumns = extractAdditionalJoinColumns(tabConfig.columns, tabTableName); - if (tabJoinColumns) { - console.log(`🔗 [분할패널] 탭 ${tabIndex} additionalJoinColumns:`, tabJoinColumns); - } - let resultData: any[] = []; // 탭의 dataFilter (API 전달용) @@ -1830,7 +1756,6 @@ export const SplitPanelLayoutComponent: React.FC if (leftTableName && !isDesignMode) { import("@/stores/modalDataStore").then(({ useModalDataStore }) => { useModalDataStore.getState().setData(leftTableName, [item]); - console.log(`✅ 분할 패널 좌측 선택: ${leftTableName}`, item); }); } }, @@ -2051,7 +1976,6 @@ export const SplitPanelLayoutComponent: React.FC } }); setLeftColumnLabels(labels); - console.log("✅ 좌측 컬럼 라벨 로드:", labels); } catch (error) { console.error("좌측 테이블 컬럼 라벨 로드 실패:", error); } @@ -2214,8 +2138,6 @@ export const SplitPanelLayoutComponent: React.FC }); }); - console.log("🔍 우측 패널 카테고리 로드 대상 테이블:", Array.from(tablesToLoad)); - // 각 테이블에 대해 카테고리 매핑 로드 for (const tableName of tablesToLoad) { try { @@ -2245,9 +2167,6 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 컬럼명만으로도 접근할 수 있도록 추가 저장 (모든 테이블) // 기존 매핑이 있으면 병합, 없으면 새로 생성 mappings[columnName] = { ...(mappings[columnName] || {}), ...valueMap }; - - console.log(`✅ 우측 카테고리 매핑 로드 [${mappingKey}]:`, valueMap); - console.log(`✅ 우측 카테고리 매핑 (컬럼명만) [${columnName}]:`, mappings[columnName]); } } catch (error) { console.error(`우측 카테고리 값 조회 실패 [${tableName}.${columnName}]:`, error); @@ -2302,10 +2221,6 @@ export const SplitPanelLayoutComponent: React.FC }), ); - console.log("✅ [SplitPanel] 좌측 추가 모달 화면 열기:", { - screenId: addButtonConfig.modalScreenId, - tableName: leftTableName, - }); return; } } @@ -2372,11 +2287,6 @@ export const SplitPanelLayoutComponent: React.FC }), ); - console.log("✅ [SplitPanel] 추가 모달 화면 열기:", { - screenId: addButtonConfig.modalScreenId, - tableName: currentTableName, - parentData, - }); return; } } @@ -2445,11 +2355,6 @@ export const SplitPanelLayoutComponent: React.FC }), ); - console.log("✅ [SplitPanel] 좌측 수정 모달 화면 열기:", { - screenId: editButtonConfig.modalScreenId, - tableName: leftTableName, - primaryKeyValue, - }); return; } } @@ -2501,14 +2406,6 @@ export const SplitPanelLayoutComponent: React.FC } } - console.log("✅ 수정 모달 열기:", { - tableName: rightTableName, - primaryKeyName, - primaryKeyValue, - screenId: modalScreenId, - fullItem: item, - }); - // modalDataStore에도 저장 (호환성 유지) import("@/stores/modalDataStore").then(({ useModalDataStore }) => { useModalDataStore.getState().setData(rightTableName, [item]); @@ -2517,12 +2414,6 @@ export const SplitPanelLayoutComponent: React.FC // 🆕 groupByColumns 추출 const groupByColumns = componentConfig.rightPanel?.editButton?.groupByColumns || []; - console.log("🔧 [SplitPanel] 수정 버튼 클릭 - groupByColumns 확인:", { - groupByColumns, - editButtonConfig: componentConfig.rightPanel?.editButton, - hasGroupByColumns: groupByColumns.length > 0, - }); - // ScreenModal 열기 이벤트 발생 (URL 파라미터로 ID + groupByColumns 전달) window.dispatchEvent( new CustomEvent("openScreenModal", { @@ -2540,13 +2431,6 @@ export const SplitPanelLayoutComponent: React.FC }), ); - console.log("✅ [SplitPanel] openScreenModal 이벤트 발생:", { - screenId: modalScreenId, - editId: primaryKeyValue, - tableName: rightTableName, - groupByColumns: groupByColumns.length > 0 ? JSON.stringify(groupByColumns) : "없음", - }); - return; } } @@ -2606,8 +2490,6 @@ export const SplitPanelLayoutComponent: React.FC cleanData.company_code = companyCode; } - console.log("📝 [SplitPanel] 커스텀 우측 패널 저장:", { tableName, primaryKey, data: cleanData }); - const response = await dataApi.updateRecord(tableName, primaryKey, cleanData); if (response.success) { @@ -2743,8 +2625,6 @@ export const SplitPanelLayoutComponent: React.FC } try { - console.log("📝 데이터 수정:", { tableName, primaryKey, data: editModalFormData }); - // 프론트엔드 전용 필드 제거 (children, level 등) const cleanData = { ...editModalFormData }; delete cleanData.children; @@ -2761,7 +2641,6 @@ export const SplitPanelLayoutComponent: React.FC rightColumn: componentConfig.rightPanel.relation.rightColumn, oldLeftValue: editModalItem[componentConfig.rightPanel.relation.leftColumn], }; - console.log("🔗 조인 관계 정보 추가:", updatePayload._relationInfo); } const result = await dataApi.updateRecord(tableName, primaryKey, updatePayload); @@ -2831,7 +2710,6 @@ export const SplitPanelLayoutComponent: React.FC // 우측 패널 + 중계 테이블 모드인 경우 if (deleteModalPanel === "right" && componentConfig.rightPanel?.addConfig?.targetTable) { tableName = componentConfig.rightPanel.addConfig.targetTable; - console.log("🔗 중계 테이블 모드: 삭제 대상 테이블 =", tableName); } } @@ -2841,9 +2719,6 @@ export const SplitPanelLayoutComponent: React.FC if (!primaryKey && deleteModalItem && typeof deleteModalItem === "object") { // id가 없는 경우에만 전체 객체 전달 (복합키 테이블) primaryKey = deleteModalItem; - console.log("🔑 복합키: 전체 객체 전달", Object.keys(primaryKey)); - } else { - console.log("🔑 단일키 삭제: id =", primaryKey, "테이블 =", tableName); } if (!tableName || !primaryKey) { @@ -2856,16 +2731,6 @@ export const SplitPanelLayoutComponent: React.FC } try { - console.log("🗑️ 데이터 삭제:", { tableName, primaryKey }); - - // 🔍 중복 제거 설정 디버깅 - console.log("🔍 중복 제거 디버깅:", { - panel: deleteModalPanel, - dataFilter: componentConfig.rightPanel?.dataFilter, - deduplication: componentConfig.rightPanel?.dataFilter?.deduplication, - enabled: componentConfig.rightPanel?.dataFilter?.deduplication?.enabled, - }); - let result; // 🔧 중복 제거가 활성화된 경우, groupByColumn 기준으로 모든 관련 레코드 삭제 @@ -2875,7 +2740,6 @@ export const SplitPanelLayoutComponent: React.FC if (groupByColumn && deleteModalItem[groupByColumn]) { const groupValue = deleteModalItem[groupByColumn]; - console.log(`🔗 중복 제거 활성화: ${groupByColumn} = ${groupValue} 기준으로 모든 레코드 삭제`); // groupByColumn 값으로 필터링하여 삭제 const filterConditions: Record = { @@ -2889,8 +2753,6 @@ export const SplitPanelLayoutComponent: React.FC filterConditions[rightColumn] = selectedLeftItem[leftColumn]; } - console.log("🗑️ 그룹 삭제 조건:", filterConditions); - // 그룹 삭제 API 호출 result = await dataApi.deleteGroupRecords(tableName, filterConditions); } else { @@ -3022,7 +2884,6 @@ export const SplitPanelLayoutComponent: React.FC if (addConfig.leftPanelColumn && addConfig.targetColumn && selectedLeftItem) { const leftValue = selectedLeftItem[addConfig.leftPanelColumn]; finalData[addConfig.targetColumn] = leftValue; - console.log(`🔗 좌측 패널 값 자동 채움: ${addConfig.targetColumn} = ${leftValue}`); } // 자동 채움 컬럼 추가 @@ -3030,7 +2891,6 @@ export const SplitPanelLayoutComponent: React.FC Object.entries(addConfig.autoFillColumns).forEach(([key, value]) => { finalData[key] = value; }); - console.log("🔧 자동 채움 컬럼:", addConfig.autoFillColumns); } } else { // 일반 테이블 모드 @@ -3066,8 +2926,6 @@ export const SplitPanelLayoutComponent: React.FC } try { - console.log("📝 데이터 추가:", { tableName, data: finalData }); - const result = await dataApi.createRecord(tableName, finalData); if (result.success) { @@ -3210,7 +3068,6 @@ export const SplitPanelLayoutComponent: React.FC useEffect(() => { const handleRefreshTable = () => { if (!isDesignMode) { - console.log("🔄 [SplitPanel] refreshTable 이벤트 수신 - 데이터 새로고침"); loadLeftData(); // 현재 활성 탭 데이터 새로고침 (좌측 미선택 시에도 전체 데이터 로드) if (activeTabIndex === 0) { @@ -3510,7 +3367,6 @@ export const SplitPanelLayoutComponent: React.FC }} // 🆕 중첩된 탭 내부 컴포넌트 선택 핸들러 - 부모 분할 패널 정보 포함 onSelectTabComponent={(tabId: string, compId: string, tabComp: any) => { - console.log("🔍 [SplitPanel-Left] onSelectTabComponent 호출:", { tabId, compId, tabComp, parentSplitPanelId: component.id }); // 탭 내 컴포넌트 선택 상태 업데이트 setNestedTabSelectedCompId(compId); // 부모 분할 패널 정보와 함께 전역 이벤트 발생 @@ -3606,12 +3462,6 @@ export const SplitPanelLayoutComponent: React.FC (() => { // 🆕 그룹별 합산된 데이터 사용 const dataSource = summedLeftData; - console.log( - "🔍 [테이블모드 렌더링] dataSource 개수:", - dataSource.length, - "leftGroupSumConfig:", - leftGroupSumConfig, - ); // 🔧 로컬 검색 필터 적용 const filteredData = leftSearchQuery @@ -3898,12 +3748,6 @@ export const SplitPanelLayoutComponent: React.FC (() => { // 🆕 그룹별 합산된 데이터 사용 const dataToDisplay = summedLeftData; - console.log( - "🔍 [렌더링] dataToDisplay 개수:", - dataToDisplay.length, - "leftGroupSumConfig:", - leftGroupSumConfig, - ); // 검색 필터링 (클라이언트 사이드) const filteredLeftData = leftSearchQuery @@ -3930,13 +3774,6 @@ export const SplitPanelLayoutComponent: React.FC const configuredColumns = componentConfig.leftPanel?.columns || []; let displayFields: { label: string; value: any }[] = []; - // 디버그 로그 - if (index === 0) { - console.log("🔍 좌측 패널 표시 로직:"); - console.log(" - 설정된 표시 컬럼:", configuredColumns); - console.log(" - item keys:", Object.keys(item)); - } - if (configuredColumns.length > 0) { // 🔧 "표시할 컬럼 선택"에서 설정한 컬럼 사용 displayFields = configuredColumns.slice(0, 2).map((col: any) => { @@ -3960,10 +3797,6 @@ export const SplitPanelLayoutComponent: React.FC value: displayValue, }; }); - - if (index === 0) { - console.log(" ✅ 설정된 컬럼 기반 표시:", displayFields); - } } else { // 설정된 컬럼이 없으면 자동으로 첫 2개 필드 표시 const keys = Object.keys(item).filter( @@ -3984,10 +3817,6 @@ export const SplitPanelLayoutComponent: React.FC value: displayValue, }; }); - - if (index === 0) { - console.log(" ⚠️ 설정된 컬럼 없음, 자동 선택:", displayFields); - } } const displayTitle = displayFields[0]?.value || item.name || item.title || `항목 ${index + 1}`; @@ -4801,7 +4630,6 @@ export const SplitPanelLayoutComponent: React.FC _isKeyColumn: true, // 구분용 플래그 })); columnsToShow = [...keyColsToAdd, ...columnsToShow]; - console.log("🔗 [우측패널] 그룹모드 - 키 컬럼 추가:", missingKeyColumns); } } } else { @@ -5120,39 +4948,24 @@ export const SplitPanelLayoutComponent: React.FC let displayEntries: [string, any, string][] = []; if (rightColumns && rightColumns.length > 0) { - console.log("🔍 [디버깅] 상세 모드 표시 로직:"); - console.log(" 📋 rightData 전체:", rightData); - console.log(" 📋 rightData keys:", Object.keys(rightData)); - console.log( - " ⚙️ 설정된 컬럼:", - rightColumns.map((c) => `${c.name} (${c.label})`), - ); - // 설정된 컬럼만 표시 (showInDetail이 false가 아닌 것만) displayEntries = rightColumns .filter((col) => col.showInDetail !== false) .map((col) => { - // 🆕 엔티티 조인 컬럼 처리 (예: item_info.item_name → item_name) let value = rightData[col.name]; - console.log(` 🔎 컬럼 "${col.name}": 직접 접근 = ${value}`); if (value === undefined && col.name.includes(".")) { const columnName = col.name.split(".").pop(); value = rightData[columnName || ""]; - console.log(` → 변환 후 "${columnName}" 접근 = ${value}`); } return [col.name, value, col.label] as [string, any, string]; - }) -; // 설정된 컬럼은 null/empty여도 항상 표시 - - console.log(" ✅ 최종 표시할 항목:", displayEntries.length, "개"); + }); } else { // 설정 없으면 모든 컬럼 표시 displayEntries = Object.entries(rightData) .filter(([_, value]) => value !== null && value !== undefined && value !== "") .map(([key, value]) => [key, value, ""] as [string, any, string]); - console.log(" ⚠️ 컬럼 설정 없음, 모든 컬럼 표시"); } const hasDetailEditButton = !isDesignMode && (componentConfig.rightPanel?.editButton?.enabled ?? true); diff --git a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx index 8bfb07f6..7f73e487 100644 --- a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx @@ -381,6 +381,7 @@ export const SingleTableWithSticky: React.FC = ({ onEditSave?.(); }; + // category/code 타입: select는 반드시 h-8(32px)로 행 높이 유지 if (hasCategoryOptions) { const selectOptions = Object.entries(categoryOptions).map(([value, info]) => ({ value, @@ -393,7 +394,7 @@ export const SingleTableWithSticky: React.FC = ({ onChange={(e) => onEditingValueChange?.(e.target.value)} onKeyDown={onEditKeyDown} onBlur={handleBlurSave} - className={commonInputClass} + className={cn(commonInputClass, "h-8")} onClick={(e) => e.stopPropagation()} > From d3acf391a40962aeb92c21cbf778d6ef1773ffdb Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 18:05:10 +0900 Subject: [PATCH 12/30] [agent-pipeline] pipe-20260317084014-ydap round-1 --- .../admin/systemMng/tableMngList/page.tsx | 27 ++++++--- .../admin/table-type/ColumnDetailPanel.tsx | 48 +++++++++++++--- .../admin/table-type/ColumnGrid.tsx | 48 +++++++++++++--- frontend/components/layout/AppLayout.tsx | 56 +++++++++++++++++-- .../table-category/CategoryColumnList.tsx | 1 + frontend/components/ui/tabs.tsx | 4 +- .../SplitPanelLayoutComponent.tsx | 8 +-- .../v2-tabs-widget/tabs-component.tsx | 12 ++-- 8 files changed, 164 insertions(+), 40 deletions(-) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 44051e28..d98c2115 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -217,10 +217,16 @@ export default function TableManagementPage() { // 메모이제이션된 입력타입 옵션 const memoizedInputTypeOptions = useMemo(() => inputTypeOptions, []); - // 참조 테이블 옵션 (실제 테이블 목록에서 가져옴) + // 참조 테이블 옵션 (한글라벨 (영어명) 동시 표시) const referenceTableOptions = [ { value: "none", label: getTextFromUI(TABLE_MANAGEMENT_KEYS.LABEL_NONE, "선택 안함") }, - ...tables.map((table) => ({ value: table.tableName, label: table.displayName || table.tableName })), + ...tables.map((table) => ({ + value: table.tableName, + label: + table.displayName && table.displayName !== table.tableName + ? `${table.displayName} (${table.tableName})` + : table.tableName, + })), ]; // 공통 코드 카테고리 목록 상태 @@ -1596,6 +1602,8 @@ export default function TableManagementPage() { onIndexToggle={(columnName, checked) => handleIndexToggle(columnName, "index", checked) } + tables={tables} + referenceTableColumns={referenceTableColumns} /> )} @@ -1795,11 +1803,16 @@ export default function TableManagementPage() {

변경될 PK 컬럼:

{pendingPkColumns.length > 0 ? (
- {pendingPkColumns.map((col) => ( - - {col} - - ))} + {pendingPkColumns.map((col) => { + const colInfo = columns.find((c) => c.columnName === col); + return ( + + {colInfo?.displayName && colInfo.displayName !== col + ? `${colInfo.displayName} (${col})` + : col} + + ); + })}
) : (

PK가 모두 제거됩니다

diff --git a/frontend/components/admin/table-type/ColumnDetailPanel.tsx b/frontend/components/admin/table-type/ColumnDetailPanel.tsx index 77f5dedf..5e4e2a07 100644 --- a/frontend/components/admin/table-type/ColumnDetailPanel.tsx +++ b/frontend/components/admin/table-type/ColumnDetailPanel.tsx @@ -78,7 +78,16 @@ export function ColumnDetailPanel({ const refTableOpts = referenceTableOptions.length ? referenceTableOptions - : [{ value: "none", label: "선택 안함" }, ...tables.map((t) => ({ value: t.tableName, label: t.displayName || t.tableName }))]; + : [ + { value: "none", label: "선택 안함" }, + ...tables.map((t) => ({ + value: t.tableName, + label: + t.displayName && t.displayName !== t.tableName + ? `${t.displayName} (${t.tableName})` + : t.tableName, + })), + ]; return (
@@ -90,7 +99,11 @@ export function ColumnDetailPanel({ {typeConf.label} )} - {column.columnName} + + {column.displayName && column.displayName !== column.columnName + ? `${column.displayName} (${column.columnName})` + : column.columnName} +
@@ -245,7 +263,13 @@ export function ColumnDetailPanel({ column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0", )} /> - {refCol.columnName} +
+ + {refCol.displayName && refCol.displayName !== refCol.columnName + ? `${refCol.displayName} (${refCol.columnName})` + : refCol.columnName} + +
))} @@ -259,12 +283,20 @@ export function ColumnDetailPanel({ {/* 참조 요약 미니맵 */} {column.referenceTable && column.referenceTable !== "none" && column.referenceColumn && (
- - {column.referenceTable} + + {(() => { + const tbl = refTableOpts.find((o) => o.value === column.referenceTable); + return tbl?.label ?? column.referenceTable; + })()} - - {column.referenceColumn} + + {(() => { + const col = refColumns.find((c) => c.columnName === column.referenceColumn); + return col?.displayName && col.displayName !== column.referenceColumn + ? `${col.displayName} (${column.referenceColumn})` + : column.referenceColumn; + })()}
)} diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx index c03c7516..825dbd36 100644 --- a/frontend/components/admin/table-type/ColumnGrid.tsx +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -5,8 +5,9 @@ import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; -import type { ColumnTypeInfo } from "./types"; +import type { ColumnTypeInfo, TableInfo } from "./types"; import { INPUT_TYPE_COLORS, getColumnGroup } from "./types"; +import type { ReferenceTableColumn } from "@/lib/api/entityJoin"; export interface ColumnGridConstraints { primaryKey: { columns: string[] }; @@ -23,6 +24,9 @@ export interface ColumnGridProps { getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean }; onPkToggle?: (columnName: string, checked: boolean) => void; onIndexToggle?: (columnName: string, checked: boolean) => void; + /** 호버 시 한글 라벨 표시용 (Badge title) */ + tables?: TableInfo[]; + referenceTableColumns?: Record; } function getIndexState( @@ -53,6 +57,8 @@ export function ColumnGrid({ getColumnIndexState: externalGetIndexState, onPkToggle, onIndexToggle, + tables, + referenceTableColumns, }: ColumnGridProps) { const getIdxState = useMemo( () => externalGetIndexState ?? ((name: string) => getIndexState(name, constraints)), @@ -136,13 +142,12 @@ export function ColumnGrid({ {/* 4px 색상바 (타입별 진한 색) */}
- {/* 라벨 + 컬럼명 */} + {/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
- {column.displayName || column.columnName} -
-
- {column.columnName} + {column.displayName && column.displayName !== column.columnName + ? `${column.displayName} (${column.columnName})` + : column.columnName}
@@ -150,11 +155,38 @@ export function ColumnGrid({
{column.inputType === "entity" && column.referenceTable && column.referenceTable !== "none" && ( <> - + { + const t = tables.find((tb) => tb.tableName === column.referenceTable); + return t?.displayName && t.displayName !== t.tableName + ? `${t.displayName} (${column.referenceTable})` + : column.referenceTable; + })() + : column.referenceTable + } + > {column.referenceTable} - + { + const refCols = referenceTableColumns[column.referenceTable]; + const c = refCols.find((rc) => rc.columnName === (column.referenceColumn ?? "")); + return c?.displayName && c.displayName !== c.columnName + ? `${c.displayName} (${column.referenceColumn})` + : column.referenceColumn ?? "—"; + })() + : column.referenceColumn ?? "—" + } + > {column.referenceColumn || "—"} diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index c80cb581..d2f13c79 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, Suspense, useEffect } from "react"; +import { useState, Suspense, useEffect, useCallback } from "react"; import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { Button } from "@/components/ui/button"; import { @@ -341,6 +341,10 @@ function AppLayoutInner({ children }: AppLayoutProps) { const currentMenus = isAdminMode ? adminMenus : userMenus; + const currentTabs = useTabStore((s) => s[s.mode].tabs); + const currentActiveTabId = useTabStore((s) => s[s.mode].activeTabId); + const activeTab = currentTabs.find((t) => t.id === currentActiveTabId); + const toggleMenu = (menuId: string) => { const newExpanded = new Set(expandedMenus); if (newExpanded.has(menuId)) { @@ -478,6 +482,26 @@ function AppLayoutInner({ children }: AppLayoutProps) { } }; + // pathname + 활성 탭 기반 활성 메뉴 판별 (탭 네비게이션에서도 사이드바 활성 표시) + const isMenuActive = useCallback( + (menu: any): boolean => { + if (pathname === menu.url) return true; + if (!activeTab) return false; + + const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0"); + + if (activeTab.type === "admin" && activeTab.adminUrl) { + return menu.url === activeTab.adminUrl; + } + if (activeTab.type === "screen") { + if (activeTab.menuObjid != null && menuObjid === activeTab.menuObjid) return true; + if (activeTab.screenId != null && menu.screenId === activeTab.screenId) return true; + } + return false; + }, + [pathname, activeTab], + ); + // 메뉴 트리 렌더링 (기존 MainLayout 스타일 적용) const renderMenu = (menu: any, level: number = 0) => { const isExpanded = expandedMenus.has(menu.id); @@ -489,8 +513,8 @@ function AppLayoutInner({ children }: AppLayoutProps) { draggable={isLeaf} onDragStart={(e) => handleMenuDragStart(e, menu)} className={`group flex min-h-[44px] cursor-pointer items-center justify-between rounded-md px-3 py-2 text-sm font-medium transition-colors duration-150 ease-in-out sm:min-h-[40px] ${ - pathname === menu.url - ? "border-primary bg-primary/8 text-primary border-l-3 font-semibold" + isMenuActive(menu) + ? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold" : isExpanded ? "bg-accent/60 text-foreground" : "text-muted-foreground hover:bg-accent hover:text-foreground" @@ -518,8 +542,8 @@ function AppLayoutInner({ children }: AppLayoutProps) { draggable={!child.hasChildren} onDragStart={(e) => handleMenuDragStart(e, child)} className={`flex min-h-[44px] cursor-pointer items-center rounded-md px-3 py-2 text-sm transition-colors duration-150 hover:cursor-pointer sm:min-h-[40px] ${ - pathname === child.url - ? "border-primary bg-primary/8 text-primary border-l-3 font-semibold" + isMenuActive(child) + ? "border-l-[3px] border-l-primary bg-primary/10 dark:bg-primary/15 text-primary font-semibold" : "text-muted-foreground hover:bg-accent hover:text-foreground" }`} onClick={() => handleMenuClick(child)} @@ -557,6 +581,28 @@ function AppLayoutInner({ children }: AppLayoutProps) { const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo); + // 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장 + useEffect(() => { + if (!activeTab || uiMenus.length === 0) return; + + const toExpand: string[] = []; + for (const menu of uiMenus) { + if (menu.hasChildren && menu.children) { + const hasActiveChild = menu.children.some((child: any) => isMenuActive(child)); + if (hasActiveChild && !expandedMenus.has(menu.id)) { + toExpand.push(menu.id); + } + } + } + if (toExpand.length > 0) { + setExpandedMenus((prev) => { + const next = new Set(prev); + toExpand.forEach((id) => next.add(id)); + return next; + }); + } + }, [activeTab, uiMenus, isMenuActive, expandedMenus]); + return (
{/* 모바일 헤더 */} diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index 2aed73fd..1b1bf0a5 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -5,6 +5,7 @@ import { apiClient } from "@/lib/api/client"; import { getCategoryValues } from "@/lib/api/tableCategoryValue"; import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; export interface CategoryColumn { tableName: string; diff --git a/frontend/components/ui/tabs.tsx b/frontend/components/ui/tabs.tsx index 95fdc082..568d44ed 100644 --- a/frontend/components/ui/tabs.tsx +++ b/frontend/components/ui/tabs.tsx @@ -26,7 +26,7 @@ function TabsList({ className={cn( "px-3 py-1 text-sm font-medium transition-colors", activeTabIndex === 0 - ? "text-foreground border-b-2 border-primary" - : "text-muted-foreground hover:text-foreground" + ? "text-primary border-b-2 border-primary font-semibold bg-primary/5" + : "text-foreground/70 hover:text-foreground hover:bg-muted/30" )} > {componentConfig.rightPanel?.title || "기본"} @@ -3994,8 +3994,8 @@ export const SplitPanelLayoutComponent: React.FC className={cn( "px-3 py-1 text-sm font-medium transition-colors", activeTabIndex === index + 1 - ? "text-foreground border-b-2 border-primary" - : "text-muted-foreground hover:text-foreground" + ? "text-primary border-b-2 border-primary font-semibold bg-primary/5" + : "text-foreground/70 hover:text-foreground hover:bg-muted/30" )} > {tab.label || `탭 ${index + 1}`} diff --git a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx index ac6b208e..03de3cc1 100644 --- a/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx +++ b/frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx @@ -48,8 +48,8 @@ const TabsDesignEditor: React.FC<{ return cn( "px-4 py-2 text-sm font-medium cursor-pointer transition-colors", isActive - ? "bg-background border-b-2 border-primary text-primary" - : "text-muted-foreground hover:text-foreground hover:bg-muted/50" + ? "bg-primary/10 border-b-2 border-primary text-primary font-semibold" + : "text-foreground/70 hover:text-foreground hover:bg-muted/50" ); }; @@ -283,7 +283,7 @@ const TabsDesignEditor: React.FC<{ return (
{/* 탭 헤더 */} -
+
{tabs.length > 0 ? ( tabs.map((tab) => (
{/* 탭 헤더 */} -
+
{tabs.length > 0 ? ( tabs.map((tab) => (
Date: Tue, 17 Mar 2026 18:17:51 +0900 Subject: [PATCH 13/30] [agent-pipeline] pipe-20260317084014-ydap round-2 --- frontend/components/layout/AppLayout.tsx | 60 ++++++++++--------- .../SplitPanelLayoutComponent.tsx | 4 +- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index d2f13c79..f206b718 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -490,12 +490,12 @@ function AppLayoutInner({ children }: AppLayoutProps) { const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0"); - if (activeTab.type === "admin" && activeTab.adminUrl) { + if (activeTab?.type === "admin" && activeTab?.adminUrl) { return menu.url === activeTab.adminUrl; } - if (activeTab.type === "screen") { - if (activeTab.menuObjid != null && menuObjid === activeTab.menuObjid) return true; - if (activeTab.screenId != null && menu.screenId === activeTab.screenId) return true; + if (activeTab?.type === "screen") { + if (activeTab?.menuObjid != null && menuObjid === activeTab.menuObjid) return true; + if (activeTab?.screenId != null && menu.screenId === activeTab.screenId) return true; } return false; }, @@ -562,6 +562,34 @@ function AppLayoutInner({ children }: AppLayoutProps) { ); }; + const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : []; + + // 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장 (기존 확장 상태 유지, 추가만 함) + useEffect(() => { + if (!activeTab || uiMenus.length === 0) return; + + const toExpand: string[] = []; + for (const menu of uiMenus) { + if (menu.hasChildren && menu.children) { + const hasActiveChild = menu.children.some((child: any) => isMenuActive(child)); + if (hasActiveChild) toExpand.push(menu.id); + } + } + if (toExpand.length > 0) { + setExpandedMenus((prev) => { + const next = new Set(prev); + let changed = false; + toExpand.forEach((id) => { + if (!next.has(id)) { + next.add(id); + changed = true; + } + }); + return changed ? next : prev; + }); + } + }, [activeTab, uiMenus, isMenuActive]); + if (isPreviewMode) { return (
{children}
@@ -579,30 +607,6 @@ function AppLayoutInner({ children }: AppLayoutProps) { ); } - const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo); - - // 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장 - useEffect(() => { - if (!activeTab || uiMenus.length === 0) return; - - const toExpand: string[] = []; - for (const menu of uiMenus) { - if (menu.hasChildren && menu.children) { - const hasActiveChild = menu.children.some((child: any) => isMenuActive(child)); - if (hasActiveChild && !expandedMenus.has(menu.id)) { - toExpand.push(menu.id); - } - } - } - if (toExpand.length > 0) { - setExpandedMenus((prev) => { - const next = new Set(prev); - toExpand.forEach((id) => next.add(id)); - return next; - }); - } - }, [activeTab, uiMenus, isMenuActive, expandedMenus]); - return (
{/* 모바일 헤더 */} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 28acdbe6..4527a85f 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -21,7 +21,7 @@ import { Move, FileSpreadsheet, List, - LayoutPanelRight, + PanelRight, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; @@ -3972,7 +3972,7 @@ export const SplitPanelLayoutComponent: React.FC >
- + {/* 탭이 없으면 제목만, 있으면 탭으로 전환 (2px primary 밑줄 인디케이터) */} {(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
From ad48b22770d0949d712991955a6f551c04e33a1c Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 18:17:51 +0900 Subject: [PATCH 14/30] [agent-pipeline] rollback to d3acf391 --- frontend/components/layout/AppLayout.tsx | 60 +++++++++---------- .../SplitPanelLayoutComponent.tsx | 4 +- 2 files changed, 30 insertions(+), 34 deletions(-) diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index f206b718..d2f13c79 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -490,12 +490,12 @@ function AppLayoutInner({ children }: AppLayoutProps) { const menuObjid = parseInt((menu.objid || menu.id)?.toString() || "0"); - if (activeTab?.type === "admin" && activeTab?.adminUrl) { + if (activeTab.type === "admin" && activeTab.adminUrl) { return menu.url === activeTab.adminUrl; } - if (activeTab?.type === "screen") { - if (activeTab?.menuObjid != null && menuObjid === activeTab.menuObjid) return true; - if (activeTab?.screenId != null && menu.screenId === activeTab.screenId) return true; + if (activeTab.type === "screen") { + if (activeTab.menuObjid != null && menuObjid === activeTab.menuObjid) return true; + if (activeTab.screenId != null && menu.screenId === activeTab.screenId) return true; } return false; }, @@ -562,34 +562,6 @@ function AppLayoutInner({ children }: AppLayoutProps) { ); }; - const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : []; - - // 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장 (기존 확장 상태 유지, 추가만 함) - useEffect(() => { - if (!activeTab || uiMenus.length === 0) return; - - const toExpand: string[] = []; - for (const menu of uiMenus) { - if (menu.hasChildren && menu.children) { - const hasActiveChild = menu.children.some((child: any) => isMenuActive(child)); - if (hasActiveChild) toExpand.push(menu.id); - } - } - if (toExpand.length > 0) { - setExpandedMenus((prev) => { - const next = new Set(prev); - let changed = false; - toExpand.forEach((id) => { - if (!next.has(id)) { - next.add(id); - changed = true; - } - }); - return changed ? next : prev; - }); - } - }, [activeTab, uiMenus, isMenuActive]); - if (isPreviewMode) { return (
{children}
@@ -607,6 +579,30 @@ function AppLayoutInner({ children }: AppLayoutProps) { ); } + const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo); + + // 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장 + useEffect(() => { + if (!activeTab || uiMenus.length === 0) return; + + const toExpand: string[] = []; + for (const menu of uiMenus) { + if (menu.hasChildren && menu.children) { + const hasActiveChild = menu.children.some((child: any) => isMenuActive(child)); + if (hasActiveChild && !expandedMenus.has(menu.id)) { + toExpand.push(menu.id); + } + } + } + if (toExpand.length > 0) { + setExpandedMenus((prev) => { + const next = new Set(prev); + toExpand.forEach((id) => next.add(id)); + return next; + }); + } + }, [activeTab, uiMenus, isMenuActive, expandedMenus]); + return (
{/* 모바일 헤더 */} diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 4527a85f..28acdbe6 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -21,7 +21,7 @@ import { Move, FileSpreadsheet, List, - PanelRight, + LayoutPanelRight, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; @@ -3972,7 +3972,7 @@ export const SplitPanelLayoutComponent: React.FC >
- + {/* 탭이 없으면 제목만, 있으면 탭으로 전환 (2px primary 밑줄 인디케이터) */} {(componentConfig.rightPanel?.additionalTabs?.length || 0) > 0 ? (
From cfd7ee9fcedb147b3d21e4aa09b257129d6a73ad Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 18:25:36 +0900 Subject: [PATCH 15/30] [agent-pipeline] pipe-20260317084014-ydap round-3 --- frontend/hooks/useLogin.ts | 62 +++++++++++--------------------------- 1 file changed, 17 insertions(+), 45 deletions(-) diff --git a/frontend/hooks/useLogin.ts b/frontend/hooks/useLogin.ts index bd0cf9a2..01231441 100644 --- a/frontend/hooks/useLogin.ts +++ b/frontend/hooks/useLogin.ts @@ -2,12 +2,13 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; -import { LoginFormData, LoginResponse } from "@/types/auth"; +import { LoginFormData } from "@/types/auth"; import { AUTH_CONFIG, FORM_VALIDATION } from "@/constants/auth"; -import { API_BASE_URL } from "@/lib/api/client"; +import { apiCall } from "@/lib/api/client"; /** * 로그인 관련 비즈니스 로직을 관리하는 커스텀 훅 + * API 호출은 lib/api/client의 apiCall(Axios) 사용 (fetch 직접 사용 금지) */ export const useLogin = () => { const router = useRouter(); @@ -73,67 +74,34 @@ export const useLogin = () => { }, [formData]); /** - * API 호출 공통 함수 - */ - const apiCall = useCallback(async (endpoint: string, options: RequestInit = {}): Promise => { - // 로컬 스토리지에서 토큰 가져오기 - const token = localStorage.getItem("authToken"); - - const response = await fetch(`${API_BASE_URL}${endpoint}`, { - credentials: "include", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - ...(token && { Authorization: `Bearer ${token}` }), - ...options.headers, - }, - ...options, - }); - - const result = await response.json(); - return result; - }, []); - - /** - * 기존 인증 상태 확인 + * 기존 인증 상태 확인 (apiCall 사용) */ const checkExistingAuth = useCallback(async () => { try { - // 로컬 스토리지에서 토큰 확인 const token = localStorage.getItem("authToken"); - if (!token) { - // 토큰이 없으면 로그인 페이지 유지 - return; - } + if (!token) return; - // 토큰이 있으면 API 호출로 유효성 확인 - const result = await apiCall(AUTH_CONFIG.ENDPOINTS.STATUS); + const result = await apiCall<{ isAuthenticated?: boolean }>("GET", AUTH_CONFIG.ENDPOINTS.STATUS); - // 백엔드가 isAuthenticated 필드를 반환함 if (result.success && result.data?.isAuthenticated) { - // 이미 로그인된 경우 메인으로 리다이렉트 router.push(AUTH_CONFIG.ROUTES.MAIN); } else { - // 토큰이 유효하지 않으면 제거 localStorage.removeItem("authToken"); document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; } - } catch (error) { - // 에러가 발생하면 토큰 제거 + } catch { localStorage.removeItem("authToken"); document.cookie = "authToken=; path=/; max-age=0; SameSite=Lax"; - console.debug("기존 인증 체크 중 오류 (정상):", error); } - }, [apiCall, router]); + }, [router]); /** - * 로그인 처리 + * 로그인 처리 (apiCall 사용 - Axios 기반, fetch 미사용) */ const handleLogin = useCallback( async (e: React.FormEvent) => { e.preventDefault(); - // 입력값 검증 const validationError = validateForm(); if (validationError) { setError(validationError); @@ -144,9 +112,13 @@ export const useLogin = () => { setError(""); try { - const result = await apiCall(AUTH_CONFIG.ENDPOINTS.LOGIN, { - method: "POST", - body: JSON.stringify(formData), + const result = await apiCall<{ + token?: string; + firstMenuPath?: string; + popLandingPath?: string; + }>("POST", AUTH_CONFIG.ENDPOINTS.LOGIN, { + userId: formData.userId, + password: formData.password, }); if (result.success && result.data?.token) { @@ -185,7 +157,7 @@ export const useLogin = () => { setIsLoading(false); } }, - [formData, validateForm, apiCall, router, isPopMode], + [formData, validateForm, router, isPopMode], ); // 컴포넌트 마운트 시 기존 인증 상태 확인 From 7e34b7bf35268bfafbee704ae226aa49abdcbb07 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 21:41:44 +0900 Subject: [PATCH 16/30] Merge branch 'mhkim-node' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node --- .gitignore | 3 +++ .../components/common/ExcelUploadModal.tsx | 21 +++++++++++++------ .../common/MultiTableExcelUploadModal.tsx | 19 +++++++++++++---- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 197ad216..fb843160 100644 --- a/.gitignore +++ b/.gitignore @@ -191,3 +191,6 @@ mcp-task-queue/ .cursor/rules/multi-agent-tester.mdc .cursor/rules/multi-agent-reviewer.mdc .cursor/rules/multi-agent-knowledge.mdc + +# 파이프라인 회고록 (자동 생성) +docs/retrospectives/ diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index d3a4b187..601d44fc 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -97,10 +97,23 @@ export interface ExcelUploadModalProps { interface ColumnMapping { excelColumn: string; systemColumn: string | null; - // 중복 체크 설정 (해당 컬럼을 중복 체크 키로 사용할지) checkDuplicate?: boolean; } +function flattenCategoryValues( + values: Array<{ valueCode: string; valueLabel: string; children?: any[] }> +): Array<{ valueCode: string; valueLabel: string }> { + const result: Array<{ valueCode: string; valueLabel: string }> = []; + const traverse = (items: any[]) => { + for (const item of items) { + result.push({ valueCode: item.valueCode, valueLabel: item.valueLabel }); + if (item.children?.length > 0) traverse(item.children); + } + }; + traverse(values); + return result; +} + export const ExcelUploadModal: React.FC = ({ open, onOpenChange, @@ -681,12 +694,8 @@ export const ExcelUploadModal: React.FC = ({ const valuesResponse = await getCategoryValues(targetTableName, catCol.systemCol); if (!valuesResponse.success || !valuesResponse.data) continue; - const validValues = valuesResponse.data as Array<{ - valueCode: string; - valueLabel: string; - }>; + const validValues = flattenCategoryValues(valuesResponse.data as any[]); - // 유효한 코드와 라벨 Set 생성 const validCodes = new Set(validValues.map((v) => v.valueCode)); const validLabels = new Set(validValues.map((v) => v.valueLabel)); const validLabelsLower = new Set(validValues.map((v) => v.valueLabel.toLowerCase())); diff --git a/frontend/components/common/MultiTableExcelUploadModal.tsx b/frontend/components/common/MultiTableExcelUploadModal.tsx index f6d11ae9..9541a13d 100644 --- a/frontend/components/common/MultiTableExcelUploadModal.tsx +++ b/frontend/components/common/MultiTableExcelUploadModal.tsx @@ -51,6 +51,20 @@ interface ColumnMapping { targetColumn: string | null; } +function flattenCategoryValues( + values: Array<{ valueCode: string; valueLabel: string; children?: any[] }> +): Array<{ valueCode: string; valueLabel: string }> { + const result: Array<{ valueCode: string; valueLabel: string }> = []; + const traverse = (items: any[]) => { + for (const item of items) { + result.push({ valueCode: item.valueCode, valueLabel: item.valueLabel }); + if (item.children?.length > 0) traverse(item.children); + } + }; + traverse(values); + return result; +} + export const MultiTableExcelUploadModal: React.FC = ({ open, onOpenChange, @@ -356,10 +370,7 @@ export const MultiTableExcelUploadModal: React.FC; + const validValues = flattenCategoryValues(valuesResponse.data as any[]); const validCodes = new Set(validValues.map((v) => v.valueCode)); const validLabels = new Set(validValues.map((v) => v.valueLabel)); From b293d184bb23eaceab6b446f7b2fcb06f84cccdc Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 21:50:37 +0900 Subject: [PATCH 17/30] 11 --- .../admin/table-type/ColumnDetailPanel.tsx | 99 ++++++++++++------- frontend/components/layout/AppLayout.tsx | 24 ++--- frontend/components/layout/TabBar.tsx | 4 +- .../V2SplitPanelLayoutConfigPanel.tsx | 14 ++- .../SplitPanelLayoutComponent.tsx | 91 ++++++++--------- .../v2-table-list/SingleTableWithSticky.tsx | 27 ++--- .../v2-table-list/TableListComponent.tsx | 97 ++++++++++++------ .../v2-tabs-widget/tabs-component.tsx | 4 +- 8 files changed, 219 insertions(+), 141 deletions(-) diff --git a/frontend/components/admin/table-type/ColumnDetailPanel.tsx b/frontend/components/admin/table-type/ColumnDetailPanel.tsx index 5e4e2a07..c462d0ff 100644 --- a/frontend/components/admin/table-type/ColumnDetailPanel.tsx +++ b/frontend/components/admin/table-type/ColumnDetailPanel.tsx @@ -76,18 +76,34 @@ export function ColumnDetailPanel({ if (!column) return null; - const refTableOpts = referenceTableOptions.length - ? referenceTableOptions - : [ - { value: "none", label: "선택 안함" }, - ...tables.map((t) => ({ - value: t.tableName, - label: - t.displayName && t.displayName !== t.tableName - ? `${t.displayName} (${t.tableName})` - : t.tableName, - })), - ]; + const refTableOpts = useMemo(() => { + const hasKorean = (s: string) => /[가-힣]/.test(s); + const raw = referenceTableOptions.length + ? [...referenceTableOptions] + : [ + { value: "none", label: "없음" }, + ...tables.map((t) => ({ + value: t.tableName, + label: + t.displayName && t.displayName !== t.tableName + ? `${t.displayName} (${t.tableName})` + : t.tableName, + })), + ]; + + const noneOpt = raw.find((o) => o.value === "none"); + const rest = raw.filter((o) => o.value !== "none"); + + rest.sort((a, b) => { + const aK = hasKorean(a.label); + const bK = hasKorean(b.label); + if (aK && !bK) return -1; + if (!aK && bK) return 1; + return a.label.localeCompare(b.label, "ko"); + }); + + return noneOpt ? [noneOpt, ...rest] : rest; + }, [referenceTableOptions, tables]); return (
@@ -183,23 +199,33 @@ export function ColumnDetailPanel({ 테이블을 찾을 수 없습니다. - {refTableOpts.map((opt) => ( - { - onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value); - if (opt.value !== "none") onLoadReferenceColumns?.(opt.value); - setEntityTableOpen(false); - }} - className="text-xs" - > - - {opt.label} - - ))} + {refTableOpts.map((opt) => { + const hasKorean = opt.value !== "none" && opt.label !== opt.value && !opt.label.startsWith(opt.value); + return ( + { + onColumnChange("referenceTable", opt.value === "none" ? undefined : opt.value); + if (opt.value !== "none") onLoadReferenceColumns?.(opt.value); + setEntityTableOpen(false); + }} + className="text-xs" + > + + {hasKorean ? ( +
+ {opt.label.replace(` (${opt.value})`, "")} + {opt.value} +
+ ) : ( + opt.label + )} +
+ ); + })}
@@ -263,13 +289,14 @@ export function ColumnDetailPanel({ column.referenceColumn === refCol.columnName ? "opacity-100" : "opacity-0", )} /> -
- - {refCol.displayName && refCol.displayName !== refCol.columnName - ? `${refCol.displayName} (${refCol.columnName})` - : refCol.columnName} - -
+ {refCol.displayName && refCol.displayName !== refCol.columnName ? ( +
+ {refCol.displayName} + {refCol.columnName} +
+ ) : ( + {refCol.columnName} + )} ))} diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index d2f13c79..2014d535 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -568,18 +568,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { ); } - if (!user) { - return ( -
-
-
-

로딩중...

-
-
- ); - } - - const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo); + const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : []; // 활성 탭에 해당하는 메뉴가 속한 부모 메뉴 자동 확장 useEffect(() => { @@ -603,6 +592,17 @@ function AppLayoutInner({ children }: AppLayoutProps) { } }, [activeTab, uiMenus, isMenuActive, expandedMenus]); + if (!user) { + return ( +
+
+
+

로딩중...

+
+
+ ); + } + return (
{/* 모바일 헤더 */} diff --git a/frontend/components/layout/TabBar.tsx b/frontend/components/layout/TabBar.tsx index e86ada2e..1ac5144e 100644 --- a/frontend/components/layout/TabBar.tsx +++ b/frontend/components/layout/TabBar.tsx @@ -493,8 +493,8 @@ export function TabBar() { className={cn( "group relative flex h-7 shrink-0 cursor-pointer items-center gap-0.5 rounded-t-md border border-b-0 px-3 select-none", isActive - ? "text-foreground z-10 -mb-px h-[30px] bg-white" - : "bg-muted/50 text-muted-foreground hover:bg-muted hover:text-foreground border-transparent", + ? "text-primary z-10 -mb-px h-[30px] bg-primary/15 dark:bg-primary/20 border-primary/40 border-t-[3px] border-t-primary font-semibold" + : "bg-transparent text-muted-foreground hover:bg-muted/50 hover:text-foreground border-transparent", )} style={{ width: TAB_WIDTH, diff --git a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx index ae5679b6..97ff71a3 100644 --- a/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SplitPanelLayoutConfigPanel.tsx @@ -1552,16 +1552,22 @@ export const V2SplitPanelLayoutConfigPanel: React.FC< /> - updateRightPanel({ showEdit: checked }) + updateRightPanel({ + showEdit: checked, + editButton: { ...config.rightPanel?.editButton!, enabled: checked }, + }) } /> - updateRightPanel({ showDelete: checked }) + updateRightPanel({ + showDelete: checked, + deleteButton: { ...config.rightPanel?.deleteButton!, enabled: checked }, + }) } />
diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 28acdbe6..0c585587 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -21,7 +21,7 @@ import { Move, FileSpreadsheet, List, - LayoutPanelRight, + PanelRight, } from "lucide-react"; import { dataApi } from "@/lib/api/data"; import { entityJoinApi } from "@/lib/api/entityJoin"; @@ -3524,7 +3524,7 @@ export const SplitPanelLayoutComponent: React.FC {columnsToShow.map((col, idx) => (
+
+
); })} {hasTabActions && ( - 작업작업
+ {col.type === "progress" ? renderProgressCell(col, item, selectedLeftItem) : formatCellValue( @@ -4256,7 +4256,7 @@ export const SplitPanelLayoutComponent: React.FC ); })} {hasTabActions && ( - 작업작업
+ {col.type === "progress" ? renderProgressCell(col, item, selectedLeftItem) : formatCellValue( @@ -4670,7 +4670,7 @@ export const SplitPanelLayoutComponent: React.FC + {(() => { + const rightEditVisible = (componentConfig.rightPanel?.showEdit ?? componentConfig.rightPanel?.editButton?.enabled) !== false; + const rightDeleteVisible = (componentConfig.rightPanel?.showDelete ?? componentConfig.rightPanel?.deleteButton?.enabled) !== false; + return !isDesignMode && (rightEditVisible || rightDeleteVisible) ? ( + 작업
{col.type === "progress" @@ -4722,12 +4725,10 @@ export const SplitPanelLayoutComponent: React.FC + {!isDesignMode && (rightEditVisible || rightDeleteVisible) && ( +
- {(componentConfig.rightPanel?.editButton?.enabled ?? true) && ( + {rightEditVisible && ( )} - {(componentConfig.rightPanel?.deleteButton?.enabled ?? true) && ( + {rightDeleteVisible && (
{col.label} 작업작업
+ {formatCellValue( col.name, getEntityJoinValue(item, col.name), @@ -4865,7 +4866,7 @@ export const SplitPanelLayoutComponent: React.FC +
{hasEditButton && (
- {/* 🆕 배치 편집 툴바 */} + {/* 필터 칩 바 */} + {filterGroups.length > 0 && filterGroups.some(g => g.conditions.some(c => c.column && c.value)) && ( +
+ {filterGroups.flatMap(group => + group.conditions + .filter(c => c.column && c.value) + .map(condition => { + const label = columnLabels[condition.column] || condition.column; + const opLabel = condition.operator === "equals" ? "=" : condition.operator === "contains" ? "⊃" : condition.operator === "notEquals" ? "≠" : condition.operator === "startsWith" ? "^" : condition.operator === "endsWith" ? "$" : condition.operator === "greaterThan" ? ">" : condition.operator === "lessThan" ? "<" : condition.operator; + return ( + + {label} {opLabel} {condition.value} + + + ); + }) + )} + +
+ )} + + {/* 배치 편집 툴바 */} {(editMode === "batch" || pendingChanges.size > 0) && (
@@ -5826,9 +5861,9 @@ export const TableListComponent: React.FC = ({
= ({
@@ -4120,7 +4153,7 @@ export const SplitPanelLayoutComponent: React.FC ); @@ -4158,7 +4192,7 @@ export const SplitPanelLayoutComponent: React.FC toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} > @@ -4243,7 +4277,7 @@ export const SplitPanelLayoutComponent: React.FC // showInSummary가 false가 아닌 것만 메인 테이블에 표시 const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); const listTabIndex = activeTabIndex - 1; - const canDragListTabColumns = isDesignMode && listSummaryColumns.length > 0 && !!onUpdateComponent; + const canDragListTabColumns = listSummaryColumns.length > 0; return (
onDragEnd={handleRightColumnDragEnd} onDrop={(e) => canDragTabColumns && handleRightColumnDrop(e, idx, tabIndex)} > + {canDragTabColumns && } {col.label || col.name}
@@ -4256,7 +4290,7 @@ export const SplitPanelLayoutComponent: React.FC ); @@ -4293,7 +4328,7 @@ export const SplitPanelLayoutComponent: React.FC toggleRightItemExpansion(`tab_${activeTabIndex}_${tabItemId}`)} > @@ -4646,6 +4681,14 @@ export const SplitPanelLayoutComponent: React.FC })); } + // 런타임 컬럼 순서 적용 + if (!isDesignMode && runtimeColumnOrder["main"]) { + const keyColCount = columnsToShow.filter((c: any) => c._isKeyColumn).length; + const keyCols = columnsToShow.slice(0, keyColCount); + const dataCols = columnsToShow.slice(keyColCount); + columnsToShow = [...keyCols, ...applyRuntimeOrder(dataCols, "main")]; + } + // 컬럼 너비 합계 계산 (작업 컬럼 제외, 100% 초과 시 스크롤) const rightTotalColWidth = columnsToShow.reduce((sum, col) => { const w = col.width && col.width <= 100 ? col.width : 0; @@ -4653,7 +4696,7 @@ export const SplitPanelLayoutComponent: React.FC }, 0); const rightConfigColumnStart = columnsToShow.filter((c: any) => c._isKeyColumn).length; - const canDragRightColumns = isDesignMode && displayColumns.length > 0 && !!onUpdateComponent; + const canDragRightColumns = displayColumns.length > 0; return (
@@ -4670,7 +4713,7 @@ export const SplitPanelLayoutComponent: React.FC
); @@ -4707,7 +4751,7 @@ export const SplitPanelLayoutComponent: React.FC const rightDeleteVisible = (componentConfig.rightPanel?.showDelete ?? componentConfig.rightPanel?.deleteButton?.enabled) !== false; return ( - + {columnsToShow.map((col, colIdx) => ( toggleRightItemExpansion(itemId)} > diff --git a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx index 00daa8eb..c264dec2 100644 --- a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx @@ -110,7 +110,7 @@ export const SingleTableWithSticky: React.FC = ({ > {actualColumns.map((column, colIndex) => { @@ -136,7 +136,7 @@ export const SingleTableWithSticky: React.FC = ({ ? "h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2" : "text-muted-foreground hover:text-foreground h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-[10px] font-bold uppercase tracking-[0.04em] whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-xs", `text-${column.align}`, - column.sortable && "hover:bg-muted/70", + column.sortable && "hover:bg-muted/50", // 고정 컬럼 스타일 column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm", column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm", @@ -151,7 +151,7 @@ export const SingleTableWithSticky: React.FC = ({ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", // 텍스트 줄바꿈 방지 - backgroundColor: "hsl(var(--muted) / 0.8)", + backgroundColor: "hsl(var(--muted) / 0.4)", // sticky 위치 설정 ...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }), @@ -230,7 +230,7 @@ export const SingleTableWithSticky: React.FC = ({ key={`row-${index}`} className={cn( "cursor-pointer border-b border-border/50 transition-[background] duration-75", - index % 2 === 0 ? "bg-background" : "bg-muted/70", + index % 2 === 0 ? "bg-background" : "bg-muted/20", tableConfig.tableStyle?.hoverEffect !== false && "hover:bg-accent", )} onClick={(e) => handleRowClick?.(row, index, e)} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index f9e39810..7331c7bd 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -5819,8 +5819,8 @@ export const TableListComponent: React.FC = ({ {/* 🆕 Multi-Level Headers (Column Bands) */} {columnBandsInfo?.hasBands && ( {visibleColumns.map((column, colIdx) => { // 이 컬럼이 속한 band 찾기 @@ -5863,7 +5863,7 @@ export const TableListComponent: React.FC = ({ {visibleColumns.map((column, columnIndex) => { @@ -5895,7 +5895,7 @@ export const TableListComponent: React.FC = ({ column.columnName === "__checkbox__" ? "px-0 py-1" : "px-3 py-2", column.sortable !== false && column.columnName !== "__checkbox__" && - "hover:text-foreground hover:bg-muted/70 cursor-pointer transition-colors", + "hover:text-foreground hover:bg-muted/50 cursor-pointer transition-colors", sortColumn === column.columnName && "!text-primary", isFrozen && "sticky z-40 shadow-[2px_0_4px_rgba(0,0,0,0.1)]", // 🆕 Column Reordering 스타일 @@ -5916,7 +5916,7 @@ export const TableListComponent: React.FC = ({ minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, userSelect: "none", - backgroundColor: "hsl(var(--muted) / 0.8)", + backgroundColor: "hsl(var(--muted) / 0.4)", ...(isFrozen && { left: `${leftPosition}px` }), }} // 🆕 Column Reordering 이벤트 @@ -6167,7 +6167,7 @@ export const TableListComponent: React.FC = ({ key={index} className={cn( "hover:bg-accent cursor-pointer border-b border-border/50 transition-[background] duration-75", - index % 2 === 0 ? "bg-background" : "bg-muted/70", + index % 2 === 0 ? "bg-background" : "bg-muted/20", )} onClick={(e) => handleRowClick(row, index, e)} > @@ -6306,9 +6306,8 @@ export const TableListComponent: React.FC = ({ key={index} className={cn( "hover:bg-accent cursor-pointer border-b border-border/50 transition-[background] duration-75", - index % 2 === 0 ? "bg-background" : "bg-muted/70", - isRowSelected && "!bg-primary/15 hover:!bg-primary/20", - isRowSelected && "[&_td]:!border-b-primary/30", + index % 2 === 0 ? "bg-background" : "bg-muted/20", + isRowSelected && "!bg-primary/10 hover:!bg-primary/15", isRowFocused && "ring-primary/50 ring-1 ring-inset", isDragEnabled && "cursor-grab active:cursor-grabbing", isDragging && "bg-muted opacity-50", @@ -6566,7 +6565,7 @@ export const TableListComponent: React.FC = ({ : undefined, ...(isFrozen && { left: `${leftPosition}px`, - backgroundColor: "hsl(var(--muted) / 0.8)", + backgroundColor: "hsl(var(--muted) / 0.4)", }), }} > From 12d4d2a8b17283829d2c670e50cf293feeaf6359 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 22:06:13 +0900 Subject: [PATCH 19/30] 11 --- .../SplitPanelLayoutComponent.tsx | 63 ++++++++++++------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 40f00e1a..0b2002f9 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -318,7 +318,7 @@ export const SplitPanelLayoutComponent: React.FC const [rightDraggedColumnIndex, setRightDraggedColumnIndex] = useState(null); const [rightDropTargetColumnIndex, setRightDropTargetColumnIndex] = useState(null); const [rightDragSource, setRightDragSource] = useState<"main" | number | null>(null); - const [runtimeColumnOrder, setRuntimeColumnOrder] = useState>({}); + const [runtimeColumnOrder, setRuntimeColumnOrder] = useState>({}); // 데이터 상태 const [leftData, setLeftData] = useState([]); @@ -2597,17 +2597,33 @@ export const SplitPanelLayoutComponent: React.FC }); } } else { - // 런타임 모드: 로컬 상태로 순서 변경 + // 런타임 모드: 컬럼 이름 순서로 로컬 상태 관리 const key = String(source); - setRuntimeColumnOrder((prev) => { - const existing = prev[key]; - const maxLen = 100; - const order = existing || Array.from({ length: maxLen }, (_, i) => i); - const reordered = [...order]; - const [removed] = reordered.splice(fromIdx, 1); - reordered.splice(targetIndex, 0, removed); - return { ...prev, [key]: reordered }; - }); + const rightPanel = componentConfig.rightPanel || {}; + let colNames: string[] = []; + + if (source === "main") { + const allColumns = rightPanel.columns || []; + colNames = allColumns.filter((c: any) => c.showInSummary !== false).map((c: any) => c.name); + } else if (typeof source === "number") { + const tabs = rightPanel.additionalTabs || []; + const tabConfig = tabs[source]; + if (tabConfig?.columns) { + colNames = tabConfig.columns.filter((c: any) => c.showInSummary !== false).map((c: any) => c.name); + } + } + + if (colNames.length > 0) { + setRuntimeColumnOrder((prev) => { + const currentOrder = prev[key] || colNames; + const reordered = [...currentOrder]; + if (fromIdx >= 0 && fromIdx < reordered.length && targetIndex >= 0 && targetIndex < reordered.length) { + const [removed] = reordered.splice(fromIdx, 1); + reordered.splice(targetIndex, 0, removed); + } + return { ...prev, [key]: reordered }; + }); + } } handleRightColumnDragEnd(); }, @@ -2622,21 +2638,26 @@ export const SplitPanelLayoutComponent: React.FC ], ); - // 런타임 컬럼 순서 적용 헬퍼 + // 런타임 컬럼 순서 적용 헬퍼 (이름 기반) const applyRuntimeOrder = useCallback( - (columns: T[], source: "main" | number): T[] => { + (columns: any[], source: "main" | number): any[] => { const key = String(source); const order = runtimeColumnOrder[key]; - if (!order) return columns; - const result: T[] = []; - for (const idx of order) { - if (idx < columns.length) result.push(columns[idx]); + if (!order || order.length === 0) return columns; + const colMap = new Map(columns.map((col) => [col.name, col])); + const result: any[] = []; + for (const name of order) { + const col = colMap.get(name); + if (col) { + result.push(col); + colMap.delete(name); + } } - // order에 없는 나머지 컬럼 추가 - for (let i = 0; i < columns.length; i++) { - if (!order.includes(i)) result.push(columns[i]); + // order에 없는 나머지 컬럼 뒤에 추가 + for (const col of colMap.values()) { + result.push(col); } - return result.length > 0 ? result : columns; + return result; }, [runtimeColumnOrder], ); From f36638e53eafd3d2e1c4c0e7b1dc9fa39730833b Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 22:13:15 +0900 Subject: [PATCH 20/30] 22 --- .../v2-split-panel-layout/SplitPanelLayoutComponent.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 0b2002f9..8467154a 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -2547,7 +2547,7 @@ export const SplitPanelLayoutComponent: React.FC return; } - if (onUpdateComponent) { + if (isDesignMode && onUpdateComponent) { // 디자인 모드: config에 영구 저장 const rightPanel = componentConfig.rightPanel || {}; if (source === "main") { @@ -2633,6 +2633,7 @@ export const SplitPanelLayoutComponent: React.FC componentConfig, component, onUpdateComponent, + isDesignMode, handleRightColumnDragEnd, setRuntimeColumnOrder, ], From ba8a2fec2b1b2bb5bcc7f8b3aa51d31173b765e9 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 17 Mar 2026 22:24:47 +0900 Subject: [PATCH 21/30] Refactor RealtimePreviewDynamic and ResponsiveGridRenderer components to enhance rendering logic using CSS scale for improved layout consistency. Update SplitPanelLayoutComponent to handle drag events more effectively by passing the event object. This ensures better user interaction during column dragging. --- .../screen/RealtimePreviewDynamic.tsx | 4 +- .../screen/ResponsiveGridRenderer.tsx | 64 +++++++++++-------- .../SplitPanelLayoutComponent.tsx | 10 +-- 3 files changed, 46 insertions(+), 32 deletions(-) diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 14314a61..6330b8d4 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -592,10 +592,10 @@ const RealtimePreviewDynamicComponent: React.FC = ({ isResizing ? "none" : isOnSplitPanel ? (isDraggingSplitPanel ? "none" : "left 0.15s ease-out, width 0.15s ease-out") : undefined, } : { - // 런타임 모드: 부모(ResponsiveGridRenderer)가 위치/너비 관리 + // 런타임 모드: CSS scale 기반 - 캔버스 픽셀 크기 그대로 사용, 부모가 scale()로 축소 ...safeComponentStyle, width: "100%", - height: displayHeight, + height: "100%", position: "relative" as const, }; diff --git a/frontend/components/screen/ResponsiveGridRenderer.tsx b/frontend/components/screen/ResponsiveGridRenderer.tsx index 1322ee99..d90a0667 100644 --- a/frontend/components/screen/ResponsiveGridRenderer.tsx +++ b/frontend/components/screen/ResponsiveGridRenderer.tsx @@ -23,8 +23,9 @@ function getComponentTypeId(component: ComponentData): string { } /** - * 디자이너 절대좌표를 캔버스 대비 비율로 변환하여 렌더링. - * 화면이 줄어들면 비율에 맞게 축소, 늘어나면 확대. + * CSS transform scale 기반 렌더링. + * 디자이너와 동일하게 캔버스 해상도(px)로 레이아웃 후 CSS scale로 축소/확대. + * 텍스트, 패딩, 버튼 등 모든 요소가 균일하게 스케일링되어 WYSIWYG 보장. */ function ProportionalRenderer({ components, @@ -47,7 +48,7 @@ function ProportionalRenderer({ }, []); const topLevel = components.filter((c) => !c.parentId); - const ratio = containerW > 0 ? containerW / canvasWidth : 1; + const scale = containerW > 0 ? containerW / canvasWidth : 1; const maxBottom = topLevel.reduce((max, c) => { const bottom = c.position.y + (c.size?.height || 40); @@ -58,30 +59,41 @@ function ProportionalRenderer({
0 ? `${maxBottom * ratio}px` : "200px" }} + className="bg-background w-full overflow-hidden" + style={{ height: containerW > 0 ? `${maxBottom * scale}px` : "200px" }} > - {containerW > 0 && - topLevel.map((component) => { - const typeId = getComponentTypeId(component); - return ( -
- {renderComponent(component)} -
- ); - })} + {containerW > 0 && ( +
+ {topLevel.map((component) => { + const typeId = getComponentTypeId(component); + return ( +
+ {renderComponent(component)} +
+ ); + })} +
+ )}
); } diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index 8467154a..fc244a3d 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -2522,9 +2522,11 @@ export const SplitPanelLayoutComponent: React.FC // 우측 패널 컬럼 헤더 드래그 (디자인 모드에서 컬럼 순서 변경) const handleRightColumnDragStart = useCallback( - (columnIndex: number, source: "main" | number) => { + (e: React.DragEvent, columnIndex: number, source: "main" | number) => { setRightDraggedColumnIndex(columnIndex); setRightDragSource(source); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", `col-${source}-${columnIndex}`); }, [], ); @@ -4181,7 +4183,7 @@ export const SplitPanelLayoutComponent: React.FC isDragging && "opacity-50", )} draggable={canDragTabColumns} - onDragStart={() => canDragTabColumns && handleRightColumnDragStart(idx, tabIndex)} + onDragStart={(e) => canDragTabColumns && handleRightColumnDragStart(e, idx, tabIndex)} onDragOver={(e) => canDragTabColumns && handleRightColumnDragOver(e, idx)} onDragEnd={handleRightColumnDragEnd} onDrop={(e) => canDragTabColumns && handleRightColumnDrop(e, idx, tabIndex)} @@ -4318,7 +4320,7 @@ export const SplitPanelLayoutComponent: React.FC isDragging && "opacity-50", )} draggable={canDragListTabColumns} - onDragStart={() => canDragListTabColumns && handleRightColumnDragStart(idx, listTabIndex)} + onDragStart={(e) => canDragListTabColumns && handleRightColumnDragStart(e, idx, listTabIndex)} onDragOver={(e) => canDragListTabColumns && handleRightColumnDragOver(e, idx)} onDragEnd={handleRightColumnDragEnd} onDrop={(e) => canDragListTabColumns && handleRightColumnDrop(e, idx, listTabIndex)} @@ -4745,7 +4747,7 @@ export const SplitPanelLayoutComponent: React.FC textAlign: col.align || "left", }} draggable={isDraggable} - onDragStart={() => isDraggable && handleRightColumnDragStart(configColIndex, "main")} + onDragStart={(e) => isDraggable && handleRightColumnDragStart(e, configColIndex, "main")} onDragOver={(e) => isDraggable && handleRightColumnDragOver(e, configColIndex)} onDragEnd={handleRightColumnDragEnd} onDrop={(e) => isDraggable && handleRightColumnDrop(e, configColIndex, "main")} From 2772c2296c91c7e2f7e749be9b3d070e4e417f18 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 22:37:13 +0900 Subject: [PATCH 22/30] feat: enhance TableManagementPage and ExcelUploadModal for improved functionality - Added handling for unique and nullable column toggles in TableManagementPage, allowing for better column configuration. - Updated ExcelUploadModal to include depth and ancestors in valid options for category values, enhancing the categorization process. - Improved user feedback in ExcelUploadModal by clarifying success messages and ensuring proper handling of duplicate actions. - Refactored category value flattening logic to maintain depth and ancestor information, improving data structure for better usability. These enhancements aim to provide users with a more flexible and intuitive experience when managing table configurations and uploading Excel data. --- .../admin/systemMng/tableMngList/page.tsx | 14 + .../components/common/ExcelUploadModal.tsx | 243 +++++++++++++----- .../common/MultiTableExcelUploadModal.tsx | 134 ++++++---- 3 files changed, 284 insertions(+), 107 deletions(-) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 356d55c3..e3d97088 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -1586,6 +1586,20 @@ export default function TableManagementPage() { selectedColumn={selectedColumn} onSelectColumn={setSelectedColumn} onColumnChange={(columnName, field, value) => { + if (field === "isUnique") { + const currentColumn = columns.find((c) => c.columnName === columnName); + if (currentColumn) { + handleUniqueToggle(columnName, currentColumn.isUnique || "NO"); + } + return; + } + if (field === "isNullable") { + const currentColumn = columns.find((c) => c.columnName === columnName); + if (currentColumn) { + handleNullableToggle(columnName, currentColumn.isNullable || "YES"); + } + return; + } const idx = columns.findIndex((c) => c.columnName === columnName); if (idx >= 0) handleColumnChange(idx, field, value); }} diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 601d44fc..8698b270 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -29,7 +29,11 @@ import { Zap, Copy, Loader2, + Check, + ChevronsUpDown, } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Checkbox } from "@/components/ui/checkbox"; import { importFromExcel, getExcelSheetNames } from "@/lib/utils/excelExport"; import { DynamicFormApi } from "@/lib/api/dynamicForm"; @@ -100,17 +104,31 @@ interface ColumnMapping { checkDuplicate?: boolean; } +interface FlatCategoryValue { + valueCode: string; + valueLabel: string; + depth: number; + ancestors: string[]; +} + function flattenCategoryValues( values: Array<{ valueCode: string; valueLabel: string; children?: any[] }> -): Array<{ valueCode: string; valueLabel: string }> { - const result: Array<{ valueCode: string; valueLabel: string }> = []; - const traverse = (items: any[]) => { +): FlatCategoryValue[] { + const result: FlatCategoryValue[] = []; + const traverse = (items: any[], depth: number, ancestors: string[]) => { for (const item of items) { - result.push({ valueCode: item.valueCode, valueLabel: item.valueLabel }); - if (item.children?.length > 0) traverse(item.children); + result.push({ + valueCode: item.valueCode, + valueLabel: item.valueLabel, + depth, + ancestors, + }); + if (item.children?.length > 0) { + traverse(item.children, depth + 1, [...ancestors, item.valueLabel]); + } } }; - traverse(values); + traverse(values, 0, []); return result; } @@ -150,6 +168,9 @@ export const ExcelUploadModal: React.FC = ({ // 중복 처리 방법 (전역 설정) const [duplicateAction, setDuplicateAction] = useState<"overwrite" | "skip">("skip"); + // 검증 화면에서 DB 중복 처리 방법 (null이면 미선택 = 업로드 차단) + const [dbDuplicateAction, setDbDuplicateAction] = useState<"overwrite" | "skip" | null>(null); + // 엑셀 데이터 사전 검증 결과 const [isDataValidating, setIsDataValidating] = useState(false); const [validationResult, setValidationResult] = useState(null); @@ -162,7 +183,7 @@ export const ExcelUploadModal: React.FC = ({ Record; + validOptions: Array<{ code: string; label: string; depth: number; ancestors: string[] }>; rowIndices: number[]; }>> >({}); @@ -723,6 +744,8 @@ export const ExcelUploadModal: React.FC = ({ const options = validValues.map((v) => ({ code: v.valueCode, label: v.valueLabel, + depth: v.depth, + ancestors: v.ancestors, })); mismatches[`${catCol.systemCol}|||${catCol.displayName}`] = Array.from(invalidMap.entries()).map( @@ -795,8 +818,7 @@ export const ExcelUploadModal: React.FC = ({ setDisplayData(newData); setShowCategoryValidation(false); setCategoryMismatches({}); - toast.success("카테고리 값이 대체되었습니다."); - setCurrentStep(3); + toast.success("카테고리 값이 대체되었습니다. '다음'을 눌러 진행해주세요."); return true; }; @@ -890,6 +912,7 @@ export const ExcelUploadModal: React.FC = ({ } // 데이터 사전 검증 (NOT NULL 값 누락, UNIQUE 중복) + setDbDuplicateAction(null); setIsDataValidating(true); try { const { validateExcelData: validateExcel } = await import("@/lib/api/tableManagement"); @@ -1105,9 +1128,33 @@ export const ExcelUploadModal: React.FC = ({ const hasNumbering = !!numberingInfo; // 중복 체크 설정 확인 - const duplicateCheckMappings = columnMappings.filter( + let duplicateCheckMappings = columnMappings.filter( (m) => m.checkDuplicate && m.systemColumn ); + let effectiveDuplicateAction = duplicateAction; + + // 검증 화면에서 DB 중복 처리 방법을 선택한 경우, 유니크 컬럼을 자동으로 중복 체크에 추가 + if (dbDuplicateAction && validationResult?.uniqueInDbErrors && validationResult.uniqueInDbErrors.length > 0) { + effectiveDuplicateAction = dbDuplicateAction; + const uniqueColumns = new Set(validationResult.uniqueInDbErrors.map((e) => e.column)); + for (const colName of uniqueColumns) { + const alreadyAdded = duplicateCheckMappings.some((m) => { + const mapped = m.systemColumn?.includes(".") ? m.systemColumn.split(".")[1] : m.systemColumn; + return mapped === colName; + }); + if (!alreadyAdded) { + const mapping = columnMappings.find((m) => { + const mapped = m.systemColumn?.includes(".") ? m.systemColumn.split(".")[1] : m.systemColumn; + return mapped === colName; + }); + if (mapping) { + duplicateCheckMappings = [...duplicateCheckMappings, { ...mapping, checkDuplicate: true }]; + } + } + } + console.log(`📊 검증 화면 DB 중복 처리: ${dbDuplicateAction}, 체크 컬럼: ${[...uniqueColumns].join(", ")}`); + } + const hasDuplicateCheck = duplicateCheckMappings.length > 0; // 중복 체크를 위한 기존 데이터 조회 (중복 체크가 설정된 경우에만) @@ -1170,7 +1217,7 @@ export const ExcelUploadModal: React.FC = ({ if (existingDataMap.has(key)) { existingRow = existingDataMap.get(key); - if (duplicateAction === "skip") { + if (effectiveDuplicateAction === "skip") { shouldSkip = true; skipCount++; console.log(`⏭️ [행 ${rowIdx + 1}] 중복으로 건너뛰기: ${key}`); @@ -1352,6 +1399,7 @@ export const ExcelUploadModal: React.FC = ({ setSystemColumns([]); setColumnMappings([]); setDuplicateAction("skip"); + setDbDuplicateAction(null); // 검증 상태 초기화 setValidationResult(null); setIsDataValidating(false); @@ -1366,7 +1414,7 @@ export const ExcelUploadModal: React.FC = ({ return ( <> - + { if (!showCategoryValidation) onOpenChange(v); }}> = ({ {/* DB 기존 데이터 중복 */} {validationResult.uniqueInDbErrors.length > 0 && ( -
-

- - DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건) -

-
+
+
+

+ {dbDuplicateAction ? : } + DB 기존 데이터와 중복 ({validationResult.uniqueInDbErrors.length}건) +

+
+ + 중복 시: + + +
+
+
{(() => { const grouped = new Map(); for (const err of validationResult.uniqueInDbErrors) { @@ -1993,7 +2079,7 @@ export const ExcelUploadModal: React.FC = ({
{items.slice(0, 5).map((item, i) => (

- {label} "{item.value}": 행 {item.rows.join(", ")} + {label} "{item.value}": 행 {item.rows.join(", ")}

))} {items.length > 5 &&

...외 {items.length - 5}건

} @@ -2001,6 +2087,13 @@ export const ExcelUploadModal: React.FC = ({ )); })()}
+ {dbDuplicateAction && ( +

+ {dbDuplicateAction === "skip" + ? "중복 데이터는 건너뛰고 신규 데이터만 업로드합니다." + : "중복 데이터는 새 값으로 덮어씁니다."} +

+ )}
)}
@@ -2114,11 +2207,24 @@ export const ExcelUploadModal: React.FC = ({ disabled={ isUploading || columnMappings.filter((m) => m.systemColumn).length === 0 || - (validationResult !== null && !validationResult.isValid) + (validationResult !== null && !validationResult.isValid && !( + validationResult.notNullErrors.length === 0 && + validationResult.uniqueInExcelErrors.length === 0 && + validationResult.uniqueInDbErrors.length > 0 && + dbDuplicateAction !== null + )) } className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm" > - {isUploading ? "업로드 중..." : validationResult && !validationResult.isValid ? "검증 실패 - 이전으로 돌아가 수정" : "업로드"} + {isUploading ? "업로드 중..." : + validationResult && !validationResult.isValid && !( + validationResult.notNullErrors.length === 0 && + validationResult.uniqueInExcelErrors.length === 0 && + validationResult.uniqueInDbErrors.length > 0 && + dbDuplicateAction !== null + ) ? "검증 실패 - 이전으로 돌아가 수정" : + dbDuplicateAction === "skip" ? "업로드 (중복 건너뛰기)" : + dbDuplicateAction === "overwrite" ? "업로드 (중복 덮어쓰기)" : "업로드"} )} @@ -2165,33 +2271,63 @@ export const ExcelUploadModal: React.FC = ({
- + + + + + + { + const opt = item.validOptions.find((o) => o.code === value); + if (!opt) return 0; + const s = search.toLowerCase(); + if (opt.label.toLowerCase().includes(s)) return 1; + if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1; + return 0; + }} + > + + + 찾을 수 없습니다 + + {item.validOptions.map((opt) => ( + { + setCategoryMismatches((prev) => { + const updated = { ...prev }; + updated[key] = updated[key].map((it, i) => + i === idx ? { ...it, replacement: val } : it + ); + return updated; + }); + }} + className="text-xs sm:text-sm" + > + + + {opt.depth > 0 && } + {opt.label} + + + ))} + + + + +
))} @@ -2210,17 +2346,6 @@ export const ExcelUploadModal: React.FC = ({ > 취소 - + + + { + const opt = item.validOptions.find((o) => o.code === value); + if (!opt) return 0; + const s = search.toLowerCase(); + if (opt.label.toLowerCase().includes(s)) return 1; + if (opt.ancestors.some((a) => a.toLowerCase().includes(s))) return 1; + return 0; + }} + > + + + 찾을 수 없습니다 + + {item.validOptions.map((opt) => ( + { + setCategoryMismatches((prev) => { + const updated = { ...prev }; + updated[key] = updated[key].map((it, i) => + i === idx ? { ...it, replacement: val } : it + ); + return updated; + }); + }} + className="text-xs sm:text-sm" + > + + + {opt.depth > 0 && } + {opt.label} + + + ))} + + + + + ))} @@ -1065,17 +1114,6 @@ export const MultiTableExcelUploadModal: React.FC 취소 -
onDragEnd={handleRightColumnDragEnd} onDrop={(e) => canDragListTabColumns && handleRightColumnDrop(e, idx, listTabIndex)} > + {canDragListTabColumns && } {col.label || col.name}
onDragEnd={handleRightColumnDragEnd} onDrop={(e) => isDraggable && handleRightColumnDrop(e, configColIndex, "main")} > + {isDraggable && } {col.label}
100 ? `${leftTotalColWidth}%` : '100%' }}> - {columnsToShow.map((col, idx) => ( + {columnsToShow.map((col, idx) => { + const isDropTarget = canDragLeftGroupedColumns && leftDropTargetColumnIndex === idx; + const isDragging = canDragLeftGroupedColumns && leftDraggedColumnIndex === idx; + return ( - ))} + ); + })} {hasGroupedLeftActions && ( @@ -3671,23 +3731,39 @@ export const SplitPanelLayoutComponent: React.FC (componentConfig.leftPanel?.showEdit !== false) || (componentConfig.leftPanel?.showDelete !== false) ); + const canDragLeftColumns = !isDesignMode && columnsToShow.length > 1; return (
canDragLeftGroupedColumns && handleLeftColumnDragStart(e, idx)} + onDragOver={(e) => canDragLeftGroupedColumns && handleLeftColumnDragOver(e, idx)} + onDragEnd={handleLeftColumnDragEnd} + onDrop={(e) => canDragLeftGroupedColumns && handleLeftColumnDrop(e, idx)} > + {canDragLeftGroupedColumns && } {col.label}
100 ? `${leftTotalColWidth}%` : '100%' }}> - {columnsToShow.map((col, idx) => ( + {columnsToShow.map((col, idx) => { + const isDropTarget = canDragLeftColumns && leftDropTargetColumnIndex === idx; + const isDragging = canDragLeftColumns && leftDraggedColumnIndex === idx; + return ( - ))} + ); + })} {hasLeftTableActions && ( @@ -4161,9 +4237,11 @@ export const SplitPanelLayoutComponent: React.FC // 테이블 모드로 표시 (행 클릭 시 상세 정보 펼치기) if (currentTabConfig?.displayMode === "table") { const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete; - // showInSummary가 false가 아닌 것만 메인 테이블에 표시 - const tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); const tabIndex = activeTabIndex - 1; + let tabSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); + if (!isDesignMode) { + tabSummaryColumns = applyRuntimeOrder(tabSummaryColumns, tabIndex); + } const canDragTabColumns = tabSummaryColumns.length > 0; return (
@@ -4188,7 +4266,7 @@ export const SplitPanelLayoutComponent: React.FC onDragEnd={handleRightColumnDragEnd} onDrop={(e) => canDragTabColumns && handleRightColumnDrop(e, idx, tabIndex)} > - {canDragTabColumns && } + {canDragTabColumns && } {col.label || col.name} ); @@ -4298,9 +4376,11 @@ export const SplitPanelLayoutComponent: React.FC // 리스트 모드도 테이블형으로 통일 (행 클릭 시 상세 정보 표시) { const hasTabActions = currentTabConfig?.showEdit || currentTabConfig?.showDelete; - // showInSummary가 false가 아닌 것만 메인 테이블에 표시 - const listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); const listTabIndex = activeTabIndex - 1; + let listSummaryColumns = tabColumns.filter((col: any) => col.showInSummary !== false); + if (!isDesignMode) { + listSummaryColumns = applyRuntimeOrder(listSummaryColumns, listTabIndex); + } const canDragListTabColumns = listSummaryColumns.length > 0; return (
@@ -4325,7 +4405,7 @@ export const SplitPanelLayoutComponent: React.FC onDragEnd={handleRightColumnDragEnd} onDrop={(e) => canDragListTabColumns && handleRightColumnDrop(e, idx, listTabIndex)} > - {canDragListTabColumns && } + {canDragListTabColumns && } {col.label || col.name} ); @@ -4752,7 +4832,7 @@ export const SplitPanelLayoutComponent: React.FC onDragEnd={handleRightColumnDragEnd} onDrop={(e) => isDraggable && handleRightColumnDrop(e, configColIndex, "main")} > - {isDraggable && } + {isDraggable && } {col.label} ); From a6aa57fece7bc0bbb885dee7512ef5841bf16cf4 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 17 Mar 2026 23:27:39 +0900 Subject: [PATCH 24/30] feat: enhance data mapping and entity join handling in components - Updated ButtonPrimaryComponent to utilize entity join metadata for improved data mapping. - Introduced getEntityJoinColumns method in TableListComponent to retrieve entity join column metadata. - Enhanced applyMappingRules function to support optional entity join columns, allowing for more flexible data resolution. - Added utility functions to build join alias maps and resolve values from entity joins, improving data handling capabilities. These enhancements aim to provide a more robust and dynamic data mapping experience, facilitating better integration of entity relationships in the application. --- .../ButtonPrimaryComponent.tsx | 8 +- .../v2-table-list/TableListComponent.tsx | 11 +++ frontend/lib/utils/dataMapping.ts | 76 +++++++++++++++++-- frontend/types/data-transfer.ts | 17 +++++ 4 files changed, 104 insertions(+), 8 deletions(-) diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 26a5d7c4..42ca862f 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -938,8 +938,14 @@ export const ButtonPrimaryComponent: React.FC = ({ effectiveMappingRules = multiTableMappings[0]?.mappingRules || []; } + // 소스 DataProvider에서 엔티티 조인 메타데이터 가져오기 + const entityJoinColumns = sourceProvider?.getEntityJoinColumns?.() || []; + if (entityJoinColumns.length > 0) { + console.log(`🔗 [ButtonPrimary] 엔티티 조인 메타데이터 ${entityJoinColumns.length}개 감지`, entityJoinColumns); + } + const mappedData = sourceData.map((row) => { - const mappedRow = applyMappingRules(row, effectiveMappingRules); + const mappedRow = applyMappingRules(row, effectiveMappingRules, entityJoinColumns); return { ...mappedRow, diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index d1faf281..617d88fe 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -1164,6 +1164,17 @@ export const TableListComponent: React.FC = ({ setSelectedRows(new Set()); setIsAllSelected(false); }, + + getEntityJoinColumns: () => { + return (tableConfig.columns || []) + .filter((col) => col.additionalJoinInfo) + .map((col) => ({ + sourceTable: col.additionalJoinInfo!.sourceTable || tableConfig.selectedTable, + sourceColumn: col.additionalJoinInfo!.sourceColumn, + joinAlias: col.additionalJoinInfo!.joinAlias, + referenceTable: col.additionalJoinInfo!.referenceTable, + })); + }, }; // DataReceivable 인터페이스 구현 diff --git a/frontend/lib/utils/dataMapping.ts b/frontend/lib/utils/dataMapping.ts index 92aa2243..993cb89d 100644 --- a/frontend/lib/utils/dataMapping.ts +++ b/frontend/lib/utils/dataMapping.ts @@ -8,21 +8,27 @@ import type { Condition, TransformFunction, } from "@/types/screen-embedding"; +import type { EntityJoinColumnMeta } from "@/types/data-transfer"; import { logger } from "./logger"; /** * 매핑 규칙 적용 * @param data 배열 또는 단일 객체 * @param rules 매핑 규칙 배열 + * @param entityJoinColumns 엔티티 조인 메타데이터 (선택적) - sourceField 값이 비었을 때 조인 alias에서 해결 * @returns 매핑된 배열 */ -export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[] { +export function applyMappingRules( + data: any[] | any, + rules: MappingRule[], + entityJoinColumns?: EntityJoinColumnMeta[], +): any[] { // 빈 데이터 처리 if (!data) { return []; } - // 🆕 배열이 아닌 경우 배열로 변환 + // 배열이 아닌 경우 배열로 변환 const dataArray = Array.isArray(data) ? data : [data]; if (dataArray.length === 0) { @@ -42,22 +48,34 @@ export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[ return [applyTransformRules(dataArray, rules)]; } + // 엔티티 조인 alias 역방향 맵 구성: { referenceColumn → joinAlias } + // ex) joinAlias "part_code_item_name" → sourceColumn "part_code", referenceColumn "item_name" + const joinAliasMap = buildJoinAliasMap(entityJoinColumns); + // 일반 매핑 (각 행에 대해 매핑) - // 🆕 원본 데이터를 복사한 후 매핑 규칙 적용 (매핑되지 않은 필드도 유지) + // 원본 데이터를 복사한 후 매핑 규칙 적용 (매핑되지 않은 필드도 유지) return dataArray.map((row) => { - // 원본 데이터 복사 const mappedRow: any = { ...row }; for (const rule of rules) { - // sourceField와 targetField가 모두 있어야 매핑 적용 if (!rule.sourceField || !rule.targetField) { continue; } - const sourceValue = getNestedValue(row, rule.sourceField); + let sourceValue = getNestedValue(row, rule.sourceField); + + // sourceField 값이 비어있으면 엔티티 조인 alias에서 해결 시도 + if (isEmptyValue(sourceValue) && joinAliasMap.size > 0) { + sourceValue = resolveFromEntityJoin(row, rule.targetField, joinAliasMap); + if (sourceValue !== undefined) { + logger.info( + `[dataMapping] 엔티티 조인 해결: ${rule.sourceField}(비어있음) → targetField "${rule.targetField}" → alias에서 값 획득`, + ); + } + } + const targetValue = sourceValue ?? rule.defaultValue; - // 소스 필드와 타겟 필드가 다르면 소스 필드 제거 후 타겟 필드에 설정 if (rule.sourceField !== rule.targetField) { delete mappedRow[rule.sourceField]; } @@ -69,6 +87,50 @@ export function applyMappingRules(data: any[] | any, rules: MappingRule[]): any[ }); } +/** + * 엔티티 조인 alias에서 역방향 참조 맵 구성 + * joinAlias 네이밍 규칙: {sourceColumn}_{referenceColumn} + * 예: "part_code_item_name" → sourceColumn="part_code", referenceColumn="item_name" + * + * 반환 Map: referenceColumn → joinAlias + * 예: "item_name" → "part_code_item_name" + */ +function buildJoinAliasMap( + entityJoinColumns?: EntityJoinColumnMeta[], +): Map { + const map = new Map(); + if (!entityJoinColumns || entityJoinColumns.length === 0) return map; + + for (const meta of entityJoinColumns) { + const prefix = `${meta.sourceColumn}_`; + if (meta.joinAlias.startsWith(prefix)) { + const referenceColumn = meta.joinAlias.slice(prefix.length); + map.set(referenceColumn, meta.joinAlias); + } + } + return map; +} + +/** + * 엔티티 조인 alias에서 targetField에 해당하는 값 해결 + * targetField 이름으로 조인 alias를 찾아 row에서 값을 가져옴 + */ +function resolveFromEntityJoin( + row: any, + targetField: string, + joinAliasMap: Map, +): any { + const joinAlias = joinAliasMap.get(targetField); + if (!joinAlias) return undefined; + + const value = row[joinAlias]; + return isEmptyValue(value) ? undefined : value; +} + +function isEmptyValue(value: any): boolean { + return value === null || value === undefined || value === ""; +} + /** * 변환 함수 적용 */ diff --git a/frontend/types/data-transfer.ts b/frontend/types/data-transfer.ts index 61aad8db..0cf04ef6 100644 --- a/frontend/types/data-transfer.ts +++ b/frontend/types/data-transfer.ts @@ -157,6 +157,17 @@ export interface DataReceivable { getData(): any; } +/** + * 엔티티 조인 컬럼 메타데이터 + * 소스 테이블의 FK가 참조 테이블과 어떻게 조인되었는지 정보 + */ +export interface EntityJoinColumnMeta { + sourceColumn: string; + joinAlias: string; + referenceTable: string; + sourceTable?: string; +} + /** * 데이터 제공 가능한 컴포넌트 인터페이스 * 데이터를 제공할 수 있는 컴포넌트가 구현해야 하는 인터페이스 @@ -180,5 +191,11 @@ export interface DataProvidable { * 선택 초기화 메서드 */ clearSelection(): void; + + /** + * 엔티티 조인 컬럼 메타데이터 반환 (선택적) + * 전달 매핑 시 조인 alias 해결에 사용 + */ + getEntityJoinColumns?(): EntityJoinColumnMeta[]; } From b2a569f908993593a3efeb4bebdf9e033329ce0a Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 00:05:40 +0900 Subject: [PATCH 25/30] 123 --- .../screen/RealtimePreviewDynamic.tsx | 6 +-- .../screen/ResponsiveGridRenderer.tsx | 18 +++----- .../v2-table-list/SingleTableWithSticky.tsx | 35 ++++++++-------- .../v2-table-list/TableListComponent.tsx | 41 +++++++++++-------- 4 files changed, 51 insertions(+), 49 deletions(-) diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index d23337b5..ce76857b 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -362,15 +362,15 @@ const RealtimePreviewDynamicComponent: React.FC = ({ // 런타임 모드에서 컴포넌트 타입별 높이 처리 if (!isDesignMode) { const compType = (component as any).componentType || component.componentConfig?.type || ""; - // 테이블: 부모 flex 컨테이너가 높이 관리 (flex: 1) - const flexGrowTypes = [ + // 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리) + const fillParentTypes = [ "table-list", "v2-table-list", "split-panel-layout", "split-panel-layout2", "v2-split-panel-layout", "screen-split-panel", "v2-tab-container", "tab-container", "tabs-widget", "v2-tabs-widget", ]; - if (flexGrowTypes.some(t => compType === t)) { + if (fillParentTypes.some(t => compType === t)) { return "100%"; } const autoHeightTypes = [ diff --git a/frontend/components/screen/ResponsiveGridRenderer.tsx b/frontend/components/screen/ResponsiveGridRenderer.tsx index 1322ee99..47a2cd52 100644 --- a/frontend/components/screen/ResponsiveGridRenderer.tsx +++ b/frontend/components/screen/ResponsiveGridRenderer.tsx @@ -23,8 +23,9 @@ function getComponentTypeId(component: ComponentData): string { } /** - * 디자이너 절대좌표를 캔버스 대비 비율로 변환하여 렌더링. - * 화면이 줄어들면 비율에 맞게 축소, 늘어나면 확대. + * 디자이너 절대좌표를 캔버스 대비 비율(%)로 변환하여 렌더링. + * 가로: 컨테이너 너비 대비 % → 반응형 스케일 + * 세로: 컨테이너 높이 대비 % → 뷰포트에 맞게 자동 조절 */ function ProportionalRenderer({ components, @@ -47,19 +48,12 @@ function ProportionalRenderer({ }, []); const topLevel = components.filter((c) => !c.parentId); - const ratio = containerW > 0 ? containerW / canvasWidth : 1; - - const maxBottom = topLevel.reduce((max, c) => { - const bottom = c.position.y + (c.size?.height || 40); - return Math.max(max, bottom); - }, 0); return (
0 ? `${maxBottom * ratio}px` : "200px" }} + className="bg-background relative h-full w-full overflow-hidden" > {containerW > 0 && topLevel.map((component) => { @@ -72,9 +66,9 @@ function ProportionalRenderer({ style={{ position: "absolute", left: `${(component.position.x / canvasWidth) * 100}%`, - top: `${component.position.y * ratio}px`, + top: `${(component.position.y / canvasHeight) * 100}%`, width: `${((component.size?.width || 100) / canvasWidth) * 100}%`, - height: `${(component.size?.height || 40) * ratio}px`, + height: `${((component.size?.height || 40) / canvasHeight) * 100}%`, zIndex: component.position.z || 1, }} > diff --git a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx index c264dec2..3a7b4dad 100644 --- a/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx +++ b/frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx @@ -144,28 +144,33 @@ export const SingleTableWithSticky: React.FC = ({ isDesignMode && column.hidden && "bg-muted/50 opacity-40", )} style={{ - width: getColumnWidth(column), - minWidth: "100px", // 최소 너비 보장 - maxWidth: "300px", // 최대 너비 제한 + width: column.columnName === "__checkbox__" ? 48 : getColumnWidth(column), + minWidth: column.columnName === "__checkbox__" ? "48px" : "100px", + maxWidth: column.columnName === "__checkbox__" ? "48px" : "300px", boxSizing: "border-box", overflow: "hidden", textOverflow: "ellipsis", - whiteSpace: "nowrap", // 텍스트 줄바꿈 방지 + whiteSpace: "nowrap", backgroundColor: "hsl(var(--muted) / 0.4)", - // sticky 위치 설정 ...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }), }} onClick={() => column.sortable && sortHandler(column.columnName)} > -
+
{column.columnName === "__checkbox__" ? ( checkboxConfig.selectAll && ( ) ) : ( @@ -327,26 +332,22 @@ export const SingleTableWithSticky: React.FC = ({ key={`cell-${column.columnName}`} id={isCurrentSearchResult ? "current-search-result" : undefined} className={cn( - "text-foreground h-10 px-3 py-[7px] align-middle text-[11px] transition-colors", - // 이미지 셀은 overflow/ellipsis 제외 (이미지 잘림 방지) + "text-foreground h-10 align-middle text-[11px] transition-colors", + column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]", !isReactElement && "whitespace-nowrap", - `text-${column.align}`, - // 고정 컬럼 스타일 + column.columnName !== "__checkbox__" && `text-${column.align}`, column.fixed === "left" && "border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm", column.fixed === "right" && "border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm", - // 편집 가능 셀 스타일 onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text", )} style={{ - width: getColumnWidth(column), - minWidth: "100px", // 최소 너비 보장 - maxWidth: "300px", // 최대 너비 제한 + width: column.columnName === "__checkbox__" ? 48 : getColumnWidth(column), + minWidth: column.columnName === "__checkbox__" ? "48px" : "100px", + maxWidth: column.columnName === "__checkbox__" ? "48px" : "300px", boxSizing: "border-box", - // 이미지 셀은 overflow 허용 ...(isReactElement ? {} : { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }), - // sticky 위치 설정 ...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }), }} diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 7331c7bd..3f41b185 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -878,10 +878,7 @@ export const TableListComponent: React.FC = ({ const [isTableOptionsOpen, setIsTableOptionsOpen] = useState(false); const [showGridLines, setShowGridLines] = useState(true); const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); - // 체크박스 컬럼은 항상 기본 틀고정 - const [frozenColumns, setFrozenColumns] = useState( - (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : [], - ); + const [frozenColumns, setFrozenColumns] = useState([]); const [frozenColumnCount, setFrozenColumnCount] = useState(0); // 🆕 Search Panel (통합 검색) 관련 상태 @@ -1173,14 +1170,10 @@ export const TableListComponent: React.FC = ({ frozenColumnCount, // 현재 틀고정 컬럼 수 onFrozenColumnCountChange: (count: number) => { setFrozenColumnCount(count); - // 체크박스 컬럼은 항상 틀고정에 포함 - const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? ["__checkbox__"] : []; - // 표시 가능한 컬럼 중 처음 N개를 틀고정 컬럼으로 설정 const visibleCols = columnsToRegister .filter((col) => col.visible !== false) .map((col) => col.columnName || col.field); - const newFrozenColumns = [...checkboxColumn, ...visibleCols.slice(0, count)]; - setFrozenColumns(newFrozenColumns); + setFrozenColumns(visibleCols.slice(0, count)); }, // 탭 관련 정보 (탭 내부의 테이블인 경우) parentTabId, @@ -3080,11 +3073,7 @@ export const TableListComponent: React.FC = ({ if (state.groupByColumns) setGroupByColumns(state.groupByColumns); if (state.frozenColumns) { // 체크박스 컬럼이 항상 포함되도록 보장 - const checkboxColumn = (tableConfig.checkbox?.enabled ?? true) ? "__checkbox__" : null; - const restoredFrozenColumns = - checkboxColumn && !state.frozenColumns.includes(checkboxColumn) - ? [checkboxColumn, ...state.frozenColumns] - : state.frozenColumns; + const restoredFrozenColumns = (state.frozenColumns || []).filter((col: string) => col !== "__checkbox__"); setFrozenColumns(restoredFrozenColumns); } if (state.frozenColumnCount !== undefined) setFrozenColumnCount(state.frozenColumnCount); // 틀고정 컬럼 수 복원 @@ -4233,7 +4222,19 @@ export const TableListComponent: React.FC = ({ if (!tableConfig.checkbox?.selectAll) return null; if (tableConfig.checkbox?.multiple === false) return null; - return ; + return ( + + ); }; const renderCheckboxCell = (row: any, index: number) => { @@ -4245,6 +4246,12 @@ export const TableListComponent: React.FC = ({ checked={isChecked} onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)} aria-label={`행 ${index + 1} 선택`} + style={{ + width: 16, + height: 16, + borderWidth: 1.5, + borderColor: isChecked ? "hsl(var(--primary))" : "hsl(var(--muted-foreground) / 0.5)", + }} /> ); }; @@ -6201,7 +6208,7 @@ export const TableListComponent: React.FC = ({ "text-foreground text-[11px] font-normal", inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]", column.columnName === "__checkbox__" - ? "px-0 py-[7px]" + ? "px-0 py-[7px] text-center" : "px-3 py-[7px]", isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]", (inputType === "code" || inputType === "category") && "font-mono text-[10px] text-primary font-medium", @@ -6370,7 +6377,7 @@ export const TableListComponent: React.FC = ({ className={cn( "text-foreground text-[11px] font-normal", inputType !== "image" && "overflow-hidden text-ellipsis whitespace-nowrap max-w-[170px]", - column.columnName === "__checkbox__" ? "px-0 py-[7px]" : "px-3 py-[7px]", + column.columnName === "__checkbox__" ? "px-0 py-[7px] text-center" : "px-3 py-[7px]", isFrozen && "sticky z-20 shadow-[2px_0_4px_rgba(0,0,0,0.08)]", isCellFocused && !editingCell && "ring-primary bg-primary/5 ring-2 ring-inset", editingCell?.rowIndex === index && editingCell?.colIndex === colIndex && "p-0", From 8630d82a69b021c0b97ad7f2b3c02f98b450eecd Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Wed, 18 Mar 2026 10:16:59 +0900 Subject: [PATCH 26/30] Refactor RealtimePreviewDynamic and ResponsiveGridRenderer components to improve layout handling and responsiveness. Update SingleTableWithSticky and TableListComponent for better checkbox handling and styling consistency. Enhance overall user experience with refined component structures and styles. --- .../v2-table-list/TableListComponent.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx index 3f41b185..31cabb70 100644 --- a/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/v2-table-list/TableListComponent.tsx @@ -3072,7 +3072,7 @@ export const TableListComponent: React.FC = ({ if (state.sortDirection) setSortDirection(state.sortDirection); if (state.groupByColumns) setGroupByColumns(state.groupByColumns); if (state.frozenColumns) { - // 체크박스 컬럼이 항상 포함되도록 보장 + // 체크박스 컬럼은 frozen 대상에서 제외 (배경색 이중 적용 방지) const restoredFrozenColumns = (state.frozenColumns || []).filter((col: string) => col !== "__checkbox__"); setFrozenColumns(restoredFrozenColumns); } @@ -6227,16 +6227,18 @@ export const TableListComponent: React.FC = ({ : `${100 / visibleColumns.length}%`, minWidth: column.columnName === "__checkbox__" ? "48px" : undefined, maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, - ...(isFrozen && { - left: `${leftPosition}px`, - backgroundColor: "hsl(var(--background))", - }), - }} - > + ...(isFrozen && { + left: `${leftPosition}px`, + backgroundColor: index % 2 === 0 + ? "hsl(var(--background))" + : "hsl(var(--muted) / 0.2)", + }), + }} + > {column.columnName === "__checkbox__" ? renderCheckboxCell(row, index) : formatCellValue(cellValue, column, row)} - + ); })}
@@ -6384,7 +6386,7 @@ export const TableListComponent: React.FC = ({ isModified && !cellValidationError && "bg-amber-100 dark:bg-amber-900/40", cellValidationError && "bg-red-50 ring-2 ring-red-500 ring-inset dark:bg-red-950/40", isSearchHighlighted && !isCellFocused && "bg-yellow-200 dark:bg-yellow-700/50", - column.editable === false && "bg-gray-50 dark:bg-gray-900/30", + column.editable === false && "bg-muted/10 dark:bg-muted/10", // 코드 컬럼: mono 폰트 + primary 색상 (inputType === "code" || inputType === "category") && "font-mono text-[10px] text-primary font-medium", // 숫자 컬럼: tabular-nums 오른쪽 정렬 @@ -6405,7 +6407,9 @@ export const TableListComponent: React.FC = ({ maxWidth: column.columnName === "__checkbox__" ? "48px" : undefined, ...(isFrozen && { left: `${leftPosition}px`, - backgroundColor: "hsl(var(--background))", + backgroundColor: index % 2 === 0 + ? "hsl(var(--background))" + : "hsl(var(--muted) / 0.2)", }), }} onClick={(e) => handleCellClick(index, colIndex, e)} From fb57bd4eafe55f7ae9b2bfe32a45008d8890c1f2 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 18 Mar 2026 10:23:08 +0900 Subject: [PATCH 27/30] feat: enhance ButtonPrimaryComponent for source tracking - Updated ButtonPrimaryComponent to automatically inject source_table and source_id for better data tracking. - Implemented logic to conditionally include source tracking information based on the presence of sourceTableName and row.id. - This enhancement aims to improve data integrity and traceability during data mapping processes, facilitating better integration of source information in the application. --- .../v2-button-primary/ButtonPrimaryComponent.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 42ca862f..8fe0d7dd 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -947,8 +947,19 @@ export const ButtonPrimaryComponent: React.FC = ({ const mappedData = sourceData.map((row) => { const mappedRow = applyMappingRules(row, effectiveMappingRules, entityJoinColumns); + // 소스 출처 추적: source_table과 source_id를 자동 주입 + // 타겟 테이블에 해당 컬럼이 있으면 저장되고, 없으면 자동 무시됨 + const sourceTracking: Record = {}; + if (sourceTableName) { + sourceTracking.source_table = sourceTableName; + } + if (row.id) { + sourceTracking.source_id = row.id; + } + return { ...mappedRow, + ...sourceTracking, ...additionalData, }; }); From 9decf130681a720b27666b975af9ae247917f6bf Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 18 Mar 2026 14:42:47 +0900 Subject: [PATCH 28/30] feat: implement shipping plan management features - Added shipping plan routes and controller to handle aggregate and batch save operations. - Introduced a new shipping plan editor component for bulk registration of shipping plans based on selected orders. - Enhanced API client functions for fetching aggregated shipping plan data and saving plans in bulk. - Updated the registry to include the new shipping plan editor component, improving the overall shipping management workflow. These changes aim to streamline the shipping plan process, allowing for efficient management and registration of shipping plans in the application. --- backend-node/src/app.ts | 2 + .../src/controllers/shippingPlanController.ts | 458 ++++++++++++++ backend-node/src/routes/shippingPlanRoutes.ts | 19 + docs/shipping-plan-editor-plan.md | 176 ++++++ frontend/lib/api/shipping.ts | 59 ++ frontend/lib/registry/components/index.ts | 1 + .../ButtonPrimaryComponent.tsx | 4 +- .../ShippingPlanEditorComponent.tsx | 576 ++++++++++++++++++ .../ShippingPlanEditorConfigPanel.tsx | 166 +++++ .../ShippingPlanEditorRenderer.tsx | 16 + .../v2-shipping-plan-editor/index.ts | 27 + .../v2-shipping-plan-editor/types.ts | 67 ++ frontend/lib/v2-core/events/types.ts | 14 + 13 files changed, 1583 insertions(+), 2 deletions(-) create mode 100644 backend-node/src/controllers/shippingPlanController.ts create mode 100644 backend-node/src/routes/shippingPlanRoutes.ts create mode 100644 docs/shipping-plan-editor-plan.md create mode 100644 frontend/lib/api/shipping.ts create mode 100644 frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorComponent.tsx create mode 100644 frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorConfigPanel.tsx create mode 100644 frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorRenderer.tsx create mode 100644 frontend/lib/registry/components/v2-shipping-plan-editor/index.ts create mode 100644 frontend/lib/registry/components/v2-shipping-plan-editor/types.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 41926dd0..9de5f66c 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -143,6 +143,7 @@ import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스) import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import moldRoutes from "./routes/moldRoutes"; // 금형 관리 +import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -335,6 +336,7 @@ app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테 app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api/mold", moldRoutes); // 금형 관리 +app.use("/api/shipping-plan", shippingPlanRoutes); // 출하계획 관리 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트) app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 diff --git a/backend-node/src/controllers/shippingPlanController.ts b/backend-node/src/controllers/shippingPlanController.ts new file mode 100644 index 00000000..99c85889 --- /dev/null +++ b/backend-node/src/controllers/shippingPlanController.ts @@ -0,0 +1,458 @@ +/** + * 출하계획 컨트롤러 + * + * 수주 마스터(sales_order_mng, INTEGER id) 또는 + * 수주 디테일(sales_order_detail, UUID id) 양쪽에서 호출 가능. + * + * ID 포맷으로 소스 테이블 자동 감지 → JOIN으로 완전한 정보 조합 + */ + +import { Response } from "express"; +import { AuthenticatedRequest } from "../types/auth"; +import { getPool } from "../database/db"; +import { logger } from "../utils/logger"; + +// UUID 포맷 감지 (하이픈 포함 36자) +const isUUID = (val: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + val + ); + +type SourceTable = "master" | "detail"; + +interface NormalizedOrder { + sourceId: string; // 원본 ID (master: 정수, detail: UUID) + masterId: number | null; + detailId: string | null; + orderNo: string; + partCode: string; + partName: string; + partnerCode: string; + partnerName: string; + dueDate: string; + orderQty: number; + shipQty: number; + balanceQty: number; +} + +// ─── 소스 테이블 감지 ─── + +function detectSource(ids: string[]): SourceTable { + if (ids.length === 0) return "detail"; + return ids.every((id) => isUUID(id)) ? "detail" : "master"; +} + +// ─── 수주 정보 정규화 (마스터/디테일 양쪽 JOIN) ─── + +async function getNormalizedOrders( + companyCode: string, + ids: string[], + source: SourceTable +): Promise { + const pool = getPool(); + + if (source === "detail") { + // 디테일 기준 → 마스터 JOIN (order_no), 거래처 JOIN (customer_mng) + // item_info는 LATERAL로 1건만 매칭 (item_number 중복 대비) + const res = await pool.query( + `SELECT + d.id AS detail_id, + m.id AS master_id, + d.order_no, + d.part_code, + COALESCE(d.part_name, i.item_name, d.part_code) AS part_name, + COALESCE(d.delivery_partner_code, m.partner_id, '') AS partner_code, + COALESCE(c.customer_name, d.delivery_partner_code, m.partner_id, '') AS partner_name, + COALESCE(d.due_date, m.due_date::text, '') AS due_date, + COALESCE(NULLIF(d.qty,'')::numeric, m.order_qty, 0) AS order_qty, + COALESCE(NULLIF(d.ship_qty,'')::numeric, m.ship_qty, 0) AS ship_qty, + COALESCE(NULLIF(d.balance_qty,'')::numeric, m.balance_qty, 0) AS balance_qty + FROM sales_order_detail d + LEFT JOIN sales_order_mng m + ON d.order_no = m.order_no AND d.company_code = m.company_code + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = d.part_code AND company_code = d.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON COALESCE(d.delivery_partner_code, m.partner_id) = c.customer_code + AND d.company_code = c.company_code + WHERE d.company_code = $1 + AND d.id = ANY($2::text[])`, + [companyCode, ids] + ); + + return res.rows.map((r) => ({ + sourceId: r.detail_id, + masterId: r.master_id, + detailId: r.detail_id, + orderNo: r.order_no || "", + partCode: r.part_code || "", + partName: r.part_name || "", + partnerCode: r.partner_code || "", + partnerName: r.partner_name || "", + dueDate: r.due_date || "", + orderQty: Number(r.order_qty || 0), + shipQty: Number(r.ship_qty || 0), + balanceQty: Number(r.balance_qty || 0), + })); + } else { + // 마스터 기준 → 거래처 JOIN + const numericIds = ids.map(Number).filter((n) => !isNaN(n)); + // item_info는 LATERAL로 1건만 매칭 (item_number 중복 대비) + const res = await pool.query( + `SELECT + m.id AS master_id, + NULL AS detail_id, + m.order_no, + m.part_code, + COALESCE(m.part_name, i.item_name, m.part_code, '') AS part_name, + COALESCE(m.partner_id, '') AS partner_code, + COALESCE(c.customer_name, m.partner_id, '') AS partner_name, + COALESCE(m.due_date::text, '') AS due_date, + COALESCE(m.order_qty, 0) AS order_qty, + COALESCE(m.ship_qty, 0) AS ship_qty, + COALESCE(m.balance_qty, 0) AS balance_qty + FROM sales_order_mng m + LEFT JOIN LATERAL ( + SELECT item_name FROM item_info + WHERE item_number = m.part_code AND company_code = m.company_code + LIMIT 1 + ) i ON true + LEFT JOIN customer_mng c + ON m.partner_id = c.customer_code AND m.company_code = c.company_code + WHERE m.company_code = $1 + AND m.id = ANY($2::int[])`, + [companyCode, numericIds] + ); + + return res.rows.map((r) => ({ + sourceId: String(r.master_id), + masterId: r.master_id, + detailId: null, + orderNo: r.order_no || "", + partCode: r.part_code || "", + partName: r.part_name || "", + partnerCode: r.partner_code || "", + partnerName: r.partner_name || "", + dueDate: r.due_date || "", + orderQty: Number(r.order_qty || 0), + shipQty: Number(r.ship_qty || 0), + balanceQty: Number(r.balance_qty || 0), + })); + } +} + +// ─── 품목별 집계 + 기존 출하계획 조회 ─── + +export async function getAggregate(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const { ids } = req.query; + + if (!ids) { + return res + .status(400) + .json({ success: false, message: "ids 파라미터가 필요합니다" }); + } + + const idList = (ids as string).split(",").filter(Boolean); + if (idList.length === 0) { + return res + .status(400) + .json({ success: false, message: "유효한 ID가 필요합니다" }); + } + + const source = detectSource(idList); + logger.info("출하계획 집계 조회", { + companyCode, + source, + idCount: idList.length, + }); + + // 1) 정규화된 수주 정보 조회 (JOIN 포함) + const orders = await getNormalizedOrders(companyCode, idList, source); + + if (orders.length === 0) { + return res + .status(404) + .json({ success: false, message: "해당 수주를 찾을 수 없습니다" }); + } + + // 2) 품목별 그룹핑 + const partCodeMap = new Map(); + for (const order of orders) { + const key = order.partCode || "UNKNOWN"; + if (!partCodeMap.has(key)) partCodeMap.set(key, []); + partCodeMap.get(key)!.push(order); + } + + const pool = getPool(); + const result: Record = {}; + + for (const [partCode, partOrders] of partCodeMap) { + // 총수주잔량: 선택된 수주들의 balance_qty 합 + const totalBalance = partOrders.reduce( + (s, o) => s + (o.balanceQty > 0 ? o.balanceQty : o.orderQty - o.shipQty), + 0 + ); + + // 기존 출하계획 조회 (detail_id 또는 sales_order_id 기준) + let existingPlans: any[] = []; + if (source === "detail") { + const planDetailIds = partOrders + .map((o) => o.detailId) + .filter(Boolean); + if (planDetailIds.length > 0) { + const planRes = await pool.query( + `SELECT id, detail_id, sales_order_id, plan_qty, plan_date, + shipment_plan_no, status + FROM shipment_plan + WHERE company_code = $1 AND detail_id = ANY($2::text[]) + ORDER BY created_date DESC`, + [companyCode, planDetailIds] + ); + existingPlans = planRes.rows.map((r) => ({ + id: r.id, + sourceId: r.detail_id, + planQty: Number(r.plan_qty || 0), + planDate: r.plan_date, + shipmentPlanNo: r.shipment_plan_no, + status: r.status, + })); + } + } else { + const planMasterIds = partOrders + .map((o) => o.masterId) + .filter((id): id is number => id != null); + if (planMasterIds.length > 0) { + const planRes = await pool.query( + `SELECT id, sales_order_id, detail_id, plan_qty, plan_date, + shipment_plan_no, status + FROM shipment_plan + WHERE company_code = $1 AND sales_order_id = ANY($2::int[]) + ORDER BY created_date DESC`, + [companyCode, planMasterIds] + ); + existingPlans = planRes.rows.map((r) => ({ + id: r.id, + sourceId: String(r.sales_order_id), + planQty: Number(r.plan_qty || 0), + planDate: r.plan_date, + shipmentPlanNo: r.shipment_plan_no, + status: r.status, + })); + } + } + + const totalPlanQty = existingPlans.reduce((s, p) => s + p.planQty, 0); + + // 현재고 + const stockRes = await pool.query( + `SELECT COALESCE(SUM(current_qty::numeric), 0) AS current_stock + FROM inventory_stock + WHERE company_code = $1 AND item_code = $2`, + [companyCode, partCode] + ); + const currentStock = Number(stockRes.rows[0]?.current_stock || 0); + + // 생산중수량 + const prodRes = await pool.query( + `SELECT COALESCE(SUM(plan_qty - COALESCE(completed_qty, 0)), 0) AS in_production + FROM production_plan_mng + WHERE company_code = $1 + AND item_code = $2 + AND status IN ('in_progress', 'planned')`, + [companyCode, partCode] + ); + const inProductionQty = Number(prodRes.rows[0]?.in_production || 0); + + result[partCode] = { + totalBalance, + totalPlanQty, + currentStock, + availableStock: currentStock - totalPlanQty, + inProductionQty, + existingPlans, + orders: partOrders.map((o) => ({ + sourceId: o.sourceId, + orderNo: o.orderNo, + partCode: o.partCode, + partName: o.partName, + partnerName: o.partnerName, + dueDate: o.dueDate, + orderQty: o.orderQty, + shipQty: o.shipQty, + balanceQty: o.balanceQty, + })), + }; + } + + logger.info("출하계획 집계 조회 완료", { + companyCode, + source, + partCodes: Array.from(partCodeMap.keys()), + orderCount: orders.length, + }); + + return res.json({ success: true, data: result, source }); + } catch (error: any) { + logger.error("출하계획 집계 조회 실패", { + error: error.message, + stack: error.stack, + }); + return res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 출하계획 일괄 저장 ─── + +export async function batchSave(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user!.companyCode; + const userId = req.user!.userId; + const { plans, source } = req.body; + + if (!Array.isArray(plans) || plans.length === 0) { + return res.status(400).json({ + success: false, + message: "저장할 출하계획 데이터가 필요합니다", + }); + } + + // source 자동 감지 (프론트에서 전달, 또는 ID 포맷으로 추론) + const detectedSource: SourceTable = + source || detectSource(plans.map((p: any) => String(p.sourceId))); + + const pool = getPool(); + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + const savedPlans = []; + + for (const plan of plans) { + const { sourceId, planQty } = plan; + if (!sourceId || !planQty || planQty <= 0) continue; + + if (detectedSource === "detail") { + // 디테일 소스: detail_id로 저장 + const detailCheck = await client.query( + `SELECT d.id, d.order_no, d.part_code, d.qty, d.ship_qty, d.balance_qty, + m.id AS master_id + FROM sales_order_detail d + LEFT JOIN sales_order_mng m + ON d.order_no = m.order_no AND d.company_code = m.company_code + WHERE d.id = $1 AND d.company_code = $2`, + [sourceId, companyCode] + ); + + if (detailCheck.rowCount === 0) { + throw new Error(`수주상세 ${sourceId}을 찾을 수 없습니다`); + } + + const detail = detailCheck.rows[0]; + const qty = Number(detail.qty || 0); + const shipQty = Number(detail.ship_qty || 0); + const balanceQty = detail.balance_qty + ? Number(detail.balance_qty) + : qty - shipQty; + + if (balanceQty > 0 && planQty > balanceQty) { + throw new Error( + `수주번호 ${detail.order_no}: 출하계획량(${planQty})이 미출하량(${balanceQty})을 초과합니다` + ); + } + + const insertRes = await client.query( + `INSERT INTO shipment_plan + (company_code, detail_id, sales_order_id, plan_qty, plan_date, status, created_by) + VALUES ($1, $2, $3, $4, CURRENT_DATE, 'READY', $5) + RETURNING *`, + [companyCode, sourceId, detail.master_id, planQty, userId] + ); + savedPlans.push(insertRes.rows[0]); + + // detail ship_qty 업데이트 + await client.query( + `UPDATE sales_order_detail + SET ship_qty = (COALESCE(NULLIF(ship_qty,'')::numeric, 0) + $1)::text, + balance_qty = (COALESCE(NULLIF(qty,'')::numeric, 0) + - COALESCE(NULLIF(ship_qty,'')::numeric, 0) - $1)::text, + updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [planQty, sourceId, companyCode] + ); + } else { + // 마스터 소스: sales_order_id로 저장 + const masterId = Number(sourceId); + const masterCheck = await client.query( + `SELECT id, order_no, order_qty, ship_qty, balance_qty + FROM sales_order_mng + WHERE id = $1 AND company_code = $2`, + [masterId, companyCode] + ); + + if (masterCheck.rowCount === 0) { + throw new Error(`수주 ID ${masterId}을 찾을 수 없습니다`); + } + + const master = masterCheck.rows[0]; + const balanceQty = Number(master.balance_qty || 0); + + if (balanceQty > 0 && planQty > balanceQty) { + throw new Error( + `수주번호 ${master.order_no}: 출하계획량(${planQty})이 미출하량(${balanceQty})을 초과합니다` + ); + } + + const insertRes = await client.query( + `INSERT INTO shipment_plan + (company_code, sales_order_id, plan_qty, plan_date, status, created_by) + VALUES ($1, $2, $3, CURRENT_DATE, 'READY', $4) + RETURNING *`, + [companyCode, masterId, planQty, userId] + ); + savedPlans.push(insertRes.rows[0]); + + // 마스터 ship_qty 업데이트 + await client.query( + `UPDATE sales_order_mng + SET ship_qty = COALESCE(ship_qty, 0) + $1, + balance_qty = COALESCE(order_qty, 0) - COALESCE(ship_qty, 0) - $1, + updated_date = NOW() + WHERE id = $2 AND company_code = $3`, + [planQty, masterId, companyCode] + ); + } + } + + await client.query("COMMIT"); + + logger.info("출하계획 일괄 저장 완료", { + companyCode, + source: detectedSource, + savedCount: savedPlans.length, + userId, + }); + + return res.json({ + success: true, + message: `${savedPlans.length}건 저장 완료`, + data: savedPlans, + }); + } catch (txError) { + await client.query("ROLLBACK"); + throw txError; + } finally { + client.release(); + } + } catch (error: any) { + logger.error("출하계획 일괄 저장 실패", { + error: error.message, + stack: error.stack, + }); + return res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/routes/shippingPlanRoutes.ts b/backend-node/src/routes/shippingPlanRoutes.ts new file mode 100644 index 00000000..16ff0050 --- /dev/null +++ b/backend-node/src/routes/shippingPlanRoutes.ts @@ -0,0 +1,19 @@ +/** + * 출하계획 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as shippingPlanController from "../controllers/shippingPlanController"; + +const router = Router(); + +router.use(authenticateToken); + +// 품목별 집계 + 기존 출하계획 조회 +router.get("/aggregate", shippingPlanController.getAggregate); + +// 출하계획 일괄 저장 +router.post("/batch", shippingPlanController.batchSave); + +export default router; diff --git a/docs/shipping-plan-editor-plan.md b/docs/shipping-plan-editor-plan.md new file mode 100644 index 00000000..1f2f95d3 --- /dev/null +++ b/docs/shipping-plan-editor-plan.md @@ -0,0 +1,176 @@ +# 출하계획 동시 등록 컴포넌트 (v2-shipping-plan-editor) 설계서 + +## 개요 + +수주 목록에서 다건 선택 후 "출하계획" 버튼 클릭 시 모달로 열리는 출하계획 일괄 등록 화면. +기존 ScreenModal + modalScreenId 매커니즘을 활용하여, DB 기반 화면(screen_definitions)으로 구현한다. + +## 핵심 기능 + +1. 선택된 수주를 **품목(part_code) 기준으로 그룹핑** +2. 그룹별 **5칸 집계 카드**: 총수주잔량, 총 출하계획량, 현재고, 가용재고, 생산중수량 +3. 그룹별 상세 테이블: 기존 계획(기존) + 신규 입력(신규) 구분 표시 +4. 출하계획량만 입력 → 확인 시 shipment_plan에 일괄 INSERT + +## 테이블 관계 + +``` +sales_order_mng (수주) + ├─ id (PK) + ├─ part_code (품목코드) ← 그룹핑 기준 + ├─ part_name (품명) + ├─ order_qty (수주수량) + ├─ ship_qty (출하수량) + ├─ balance_qty (잔량) = order_qty - ship_qty + ├─ partner_id (거래처) + └─ due_date (납기일) + +shipment_plan (출하계획) + ├─ sales_order_id (FK → sales_order_mng.id) + ├─ plan_qty (출하계획수량) + ├─ plan_date (출하예정일) + ├─ shipment_plan_no (자동 채번) + └─ status (READY) + +inventory_stock (재고) + ├─ item_code (품목코드) + └─ current_qty (현재고) + +production_plan_mng (생산계획) + ├─ item_code (품목코드) + ├─ plan_qty (계획수량) + ├─ completed_qty (완료수량) + └─ status (진행중 = in_progress / planned) +``` + +## 집계 카드 데이터 소스 + +| 카드 | 계산 방법 | +|------|----------| +| 총수주잔량 | SUM(sales_order_mng.balance_qty) WHERE part_code = ? | +| 총 출하계획량 | SUM(shipment_plan.plan_qty) WHERE sales_order_id IN (해당 품목 수주들) | +| 현재고 | SUM(inventory_stock.current_qty) WHERE item_code = part_code | +| 가용재고 | 현재고 - 총 출하계획량 (기존 계획분) | +| 생산중수량 | SUM(production_plan_mng.plan_qty - completed_qty) WHERE item_code = part_code AND status IN ('in_progress', 'planned') | + +## 상세 테이블 컬럼 + +| 컬럼 | 소스 | 편집 | +|------|------|------| +| 구분 | "기존" or "신규" | 읽기 전용 (배지) | +| 수주번호 | sales_order_mng.order_no | 읽기 전용 | +| 거래처 | sales_order_mng.partner_id (엔티티 조인) | 읽기 전용 | +| 납기일 | sales_order_mng.due_date | 읽기 전용 | +| 미출하 | sales_order_mng.balance_qty | 읽기 전용 | +| 출하계획량 | 입력값 / shipment_plan.plan_qty | **입력 가능** | + +## 데이터 흐름 + +``` +1. 수주 목록에서 체크박스 선택 → "출하계획" 버튼 클릭 +2. openScreenModal 이벤트 발생 (selectedData = 선택된 수주 배열) +3. ScreenModal이 모달 화면 로드 (v2-shipping-plan-editor 컴포넌트) +4. 컴포넌트가 groupedData (= selectedData) 수신 +5. part_code 기준 그룹핑 +6. 백엔드 API 호출: GET /api/shipping-plan/aggregate + → 품목별 재고, 생산중수량, 기존 출하계획 조회 +7. UI 렌더링 (집계 카드 + 상세 테이블) +8. 사용자가 출하계획량 입력 +9. 확인 버튼 → POST /api/shipping-plan/batch + → shipment_plan INSERT + sales_order_mng.plan_ship_qty UPDATE +``` + +## 파일 구조 + +``` +frontend/lib/registry/components/v2-shipping-plan-editor/ + ├── index.ts # createComponentDefinition + ├── ShippingPlanEditorRenderer.tsx # AutoRegisteringComponentRenderer + ├── ShippingPlanEditorComponent.tsx # 메인 UI 컴포넌트 + └── types.ts # 타입 정의 + +frontend/lib/api/ + └── shipping.ts # API 클라이언트 함수 + +backend-node/src/ + ├── controllers/shippingPlanController.ts # API 핸들러 + └── routes/shippingPlanRoutes.ts # 라우터 +``` + +## 백엔드 API + +### GET /api/shipping-plan/aggregate +품목별 집계 + 기존 출하계획 조회 + +Request: `?partCodes=ITEM001,SEAL-100&orderIds=172,175,178` +Response: +```json +{ + "success": true, + "data": { + "ITEM001": { + "totalBalance": 1700, + "totalPlanQty": 500, + "currentStock": 1000, + "availableStock": 500, + "inProductionQty": 300, + "existingPlans": [ + { "id": 76, "salesOrderId": 172, "planQty": 500, "planDate": "2025-12-10", "shipmentPlanNo": "SPL-..." } + ] + } + } +} +``` + +### POST /api/shipping-plan/batch +출하계획 일괄 저장 + +Request: +```json +{ + "plans": [ + { "salesOrderId": 172, "planQty": 1000 }, + { "salesOrderId": 175, "planQty": 500 } + ] +} +``` + +## 구현 상태 + +### 완료 +- [x] types.ts (타입 정의) +- [x] index.ts (컴포넌트 정의) +- [x] ShippingPlanEditorRenderer.tsx (레지스트리 등록) +- [x] ShippingPlanEditorComponent.tsx (메인 UI) +- [x] frontend/lib/api/shipping.ts (API 클라이언트) +- [x] backend-node/src/controllers/shippingPlanController.ts (집계 + 일괄 저장) +- [x] backend-node/src/routes/shippingPlanRoutes.ts (라우터) +- [x] screen_definitions (screen_id: 4573, screen_code: *_SHIP_PLAN_EDITOR) +- [x] screen_layouts_v2 (layout_id: 11562) + +### 연동 정보 +| 항목 | 마스터(*) | 탑씰(COMPANY_7) | +|------|-----------|-----------------| +| screen_id | 4573 | 4574 | +| screen_code | *_SHIP_PLAN_EDITOR | TOPSEAL_SHIP_PLAN_EDITOR | +| layout_id | 11562 | 11563 | + +탑씰 수주관리 화면(screen_id: 156)의 "출하계획" 버튼(comp_33659)이 +targetScreenId: 4574로 연결되어, 체크박스 선택 → 버튼 클릭 → 모달 오픈. +선택된 수주 데이터는 `groupedData` prop으로 전달됨. + +## 테스트 계획 + +### 1단계: 기본 기능 +- [ ] 수주 선택 → 모달 열기 → groupedData 수신 확인 +- [ ] part_code 기준 그룹핑 확인 +- [ ] 집계 카드 데이터 표시 확인 + +### 2단계: CRUD +- [ ] 출하계획량 입력 → 집계 자동 재계산 +- [ ] 확인 버튼 → shipment_plan INSERT 확인 +- [ ] 기존 계획 "기존" 배지 표시 확인 + +### 3단계: 검증 +- [ ] 출하계획량 > 미출하 시 에러 처리 +- [ ] 멀티테넌시 (company_code) 필터링 확인 diff --git a/frontend/lib/api/shipping.ts b/frontend/lib/api/shipping.ts new file mode 100644 index 00000000..0aa9c8b3 --- /dev/null +++ b/frontend/lib/api/shipping.ts @@ -0,0 +1,59 @@ +import { apiClient } from "./client"; + +export interface EnrichedOrder { + sourceId: string; + orderNo: string; + partCode: string; + partName: string; + partnerName: string; + dueDate: string; + orderQty: number; + shipQty: number; + balanceQty: number; +} + +export interface ExistingPlan { + id: number; + sourceId: string; + planQty: number; + planDate: string; + shipmentPlanNo: string; + status: string; +} + +export interface AggregateResponse { + [partCode: string]: { + totalBalance: number; + totalPlanQty: number; + currentStock: number; + availableStock: number; + inProductionQty: number; + existingPlans: ExistingPlan[]; + orders: EnrichedOrder[]; + }; +} + +export interface BatchSavePlan { + sourceId: string; + planQty: number; +} + +// ID만 전달 → 백엔드에서 소스 테이블 자동 감지 + JOIN +export async function getShippingPlanAggregate(ids: string[]) { + const res = await apiClient.get("/shipping-plan/aggregate", { + params: { ids: ids.join(",") }, + }); + return res.data as { + success: boolean; + data: AggregateResponse; + source: "master" | "detail"; + }; +} + +export async function batchSaveShippingPlans( + plans: BatchSavePlan[], + source?: string +) { + const res = await apiClient.post("/shipping-plan/batch", { plans, source }); + return res.data as { success: boolean; message?: string; data?: any }; +} diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 46e92af1..f3f4e552 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -119,6 +119,7 @@ import "./v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화 import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드 import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준 import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅 +import "./v2-shipping-plan-editor/ShippingPlanEditorRenderer"; // 출하계획 동시등록 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx index 8fe0d7dd..403183b5 100644 --- a/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx @@ -604,7 +604,7 @@ export const ButtonPrimaryComponent: React.FC = ({ toast.dismiss(); // UI 전환 액션 및 모달 액션은 로딩 토스트 표시하지 않음 - const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "approval"]; + const silentActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "approval", "event"]; if (!silentActions.includes(actionConfig.type)) { currentLoadingToastRef.current = toast.loading( actionConfig.type === "save" @@ -631,7 +631,7 @@ export const ButtonPrimaryComponent: React.FC = ({ // 실패한 경우 오류 처리 if (!success) { // UI 전환 액션 및 모달 액션은 에러도 조용히 처리 (모달 내부에서 자체 에러 표시) - const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert"]; + const silentErrorActions = ["edit", "modal", "navigate", "excel_upload", "barcode_scan", "save", "delete", "quickInsert", "event"]; if (silentErrorActions.includes(actionConfig.type)) { return; } diff --git a/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorComponent.tsx b/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorComponent.tsx new file mode 100644 index 00000000..c96d9286 --- /dev/null +++ b/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorComponent.tsx @@ -0,0 +1,576 @@ +"use client"; + +import React, { useEffect, useState, useCallback, useMemo, useRef } from "react"; +import { ComponentRendererProps } from "@/types/component"; +import { + Loader2, + Package, + TrendingUp, + Warehouse, + CheckCircle, + Factory, + Truck, +} from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { + ShippingPlanEditorConfig, + ItemGroup, + PlanDetailRow, + ItemAggregation, +} from "./types"; +import { getShippingPlanAggregate, batchSaveShippingPlans } from "@/lib/api/shipping"; + +export interface ShippingPlanEditorComponentProps + extends ComponentRendererProps {} + +export const ShippingPlanEditorComponent: React.FC< + ShippingPlanEditorComponentProps +> = ({ component, isDesignMode = false, groupedData, formData, onFormDataChange, onClose, ...props }) => { + const config = (component?.componentConfig || + {}) as ShippingPlanEditorConfig; + const [itemGroups, setItemGroups] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [source, setSource] = useState<"master" | "detail">("detail"); + const itemGroupsRef = useRef([]); + const sourceRef = useRef<"master" | "detail">("detail"); + + // groupedData에서 선택된 행 추출 (마스터든 디테일이든 그대로) + const selectedRows = useMemo(() => { + if (!groupedData) return []; + if (Array.isArray(groupedData)) return groupedData; + if (groupedData.selectedRows) return groupedData.selectedRows; + if (groupedData.data) return groupedData.data; + return []; + }, [groupedData]); + + // 선택된 행의 ID 목록 추출 (문자열) + const selectedIds = useMemo(() => { + return selectedRows + .map((row: any) => String(row.id)) + .filter((id: string) => id && id !== "undefined" && id !== "null"); + }, [selectedRows]); + + const loadData = useCallback(async () => { + if (selectedIds.length === 0 || isDesignMode) return; + + setLoading(true); + try { + // ID만 보내면 백엔드에서 소스 감지 + JOIN + 정규화 + const res = await getShippingPlanAggregate(selectedIds); + + if (!res.success) { + toast.error("집계 데이터 조회 실패"); + return; + } + + setSource(res.source); + const aggregateData = res.data || {}; + + const groups: ItemGroup[] = Object.entries(aggregateData).map( + ([partCode, data]) => { + const details: PlanDetailRow[] = []; + + // 수주별로 기존 계획 합산량 계산 + const existingPlansBySource = new Map(); + for (const plan of data.existingPlans || []) { + const prev = existingPlansBySource.get(plan.sourceId) || 0; + existingPlansBySource.set(plan.sourceId, prev + plan.planQty); + } + + // 신규 행 먼저: 모든 수주에 대해 항상 추가 (분할출하 대응) + for (const order of data.orders || []) { + const alreadyPlanned = existingPlansBySource.get(order.sourceId) || 0; + const remainingBalance = Math.max(0, order.balanceQty - alreadyPlanned); + details.push({ + type: "new", + sourceId: order.sourceId, + orderNo: order.orderNo, + partnerName: order.partnerName, + dueDate: order.dueDate, + balanceQty: remainingBalance, + planQty: 0, + }); + } + + // 기존 출하계획 아래에 표시 + for (const plan of data.existingPlans || []) { + const matchOrder = data.orders?.find( + (o) => o.sourceId === plan.sourceId + ); + details.push({ + type: "existing", + sourceId: plan.sourceId, + orderNo: matchOrder?.orderNo || "-", + partnerName: matchOrder?.partnerName || "-", + dueDate: matchOrder?.dueDate || "-", + balanceQty: matchOrder?.balanceQty || 0, + planQty: plan.planQty, + existingPlanId: plan.id, + }); + } + + // partName: orders에서 가져오기 + const partName = + data.orders?.[0]?.partName || partCode; + + return { + partCode, + partName, + aggregation: { + totalBalance: data.totalBalance, + totalPlanQty: data.totalPlanQty, + currentStock: data.currentStock, + availableStock: data.availableStock, + inProductionQty: data.inProductionQty, + }, + details, + }; + } + ); + + setItemGroups(groups); + } catch (err) { + console.error("[v2-shipping-plan-editor] 데이터 로드 실패:", err); + toast.error("데이터를 불러오는데 실패했습니다"); + } finally { + setLoading(false); + } + }, [selectedIds, isDesignMode]); + + useEffect(() => { + loadData(); + }, [loadData]); + + // ref 동기화 (이벤트 핸들러에서 최신 state 접근용) + useEffect(() => { + itemGroupsRef.current = itemGroups; + }, [itemGroups]); + + useEffect(() => { + sourceRef.current = source; + }, [source]); + + // 저장 로직 (ref 기반으로 최신 state 접근, 재구독 방지) + const savingRef = useRef(false); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + const configRef = useRef(config); + configRef.current = config; + + const handleSave = useCallback(async () => { + if (savingRef.current) return; + + const currentGroups = itemGroupsRef.current; + const currentSource = sourceRef.current; + const currentConfig = configRef.current; + + const plans = currentGroups.flatMap((g) => + g.details + .filter((d) => d.type === "new" && d.planQty > 0) + .map((d) => ({ sourceId: d.sourceId, planQty: d.planQty, balanceQty: d.balanceQty })) + ); + + if (plans.length === 0) { + toast.warning("저장할 출하계획이 없습니다. 수량을 입력해주세요."); + return; + } + + // 잔량 초과 검증 (allowOverPlan = false일 때) + if (!currentConfig.allowOverPlan) { + const overPlan = plans.find((p) => p.balanceQty > 0 && p.planQty > p.balanceQty); + if (overPlan) { + toast.error("출하계획량이 미출하량을 초과합니다."); + return; + } + } + + // 저장 전 확인 (confirmBeforeSave = true일 때) + if (currentConfig.confirmBeforeSave) { + const msg = currentConfig.confirmMessage || "출하계획을 저장하시겠습니까?"; + if (!window.confirm(msg)) return; + } + + savingRef.current = true; + setSaving(true); + try { + const savePlans = plans.map((p) => ({ sourceId: p.sourceId, planQty: p.planQty })); + const res = await batchSaveShippingPlans(savePlans, currentSource); + if (res.success) { + toast.success(`${plans.length}건의 출하계획이 저장되었습니다.`); + if (currentConfig.autoCloseOnSave !== false && onCloseRef.current) { + onCloseRef.current(); + } + } else { + toast.error(res.error || "출하계획 저장에 실패했습니다."); + } + } catch (err) { + console.error("[v2-shipping-plan-editor] 저장 실패:", err); + toast.error("출하계획 저장 중 오류가 발생했습니다."); + } finally { + savingRef.current = false; + setSaving(false); + } + }, []); + + // V2 이벤트 버스 구독 (마운트 1회만, ref로 최신 핸들러 참조) + const handleSaveRef = useRef(handleSave); + handleSaveRef.current = handleSave; + + useEffect(() => { + let unsubscribe: (() => void) | null = null; + let mounted = true; + + (async () => { + const { v2EventBus, V2_EVENTS } = await import("@/lib/v2-core"); + if (!mounted) return; + unsubscribe = v2EventBus.subscribe(V2_EVENTS.SHIPPING_PLAN_SAVE, () => { + handleSaveRef.current(); + }); + })(); + + return () => { + mounted = false; + if (unsubscribe) unsubscribe(); + }; + }, []); + + const handlePlanQtyChange = useCallback( + (groupIdx: number, detailIdx: number, value: string) => { + setItemGroups((prev) => { + const next = [...prev]; + const group = { ...next[groupIdx] }; + const details = [...group.details]; + const detail = { ...details[detailIdx] }; + + detail.planQty = Number(value) || 0; + details[detailIdx] = detail; + group.details = details; + + const newPlanTotal = details + .filter((d) => d.type === "new") + .reduce((sum, d) => sum + d.planQty, 0); + const existingPlanTotal = details + .filter((d) => d.type === "existing") + .reduce((sum, d) => sum + d.planQty, 0); + + group.aggregation = { + ...group.aggregation, + totalPlanQty: existingPlanTotal + newPlanTotal, + availableStock: + group.aggregation.currentStock - + (existingPlanTotal + newPlanTotal), + }; + + next[groupIdx] = group; + return next; + }); + }, + [] + ); + + if (isDesignMode) { + return ( +
+
+ + + {config.title || "출하계획 등록"} + +
+
+ {[ + "총수주잔량", + "총출하계획량", + "현재고", + "가용재고", + "생산중수량", + ].map((label) => ( +
+ 0 + {label} +
+ ))} +
+
+ 상세 테이블 영역 +
+
+ ); + } + + if (loading || saving) { + return ( +
+
+ + + {saving ? "출하계획 저장 중..." : "데이터 로딩 중..."} + +
+
+ ); + } + + if (selectedIds.length === 0) { + return ( +
+ + 선택된 수주가 없습니다 + +
+ ); + } + + const showSummary = config.showSummaryCards !== false; + const showExisting = config.showExistingPlans !== false; + + return ( +
+ {itemGroups.map((group, groupIdx) => ( +
+
+ + + {group.partName} ({group.partCode}) + +
+ + {showSummary && ( + + )} + + +
+ ))} + +
+ ); +}; + +interface VisibleCards { + totalBalance?: boolean; + totalPlanQty?: boolean; + currentStock?: boolean; + availableStock?: boolean; + inProductionQty?: boolean; +} + +const SummaryCards: React.FC<{ + aggregation: ItemAggregation; + visibleCards?: VisibleCards; +}> = ({ aggregation, visibleCards }) => { + const allCards = [ + { + key: "totalBalance" as const, + label: "총수주잔량", + value: aggregation.totalBalance, + icon: TrendingUp, + color: { + bg: "bg-blue-50", + text: "text-blue-600", + border: "border-blue-200", + }, + }, + { + key: "totalPlanQty" as const, + label: "총출하계획량", + value: aggregation.totalPlanQty, + icon: Truck, + color: { + bg: "bg-indigo-50", + text: "text-indigo-600", + border: "border-indigo-200", + }, + }, + { + key: "currentStock" as const, + label: "현재고", + value: aggregation.currentStock, + icon: Warehouse, + color: { + bg: "bg-emerald-50", + text: "text-emerald-600", + border: "border-emerald-200", + }, + }, + { + key: "availableStock" as const, + label: "가용재고", + value: aggregation.availableStock, + icon: CheckCircle, + color: { + bg: aggregation.availableStock < 0 ? "bg-red-50" : "bg-amber-50", + text: + aggregation.availableStock < 0 + ? "text-red-600" + : "text-amber-600", + border: + aggregation.availableStock < 0 + ? "border-red-200" + : "border-amber-200", + }, + }, + { + key: "inProductionQty" as const, + label: "생산중수량", + value: aggregation.inProductionQty, + icon: Factory, + color: { + bg: "bg-purple-50", + text: "text-purple-600", + border: "border-purple-200", + }, + }, + ]; + + const cards = allCards.filter( + (c) => !visibleCards || visibleCards[c.key] !== false + ); + + return ( +
+ {cards.map((card) => { + const Icon = card.icon; + return ( +
+
+ + + {card.value.toLocaleString()} + +
+ + {card.label} + +
+ ); + })} +
+ ); +}; + +const DetailTable: React.FC<{ + details: PlanDetailRow[]; + groupIdx: number; + onPlanQtyChange: ( + groupIdx: number, + detailIdx: number, + value: string + ) => void; + showExisting?: boolean; +}> = ({ details, groupIdx, onPlanQtyChange, showExisting = true }) => { + const visibleDetails = details + .map((d, idx) => ({ ...d, _origIdx: idx })) + .filter((d) => showExisting || d.type === "new"); + return ( +
+
canDragLeftColumns && handleLeftColumnDragStart(e, idx)} + onDragOver={(e) => canDragLeftColumns && handleLeftColumnDragOver(e, idx)} + onDragEnd={handleLeftColumnDragEnd} + onDrop={(e) => canDragLeftColumns && handleLeftColumnDrop(e, idx)} > + {canDragLeftColumns && } {col.label}
+ + + + + + + + + + + + {visibleDetails.map((detail, detailIdx) => ( + + + + + + + + + ))} + {visibleDetails.length === 0 && ( + + + + )} + +
+ 구분 + + 수주번호 + + 거래처 + + 납기일 + + 미출하 + + 출하계획량 +
+ {detail.type === "existing" ? ( + + 기존 + + ) : ( + + 신규 + + )} + {detail.orderNo}{detail.partnerName} + {detail.dueDate || "-"} + + {detail.balanceQty.toLocaleString()} + + {detail.type === "existing" ? ( + + {detail.planQty.toLocaleString()} + + ) : ( + 0 ? detail.balanceQty : undefined + } + value={detail.planQty || ""} + onChange={(e) => + onPlanQtyChange(groupIdx, detail._origIdx, e.target.value) + } + className="ml-auto h-7 w-24 text-right text-xs" + placeholder="0" + /> + )} +
+ 데이터가 없습니다 +
+
+ ); +}; + +export const ShippingPlanEditorWrapper: React.FC< + ShippingPlanEditorComponentProps +> = (props) => { + return ; +}; diff --git a/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorConfigPanel.tsx b/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorConfigPanel.tsx new file mode 100644 index 00000000..de21012a --- /dev/null +++ b/frontend/lib/registry/components/v2-shipping-plan-editor/ShippingPlanEditorConfigPanel.tsx @@ -0,0 +1,166 @@ +"use client"; + +import React from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; + +interface ShippingPlanEditorConfigPanelProps { + config: any; + onChange: (config: any) => void; +} + +export const ShippingPlanEditorConfigPanel: React.FC< + ShippingPlanEditorConfigPanelProps +> = ({ config, onChange }) => { + const handleChange = (key: string, value: any) => { + onChange({ ...config, [key]: value }); + }; + + const handleSummaryCardToggle = (cardKey: string, checked: boolean) => { + onChange({ + ...config, + visibleSummaryCards: { + ...(config.visibleSummaryCards || defaultSummaryCards), + [cardKey]: checked, + }, + }); + }; + + const defaultSummaryCards = { + totalBalance: true, + totalPlanQty: true, + currentStock: true, + availableStock: true, + inProductionQty: true, + }; + + const summaryCards = config.visibleSummaryCards || defaultSummaryCards; + + const summaryCardLabels: Record = { + totalBalance: "총수주잔량", + totalPlanQty: "총출하계획량", + currentStock: "현재고", + availableStock: "가용재고", + inProductionQty: "생산중수량", + }; + + return ( +
+ {/* 기본 설정 */} +
+ 기본 설정 +
+ +
+ + handleChange("title", e.target.value)} + placeholder="출하계획 등록" + className="h-8 text-xs" + /> +
+ + + + {/* 표시 설정 */} +
+ 표시 설정 +
+ +
+ + + handleChange("showSummaryCards", checked) + } + /> +
+ +
+ + + handleChange("showExistingPlans", checked) + } + /> +
+ + {config.showSummaryCards !== false && ( + <> + +
+ 집계 카드 항목 +
+
+ {Object.entries(summaryCardLabels).map(([key, label]) => ( +
+ + + handleSummaryCardToggle(key, checked) + } + /> +
+ ))} +
+ + )} + + + + {/* 저장 설정 */} +
+ 저장 설정 +
+ +
+ + + handleChange("allowOverPlan", checked) + } + /> +
+ +
+ + + handleChange("autoCloseOnSave", checked) + } + /> +
+ +
+ + + handleChange("confirmBeforeSave", checked) + } + /> +
+ + {config.confirmBeforeSave && ( +
+ +