From b25a6324f883b6814f2bc9e6f0ede9fdf0de9f89 Mon Sep 17 00:00:00 2001 From: johngreen Date: Tue, 19 May 2026 14:48:47 +0900 Subject: [PATCH 01/11] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20CreateTableModal=20=EB=8B=A4=EC=9D=B4=EC=96=BC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20flex=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=E2=80=94=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=9C=20=EB=B3=B8=EB=AC=B8=20+=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=ED=91=B8=ED=84=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/components/admin/CreateTableModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/components/admin/CreateTableModal.tsx b/frontend/components/admin/CreateTableModal.tsx index c50c8e0a..ec591e91 100644 --- a/frontend/components/admin/CreateTableModal.tsx +++ b/frontend/components/admin/CreateTableModal.tsx @@ -322,7 +322,7 @@ export function CreateTableModal({ return ( - + @@ -336,7 +336,7 @@ export function CreateTableModal({ -
+
{/* 테이블 기본 정보 */}
From f73e468f6673199120537cbb8cb3df201f48c4dd Mon Sep 17 00:00:00 2001 From: johngreen Date: Tue, 19 May 2026 14:49:07 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20=EC=BB=AC=EB=9F=BC=20=EB=8B=A8=EA=B1=B4=20DROP=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=E2=80=94=20ColumnGrid=20=E2=8B=AF=20?= =?UTF-8?q?=EB=A9=94=EB=89=B4=EC=97=90=20"=EC=BB=AC=EB=9F=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C"=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DdlService.dropColumn: ALTER TABLE ... DROP COLUMN (CASCADE 미사용 → FK 참조 시 Postgres 거부, DBeaver 동일) - 시스템 테이블 / 예약 컬럼(id/created_date/updated_date/company_code/writer) 보호 - 같은 트랜잭션에서 table_type_columns / column_labels 메타 청소 + ddl_execution_log 기록 - DdlController: DELETE /api/ddl/tables/{table}/columns/{column} (SUPER_ADMIN 전용) - ddlApi.dropColumn 헬퍼 - ColumnGrid: ... 버튼을 DropdownMenu 로 교체, "컬럼 삭제" destructive 메뉴 아이템 - page.tsx: 컬럼 삭제 확인 다이얼로그 + 핸들러, FK 거부 시 토스트로 안내 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../com/erp/controller/DdlController.java | 26 +++++ .../main/java/com/erp/service/DdlService.java | 73 ++++++++++++ .../admin/systemMng/tableMngList/page.tsx | 104 ++++++++++++++++++ .../admin/table-type/ColumnGrid.tsx | 49 ++++++--- frontend/lib/api/ddl.ts | 8 ++ 5 files changed, 246 insertions(+), 14 deletions(-) diff --git a/backend-spring/src/main/java/com/erp/controller/DdlController.java b/backend-spring/src/main/java/com/erp/controller/DdlController.java index 205f2969..3ad8a194 100644 --- a/backend-spring/src/main/java/com/erp/controller/DdlController.java +++ b/backend-spring/src/main/java/com/erp/controller/DdlController.java @@ -91,6 +91,32 @@ public class DdlController { return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message"))); } + /** + * DELETE /api/ddl/tables/{tableName}/columns/{columnName} - 컬럼 삭제 + */ + @DeleteMapping("/tables/{tableName}/columns/{columnName}") + public ResponseEntity> dropColumn( + @PathVariable String tableName, + @PathVariable String columnName, + @RequestAttribute("company_code") String companyCode, + @RequestAttribute("user_id") String userId) { + + if (!isSuperAdmin(companyCode)) { + return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다.")); + } + + Map result = ddlService.dropColumn(tableName, columnName, companyCode, userId); + + if (Boolean.TRUE.equals(result.get("success"))) { + return ResponseEntity.ok(ApiResponse.success(Map.of( + "table_name", result.get("table_name"), + "column_name", result.get("column_name"), + "executed_query", result.get("executed_query") + ), (String) result.get("message"))); + } + return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message"))); + } + /** * DELETE /api/ddl/tables/{tableName} - 테이블 삭제 */ diff --git a/backend-spring/src/main/java/com/erp/service/DdlService.java b/backend-spring/src/main/java/com/erp/service/DdlService.java index 745b810e..0da2f6c6 100644 --- a/backend-spring/src/main/java/com/erp/service/DdlService.java +++ b/backend-spring/src/main/java/com/erp/service/DdlService.java @@ -226,6 +226,79 @@ public class DdlService extends BaseService { } } + // ───────────────────────────────────────────────────────────────────────── + // DROP COLUMN (DBeaver 방식: FK 등 위반은 Postgres 가 던지는 에러를 그대로 노출) + // ───────────────────────────────────────────────────────────────────────── + + public Map dropColumn(String tableName, String columnName, + String companyCode, String userId) { + // 1. 시스템 테이블 보호 + if (SYSTEM_TABLES.contains(tableName.toLowerCase())) { + String errorMsg = "'" + tableName + "'은 시스템 테이블이므로 컬럼을 삭제할 수 없습니다."; + logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, + "SYSTEM_TABLE_PROTECTED", false, errorMsg); + return Map.of("success", false, "message", errorMsg, "error_code", "SYSTEM_TABLE_PROTECTED"); + } + + // 2. 예약 컬럼 보호 (id / created_date / updated_date / company_code / writer) + if (RESERVED_COLUMNS.contains(columnName.toLowerCase()) || "writer".equalsIgnoreCase(columnName)) { + String errorMsg = "'" + columnName + "'은 시스템 예약 컬럼이므로 삭제할 수 없습니다."; + logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, + "RESERVED_COLUMN_PROTECTED", false, errorMsg); + return Map.of("success", false, "message", errorMsg, "error_code", "RESERVED_COLUMN_PROTECTED"); + } + + // 3. 테이블/컬럼 존재 여부 + if (!tableExists(tableName)) { + String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다."; + logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "TABLE_NOT_FOUND", false, errorMsg); + return Map.of("success", false, "message", errorMsg, "error_code", "TABLE_NOT_FOUND"); + } + if (!columnExists(tableName, columnName)) { + String errorMsg = "컬럼 '" + columnName + "'이 존재하지 않습니다."; + logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "COLUMN_NOT_FOUND", false, errorMsg); + return Map.of("success", false, "message", errorMsg, "error_code", "COLUMN_NOT_FOUND"); + } + + // 4. DDL 실행 — CASCADE 안 붙임 → FK 참조 있으면 Postgres 가 거부 (DBeaver 와 동일) + String ddlQuery = "ALTER TABLE \"" + sanitize(tableName) + "\" DROP COLUMN \"" + sanitize(columnName) + "\""; + + try { + transactionTemplate.execute(status -> { + jdbcTemplate.execute(ddlQuery); + // 컬럼 메타 청소 + jdbcTemplate.update( + "DELETE FROM table_type_columns WHERE table_name = ? AND column_name = ?", + tableName, columnName); + jdbcTemplate.update( + "DELETE FROM column_labels WHERE table_name = ? AND column_name = ?", + tableName, columnName); + return null; + }); + + logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, ddlQuery, true, null); + log.info("컬럼 삭제 성공: {}.{}, 사용자: {}", tableName, columnName, userId); + + return Map.of( + "success", true, + "message", "컬럼 '" + columnName + "'이 성공적으로 삭제되었습니다.", + "table_name", tableName, + "column_name", columnName, + "executed_query", ddlQuery + ); + } catch (Exception e) { + String rawMsg = e.getMessage() != null ? e.getMessage() : ""; + String guidance = rawMsg.toLowerCase().contains("depend") || rawMsg.toLowerCase().contains("foreign key") + ? " (다른 테이블에서 외래키로 참조 중인 컬럼은 삭제할 수 없습니다)" + : ""; + String errorMsg = "컬럼 삭제 실패: " + rawMsg + guidance; + logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, + "FAILED: " + rawMsg, false, errorMsg); + log.error("컬럼 삭제 실패: {}.{}, 사용자: {}, 오류: {}", tableName, columnName, userId, rawMsg, e); + return Map.of("success", false, "message", errorMsg, "error_code", "EXECUTION_FAILED"); + } + } + // ───────────────────────────────────────────────────────────────────────── // VALIDATE // ───────────────────────────────────────────────────────────────────────── diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 1054a84f..ec166cbc 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -120,6 +120,9 @@ export default function TableManagementPage() { // 테이블 삭제 확인 다이얼로그 상태 const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [tableToDelete, setTableToDelete] = useState(""); + const [deleteColumnDialogOpen, setDeleteColumnDialogOpen] = useState(false); + const [columnToDelete, setColumnToDelete] = useState(""); + const [isDeletingColumn, setIsDeletingColumn] = useState(false); const [isDeleting, setIsDeleting] = useState(false); // PK/인덱스 관리 상태 @@ -984,7 +987,20 @@ export default function TableManagementPage() { (table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()), ); const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str); + const q = searchTerm.trim().toLowerCase(); + // 검색 매치 강도: 0=정확, 1=시작, 2=포함 — 낮을수록 위 + const matchScore = (t: typeof tables[number]) => { + if (!q) return 0; + const tn = (t.table_name ?? "").toLowerCase(); + const dn = (t.display_name ?? "").toLowerCase(); + if (tn === q || dn === q) return 0; + if (tn.startsWith(q) || dn.startsWith(q)) return 1; + return 2; + }; return filtered.sort((a, b) => { + const sa = matchScore(a); + const sb = matchScore(b); + if (sa !== sb) return sa - sb; const nameA = a.display_name || a.table_name; const nameB = b.display_name || b.table_name; const aKo = isKorean(nameA); @@ -1188,6 +1204,37 @@ export default function TableManagementPage() { setDeleteDialogOpen(true); }; + // 컬럼 삭제 (DBeaver 방식 — FK 참조 있으면 Postgres 가 거부) + const handleDeleteColumnClick = (columnName: string) => { + setColumnToDelete(columnName); + setDeleteColumnDialogOpen(true); + }; + + const handleDeleteColumn = async () => { + if (!selectedTable || !columnToDelete) return; + setIsDeletingColumn(true); + try { + const result = await ddlApi.dropColumn(selectedTable, columnToDelete); + if (result.success) { + toast.success(`컬럼 '${columnToDelete}'이 삭제되었습니다.`); + if (selectedColumn === columnToDelete) setSelectedColumn(null); + await loadColumnTypes(selectedTable); + } else { + showErrorToast("컬럼 삭제에 실패했습니다", result.message, { + guidance: "다른 테이블에서 외래키로 참조 중이거나 종속 객체가 있는지 확인해 주세요.", + }); + } + } catch (error) { + showErrorToast("컬럼 삭제에 실패했습니다", error, { + guidance: "다른 테이블에서 외래키로 참조 중이거나 종속 객체가 있는지 확인해 주세요.", + }); + } finally { + setIsDeletingColumn(false); + setDeleteColumnDialogOpen(false); + setColumnToDelete(""); + } + }; + // 테이블 삭제 실행 const handleDeleteTable = async () => { if (!tableToDelete) return; @@ -1678,6 +1725,7 @@ export default function TableManagementPage() { onIndexToggle={(columnName, checked) => handleIndexToggle(columnName, "index", checked) } + onDeleteColumn={handleDeleteColumnClick} tables={tables} referenceTableColumns={referenceTableColumns} /> @@ -1863,6 +1911,62 @@ export default function TableManagementPage() {
+ + {/* 컬럼 삭제 확인 다이얼로그 */} + + + + 컬럼 삭제 확인 + + 정말 삭제할까요? 이 작업은 되돌릴 수 없습니다. + + + + {columnToDelete && ( +
+
+

경고

+

+ {selectedTable}.{columnToDelete} 컬럼과 해당 컬럼의 + 모든 데이터가 영구적으로 삭제됩니다. +

+
+
+ )} + + + + + +
+
)} diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx index 2a17b716..74191797 100644 --- a/frontend/components/admin/table-type/ColumnGrid.tsx +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -1,9 +1,15 @@ "use client"; import React, { useMemo } from "react"; -import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react"; +import { MoreHorizontal, Database, Layers, FileStack, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; import type { ColumnTypeInfo, TableInfo } from "./types"; import { INPUT_TYPE_COLORS, getColumnGroup } from "./types"; @@ -24,6 +30,7 @@ export interface ColumnGridProps { getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean }; onPkToggle?: (columnName: string, checked: boolean) => void; onIndexToggle?: (columnName: string, checked: boolean) => void; + onDeleteColumn?: (columnName: string) => void; /** 호버 시 한글 라벨 표시용 (Badge title) */ tables?: TableInfo[]; referenceTableColumns?: Record; @@ -57,6 +64,7 @@ export function ColumnGrid({ getColumnIndexState: externalGetIndexState, onPkToggle, onIndexToggle, + onDeleteColumn, tables, referenceTableColumns, }: ColumnGridProps) { @@ -286,19 +294,32 @@ export function ColumnGrid({
- + + + + + e.stopPropagation()}> + { + e.preventDefault(); + onDeleteColumn?.(column.column_name); + }} + > + + 컬럼 삭제 + + +
); diff --git a/frontend/lib/api/ddl.ts b/frontend/lib/api/ddl.ts index 0c372b64..471306f2 100644 --- a/frontend/lib/api/ddl.ts +++ b/frontend/lib/api/ddl.ts @@ -37,6 +37,14 @@ export const ddlApi = { return response.data; }, + /** + * 컬럼 삭제 (ALTER TABLE ... DROP COLUMN) + */ + dropColumn: async (tableName: string, columnName: string): Promise => { + const response = await apiClient.delete(`/ddl/tables/${tableName}/columns/${columnName}`); + return response.data; + }, + /** * 테이블 생성 사전 검증 (실제 생성하지 않고 검증만) */ From 4c5b672f400c285240ed22ce6d6d13fe6315a078 Mon Sep 17 00:00:00 2001 From: johngreen Date: Tue, 19 May 2026 18:44:55 +0900 Subject: [PATCH 03/11] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20=E2=8B=AF=20=EB=93=9C=EB=A1=AD=EB=8B=A4=EC=9A=B4?= =?UTF-8?q?=20=ED=81=B4=EB=A6=AD=EC=9D=B4=20row=20=EB=A1=9C=20=EC=83=88?= =?UTF-8?q?=EC=84=9C=20=EC=83=81=EC=84=B8=20=ED=8C=A8=EB=84=90=EC=9D=B4=20?= =?UTF-8?q?=EC=97=B4=EB=A6=AC=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Radix DropdownMenuTrigger 는 onPointerDown 으로 트리거되는데 기존엔 onClick stopPropagation 만 있어서, 부모 row 의 onClick(=setSelectedColumn)이 같이 발화 → 상세 패널이 슬라이드 in → 중앙 ColumnGrid 가 오버레이에 가려져 squish 처럼 보이던 문제. ⋯ 버튼을 감싸는 div 에 onClick / onPointerDown / onMouseDown 세 가지에 모두 stopPropagation 추가. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/components/admin/table-type/ColumnGrid.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx index 74191797..e2f5610e 100644 --- a/frontend/components/admin/table-type/ColumnGrid.tsx +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -293,7 +293,12 @@ export function ColumnGrid({ -
+
e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > - e.stopPropagation()}> + { From 2f398ae0b3245bef6d9e1c76ba1c6f2fa85c9631 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Tue, 19 May 2026 21:31:03 +0900 Subject: [PATCH 04/11] =?UTF-8?q?chore:=20=EC=A0=9C=EC=96=B4=EB=AA=A8?= =?UTF-8?q?=EB=93=9C=20IDE=20=EC=9E=91=EC=97=85=20+=20v2/legacy=20?= =?UTF-8?q?=EB=A0=88=EC=A7=80=EC=8A=A4=ED=8A=B8=EB=A6=AC=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=ED=8F=90=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control - 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합 - InvLegacyButtonConfigPanel cp 마이그레이션 - canonical data view cleanup 후속 노트 --- .../erp/security/SubstituteContextFilter.java | 14 + .../com/erp/service/ScreenGroupService.java | 20 +- .../src/main/resources/mapper/admin.xml | 96 +- .../src/main/resources/mapper/screenGroup.xml | 12 +- .../app/(main)/screens/[screenId]/page.tsx | 7 +- frontend/app/test-card-responsive/page.tsx | 6 +- .../components/control/ConnectionLine.tsx | 18 +- .../components/control/ControlCardPanel.tsx | 217 ++ frontend/components/control/ControlMode.tsx | 184 +- frontend/components/control/ControlNode.tsx | 118 +- .../components/control/ControlPalette.tsx | 242 +- frontend/components/control/FlowViewer.tsx | 6 +- .../components/control/NodeConfigPopover.tsx | 453 +++- frontend/components/control/PortHandle.tsx | 6 +- frontend/components/control/RuleBuilder.tsx | 115 +- frontend/components/control/TableNode.tsx | 112 +- .../control/hooks/useControlMode.ts | 28 +- .../components/control/hooks/usePortDrag.ts | 81 +- frontend/components/control/ide/Canvas.tsx | 396 +++ .../components/control/ide/ContextBar.tsx | 137 + frontend/components/control/ide/CtrlFab.tsx | 21 + frontend/components/control/ide/LeftRail.tsx | 229 ++ .../components/control/ide/PanZoomStage.tsx | 182 ++ frontend/components/control/ide/RightRail.tsx | 276 +++ frontend/components/control/ide/StatusBar.tsx | 48 + .../components/control/ide/V3RuleNode.tsx | 159 ++ frontend/components/control/schemas.ts | 156 ++ frontend/components/dash/BlockRenderer.tsx | 6 +- frontend/components/dash/DashboardLayout.tsx | 14 +- .../screen/InteractiveScreenViewer.tsx | 11 +- frontend/components/screen/InvyoneStudio.tsx | 48 +- .../components/screen/RealtimePreview.tsx | 11 +- .../screen/RealtimePreviewDynamic.tsx | 29 +- frontend/components/screen/ScreenNode.tsx | 9 +- .../config-panels/button-config/ActionTab.tsx | 50 +- .../screen/config-panels/button/DataTab.tsx | 27 +- .../screen/modals/MultilangSettingsModal.tsx | 43 +- .../screen/panels/ComponentsPanel.tsx | 30 +- .../screen/panels/DetailSettingsPanel.tsx | 5 +- .../screen/panels/V2PropertiesPanel.tsx | 8 +- .../components/screen/widgets/FlowWidget.tsx | 6 +- .../components/screen/widgets/TabsWidget.tsx | 11 +- .../components/unified/UnifiedRepeater.tsx | 2 +- frontend/components/v2/V2List.tsx | 2 +- .../InvLegacyButtonConfigPanel.tsx | 19 +- .../V2AggregationWidgetConfigPanel.tsx | 1085 -------- .../v2/config-panels/V2ListConfigPanel.tsx | 4 +- .../V2StatusCountConfigPanel.tsx | 679 ----- .../config-panels/V2TableListConfigPanel.tsx | 2 +- frontend/contexts/MenuContext.tsx | 24 +- frontend/hooks/useAuth.ts | 62 +- frontend/lib/api/control.ts | 111 + .../lib/registry/DynamicComponentRenderer.tsx | 9 +- .../lib/registry/components/TabsRenderer.tsx | 55 - .../AggregationWidgetComponent.tsx | 314 --- .../AggregationWidgetConfigPanel.tsx | 539 ---- .../AggregationWidgetRenderer.tsx | 12 - .../components/aggregation-widget/index.ts | 43 - .../components/aggregation-widget/types.ts | 67 - .../button-primary/ButtonPrimaryComponent.tsx | 7 +- .../container/ContainerComponent.tsx | 18 + frontend/lib/registry/components/index.ts | 33 +- .../ScreenSplitPanelConfigPanel.tsx | 17 +- .../section-card/SectionCardComponent.tsx | 178 -- .../section-card/SectionCardConfigPanel.tsx | 172 -- .../section-card/SectionCardRenderer.tsx | 27 - .../registry/components/section-card/index.ts | 44 - .../section-paper/SectionPaperComponent.tsx | 148 -- .../section-paper/SectionPaperConfigPanel.tsx | 151 -- .../section-paper/SectionPaperRenderer.tsx | 28 - .../components/section-paper/index.ts | 41 - .../table-list/CardModeRenderer.tsx | 220 -- .../registry/components/table-list/README.md | 241 -- .../table-list/SingleTableWithSticky.tsx | 376 --- .../table-list/TableListRenderer.tsx | 76 - .../registry/components/table-list/index.ts | 112 - .../registry/components/table-list/types.ts | 348 --- .../_shared}/CardModeRenderer.tsx | 36 +- .../table/_shared/SingleTableWithSticky.tsx | 591 +++++ .../_shared}/TableListComponent.tsx | 2 +- .../_shared}/TableListConfigPanel.tsx | 3 +- .../_shared/V2TableListComponent.tsx} | 4 +- .../_shared/V2TableListContainerWrapper.tsx} | 2 +- .../_shared/tableListConfigTypes.ts} | 6 +- .../components/tabs/tabs-component.tsx | 70 - .../UniversalFormModalConfigPanel.tsx | 6 +- .../AggregationWidgetComponent.tsx | 851 ------- .../AggregationWidgetConfigPanel.tsx | 1189 --------- .../AggregationWidgetRenderer.tsx | 12 - .../components/v2-aggregation-widget/index.ts | 57 - .../components/v2-aggregation-widget/types.ts | 120 - .../ButtonPrimaryComponent.tsx | 15 +- .../v2-section-card/SectionCardComponent.tsx | 178 -- .../SectionCardConfigPanel.tsx | 172 -- .../v2-section-card/SectionCardRenderer.tsx | 27 - .../components/v2-section-card/index.ts | 44 - .../SectionPaperComponent.tsx | 148 -- .../SectionPaperConfigPanel.tsx | 151 -- .../v2-section-paper/SectionPaperRenderer.tsx | 28 - .../components/v2-section-paper/index.ts | 41 - .../SplitPanelLayoutComponent.tsx | 21 +- .../v2-status-count/StatusCountComponent.tsx | 144 -- .../StatusCountConfigPanel.tsx | 508 ---- .../v2-status-count/StatusCountRenderer.tsx | 16 - .../components/v2-status-count/index.ts | 39 - .../components/v2-status-count/types.ts | 29 - .../components/v2-table-list/README.md | 241 -- .../v2-table-list/SingleTableWithSticky.tsx | 472 ---- .../v2-table-list/TableListConfigPanel.tsx | 1640 ------------ .../v2-table-list/TableListRenderer.tsx | 76 - .../config-panels/BasicConfigPanel.tsx | 125 - .../config-panels/ColumnsConfigPanel.tsx | 565 ----- .../config-panels/OptionsConfigPanel.tsx | 290 --- .../config-panels/StyleConfigPanel.tsx | 129 - .../components/v2-table-list/index.ts | 135 - .../v2-tabs-widget/TabsRenderer.tsx | 55 - .../v2-tabs-widget/tabs-component.tsx | 611 ----- .../lib/registry/hoc/withContainerQuery.css | 11 +- frontend/lib/schemas/componentConfig.ts | 122 +- frontend/lib/utils/buttonActions.ts | 38 +- frontend/lib/utils/componentTypeUtils.ts | 100 + .../lib/utils/getComponentConfigPanel.tsx | 37 +- frontend/lib/utils/layoutV2Converter.ts | 26 +- frontend/lib/utils/multilangLabelExtractor.ts | 56 +- frontend/lib/utils/responsiveDefaults.ts | 13 +- frontend/lib/utils/templateMigrate.ts | 11 +- frontend/styles/control-mode.css | 2207 ++++++++++++++++- frontend/types/component-events.ts | 4 +- frontend/types/v2-components.ts | 7 +- .../2026-05-14-container-twin/index.html | 1833 +++++++++++++- .../2026-05-15-numbering-rule-clean-v4.html | 1962 +++++++++++++++ ...5-18-canonical-data-view-cleanup-report.md | 171 ++ .../2026-05-18-canonical-data-view-goal.md | 1089 ++++++++ ...2026-05-18-claude-goal-prompt-v2-strict.md | 107 + notes/gbpark/2026-05-18-claude-goal-prompt.md | 162 ++ ...19-canonical-data-view-cleanup-followup.md | 461 ++++ ...6-05-19-claude-cleanup-scope-fix-prompt.md | 210 ++ .../2026-05-19-control-mockup/index.html | 1272 ++++++++++ 138 files changed, 13662 insertions(+), 13999 deletions(-) create mode 100644 frontend/components/control/ControlCardPanel.tsx create mode 100644 frontend/components/control/ide/Canvas.tsx create mode 100644 frontend/components/control/ide/ContextBar.tsx create mode 100644 frontend/components/control/ide/CtrlFab.tsx create mode 100644 frontend/components/control/ide/LeftRail.tsx create mode 100644 frontend/components/control/ide/PanZoomStage.tsx create mode 100644 frontend/components/control/ide/RightRail.tsx create mode 100644 frontend/components/control/ide/StatusBar.tsx create mode 100644 frontend/components/control/ide/V3RuleNode.tsx create mode 100644 frontend/components/control/schemas.ts delete mode 100644 frontend/components/v2/config-panels/V2AggregationWidgetConfigPanel.tsx delete mode 100644 frontend/components/v2/config-panels/V2StatusCountConfigPanel.tsx create mode 100644 frontend/lib/api/control.ts delete mode 100644 frontend/lib/registry/components/TabsRenderer.tsx delete mode 100644 frontend/lib/registry/components/aggregation-widget/AggregationWidgetComponent.tsx delete mode 100644 frontend/lib/registry/components/aggregation-widget/AggregationWidgetConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/aggregation-widget/AggregationWidgetRenderer.tsx delete mode 100644 frontend/lib/registry/components/aggregation-widget/index.ts delete mode 100644 frontend/lib/registry/components/aggregation-widget/types.ts delete mode 100644 frontend/lib/registry/components/section-card/SectionCardComponent.tsx delete mode 100644 frontend/lib/registry/components/section-card/SectionCardConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/section-card/SectionCardRenderer.tsx delete mode 100644 frontend/lib/registry/components/section-card/index.ts delete mode 100644 frontend/lib/registry/components/section-paper/SectionPaperComponent.tsx delete mode 100644 frontend/lib/registry/components/section-paper/SectionPaperConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/section-paper/SectionPaperRenderer.tsx delete mode 100644 frontend/lib/registry/components/section-paper/index.ts delete mode 100644 frontend/lib/registry/components/table-list/CardModeRenderer.tsx delete mode 100644 frontend/lib/registry/components/table-list/README.md delete mode 100644 frontend/lib/registry/components/table-list/SingleTableWithSticky.tsx delete mode 100644 frontend/lib/registry/components/table-list/TableListRenderer.tsx delete mode 100644 frontend/lib/registry/components/table-list/index.ts delete mode 100644 frontend/lib/registry/components/table-list/types.ts rename frontend/lib/registry/components/{v2-table-list => table/_shared}/CardModeRenderer.tsx (85%) create mode 100644 frontend/lib/registry/components/table/_shared/SingleTableWithSticky.tsx rename frontend/lib/registry/components/{table-list => table/_shared}/TableListComponent.tsx (99%) rename frontend/lib/registry/components/{table-list => table/_shared}/TableListConfigPanel.tsx (99%) rename frontend/lib/registry/components/{v2-table-list/TableListComponent.tsx => table/_shared/V2TableListComponent.tsx} (99%) rename frontend/lib/registry/components/{v2-table-list/TableListContainerWrapper.tsx => table/_shared/V2TableListContainerWrapper.tsx} (98%) rename frontend/lib/registry/components/{v2-table-list/types.ts => table/_shared/tableListConfigTypes.ts} (98%) delete mode 100644 frontend/lib/registry/components/tabs/tabs-component.tsx delete mode 100644 frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetComponent.tsx delete mode 100644 frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/v2-aggregation-widget/AggregationWidgetRenderer.tsx delete mode 100644 frontend/lib/registry/components/v2-aggregation-widget/index.ts delete mode 100644 frontend/lib/registry/components/v2-aggregation-widget/types.ts delete mode 100644 frontend/lib/registry/components/v2-section-card/SectionCardComponent.tsx delete mode 100644 frontend/lib/registry/components/v2-section-card/SectionCardConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/v2-section-card/SectionCardRenderer.tsx delete mode 100644 frontend/lib/registry/components/v2-section-card/index.ts delete mode 100644 frontend/lib/registry/components/v2-section-paper/SectionPaperComponent.tsx delete mode 100644 frontend/lib/registry/components/v2-section-paper/SectionPaperConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/v2-section-paper/SectionPaperRenderer.tsx delete mode 100644 frontend/lib/registry/components/v2-section-paper/index.ts delete mode 100644 frontend/lib/registry/components/v2-status-count/StatusCountComponent.tsx delete mode 100644 frontend/lib/registry/components/v2-status-count/StatusCountConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/v2-status-count/StatusCountRenderer.tsx delete mode 100644 frontend/lib/registry/components/v2-status-count/index.ts delete mode 100644 frontend/lib/registry/components/v2-status-count/types.ts delete mode 100644 frontend/lib/registry/components/v2-table-list/README.md delete mode 100644 frontend/lib/registry/components/v2-table-list/SingleTableWithSticky.tsx delete mode 100644 frontend/lib/registry/components/v2-table-list/TableListConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/v2-table-list/TableListRenderer.tsx delete mode 100644 frontend/lib/registry/components/v2-table-list/config-panels/BasicConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/v2-table-list/config-panels/ColumnsConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/v2-table-list/config-panels/OptionsConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/v2-table-list/config-panels/StyleConfigPanel.tsx delete mode 100644 frontend/lib/registry/components/v2-table-list/index.ts delete mode 100644 frontend/lib/registry/components/v2-tabs-widget/TabsRenderer.tsx delete mode 100644 frontend/lib/registry/components/v2-tabs-widget/tabs-component.tsx create mode 100644 notes/gbpark/2026-05-15-numbering-rule-clean-v4.html create mode 100644 notes/gbpark/2026-05-18-canonical-data-view-cleanup-report.md create mode 100644 notes/gbpark/2026-05-18-canonical-data-view-goal.md create mode 100644 notes/gbpark/2026-05-18-claude-goal-prompt-v2-strict.md create mode 100644 notes/gbpark/2026-05-18-claude-goal-prompt.md create mode 100644 notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md create mode 100644 notes/gbpark/2026-05-19-claude-cleanup-scope-fix-prompt.md create mode 100644 notes/gbpark/2026-05-19-control-mockup/index.html diff --git a/backend-spring/src/main/java/com/erp/security/SubstituteContextFilter.java b/backend-spring/src/main/java/com/erp/security/SubstituteContextFilter.java index a9e947f2..aea05e8b 100644 --- a/backend-spring/src/main/java/com/erp/security/SubstituteContextFilter.java +++ b/backend-spring/src/main/java/com/erp/security/SubstituteContextFilter.java @@ -57,6 +57,13 @@ public class SubstituteContextFilter extends OncePerRequestFilter { return; } + // 대무자 컨텍스트가 의미 없는 경로 skip — 초기 페이지 로드 latency 의 큰 부분. + // ApprovalController 만 effective_user_ids 를 참조하므로 결재 외 경로는 DB 조회 불필요. + if (isSkippablePath(path)) { + chain.doFilter(request, response); + return; + } + String userId = (String) request.getAttribute("user_id"); String companyCode = (String) request.getAttribute("company_code"); @@ -85,4 +92,11 @@ public class SubstituteContextFilter extends OncePerRequestFilter { chain.doFilter(request, response); } + + private static boolean isSkippablePath(String path) { + return path.startsWith("/api/auth/") + || path.equals("/api/admin/menus") + || path.equals("/api/admin/user-menus") + || path.equals("/api/admin/user-locale"); + } } diff --git a/backend-spring/src/main/java/com/erp/service/ScreenGroupService.java b/backend-spring/src/main/java/com/erp/service/ScreenGroupService.java index 5b3a7186..acb3facf 100644 --- a/backend-spring/src/main/java/com/erp/service/ScreenGroupService.java +++ b/backend-spring/src/main/java/com/erp/service/ScreenGroupService.java @@ -16,6 +16,18 @@ public class ScreenGroupService extends BaseService { private static final String NS = "screenGroup."; + /** + * canonical table / legacy table-list / hidden v2-table-list 위젯 카운트 합산. + * screen type inference 시 셋 모두 grid 화면으로 인식해야 한다 (frontend + * isTableLikeComponentType 와 동일 정책 — 2026-05-19 canonical cleanup follow-up). + */ + private static int countTableLikeWidgets(Map widgetCounts) { + if (widgetCounts == null) return 0; + return widgetCounts.getOrDefault("table", 0) + + widgetCounts.getOrDefault("table-list", 0) + + widgetCounts.getOrDefault("v2-table-list", 0); + } + // ══════════════════════════════════════════════════════════════ // Screen Groups // ══════════════════════════════════════════════════════════════ @@ -356,8 +368,10 @@ public class ScreenGroupService extends BaseService { } // 화면 타입 추론 + // table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list') + // 어느 것이든 있으면 grid 로 본다. String screenType = "form"; - if (widgetCounts.getOrDefault("table", 0) > 0) { + if (countTableLikeWidgets(widgetCounts) > 0) { screenType = "grid"; } else if (widgetCounts.getOrDefault("custom", 0) > 2) { screenType = "dashboard"; @@ -433,11 +447,11 @@ public class ScreenGroupService extends BaseService { if (bottomEdge > toInt(summary.get("canvas_height"))) summary.put("canvas_height", bottomEdge); } - // 화면 타입 추론 + // 화면 타입 추론 — canonical / legacy / hidden v2 모두 grid 로 인식 summaryMap.values().forEach(summary -> { @SuppressWarnings("unchecked") Map wc = (Map) summary.get("widget_counts"); - if (wc.getOrDefault("table-list", 0) > 0) { + if (countTableLikeWidgets(wc) > 0) { summary.put("screen_type", "grid"); } else if (wc.getOrDefault("table-search-widget", 0) > 1) { summary.put("screen_type", "dashboard"); diff --git a/backend-spring/src/main/resources/mapper/admin.xml b/backend-spring/src/main/resources/mapper/admin.xml index 91a64af0..a68ef002 100644 --- a/backend-spring/src/main/resources/mapper/admin.xml +++ b/backend-spring/src/main/resources/mapper/admin.xml @@ -130,26 +130,8 @@ , COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC , COALESCE(V.MENU_ICON, '') AS MENU_ICON , COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME - , COALESCE( - (SELECT MLT.LANG_TEXT - FROM MULTI_LANG_KEY_MASTER MLKM - JOIN MULTI_LANG_TEXT MLT - ON MLKM.KEY_ID = MLT.KEY_ID - WHERE MLKM.LANG_KEY = V.LANG_KEY - AND MLT.LANG_CODE = #{user_lang} - LIMIT 1), - V.MENU_NAME_KOR - ) AS TRANSLATED_NAME - , COALESCE( - (SELECT MLT.LANG_TEXT - FROM MULTI_LANG_KEY_MASTER MLKM - JOIN MULTI_LANG_TEXT MLT - ON MLKM.KEY_ID = MLT.KEY_ID - WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC - AND MLT.LANG_CODE = #{user_lang} - LIMIT 1), - COALESCE(V.MENU_DESC, '') - ) AS TRANSLATED_DESC + , COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME + , COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC , CASE UPPER(V.STATUS) WHEN 'ACTIVE' THEN '활성화' WHEN 'INACTIVE' THEN '비활성화' @@ -158,6 +140,16 @@ FROM V_MENU V LEFT JOIN COMPANY_MNG CM ON V.COMPANY_CODE = CM.COMPANY_CODE + LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME + ON MLKM_NAME.LANG_KEY = V.LANG_KEY + LEFT JOIN MULTI_LANG_TEXT MLT_NAME + ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID + AND MLT_NAME.LANG_CODE = #{user_lang} + LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC + ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC + LEFT JOIN MULTI_LANG_TEXT MLT_DESC + ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID + AND MLT_DESC.LANG_CODE = #{user_lang} ORDER BY V.PATH, V.SEQ @@ -243,26 +235,8 @@ , COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC , COALESCE(V.MENU_ICON, '') AS MENU_ICON , COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME - , COALESCE( - (SELECT MLT.LANG_TEXT - FROM MULTI_LANG_KEY_MASTER MLKM - JOIN MULTI_LANG_TEXT MLT - ON MLKM.KEY_ID = MLT.KEY_ID - WHERE MLKM.LANG_KEY = V.LANG_KEY - AND MLT.LANG_CODE = #{user_lang} - LIMIT 1), - V.MENU_NAME_KOR - ) AS TRANSLATED_NAME - , COALESCE( - (SELECT MLT.LANG_TEXT - FROM MULTI_LANG_KEY_MASTER MLKM - JOIN MULTI_LANG_TEXT MLT - ON MLKM.KEY_ID = MLT.KEY_ID - WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC - AND MLT.LANG_CODE = #{user_lang} - LIMIT 1), - COALESCE(V.MENU_DESC, '') - ) AS TRANSLATED_DESC + , COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME + , COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC , CASE UPPER(V.STATUS) WHEN 'ACTIVE' THEN '활성화' WHEN 'INACTIVE' THEN '비활성화' @@ -271,6 +245,16 @@ FROM V_MENU V LEFT JOIN COMPANY_MNG CM ON V.COMPANY_CODE = CM.COMPANY_CODE + LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME + ON MLKM_NAME.LANG_KEY = V.LANG_KEY + LEFT JOIN MULTI_LANG_TEXT MLT_NAME + ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID + AND MLT_NAME.LANG_CODE = #{user_lang} + LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC + ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC + LEFT JOIN MULTI_LANG_TEXT MLT_DESC + ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID + AND MLT_DESC.LANG_CODE = #{user_lang} ORDER BY V.PATH, V.SEQ @@ -377,26 +361,8 @@ , COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC , COALESCE(V.MENU_ICON, '') AS MENU_ICON , COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME - , COALESCE( - (SELECT MLT.LANG_TEXT - FROM MULTI_LANG_KEY_MASTER MLKM - JOIN MULTI_LANG_TEXT MLT - ON MLKM.KEY_ID = MLT.KEY_ID - WHERE MLKM.LANG_KEY = V.LANG_KEY - AND MLT.LANG_CODE = #{user_lang} - LIMIT 1), - V.MENU_NAME_KOR - ) AS TRANSLATED_NAME - , COALESCE( - (SELECT MLT.LANG_TEXT - FROM MULTI_LANG_KEY_MASTER MLKM - JOIN MULTI_LANG_TEXT MLT - ON MLKM.KEY_ID = MLT.KEY_ID - WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC - AND MLT.LANG_CODE = #{user_lang} - LIMIT 1), - COALESCE(V.MENU_DESC, '') - ) AS TRANSLATED_DESC + , COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME + , COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC , CASE UPPER(V.STATUS) WHEN 'ACTIVE' THEN '활성화' WHEN 'INACTIVE' THEN '비활성화' @@ -405,6 +371,16 @@ FROM V_MENU V LEFT JOIN COMPANY_MNG CM ON V.COMPANY_CODE = CM.COMPANY_CODE + LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME + ON MLKM_NAME.LANG_KEY = V.LANG_KEY + LEFT JOIN MULTI_LANG_TEXT MLT_NAME + ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID + AND MLT_NAME.LANG_CODE = #{user_lang} + LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC + ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC + LEFT JOIN MULTI_LANG_TEXT MLT_DESC + ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID + AND MLT_DESC.LANG_CODE = #{user_lang} ORDER BY V.PATH, V.SEQ diff --git a/backend-spring/src/main/resources/mapper/screenGroup.xml b/backend-spring/src/main/resources/mapper/screenGroup.xml index 35282244..ee380e5c 100644 --- a/backend-spring/src/main/resources/mapper/screenGroup.xml +++ b/backend-spring/src/main/resources/mapper/screenGroup.xml @@ -704,11 +704,19 @@ AND SL.PROPERTIES->'componentConfig'->'action'->>'type' = 'save' AND SL.PROPERTIES->'componentConfig'->'action'->>'targetScreenId' IS NULL + AND NOT EXISTS ( SELECT 1 FROM SCREEN_LAYOUTS SL_LIST WHERE SL_LIST.SCREEN_ID = SD.SCREEN_ID - AND SL_LIST.PROPERTIES->>'componentType' = 'table-list' - AND (SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE + AND SL_LIST.PROPERTIES->>'componentType' IN ('table', 'table-list', 'v2-table-list') + AND ( + (SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE + OR (SL_LIST.PROPERTIES->'componentConfig'->>'showCheckbox')::BOOLEAN = TRUE + ) ) AND NOT EXISTS ( SELECT 1 FROM SCREEN_LAYOUTS SL_MODAL diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 99aba929..e07d85f7 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -10,6 +10,7 @@ import { LayerDefinition } from "@/types/screen-management"; import { useRouter } from "next/navigation"; import { showErrorToast } from "@/lib/utils/toastUtils"; import { initializeComponents } from "@/lib/registry/components"; +import { isTableLikeComponent } from "@/lib/utils/componentTypeUtils"; import { EditModal } from "@/components/screen/EditModal"; import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; @@ -428,10 +429,8 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = { } // 테이블 위젯이 있으면 자동 로드 건너뜀 (테이블 행 선택으로 데이터 로드) - const hasTableWidget = layout.components.some( - (comp: any) => - comp.componentType === "table-list" || comp.componentType === "v2-table-list" || comp.widgetType === "table", - ); + // canonical table / legacy table-list / hidden v2-table-list / widgetType=table 모두 동일하게 skip + const hasTableWidget = layout.components.some((comp: any) => isTableLikeComponent(comp)); if (hasTableWidget) { return; diff --git a/frontend/app/test-card-responsive/page.tsx b/frontend/app/test-card-responsive/page.tsx index c9a7fc4b..7b4d3f92 100644 --- a/frontend/app/test-card-responsive/page.tsx +++ b/frontend/app/test-card-responsive/page.tsx @@ -92,10 +92,10 @@ export default function TestCardResponsivePage() { {/* ── 1. v2-text-display (경량, 항상 동일) ── */}
수주관리
- {/* ── 2. v2-aggregation-widget (경량, container-type 만 부착) ── */} + {/* ── 2. canonical stats (경량, container-type 만 부착) ── */}
{[ { label: "전체", v: "128" }, @@ -214,7 +214,7 @@ export default function TestCardResponsivePage() { 같은 조건에서 v2-table-search-widget 의 필터/버튼이 가로 → 세로 스택으로 재배열 (CSS @container 기반).
  • - 나머지 컴포넌트(text-display, aggregation-widget, button-primary)는 container-type: inline-size 만 부착된 상태. + 나머지 컴포넌트(text-display, stats, button-primary)는 container-type: inline-size 만 부착된 상태. 모드 분기는 Phase 2 에서 개별 재작성.
  • diff --git a/frontend/components/control/ConnectionLine.tsx b/frontend/components/control/ConnectionLine.tsx index 7328bc80..9388df74 100644 --- a/frontend/components/control/ConnectionLine.tsx +++ b/frontend/components/control/ConnectionLine.tsx @@ -41,10 +41,22 @@ export function ConnectionSvg({ children }: ConnectionSvgProps) { ); } -/** bezier 경로 계산: from(x1,y1) → to(x2,y2) */ +/** + * 연결선 path — mockup v3 EditCanvas 의 orthogonal-with-rounded-corners 스타일 + * from(x1,y1) → 가로 → 둥근 코너 → 세로 → 둥근 코너 → 가로 → to(x2,y2) + * 같은 y 면 직선, 역방향(x1>x2)이면 부드러운 베지어로 fallback (어색한 backward 회피) + */ export function bezierPath(x1: number, y1: number, x2: number, y2: number): string { - const dx = x2 - x1; - return `M${x1},${y1} C${x1 + dx * 0.5},${y1} ${x1 + dx * 0.5},${y2} ${x2},${y2}`; + // 역방향 (오른쪽→왼쪽): 직각 라우팅이 카드 위로 휘감으면 어색 → 베지어 사용 + if (x2 < x1 - 20) { + const dx = x2 - x1; + return `M ${x1} ${y1} C ${x1 + Math.abs(dx) * 0.4} ${y1}, ${x2 - Math.abs(dx) * 0.4} ${y2}, ${x2} ${y2}`; + } + const sign = Math.sign(y2 - y1); + if (sign === 0) return `M ${x1} ${y1} L ${x2} ${y2}`; + const mx = (x1 + x2) / 2; + const r = Math.min(10, Math.abs(y2 - y1) / 2, Math.abs(x2 - x1) / 4); + return `M ${x1} ${y1} L ${mx - r} ${y1} Q ${mx} ${y1}, ${mx} ${y1 + sign * r} L ${mx} ${y2 - sign * r} Q ${mx} ${y2}, ${mx + r} ${y2} L ${x2} ${y2}`; } /** 타입별 CSS 클래스 + 마커 */ diff --git a/frontend/components/control/ControlCardPanel.tsx b/frontend/components/control/ControlCardPanel.tsx new file mode 100644 index 00000000..c265bc6a --- /dev/null +++ b/frontend/components/control/ControlCardPanel.tsx @@ -0,0 +1,217 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Eye, Wrench, Save, FolderOpen, X, Database } from 'lucide-react'; +import { useControlMode } from './hooks/useControlMode'; +import { + getBusinessRuleList, + getBusinessRuleInfo, + insertBusinessRule, + updateBusinessRule, +} from '@/lib/api/businessRule'; +import { toast } from 'sonner'; + +interface ControlCardPanelProps { + dashboardId: string; + card: Record; +} + +/** + * 선택된 카드의 부속 제어 패널 + * - 카드가 좌측 상단(예: left:20, top:90, 320x240)으로 축소되면 + * 이 패널이 카드 바로 오른쪽 (left:360, top:90 부근)에 floating + * - 패널 안: 카드명 / [읽기 | 편집] 토글 / 액션 / ✕ 닫기 + */ +export function ControlCardPanel({ dashboardId, card }: ControlCardPanelProps) { + const { + mode, + setMode, + setSelectedCardId, + ruleNodes, + ruleConnections, + activeRuleId, + setActiveRuleId, + setRuleNodes, + setRuleConnections, + } = useControlMode(); + const [ruleList, setRuleList] = useState[]>([]); + const [showRuleList, setShowRuleList] = useState(false); + + const cardLabel = + card.title ?? card.TITLE ?? card.template_name ?? card.TEMPLATE_NAME ?? '제목 없음'; + const cardTable = + card.primary_table ?? card.PRIMARY_TABLE ?? card.source_table ?? card.SOURCE_TABLE ?? null; + const cardType = + card.component_type ?? card.COMPONENT_TYPE ?? card.template_type ?? card.TEMPLATE_TYPE ?? null; + + // 편집 모드에서만 규칙 목록 로드 + useEffect(() => { + if (mode !== 'edit') return; + getBusinessRuleList(dashboardId) + .then((res) => setRuleList(res?.list ?? res?.data ?? [])) + .catch(() => setRuleList([])); + }, [mode, dashboardId]); + + const handleLoadRule = useCallback( + async (ruleId: string) => { + try { + const detail = await getBusinessRuleInfo(ruleId); + if (!detail) { + toast.error('규칙을 찾을 수 없습니다'); + return; + } + setRuleNodes(detail.nodes ?? []); + setRuleConnections(detail.connections ?? []); + setActiveRuleId(ruleId); + setShowRuleList(false); + toast.success(`"${detail.name ?? ruleId}" 로드됨`); + } catch { + toast.error('규칙 로드 실패'); + } + }, + [setRuleNodes, setRuleConnections, setActiveRuleId], + ); + + const handleSave = async () => { + if (ruleNodes.length === 0) { + toast.warning('저장할 노드가 없습니다'); + return; + } + try { + const data = { + name: `${cardLabel} 규칙 ${new Date().toLocaleString('ko-KR')}`, + nodes: ruleNodes, + connections: ruleConnections, + card_id: card.card_id ?? card.CARD_ID ?? card.id, + }; + if (activeRuleId) { + await updateBusinessRule(activeRuleId, data); + toast.success('규칙 저장됨'); + } else { + const result = await insertBusinessRule(dashboardId, data); + if (result?.rule_id) setActiveRuleId(result.rule_id); + toast.success('규칙 생성됨'); + } + } catch { + toast.error('저장 실패'); + } + }; + + const handleSourceDragStart = (e: React.DragEvent) => { + if (!cardTable) return; + e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'table', name: cardTable })); + e.dataTransfer.effectAllowed = 'copy'; + }; + + return ( +
    + {/* 헤더 — "제어" + ✕ 닫기 (카드명은 좌측 카드 자체에 이미 보이므로 중복 X) */} +
    +
    +
    +
    제어
    + {cardType &&
    {cardType}
    } +
    + +
    + + {/* 데이터 소스 칩 (드래그 가능, 편집 모드에서 룰 빌더로 추가) */} + {cardTable && ( +
    + + + {cardTable} + +
    + )} + + {/* 모드 토글 — 카드 컨텍스트 안의 segmented */} +
    + + +
    + + {/* 편집 모드 액션 */} + {mode === 'edit' && ( + <> +
    +
    + + {showRuleList && ruleList.length > 0 && ( +
    + {ruleList.map((rule) => { + const id = rule.rule_id ?? rule.RULE_ID; + const name = rule.name ?? rule.NAME ?? id; + const isActive = id === activeRuleId; + return ( + + ); + })} +
    + )} +
    + +
    + {ruleNodes.length > 0 && ( +
    + {ruleNodes.length}개 노드 · {ruleConnections.length}개 연결 +
    + )} + + )} + + {mode === 'view' && ( +
    + 우측에 데이터 흐름이 자동으로 펼쳐집니다 +
    + )} +
    + ); +} diff --git a/frontend/components/control/ControlMode.tsx b/frontend/components/control/ControlMode.tsx index 3dbf9e45..9bed40b0 100644 --- a/frontend/components/control/ControlMode.tsx +++ b/frontend/components/control/ControlMode.tsx @@ -1,11 +1,17 @@ 'use client'; -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; +import { MousePointerClick } from 'lucide-react'; import { useControlMode } from './hooks/useControlMode'; -import { ControlToolbar } from './ControlToolbar'; -import { ControlPalette } from './ControlPalette'; import { FlowViewer } from './FlowViewer'; -import { RuleBuilder } from './RuleBuilder'; +import { getMetaFields } from '@/lib/api/meta'; +import type { FieldConfig } from '@/types/invyone-component'; +import { ContextBar } from './ide/ContextBar'; +import { LeftRail } from './ide/LeftRail'; +import { RightRail } from './ide/RightRail'; +import { Canvas } from './ide/Canvas'; +import { StatusBar } from './ide/StatusBar'; +import { CtrlFab } from './ide/CtrlFab'; import '@/styles/control-mode.css'; interface ControlModeProps { @@ -15,43 +21,165 @@ interface ControlModeProps { } /** - * 제어 모드 오버레이 — 캔버스 위에 렌더 - * ⚡ 버튼으로 토글, 읽기/편집 모드 전환 + * 제어 모드 — Control IDE (v3 V3Takeover 베이스) + * + * 흐름: + * 1) ⚡ 토글 ON → 대시보드 카드들이 흐려지고 FlowViewer 가 호버 토폴로지 표시 + ctrl-mode-hint + FAB + * 2) 카드 클릭 → IDE 5-분할 takeover (ContextBar / LeftRail / Canvas / RightRail / StatusBar) + * 3) ContextBar 의 4-segmented tabs 로 READ / EDIT / RUN / HISTORY 전환 + * 4) ContextBar 의 ✕ 닫기 → 카드 선택 해제 (제어 유지) + * 5) ContextBar 의 제어 종료 → 제어 모드 OFF */ export function ControlMode({ dashboardId, cards, canvasRef }: ControlModeProps) { - const { active, mode } = useControlMode(); + const active = useControlMode((s) => s.active); + const mode = useControlMode((s) => s.mode); + const selectedCardId = useControlMode((s) => s.selectedCardId); + const tablePositions = useControlMode((s) => s.tablePositions); + const flowEdges = useControlMode((s) => s.flowEdges); + const setSelectedCardId = useControlMode((s) => s.setSelectedCardId); + const toggleControlMode = useControlMode((s) => s.toggleControlMode); + const setRuleNodes = useControlMode((s) => s.setRuleNodes); + const setRuleConnections = useControlMode((s) => s.setRuleConnections); + const editInitDone = useRef(null); + + const selectedCard = selectedCardId + ? cards.find((c) => (c.card_id ?? c.CARD_ID ?? c.id) === selectedCardId) ?? null + : null; + + // edit 진입 시 자동 노드 등록: + // - view 에서 펼쳐진 테이블이 있으면 그것들 + 관계선 + // - 없으면 primary_table 1개만 좌측에 카드로 등장 (사용자가 거기서 컬럼별 마우스 연결로 룰 작성) + useEffect(() => { + if (!active || mode !== 'edit' || !selectedCardId) return; + const key = `${selectedCardId}:${mode}:${Object.keys(tablePositions).join(',')}`; + if (editInitDone.current === key) return; + const { ruleNodes } = useControlMode.getState(); + if (ruleNodes.length > 0) { + editInitDone.current = key; + return; + } + editInitDone.current = key; + + const hasView = Object.keys(tablePositions).length > 0; + + if (!hasView) return; // primary_table 자동 등장 X — 사용자가 LeftRail 에서 드래그할 때만 추가 + + // view 에서 펼쳐진 테이블 우선 + if (hasView) { + const tableIdMap: Record = {}; + Object.keys(tablePositions).forEach((name) => { tableIdMap[name] = `tbl-${name}`; }); + const xs = Object.values(tablePositions).map((p) => p.x); + const ys = Object.values(tablePositions).map((p) => p.y); + const minX = xs.length ? Math.min(...xs) : 0; + const minY = ys.length ? Math.min(...ys) : 0; + + (async () => { + const newNodes: Record[] = await Promise.all( + Object.entries(tablePositions).map(async ([name, pos]) => { + let columns: FieldConfig[] = []; + let label = name; + try { + const meta = await getMetaFields(name); + columns = (meta.fields ?? []).filter((f: FieldConfig) => !f.system); // 모든 컬럼 로드 (Phase 2 dropdown 용) + label = meta.table_label ?? name; + } catch { /* 빈 컬럼 */ } + return { + id: tableIdMap[name], type: 'table', table_name: name, label, + x: pos.x - minX + 50, y: pos.y - minY + 50, columns, + }; + }), + ); + const newConns: Record[] = []; + flowEdges.forEach((edge: Record, i: number) => { + if (typeof edge.from === 'string' && edge.from.startsWith('CARD:')) return; + const fromId = tableIdMap[edge.from]; + const toId = tableIdMap[edge.to]; + if (!fromId || !toId) return; + newConns.push({ + id: `conn-edit-${i}`, + from_node_id: fromId, from_port: 'out', + to_node_id: toId, to_port: 'in', + }); + }); + setRuleNodes(newNodes); + setRuleConnections(newConns); + })(); + } + }, [active, mode, selectedCardId, tablePositions, flowEdges, setRuleNodes, setRuleConnections]); + + // mode 가 view 로 돌아가거나 카드 변경 시 init guard 리셋 + useEffect(() => { + if (mode === 'view' || !selectedCardId) { + editInitDone.current = null; + } + }, [mode, selectedCardId]); + + // 캔버스에 mode 클래스 + 선택 카드 강조 클래스 + useEffect(() => { + const cv = canvasRef.current; + if (!cv) return; + cv.classList.toggle('control-mode-edit', active && mode === 'edit'); + return () => { + cv.classList.remove('control-mode-edit'); + }; + }, [active, mode, canvasRef]); + + // 선택된 카드 element 에 data-flow-active 토글 + useEffect(() => { + const cv = canvasRef.current; + if (!cv) return; + cv.querySelectorAll('[data-card-id]').forEach((el) => { + const id = el.dataset.cardId; + if (active && id === selectedCardId) { + el.setAttribute('data-flow-active', '1'); + } else { + el.removeAttribute('data-flow-active'); + } + }); + return () => { + cv.querySelectorAll('[data-card-id]').forEach((el) => { + el.removeAttribute('data-flow-active'); + }); + }; + }, [active, selectedCardId, canvasRef]); if (!active) return null; return ( <> - {/* 제어 모드 툴바 */} - - - {/* 읽기 모드: 카드 클릭 → 흐름 시각화 */} - {mode === 'view' && ( - + {/* 카드 미선택 — FlowViewer (호버 토폴로지) + 안내 칩 + FAB */} + {!selectedCard && ( + <> + +
    + + 카드를 클릭하면 Control IDE 가 펼쳐집니다 +
    + + )} - {/* 편집 모드: 규칙 빌더 */} - {mode === 'edit' && ( - + {/* 카드 선택 — IDE 5-분할 takeover */} + {selectedCard && ( +
    + setSelectedCardId(null)} + onCtrlExit={toggleControlMode} + /> + +
    + +
    + + +
    )} ); } -/** - * 제어 모드 팔레트 wrapper — 사이드바에 삽입 - */ +/** 호환성 stub — 외부에서 이름으로만 import 하는 경우 */ export function ControlPaletteWrapper() { - const { active, mode, addRuleNode } = useControlMode(); - if (!active || mode !== 'edit') return null; - - return ( - {}} - onDropControl={() => {}} - /> - ); + return null; } diff --git a/frontend/components/control/ControlNode.tsx b/frontend/components/control/ControlNode.tsx index 69b65cdf..53d9ae24 100644 --- a/frontend/components/control/ControlNode.tsx +++ b/frontend/components/control/ControlNode.tsx @@ -2,6 +2,7 @@ import { useRef, useCallback } from 'react'; import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode'; +import { getNodeIcon } from './schemas'; import { PortHandle } from './PortHandle'; interface ControlNodeProps { @@ -11,80 +12,124 @@ interface ControlNodeProps { } /** - * 제어 노드 (16종) — mockup buildCtrlNode 포팅 + * 제어 노드 (16종) — mockup V3RuleNode 비주얼 (cat-stripe + cat-chip header + label + summary + ports) */ export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps) { - const { removeRuleNode, moveRuleNode, setConfigNodeId } = useControlMode(); + const { removeRuleNode, moveRuleNode, setConfigNodeId, configNodeId } = useControlMode(); const nodeRef = useRef(null); const def = CTRL_NODE_TYPES[node.type]; if (!def) return null; + const rgb = def.rgb; + const Ic = getNodeIcon(node.type); const outPorts = def.out || [{ port: 'out', label: '→', cls: '' }]; + const selected = configNodeId === node.id; + const dim = !!configNodeId && configNodeId !== node.id; - const handleHeadMouseDown = useCallback((e: React.MouseEvent) => { + const handleNodeMouseDown = useCallback((e: React.MouseEvent) => { + const target = e.target as HTMLElement; + // port / del 버튼 클릭은 드래그 X + if (target.closest('.ctrl-io-port, button')) return; e.preventDefault(); e.stopPropagation(); const sx = e.clientX, sy = e.clientY; const sl = node.x, st = node.y; const el = nodeRef.current; if (el) el.style.zIndex = '30'; + let moved = false; const mv = (ev: MouseEvent) => { - moveRuleNode(node.id, sl + ev.clientX - sx, st + ev.clientY - sy); + const dx = ev.clientX - sx, dy = ev.clientY - sy; + if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return; + moved = true; + moveRuleNode(node.id, sl + dx, st + dy); }; const up = () => { if (el) el.style.zIndex = '20'; document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); + if (!moved) setConfigNodeId(node.id === configNodeId ? null : node.id); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); - }, [node.id, node.x, node.y, moveRuleNode]); + }, [node.id, node.x, node.y, moveRuleNode, setConfigNodeId, configNodeId]); + + // summary 표시 우선순위: + // 1. node.config.summary — NodeConfigPopover 가 저장한 한글 라벨 (예: "결재상태 = '결재완료'") + // 2. node.summary[0] — mock/seed 데이터의 summary + // 3. config entries fallback — { field, op, value, ... } 의 핵심 값을 chip 으로 + // 4. '클릭하여 설정' + const formatVal = (v: any): string => { + if (v == null || v === '') return ''; + if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v); + if (typeof v === 'object') { + // fully qualified field { table, column } + if (v.column) return String(v.label ?? v.column); + return ''; + } + return String(v); + }; + const summary = (() => { + if (node.config?.summary) return String(node.config.summary); + if (node.summary?.[0]) return String(node.summary[0]); + if (node.config && Object.keys(node.config).length > 0) { + const parts = Object.entries(node.config) + .filter(([k]) => k !== 'summary') + .map(([k, v]) => `${k}: ${formatVal(v)}`) + .filter((s) => !s.endsWith(': ')) + .slice(0, 2); + if (parts.length > 0) return parts.join(' · '); + } + return '클릭하여 설정'; + })(); return (
    - {/* Input 포트 */} - + {/* cat-color stripe */} +
    - {/* 헤더 */} -
    -
    {def.icon}
    - {def.label} - -
    - - {/* 본문 */} -
    setConfigNodeId(node.id)} - > -
    - {node.config?.summary || '클릭하여 설정'} + {/* body */} +
    +
    +
    + +
    + + {def.label} + +
    +
    {node.label ?? def.label}
    + {summary &&
    {summary}
    }
    - {/* Output 포트 */} + {/* Input 포트 (좌측) */} + + + {/* Output 포트 (우측, 다중 지원) — label 텍스트(✓/✗) 없이 색만으로 구분 (yes=초록, no=회색 dashed) */}
    {outPorts.map((p) => ( ))} diff --git a/frontend/components/control/ControlPalette.tsx b/frontend/components/control/ControlPalette.tsx index d6a46681..f6840754 100644 --- a/frontend/components/control/ControlPalette.tsx +++ b/frontend/components/control/ControlPalette.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { CTRL_NODE_TYPES } from './hooks/useControlMode'; +import { useEffect, useMemo, useState } from 'react'; +import { Search, Star } from 'lucide-react'; +import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode'; import { getMetaTableList } from '@/lib/api/meta'; interface ControlPaletteProps { @@ -11,73 +12,210 @@ interface ControlPaletteProps { /** * 제어 모드 팔레트 — 사이드바 교체 - * mockup renderCtrlPalette 포팅 + * - 검색박스 + * - ⭐ 시연용 추천 (화이트리스트) + * - DB 테이블 max-height + 내부 스크롤 + * - 영어/한국어 동시 표시 + * - 제어 노드 16종 카테고리별 그룹 */ -export function ControlPalette({ onDropTable, onDropControl }: ControlPaletteProps) { + +// 시연용 추천 화이트리스트 (있을 만한 ERP 표준 테이블 + 메뉴 캡쳐에서 확인된 것) +const RECOMMENDED_TABLES = [ + 'user_info', + 'department', + 'role_info', + 'menu_master', + 'authority_master', + 'approval_definitions', + 'approval_requests', + 'approval_lines', + 'audit_log', + 'attach_file_info', +]; + +// 도메인 아이콘 매핑 (prefix 기준) +function pickIcon(name: string): string { + const n = name.toLowerCase(); + if (n.startsWith('user') || n === 'user_info') return '👤'; + if (n.startsWith('department') || n.startsWith('dept')) return '🏢'; + if (n.startsWith('role') || n.startsWith('authority')) return '🛡'; + if (n.startsWith('menu')) return '📂'; + if (n.startsWith('approval')) return '✋'; + if (n.startsWith('audit') || n.startsWith('log')) return '📜'; + if (n.startsWith('attach') || n.startsWith('file')) return '📎'; + if (n.startsWith('mail')) return '📨'; + if (n.startsWith('ai_')) return '🤖'; + if (n.startsWith('order')) return '📦'; + if (n.startsWith('project')) return '📋'; + if (n.startsWith('barcode') || n.startsWith('label')) return '🏷'; + if (n.startsWith('batch')) return '⚙'; + if (n.startsWith('config') || n.startsWith('setting')) return '⚙'; + return '🗂'; +} + +export function ControlPalette(_props: ControlPaletteProps) { const [tables, setTables] = useState[]>([]); + const [search, setSearch] = useState(''); + const mode = useControlMode((s) => s.mode); + const isEditMode = mode === 'edit'; useEffect(() => { getMetaTableList().then(setTables).catch(() => {}); }, []); + // 검색 + 추천/일반 분리 + const { recommended, others } = useMemo(() => { + const q = search.trim().toLowerCase(); + const filtered = q + ? tables.filter((t) => { + const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase(); + const label = String(t.table_label ?? t.TABLE_LABEL ?? '').toLowerCase(); + return name.includes(q) || label.includes(q); + }) + : tables; + + const rec: Record[] = []; + const oth: Record[] = []; + filtered.forEach((t) => { + const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase(); + if (RECOMMENDED_TABLES.includes(name)) rec.push(t); + else oth.push(t); + }); + + // 추천은 화이트리스트 순서 유지 + rec.sort((a, b) => { + const an = String(a.table_name ?? a.TABLE_NAME ?? '').toLowerCase(); + const bn = String(b.table_name ?? b.TABLE_NAME ?? '').toLowerCase(); + return RECOMMENDED_TABLES.indexOf(an) - RECOMMENDED_TABLES.indexOf(bn); + }); + + return { recommended: rec, others: oth }; + }, [tables, search]); + const handleDragStart = (e: React.DragEvent, data: Record) => { e.dataTransfer.setData('text/plain', JSON.stringify(data)); e.dataTransfer.effectAllowed = 'copy'; }; const catLabels: Record = { - '트리거': '트리거', - '조건': '조건 / 분기', - '액션': '액션', - '흐름': '흐름 제어', - '연동': '외부 연동', - '기록': '기록', + 트리거: '트리거', + 조건: '조건 / 분기', + 액션: '액션', + 흐름: '흐름 제어', + 연동: '외부 연동', + 기록: '기록', }; const cats = ['트리거', '조건', '액션', '흐름', '연동', '기록']; - return ( -
    - {/* DB 테이블 섹션 */} -
    DB 테이블
    - {tables.map((t) => { - const name = t.table_name ?? t.TABLE_NAME; - const label = t.table_label ?? t.TABLE_LABEL ?? name; - return ( -
    handleDragStart(e, { kind: 'table', name })} - > - 🏢 - {name} -
    - ); - })} + const renderTableItem = (t: Record, isRecommended: boolean) => { + const name = t.table_name ?? t.TABLE_NAME; + const rawLabel = t.table_label ?? t.TABLE_LABEL; + const label = rawLabel && rawLabel !== name ? rawLabel : null; + const icon = pickIcon(String(name)); + return ( +
    handleDragStart(e, { kind: 'table', name })} + > + {icon} + + {label ?? name} + {label && {name}} + + {isRecommended && } +
    + ); + }; - {/* 제어 노드 — 카테고리별 그룹 */} - {cats.map((cat) => { - const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => d.cat === cat); - if (!items.length) return null; - return ( -
    -
    {catLabels[cat] ?? cat}
    - {items.map(([type, def]) => ( -
    handleDragStart(e, { kind: 'control', type })} - > - {def.icon} - {def.label} -
    - ))} -
    - ); - })} + return ( +
    + {/* 헤더 */} +
    + 제어 팔레트 + {!isEditMode && ( + 편집 모드에서 활성 + )} +
    + + {/* 검색박스 */} +
    + + setSearch(e.target.value)} + disabled={!isEditMode} + /> +
    + +
    + {/* 주요 테이블 (자주 쓰는 ERP 표준) */} + {recommended.length > 0 && ( + <> +
    + + 주요 테이블 + {recommended.length} +
    +
    + {recommended.map((t) => renderTableItem(t, true))} +
    + + )} + + {/* 전체 DB 테이블 (max-height + 내부 스크롤) */} +
    + DB 테이블 + {others.length > 0 && {others.length}} +
    +
    + {others.map((t) => renderTableItem(t, false))} + {others.length === 0 && search && ( +
    검색 결과 없음
    + )} + {others.length === 0 && !search && tables.length === 0 && ( +
    로딩 중…
    + )} +
    + + {/* 제어 노드 카테고리별 */} + {cats.map((cat) => { + const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => { + if (d.cat !== cat) return false; + if (!search.trim()) return true; + const q = search.trim().toLowerCase(); + return d.label.toLowerCase().includes(q); + }); + if (!items.length) return null; + return ( +
    +
    {catLabels[cat] ?? cat}
    + {items.map(([type, def]) => ( +
    handleDragStart(e, { kind: 'control', type })} + > + {def.icon} + + {def.label} + +
    + ))} +
    + ); + })} +
    ); } diff --git a/frontend/components/control/FlowViewer.tsx b/frontend/components/control/FlowViewer.tsx index d2f9f227..77f267c5 100644 --- a/frontend/components/control/FlowViewer.tsx +++ b/frontend/components/control/FlowViewer.tsx @@ -79,6 +79,7 @@ export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) { flowEdges, tablePositions, setActiveFlowCard, + setSelectedCardId, setFlowEdges, setTablePositions, } = useControlMode(); @@ -90,14 +91,17 @@ export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) { const [ruleOverlays, setRuleOverlays] = useState([]); const animRef = useRef[]>([]); - // 카드 클릭 → 흐름 표시 + // 카드 클릭 → 흐름 표시 + 카드 선택 (selectedCardId 동기화) const handleCardClick = useCallback(async (cardId: string) => { // 같은 카드 클릭 → 닫기 if (activeFlowCardId === cardId) { clearFlow(); + setSelectedCardId(null); return; } + setSelectedCardId(cardId); + const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId); if (!card) return; diff --git a/frontend/components/control/NodeConfigPopover.tsx b/frontend/components/control/NodeConfigPopover.tsx index 0d6b12e3..35d62eff 100644 --- a/frontend/components/control/NodeConfigPopover.tsx +++ b/frontend/components/control/NodeConfigPopover.tsx @@ -1,20 +1,37 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode'; /** - * 노드 설정 팝오버 (mockup showNodeConfig/_buildCfgForm 포팅) - * 노드 타입별 설정 폼 + * 노드 설정 팝오버 — Phase 2: schema-driven dropdown + * + * 핵심: 노드와 연결된 테이블의 컬럼/enum 메타를 dropdown 으로 자동 매핑. + * - 영어 자유 입력 폐기 (실사용 불가) + * - 한글 라벨 우선 + 영문 컬럼 sub + * - enum 컬럼이면 값도 dropdown + * - multi-table 시 optgroup 으로 namespace 구분 + * - 저장은 fully qualified { table, column } 객체 (Phase 3 준비) */ export function NodeConfigPopover() { - const { configNodeId, ruleNodes, setConfigNodeId, updateRuleNode } = useControlMode(); + const { configNodeId, ruleNodes, ruleConnections, setConfigNodeId, updateRuleNode } = useControlMode(); const popRef = useRef(null); const [open, setOpen] = useState(false); const node = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null; const def = node ? CTRL_NODE_TYPES[node.type] : null; + // 현재 노드와 연결된 테이블 노드들 (양방향 — from/to 어느 쪽이든) + const connectedTables = useMemo[]>(() => { + if (!configNodeId) return []; + const tableNodeIds = new Set(); + ruleConnections.forEach((c) => { + if (c.from_node_id === configNodeId) tableNodeIds.add(c.to_node_id); + if (c.to_node_id === configNodeId) tableNodeIds.add(c.from_node_id); + }); + return ruleNodes.filter((n) => n.type === 'table' && tableNodeIds.has(n.id)); + }, [configNodeId, ruleNodes, ruleConnections]); + useEffect(() => { if (configNodeId && node) { requestAnimationFrame(() => setOpen(true)); @@ -23,12 +40,14 @@ export function NodeConfigPopover() { } }, [configNodeId, node]); - // 외부 클릭 닫기 useEffect(() => { const handler = (e: MouseEvent) => { if (!configNodeId) return; - if ((e.target as HTMLElement).closest('.ctrl-cfg-pop')) return; - if ((e.target as HTMLElement).closest('.ctrl-an-body')) return; + const t = e.target as HTMLElement; + if (t.closest('.ctrl-cfg-pop')) return; + if (t.closest('.v3-rule-node')) return; + if (t.closest('.tbl-node')) return; + if (t.closest('.ctrl-an-body')) return; setConfigNodeId(null); }; document.addEventListener('click', handler); @@ -49,52 +68,291 @@ export function NodeConfigPopover() { style={{ left: node.x + 172, top: node.y }} >
    {def.icon} {def.label} 설정
    - setConfigNodeId(null)} /> + setConfigNodeId(null)} + />
    ); } -function ConfigForm({ type, config, onSave, onClose }: { - type: string; config: Record; +/* ─── Helpers ─── */ + +interface ColumnMeta { + tableName: string; + tableLabel: string; + column: string; + label: string; + type: string; + options?: Array<{ value: string; label: string }>; + pk?: boolean; +} + +/** 연결된 테이블들의 모든 컬럼을 flat 으로 + 표시 정보 포함 */ +function flattenColumns(tables: Record[]): ColumnMeta[] { + const out: ColumnMeta[] = []; + tables.forEach((t) => { + const tName = t.table_name ?? t.tableName ?? ''; + const tLabel = t.label ?? tName; + (t.columns ?? []).forEach((c: Record) => { + const colName = c.column ?? c.name ?? c.COLUMN_NAME ?? ''; + if (!colName) return; + out.push({ + tableName: tName, + tableLabel: tLabel, + column: colName, + label: c.label ?? c.dname ?? colName, + type: c.type ?? c.dtype ?? 'text', + options: c.options, + pk: !!c.pk, + }); + }); + }); + return out; +} + +/** fully qualified id ↔ 객체 변환 */ +function serializeField(field: any): string { + if (!field) return ''; + if (typeof field === 'string') return field; // legacy + if (field.table && field.column) return `${field.table}|${field.column}`; + return ''; +} +function deserializeField(s: string): { table: string; column: string } | null { + if (!s || !s.includes('|')) return null; + const [table, column] = s.split('|'); + return { table, column }; +} + +/** field value (string or {table,column}) 으로 ColumnMeta 찾기 */ +function findColumn(cols: ColumnMeta[], field: any): ColumnMeta | null { + if (!field) return null; + if (typeof field === 'string') return cols.find((c) => c.column === field) ?? null; + if (field.table && field.column) { + return cols.find((c) => c.tableName === field.table && c.column === field.column) ?? null; + } + return null; +} + +/** 한글 라벨 표시 (field) */ +function displayField(field: any, cols: ColumnMeta[]): string { + const col = findColumn(cols, field); + if (col) return col.label; + if (typeof field === 'string') return field; + if (field?.column) return field.column; + return '?'; +} + +/* ─── Reusable pickers ─── */ + +function FieldPicker({ + tables, value, onChange, placeholder, +}: { + tables: Record[]; + value: any; + onChange: (field: { table: string; column: string }) => void; + placeholder?: string; +}) { + const cols = useMemo(() => flattenColumns(tables), [tables]); + if (tables.length === 0) { + return
    연결된 테이블 없음 — 먼저 테이블 카드를 노드에 연결
    ; + } + const currentId = serializeField(value); + + return ( + + ); +} + +function TablePicker({ + tables, value, onChange, placeholder, +}: { + tables: Record[]; + value: any; + onChange: (tableName: string) => void; + placeholder?: string; +}) { + // 자동 채움 — Strict 모드 안전 useEffect (committed lifecycle 에서만 실행) + const single = tables.length === 1 + ? (tables[0].table_name ?? tables[0].tableName ?? '') + : null; + useEffect(() => { + if (single && value !== single) onChange(single); + }, [single, value, onChange]); + + if (tables.length === 0) { + return
    연결된 테이블 없음 — 먼저 테이블 카드를 노드에 연결
    ; + } + // 1개면 자동 readonly + if (tables.length === 1) { + const t = tables[0]; + const tName = t.table_name ?? t.tableName ?? ''; + const tLabel = t.label ?? tName; + return ( +
    + {tLabel} + {tLabel !== tName && {tName}} + (자동) +
    + ); + } + // 2개+ 면 dropdown + const current = typeof value === 'string' ? value : (value?.table ?? ''); + return ( + + ); +} + +function ValuePicker({ + tables, fieldRef, value, onChange, placeholder, +}: { + tables: Record[]; + fieldRef: any; // 어느 컬럼의 값인지 + value: any; + onChange: (v: string) => void; + placeholder?: string; +}) { + const cols = useMemo(() => flattenColumns(tables), [tables]); + const col = findColumn(cols, fieldRef); + + // enum 컬럼이면 dropdown + if (col?.type === 'select' && col.options && col.options.length > 0) { + return ( + + ); + } + // 기본 typed input + return ( + onChange(e.target.value)} + placeholder={placeholder ?? (col ? `${col.label} 값` : '값 입력')} + /> + ); +} + +/* ─── ConfigForm ─── */ + +function ConfigForm({ + type, config, connectedTables, onSave, onClose, +}: { + type: string; + config: Record; + connectedTables: Record[]; onSave: (summary: string, config: Record) => void; onClose: () => void; }) { const [vals, setVals] = useState>(config); const set = (k: string, v: any) => setVals((p) => ({ ...p, [k]: v })); + const cols = useMemo(() => flattenColumns(connectedTables), [connectedTables]); const handleSave = () => { let summary = ''; + const fLabel = (f: any) => displayField(f, cols); + const tLabel = (tName: string) => { + const t = connectedTables.find((x) => (x.table_name ?? x.tableName) === tName); + return t?.label ?? tName ?? '?'; + }; switch (type) { case 'condition': - summary = `${vals.field || '?'} ${vals.op || '='} "${vals.value || '?'}"`; + summary = `${fLabel(vals.field)} ${vals.op || '='} "${vals.value || '?'}"`; break; case 'status-change': - summary = `${vals.table || '?'}.${vals.field || 'STATUS'} → "${vals.value || '?'}"`; + summary = `${tLabel(vals.table)}.${fLabel(vals.field)} → "${vals.value || '?'}"`; break; case 'auto-insert': - summary = `→ ${vals.table || '?'} INSERT`; + summary = `→ ${tLabel(vals.table)} INSERT`; break; case 'timer': - summary = `${vals.field || '?'} +${vals.amount || 0}${vals.unit || '일'} 경과`; + summary = `${fLabel(vals.field)} +${vals.amount || 0}${vals.unit || '일'} 경과`; break; case 'notification': summary = `${vals.channel || '이메일'} → ${vals.target || '담당자'}`; break; case 'approval': - summary = `${vals.approver || '팀장'} 승인 (${vals.condition || ''})`; + summary = `${vals.approver || '팀장'} 승인${vals.condition ? ` (${vals.condition})` : ''}`; break; case 'calculation': - summary = `${vals.table || '?'}.${vals.field || '?'} = ${vals.formula || '?'}`; + summary = `${tLabel(vals.table)}.${fLabel(vals.field)} = ${vals.formula || '?'}`; break; case 'webhook': summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`; break; case 'validation': - summary = `${vals.field || '?'} ${vals.rule || '필수값'}`; + summary = `${fLabel(vals.field)} ${vals.rule || '필수값'}`; break; case 'log': summary = `로그: ${vals.content || '?'}`; break; + case 'delete': + summary = `${tLabel(vals.table)} ${vals.mode === 'soft' ? 'soft delete' : 'hard delete'}`; + break; + case 'document': + summary = `${vals.template || '?'} → ${vals.format || 'pdf'}`; + break; + case 'delay': + summary = `${vals.amount || 0}${vals.unit || '분'} 대기`; + break; + case 'loop': + summary = vals.iterField ? `for each ${vals.iterField}` : `${vals.count || 1}회 반복`; + break; + case 'parallel': + summary = `${vals.branches || 2}개 병렬 실행`; + break; + case 'merge': + summary = vals.strategy === 'all' ? '모든 분기 대기 (all)' : '먼저 도착 (any)'; + break; default: summary = vals.summary || '설정됨'; } @@ -103,7 +361,7 @@ function ConfigForm({ type, config, onSave, onClose }: { return ( <> - {renderFields(type, vals, set)} + {renderFields(type, vals, set, connectedTables)}
    @@ -115,21 +373,25 @@ function ConfigForm({ type, config, onSave, onClose }: { function renderFields( type: string, vals: Record, - set: (k: string, v: any) => void + set: (k: string, v: any) => void, + tables: Record[], ) { switch (type) { + /* ─── Phase 2 schema-driven 4종 ─── */ case 'condition': return ( <> - set('field', v)} placeholder="STATUS" /> + set('field', f)} + placeholder="비교할 컬럼 선택..." /> ', '<', '기한 경과', '포함']} /> + options={['=', '≠', '>', '<', '≥', '≤', '포함', '기한 경과']} /> - set('value', v)} placeholder="비교값" /> + set('value', v)} /> ); @@ -137,27 +399,61 @@ function renderFields( return ( <> - set('table', v)} placeholder="테이블명" /> + set('table', v)} /> - set('field', v)} /> + set('field', f)} + placeholder="변경할 컬럼 선택..." /> - set('value', v)} placeholder="새 값" /> + set('value', v)} placeholder="새 값" /> ); + case 'calculation': + return ( + <> + + set('table', v)} /> + + + set('field', f)} + placeholder="저장할 컬럼 선택..." /> + + + set('formula', v)} + placeholder="QTY * UNIT_PRICE" /> + + + ); + case 'validation': + return ( + <> + + set('field', f)} + placeholder="검증할 컬럼 선택..." /> + + + set('rule', v)} + options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} /> + + + ); + + /* ─── 기존 케이스 유지 (테이블 컬럼 의존성 없는 노드들) ─── */ case 'auto-insert': return ( - set('table', v)} placeholder="테이블명" /> + set('table', v)} /> ); case 'timer': return ( <> - set('field', v)} placeholder="ORDER_DATE" /> + set('field', f)} + placeholder="시간 기준 컬럼..." />
    @@ -196,20 +492,6 @@ function renderFields( ); - case 'calculation': - return ( - <> - - set('table', v)} placeholder="테이블명" /> - - - set('field', v)} placeholder="필드명" /> - - - set('formula', v)} placeholder="QTY * UNIT_PRICE" /> - - - ); case 'webhook': return ( <> @@ -222,22 +504,91 @@ function renderFields( ); - case 'validation': + case 'log': return ( <> - - set('field', v)} placeholder="필드명" /> + + set('level', v)} + options={['info', 'warn', 'error', 'debug']} /> - - set('rule', v)} - options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} /> + + set('content', v)} placeholder="액션 설명" /> ); - case 'log': + case 'delete': return ( - - set('content', v)} placeholder="액션 설명" /> + <> + + set('table', v)} /> + + + set('mode', v)} + options={['soft', 'hard']} /> + + + set('where', v)} placeholder="id = ?" /> + + + ); + case 'document': + return ( + <> + + set('template', v)} placeholder="출고확인서.docx" /> + + + set('output', v)} placeholder="/docs/{id}.pdf" /> + + + set('format', v)} + options={['pdf', 'docx', 'xlsx', 'html']} /> + + + ); + case 'delay': + return ( + +
    + set('amount', v)} placeholder="0" /> + set('unit', v)} + options={['초', '분', '시간', '일']} /> +
    +
    + ); + case 'loop': + return ( + <> + + set('mode', v)} + options={['count', 'forEach', 'while']} /> + + {vals.mode === 'forEach' ? ( + + set('iterField', f)} /> + + ) : vals.mode === 'while' ? ( + + set('condition', v)} placeholder="x < 10" /> + + ) : ( + + set('count', v)} placeholder="1" /> + + )} + + ); + case 'parallel': + return ( + + set('branches', v)} placeholder="2" /> + + ); + case 'merge': + return ( + + set('strategy', v)} + options={['any', 'all']} /> ); default: @@ -245,6 +596,8 @@ function renderFields( } } +/* ─── 공통 atoms ─── */ + function CfgSec({ label, children }: { label: string; children: React.ReactNode }) { return (
    diff --git a/frontend/components/control/PortHandle.tsx b/frontend/components/control/PortHandle.tsx index 1ea449e0..c07eeeeb 100644 --- a/frontend/components/control/PortHandle.tsx +++ b/frontend/components/control/PortHandle.tsx @@ -17,15 +17,17 @@ interface PortHandleProps { } export function PortHandle({ nodeId, port, type, cls, label, isTable, onDragStart, onDragEnd }: PortHandleProps) { + // 단일 동그라미가 mousedown(연결 시작) + mouseup(연결 종료) 둘 다 받음 + // (테이블 컬럼 port 처럼 시각적으로 하나만 보이는 경우) const handleMouseDown = (e: React.MouseEvent) => { - if (type !== 'out' || !onDragStart) return; + if (!onDragStart) return; e.preventDefault(); e.stopPropagation(); onDragStart(nodeId, port, e); }; const handleMouseUp = (e: React.MouseEvent) => { - if (type !== 'in' || !onDragEnd) return; + if (!onDragEnd) return; e.stopPropagation(); onDragEnd(nodeId, port); }; diff --git a/frontend/components/control/RuleBuilder.tsx b/frontend/components/control/RuleBuilder.tsx index 43d8aaf6..b27dc25d 100644 --- a/frontend/components/control/RuleBuilder.tsx +++ b/frontend/components/control/RuleBuilder.tsx @@ -56,7 +56,7 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) { } else { try { const meta = await getMetaFields(d.name); - cols = (meta.fields ?? []).filter((f: FieldConfig) => !f.system).slice(0, 8); + cols = (meta.fields ?? []).filter((f: FieldConfig) => !f.system); // 모든 컬럼 로드 (Phase 2 dropdown 용) fieldCache[d.name] = cols; } catch { /* 빈 필드 */ } } @@ -88,13 +88,20 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) { }, []); // 노드 좌표에서 포트 위치 계산 - const portPos = useCallback((nodeId: string, port: string) => { + // dir: 'from' (출력측, 우측) | 'to' (입력측, 좌측) — 컬럼별 port 의 좌/우 결정용 + const portPos = useCallback((nodeId: string, port: string, dir: 'from' | 'to' = 'from') => { const node = ruleNodes.find((n) => n.id === nodeId); if (!node) return null; if (node.type === 'table') { - if (port === 'in') return { x: node.x, y: node.y + 18 }; - return { x: node.x + 200, y: node.y + 18 }; + // 테이블 단위 단일 port — 카드 좌측(in) / 우측(out) 중앙 + // (Phase 1: 컬럼별 port 폐기. 컬럼 선택은 NodeConfigPopover dropdown 에서) + void dir; + const cardW = 180; + const cardH = 70; // stripe + head + stats + const yMid = node.y + cardH / 2; + if (port === 'in') return { x: node.x, y: yMid }; + return { x: node.x + cardW, y: yMid }; } // 제어 노드 @@ -114,14 +121,12 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) { }, [ruleNodes]); return ( - <> - {/* 드롭존 (캔버스 전체에 이벤트 걸기 위한 투명 레이어) */} -
    - +
    {/* 연결선 SVG */} @@ -137,32 +142,76 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) { {ruleConnections.map((c) => { - const f = portPos(c.from_node_id, c.from_port); - const t = portPos(c.to_node_id, c.to_port); + const f = portPos(c.from_node_id, c.from_port, 'from'); + const t = portPos(c.to_node_id, c.to_port, 'to'); if (!f || !t) return null; - const cls = c.from_port === 'yes' ? 'rule-conn-path conn-yes' - : c.from_port === 'no' ? 'rule-conn-path conn-no' - : 'rule-conn-path'; - const marker = c.from_port === 'yes' ? 'url(#arr-yes)' - : c.from_port === 'no' ? 'url(#arr-no)' - : 'url(#arr-rule)'; + // Phase 3: edge_type 별 stroke 분기 (yes/no 우선, 그 다음 edge_type) + const portCls = c.from_port === 'yes' ? 'conn-yes' + : c.from_port === 'no' ? 'conn-no' : ''; + const edgeCls = c.edge_type ? `edge-${c.edge_type}` : ''; + const cls = ['rule-conn-path', portCls, edgeCls].filter(Boolean).join(' '); + + // 선 중간 라벨 — yes/no 같은 분기 + edge_type 시각화 (mockup v3 EditCanvas style) + const portLabel = + c.label ?? + (c.from_port === 'yes' ? '예' + : c.from_port === 'no' ? '아니오' + : c.from_port === 'pass' ? '통과' + : c.from_port === 'fail' ? '실패' + : c.from_port === 'approved'? '승인' + : c.from_port === 'rejected'? '반려' + : c.from_port === 'each' ? '반복' + : c.from_port === 'done' ? '완료' + : null); + const labelColor = c.from_port === 'yes' ? 'var(--ctrl-green)' + : c.from_port === 'no' ? 'var(--v5-text-muted, #888)' + : c.from_port === 'pass' ? 'var(--ctrl-green)' + : c.from_port === 'fail' ? 'rgb(255, 71, 87)' + : c.from_port === 'approved' ? 'var(--ctrl-green)' + : c.from_port === 'rejected' ? 'var(--v5-text-muted, #888)' + : c.edge_type === 'table-mutation' ? 'rgb(253, 121, 168)' + : c.edge_type === 'execution-flow' ? 'var(--ctrl-primary)' + : c.edge_type === 'lookup' ? 'var(--ctrl-green)' + : 'var(--ctrl-cyan)'; + const mx = (f.x + t.x) / 2; + const my = (f.y + t.y) / 2; + const labelW = Math.max(36, (portLabel?.length ?? 0) * 8 + 14); return ( - + + + {portLabel && ( + + + + {portLabel} + + + )} + ); })} {/* 연결 삭제 뱃지 */} {ruleConnections.map((c) => { - const f = portPos(c.from_node_id, c.from_port); - const t = portPos(c.to_node_id, c.to_port); + const f = portPos(c.from_node_id, c.from_port, 'from'); + const t = portPos(c.to_node_id, c.to_port, 'to'); if (!f || !t) return null; const mx = (f.x + t.x) / 2, my = (f.y + t.y) / 2; @@ -199,12 +248,10 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) { y={node.y} onMove={(_, x, y) => moveRuleNode(node.id, x, y)} style={{ overflow: 'visible' }} + nodeId={node.id} + onPortDragStart={startDrag} + onPortDragEnd={finishDrag} /> - {/* I/O 포트 */} - -
    - -
    ); } @@ -221,6 +268,6 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) { {/* 설정 팝오버 */} - +
    ); } diff --git a/frontend/components/control/TableNode.tsx b/frontend/components/control/TableNode.tsx index 69744644..6adeb940 100644 --- a/frontend/components/control/TableNode.tsx +++ b/frontend/components/control/TableNode.tsx @@ -1,31 +1,56 @@ 'use client'; import { useRef, useCallback } from 'react'; +import { Database, X } from 'lucide-react'; +import { PortHandle } from './PortHandle'; +import { useControlMode } from './hooks/useControlMode'; interface TableNodeProps { tableName: string; label: string; - icon: string; + /** 호환용 — 더 이상 사용 X (V3 컴팩트로 갈아엎으면서 이모지 폐기, Lucide Database 아이콘 고정) */ + icon?: string; columns: Record[]; x: number; y: number; style?: React.CSSProperties; onMove?: (name: string, x: number, y: number) => void; + /** 룰 노드 ID (PortHandle 연결용). 없으면 시각 카드만 (read-only) */ + nodeId?: string; + onPortDragStart?: (nodeId: string, port: string, e: React.MouseEvent) => void; + onPortDragEnd?: (nodeId: string, port: string) => void; } -export function TableNode({ tableName, label, icon, columns, x, y, style, onMove }: TableNodeProps) { +/** + * 테이블 카드 — V3RuleNode 와 일관된 컴팩트 디자인 + * - 180px 폭, cyan top stripe, Lucide Database 아이콘 + * - 한글 라벨 메인 + mono 영문 sub + * - stats row: `{N} cols · {K} FK` + * - 좌·우 edge 에 단일 port 1개씩 (테이블 단위 연결 — 컬럼은 노드 설정창 dropdown 에서) + */ +export function TableNode({ + tableName, label, columns, x, y, style, onMove, nodeId, onPortDragStart, onPortDragEnd, +}: TableNodeProps) { const nodeRef = useRef(null); + const removeRuleNode = useControlMode((s) => s.removeRuleNode); const handleMouseDown = useCallback((e: React.MouseEvent) => { if (!onMove) return; + const target = e.target as HTMLElement; + if (target.closest('.ctrl-io-port, button')) return; e.preventDefault(); + e.stopPropagation(); const sx = e.clientX, sy = e.clientY; const sl = x, st = y; const el = nodeRef.current; if (el) el.style.zIndex = '30'; + let moved = false; const move = (ev: MouseEvent) => { - onMove(tableName, sl + ev.clientX - sx, st + ev.clientY - sy); + const dx = ev.clientX - sx, dy = ev.clientY - sy; + if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return; + moved = true; + onMove(tableName, sl + dx, st + dy); }; const up = () => { if (el) el.style.zIndex = '20'; @@ -36,42 +61,69 @@ export function TableNode({ tableName, label, icon, columns, x, y, style, onMove document.addEventListener('mouseup', up); }, [onMove, tableName, x, y]); - const dtypeIcons: Record = { - text: 'Aa', number: '#', date: '📅', select: '▼', checkbox: '☑', file: '📎', code: '⚡', - textarea: 'Aa', datetime: '📅', entity: '🔗', - }; + // stats + const totalCols = columns?.length ?? 0; + const fkCount = (columns ?? []).filter((c) => c.mark === 'FK' || c.type === 'entity').length; + const pkCount = (columns ?? []).filter((c) => c.pk).length; + const hasKoLabel = label && label !== tableName; return (
    -
    -
    {icon}
    - {tableName} - {label} -
    -
    - {columns.map((col) => { - const name = col.column ?? col.name ?? col.COLUMN_NAME ?? ''; - const type = col.type ?? col.dtype ?? 'text'; - const mark = col.pk ? 'PK' : col.mark === 'FK' ? 'FK' : ''; - const portCls = mark === 'PK' ? 'pk' : mark === 'FK' ? 'fk' : ''; - const displayName = col.label ?? col.dname ?? name; - const dtIcon = dtypeIcons[type] || 'Aa'; + {/* cyan top stripe (V3RuleNode cat-stripe 와 일관) */} +
    - return ( -
    -
    - {displayName} - {dtIcon} {type} - {mark && {mark}} -
    - ); - })} +
    +
    +
    +
    {hasKoLabel ? label : tableName}
    + {hasKoLabel &&
    {tableName}
    } +
    + {nodeId && ( + + )}
    + +
    + {totalCols} cols + {pkCount > 0 && · {pkCount} PK} + {fkCount > 0 && · {fkCount} FK} +
    + + {/* 좌·우 단일 port — 테이블 단위 연결 (컬럼 선택은 노드 설정창 dropdown) */} + {nodeId && ( + <> + +
    + +
    + + )}
    ); } diff --git a/frontend/components/control/hooks/useControlMode.ts b/frontend/components/control/hooks/useControlMode.ts index b00d3e72..5284f6b2 100644 --- a/frontend/components/control/hooks/useControlMode.ts +++ b/frontend/components/control/hooks/useControlMode.ts @@ -34,9 +34,11 @@ export const CTRL_NODE_TYPES: Record[]; @@ -55,7 +57,8 @@ interface ControlModeState { // 액션 toggleControlMode: () => void; - setMode: (mode: 'view' | 'edit') => void; + setMode: (mode: 'view' | 'edit' | 'run' | 'history') => void; + setSelectedCardId: (cardId: string | null) => void; setActiveFlowCard: (cardId: string | null) => void; setFlowEdges: (edges: Record[]) => void; setTablePositions: (pos: Record) => void; @@ -82,6 +85,7 @@ export const useControlMode = create()( (set) => ({ active: false, mode: 'view', + selectedCardId: null, activeFlowCardId: null, flowEdges: [], tablePositions: {}, @@ -94,14 +98,29 @@ export const useControlMode = create()( set((s) => ({ active: !s.active, mode: 'view', + selectedCardId: null, activeFlowCardId: null, flowEdges: [], tablePositions: {}, + ruleNodes: [], + ruleConnections: [], configNodeId: null, })), setMode: (mode) => set({ mode, configNodeId: null }), + setSelectedCardId: (cardId) => + set({ + selectedCardId: cardId, + // 카드 바꾸면 모드/룰 초기화 (각 카드는 자기 제어 컨텍스트) + mode: 'view', + activeFlowCardId: cardId, + ruleNodes: [], + ruleConnections: [], + activeRuleId: null, + configNodeId: null, + }), + setActiveFlowCard: (cardId) => set({ activeFlowCardId: cardId }), setFlowEdges: (edges) => set({ flowEdges: edges }), @@ -152,6 +171,7 @@ export const useControlMode = create()( set({ active: false, mode: 'view', + selectedCardId: null, activeFlowCardId: null, flowEdges: [], tablePositions: {}, diff --git a/frontend/components/control/hooks/usePortDrag.ts b/frontend/components/control/hooks/usePortDrag.ts index 2998bc50..64306bd8 100644 --- a/frontend/components/control/hooks/usePortDrag.ts +++ b/frontend/components/control/hooks/usePortDrag.ts @@ -59,24 +59,58 @@ export function usePortDrag(canvasRef: React.RefObject) { cleanup(); return; } - // 중복 방지 - if (ruleConnections.find((c) => + // ★ [HIGH] port direction validation — output → output 역방향 엣지 차단 + // from_port 는 in/out/yes/no/pass/fail/approved/rejected 등 (output port 만 허용) + // to_port 는 in 만 허용 (input port 도착점) + // 단 테이블 port 는 양방향 (in/out 둘 다 가능, PortHandle 단일 dot 양방향화) + // → 노드 type 으로 분기 + const stateForValidate = useControlMode.getState(); + const fromNodeForVal = stateForValidate.ruleNodes.find((n) => n.id === d.fromNodeId); + const toNodeForVal = stateForValidate.ruleNodes.find((n) => n.id === toNodeId); + // 도착이 action 노드면 to_port 는 'in' 이어야 함 (action 노드는 좌측 in 만 mouseup 받음) + if (toNodeForVal && toNodeForVal.type !== 'table' && toPort !== 'in') { + cleanup(); + return; + } + // 출발이 action 노드면 from_port 는 in 이 아니어야 함 (action 노드의 in 에서 시작은 의미 없음) + if (fromNodeForVal && fromNodeForVal.type !== 'table' && d.fromPort === 'in') { + cleanup(); + return; + } + // 중복 방지 — getState() 로 최신 ruleConnections 사용 (render-captured stale 회피) + const currentConns = stateForValidate.ruleConnections; + if (currentConns.find((c) => c.from_node_id === d.fromNodeId && c.from_port === d.fromPort && c.to_node_id === toNodeId )) { cleanup(); return; } + // Phase 3: edge_type 자동 추론 (위 validation 에서 가져온 노드 재사용) + // table → table = lookup (FK 참조) + // table → action = data-context (테이블 데이터를 노드 입력으로) + // action → table = table-mutation (노드 결과를 테이블에 저장/수정) + // action → action = execution-flow (실행 순서) + const fromIsTable = fromNodeForVal?.type === 'table'; + const toIsTable = toNodeForVal?.type === 'table'; + let edgeType: 'data-context' | 'execution-flow' | 'table-mutation' | 'lookup'; + if (fromIsTable && toIsTable) edgeType = 'lookup'; + else if (fromIsTable && !toIsTable) edgeType = 'data-context'; + else if (!fromIsTable && toIsTable) edgeType = 'table-mutation'; + else edgeType = 'execution-flow'; + addRuleConnection({ id: genConnId(), from_node_id: d.fromNodeId, from_port: d.fromPort, to_node_id: toNodeId, to_port: toPort, + edge_type: edgeType, }); cleanup(); - }, [addRuleConnection, ruleConnections]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [addRuleConnection]); const cleanup = useCallback(() => { const d = dragRef.current; @@ -89,6 +123,8 @@ export function usePortDrag(canvasRef: React.RefObject) { }, [canvasRef]); // 마우스 이동/종료 전역 핸들러 + // ★ mouseup 시 e.target 의 closest .ctrl-io-port 를 직접 찾아서 finishDrag 호출 + // (PortHandle 의 onMouseUp 에 의존하면 race + 6px hit-target 문제로 연결 실패) useEffect(() => { const onMove = (e: MouseEvent) => { const d = dragRef.current; @@ -99,10 +135,43 @@ export function usePortDrag(canvasRef: React.RefObject) { const x2 = e.clientX - cr.left + cv.scrollLeft; const y2 = e.clientY - cr.top + cv.scrollTop; d.line.setAttribute('d', bezierPath(d.x1, d.y1, x2, y2)); + + // 호버 중인 port 강조 + document.querySelectorAll('.ctrl-io-port.port-hover').forEach((el) => el.classList.remove('port-hover')); + const hoverPort = (e.target as HTMLElement)?.closest?.('.ctrl-io-port') as HTMLElement | null; + if (hoverPort && hoverPort.dataset.node !== d.fromNodeId) { + hoverPort.classList.add('port-hover'); + } }; - const onUp = () => { - if (dragRef.current) cleanup(); + const onUp = (e: MouseEvent) => { + if (!dragRef.current) return; + // ① e.target 의 closest 로 port 찾기 (정확히 port 위에서 mouseup 한 경우) + let portEl = (e.target as HTMLElement | null)?.closest?.('.ctrl-io-port') as HTMLElement | null; + // ② 못 찾으면 마우스 좌표 주변 20px 내 가장 가까운 port 검색 (port 근처에서 mouseup) + if (!portEl) { + const candidates = document.querySelectorAll('.ctrl-io-port'); + let best: { el: HTMLElement; dist: number } | null = null; + candidates.forEach((el) => { + const r = el.getBoundingClientRect(); + const cx = r.left + r.width / 2, cy = r.top + r.height / 2; + const dx = e.clientX - cx, dy = e.clientY - cy; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 24 && (!best || dist < best.dist)) { + best = { el, dist }; + } + }); + if (best) portEl = (best as { el: HTMLElement; dist: number }).el; + } + if (portEl) { + const toNodeId = portEl.dataset.node; + const toPort = portEl.dataset.port; + if (toNodeId && toPort) { + finishDrag(toNodeId, toPort); + return; + } + } + cleanup(); }; document.addEventListener('mousemove', onMove); @@ -111,7 +180,7 @@ export function usePortDrag(canvasRef: React.RefObject) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; - }, [canvasRef, cleanup]); + }, [canvasRef, cleanup, finishDrag]); return { startDrag, finishDrag }; } diff --git a/frontend/components/control/ide/Canvas.tsx b/frontend/components/control/ide/Canvas.tsx new file mode 100644 index 00000000..dc38f183 --- /dev/null +++ b/frontend/components/control/ide/Canvas.tsx @@ -0,0 +1,396 @@ +'use client'; + +/** + * Canvas — 4-모드 중앙 캔버스 (v3 V3Canvas / V3ViewCanvas / V3EditCanvas / V3RunCanvas / V3HistoryCanvas) + * + * view : 관계 트리 (listRelations API) + * edit : 룰 에디터 (기존 RuleBuilder 호출, 단계 6 에서 PanZoomStage 베이스로 갈아끼움) + * run : 단계별 실행 시각화 (mock 진행) + * history : 실행 이력 테이블 (listExecutionHistory API) + */ + +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + Table2, History as HistoryIcon, Play, Pause, SkipBack, SkipForward, + ChevronLeft, ChevronRight, Check, +} from 'lucide-react'; +import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode'; +import { PanZoomStage } from './PanZoomStage'; +import { RuleBuilder } from '../RuleBuilder'; +import { + listRelations, listExecutionHistory, + type TableRelation, type ExecutionRecord, +} from '@/lib/api/control'; + +interface CanvasProps { + card: Record; + /** DashboardCanvas ref (호환용, IDE EditCanvas 는 자체 ref 사용) */ + canvasRef: React.RefObject; + dashboardId: string; +} + +export function Canvas({ card, dashboardId }: CanvasProps) { + const mode = useControlMode((s) => s.mode); + + return ( +
    + {mode === 'view' && } + {mode === 'edit' && } + {mode === 'run' && } + {mode === 'history' && } +
    + ); +} + +/* ─── VIEW — 관계 트리 ─── */ +function ViewCanvas({ card }: { card: Record; dashboardId: string }) { + const tableName = card.primary_table ?? card.PRIMARY_TABLE ?? ''; + const cardTitle = card.title ?? card.TITLE ?? '카드'; + const [rels, setRels] = useState([]); + + useEffect(() => { + if (!tableName) return; + let alive = true; + listRelations(tableName).then((r) => { if (alive) setRels(r); }); + return () => { alive = false; }; + }, [tableName]); + + const W = 1000, H = 540; + const targets = useMemo(() => { + if (rels.length === 0) return []; + return rels.map((r, i) => { + const t = rels.length === 1 ? 0.5 : i / (rels.length - 1); + return { x: 750, y: 80 + t * 380, name: r.to, type: r.type, edgeLabel: r.label }; + }); + }, [rels]); + + return ( + + + + + + + + + + {/* edges */} + {targets.map((t, i) => { + const isAuto = t.type === 'auto'; + const rgb = isAuto ? '108,92,231' : '0,154,150'; + const x1 = 250, y1 = H / 2, x2 = t.x - 100, y2 = t.y; + const mx = (x1 + x2) / 2; + return ( + + + + + + {t.edgeLabel} + + + + ); + })} + + {/* source highlight */} + + + + {/* source label */} +
    +
    SOURCE
    +
    {cardTitle}
    +
    {tableName || '—'}
    +
    + + {/* target nodes */} + {targets.map((t) => ( +
    +
    + + {t.type === 'auto' ? 'AUTO' : 'FK'} +
    +
    {t.name}
    +
    + {t.type === 'auto' ? '동기화' : '참조'} +
    +
    + ))} + + {targets.length === 0 && ( +
    + 이 테이블의 관계 정보가 없습니다 + API: GET /api/control/tables/{tableName}/relations +
    + )} +
    + ); +} + +/* ─── EDIT — RuleBuilder 위임 (컬럼별 마우스 연결 + 노드 드래그 + 팔레트 드롭) ─── */ +function EditCanvas() { + const canvasRef = useRef(null); + return ( +
    + +
    + ); +} + +/* ─── RUN — 단계별 실행 시각화 (mock 진행) ─── */ +function RunCanvas() { + const ruleNodes = useControlMode((s) => s.ruleNodes); + const [playState, setPlayState] = useState<'paused' | 'playing'>('paused'); + const [playStep, setPlayStep] = useState(0); + + const totalSteps = Math.max(ruleNodes.length, 1); + const current = Math.min(playStep, totalSteps); + + useEffect(() => { + if (playState !== 'playing') return; + if (current >= totalSteps) return; + const t = setTimeout(() => setPlayStep((s) => s + 1), 700); + return () => clearTimeout(t); + }, [playState, current, totalSteps]); + + return ( +
    + {/* top — playback controls */} +
    +
    + {playState === 'playing' ? : } +
    +
    +
    + {playState === 'playing' ? 'LIVE TRACE · 재생 중' : 'LIVE TRACE · 일시정지'} +
    +
    + {ruleNodes.length === 0 ? '룰 없음' : `노드 ${ruleNodes.length}개`} +
    +
    + +
    + +
    + setPlayStep(0)} title="처음" /> + setPlayStep((s) => Math.max(0, s - 1))} title="이전" /> + setPlayState((p) => (p === 'playing' ? 'paused' : 'playing'))} + title={playState === 'playing' ? '일시정지' : '재생'} + /> + setPlayStep((s) => Math.min(totalSteps, s + 1))} title="다음" /> + setPlayStep(totalSteps)} title="끝" /> +
    + +
    +
    {current}/{totalSteps}
    +
    {Math.round((current / totalSteps) * 100)}%
    +
    +
    + + {/* progress */} +
    +
    +
    + + {/* steps */} +
    + {ruleNodes.length === 0 && ( +
    + 실행할 룰이 없습니다 — EDIT 모드에서 노드를 추가하세요 +
    + )} + {ruleNodes.map((n, i) => { + const def = CTRL_NODE_TYPES[n.type]; + const rgb = def?.rgb ?? '108,92,231'; + const done = i < current; + const active = i === current - 1 && playState === 'playing'; + const pending = i >= current; + return ( +
    +
    + {done ? : i + 1} +
    +
    + {def?.icon ?? '?'} +
    +
    +
    {n.label ?? def?.label ?? n.type}
    +
    {n.summary?.[0] ?? ''}
    +
    + + + {done ? '완료' : active ? '진행 중…' : '대기'} + +
    + ); + })} +
    +
    + ); +} + +function PlayBtn({ + Ic, onClick, primary, title, +}: { Ic: any; onClick: () => void; primary?: boolean; title: string }) { + return ( + + ); +} + +function LatencyBar({ ms, max }: { ms: number; max: number }) { + const pct = Math.min(100, (ms / max) * 100); + const color = pct < 50 ? 'var(--v5-green)' : pct < 80 ? 'var(--v5-amber)' : 'var(--v5-red)'; + return ( +
    +
    + {ms}ms +
    + ); +} + +/* ─── HISTORY — 실행 이력 테이블 ─── */ +function HistoryCanvas({ card }: { card: Record }) { + const cardId = card.card_id ?? card.CARD_ID ?? card.id ?? ''; + const [items, setItems] = useState([]); + const [filter, setFilter] = useState<'all' | 'ok' | 'fail'>('all'); + + useEffect(() => { + if (!cardId) return; + let alive = true; + listExecutionHistory(cardId, { limit: 50 }).then((r) => { if (alive) setItems(r); }); + return () => { alive = false; }; + }, [cardId]); + + const filtered = useMemo(() => { + if (filter === 'all') return items; + if (filter === 'ok') return items.filter((i) => i.ok); + return items.filter((i) => !i.ok); + }, [items, filter]); + + const okCount = items.filter((i) => i.ok).length; + const failCount = items.length - okCount; + + return ( +
    +
    +
    + + EXECUTION HISTORY +
    +
    + 최근 {items.length}회 · 24h +
    +
    + +
    + +
    + + + + + + + + + + + + + + + {filtered.map((ex) => ( + + + + + + + + + + + ))} + {filtered.length === 0 && ( + + + + )} + +
    TSTRIGGERWHOSTEPSLATENCYRESULT
    + + {ex.ts}{ex.trig}{ex.who}{ex.steps}/8 + + + + {ex.ok ? 'OK' : 'FAIL'} + + + +
    +
    + 실행 이력 없음 +
    +
    +
    +
    + ); +} diff --git a/frontend/components/control/ide/ContextBar.tsx b/frontend/components/control/ide/ContextBar.tsx new file mode 100644 index 00000000..20b3c3f6 --- /dev/null +++ b/frontend/components/control/ide/ContextBar.tsx @@ -0,0 +1,137 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { + Eye, Pencil, Play, History, Zap, LayoutDashboard, + Save, Undo2, FolderOpen, Search, X, +} from 'lucide-react'; +import { useControlMode } from '../hooks/useControlMode'; +import { listPresence, type PresenceUser } from '@/lib/api/control'; + +interface ContextBarProps { + selectedCard: Record; + onExit: () => void; // 카드 닫기 (제어 유지) + onCtrlExit: () => void; // 제어 종료 +} + +const MODE_TABS = [ + { k: 'view' as const, Ic: Eye, label: 'READ' }, + { k: 'edit' as const, Ic: Pencil, label: 'EDIT' }, + { k: 'run' as const, Ic: Play, label: 'RUN' }, + { k: 'history' as const, Ic: History, label: 'HISTORY' }, +]; + +export function ContextBar({ selectedCard, onExit, onCtrlExit }: ContextBarProps) { + const mode = useControlMode((s) => s.mode); + const setMode = useControlMode((s) => s.setMode); + + const [presence, setPresence] = useState([]); + useEffect(() => { + let alive = true; + listPresence('').then((p) => { if (alive) setPresence(p); }); + return () => { alive = false; }; + }, []); + + const tableName = selectedCard.primary_table ?? selectedCard.PRIMARY_TABLE ?? ''; + const cardTitle = selectedCard.title ?? selectedCard.TITLE ?? '카드'; + const dirtyCount = 0; // TODO 단계 6에서 store 도입 + + return ( +
    + {/* 좌측 — 배지 + brumb */} +
    + + CONTROL IDE +
    + / + + / +
    + {cardTitle} + {tableName && {tableName}} +
    + +
    + + {/* presence stack — 빈 배열이면 미렌더 */} + {presence.length > 0 && ( + <> +
    + {presence.slice(0, 4).map((p, i) => ( + + {p.short} + + ))} + {presence.length > 4 && ( + +{presence.length - 4} + )} +
    +
    + ); +} diff --git a/frontend/components/control/ide/CtrlFab.tsx b/frontend/components/control/ide/CtrlFab.tsx new file mode 100644 index 00000000..0f7b5160 --- /dev/null +++ b/frontend/components/control/ide/CtrlFab.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { X, Zap } from 'lucide-react'; + +interface CtrlFabProps { + onExit: () => void; +} + +export function CtrlFab({ onExit }: CtrlFabProps) { + return ( +
    + + + 제어 활성 — 카드를 선택하세요 + + +
    + ); +} diff --git a/frontend/components/control/ide/LeftRail.tsx b/frontend/components/control/ide/LeftRail.tsx new file mode 100644 index 00000000..1a1cf629 --- /dev/null +++ b/frontend/components/control/ide/LeftRail.tsx @@ -0,0 +1,229 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { Search, LayoutDashboard, Boxes, Database, ChevronRight } from 'lucide-react'; +import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode'; +import { NODE_CATEGORIES, ctrlCatToV3, getNodeIcon } from '../schemas'; +import { getMetaTableList } from '@/lib/api/meta'; + +interface LeftRailProps { + cards: Record[]; + selectedCardId: string; +} + +/** + * LeftRail — v3 V3LeftRail 베이스 + invyone 테이블 팔레트 + * + * 섹션: + * 1) 이 대시보드의 카드 + * 2) DB 테이블 — 한글 라벨 가나다순 우선, 영문 name 보조. 이모티콘 / 추천 화이트리스트 없음 + * 3) 노드 팔레트 (edit 모드만) + * + * dataTransfer 포맷: text/plain = JSON({ kind: 'table'|'control', name|type }) + */ +export function LeftRail({ cards, selectedCardId }: LeftRailProps) { + const mode = useControlMode((s) => s.mode); + const setSelectedCardId = useControlMode((s) => s.setSelectedCardId); + const [query, setQuery] = useState(''); + const [tables, setTables] = useState[]>([]); + + useEffect(() => { + if (mode !== 'edit') return; + getMetaTableList().then(setTables).catch(() => {}); + }, [mode]); + + const { sortedTables, nodeEntries } = useMemo(() => { + const q = query.trim().toLowerCase(); + + // 테이블 필터 + 정렬 + const filtered = q + ? tables.filter((t) => { + const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase(); + const label = String(t.table_label ?? t.TABLE_LABEL ?? '').toLowerCase(); + return name.includes(q) || label.includes(q); + }) + : tables; + + // 정렬: 한글 label 있는 것 가나다순 → label 없는 것 (영문 name) 알파벳순 + const koCollator = new Intl.Collator('ko'); + const sorted = [...filtered].sort((a, b) => { + const aLabel = String(a.table_label ?? a.TABLE_LABEL ?? ''); + const bLabel = String(b.table_label ?? b.TABLE_LABEL ?? ''); + const aName = String(a.table_name ?? a.TABLE_NAME ?? ''); + const bName = String(b.table_name ?? b.TABLE_NAME ?? ''); + const aHasKo = !!aLabel && aLabel !== aName; + const bHasKo = !!bLabel && bLabel !== bName; + if (aHasKo !== bHasKo) return aHasKo ? -1 : 1; + if (aHasKo) return koCollator.compare(aLabel, bLabel); + return aName.localeCompare(bName); + }); + + // 노드 필터 + const filteredNodes = Object.entries(CTRL_NODE_TYPES).filter(([type, def]) => { + if (!q) return true; + return def.label.toLowerCase().includes(q) || type.toLowerCase().includes(q); + }); + + return { sortedTables: sorted, nodeEntries: filteredNodes }; + }, [tables, query]); + + /** 드래그 시작 — text/plain JSON, EditCanvas.handleCanvasDrop 와 호환 */ + const onDragTable = (e: React.DragEvent, name: string) => { + e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'table', name })); + e.dataTransfer.effectAllowed = 'copy'; + }; + const onDragNode = (e: React.DragEvent, type: string) => { + e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'control', type })); + e.dataTransfer.effectAllowed = 'copy'; + }; + + const renderTableItem = (t: Record) => { + const name = t.table_name ?? t.TABLE_NAME; + const rawLabel = t.table_label ?? t.TABLE_LABEL; + const hasKoLabel = !!rawLabel && rawLabel !== name; + return ( +
    onDragTable(e, name)} + > + + + {hasKoLabel ? rawLabel : name} + {hasKoLabel && {name}} + +
    + ); + }; + + return ( +
    + {/* 검색 */} +
    + + setQuery(e.target.value)} + placeholder="테이블 / 노드 검색…" + /> +
    + + {/* ① 카드 */} + } title="이 대시보드의 카드" count={cards.length}> +
    + {cards.map((c) => { + const id = c.card_id ?? c.CARD_ID ?? c.id; + const title = c.title ?? c.TITLE ?? '카드'; + const table = c.primary_table ?? c.PRIMARY_TABLE ?? ''; + const sel = id === selectedCardId; + return ( + + ); + })} + {cards.length === 0 &&
    카드 없음
    } +
    +
    + + {/* ② DB 테이블 (edit 모드일 때만) — 한글 라벨 가나다순 우선, 이모티콘 없음 */} + {mode === 'edit' && ( + } title="DB 테이블" count={sortedTables.length} expand> +
    + {sortedTables.map((t) => renderTableItem(t))} + {sortedTables.length === 0 && query && ( +
    검색 결과 없음
    + )} + {sortedTables.length === 0 && !query && tables.length === 0 && ( +
    로딩 중…
    + )} +
    +
    + )} + + {/* ③ 노드 팔레트 (edit 모드만) */} + {mode === 'edit' && ( + } title="노드 팔레트" count={Object.keys(CTRL_NODE_TYPES).length} expand> +
    + {NODE_CATEGORIES.map((cat) => { + const items = nodeEntries.filter(([, def]) => ctrlCatToV3(def.cat) === cat.id); + if (items.length === 0) return null; + return ( +
    +
    + + {cat.label} + {items.length} +
    +
    + {items.map(([type, def]) => { + const Ic = getNodeIcon(type); + return ( +
    onDragNode(e, type)} + title={`${def.label} (${type}) — 캔버스로 드래그`} + > + + {def.label} +
    + ); + })} +
    +
    + ); + })} + {nodeEntries.length === 0 && ( +
    검색 결과 없음
    + )} +
    +
    + )} + + {mode !== 'edit' && ( +
    + + EDIT 모드에서 DB 테이블 / 노드 팔레트가 열립니다 +
    + )} +
    + ); +} + +function RailSection({ + ic, title, count, expand, children, +}: { + ic: React.ReactNode; + title: string; + count: number; + expand?: boolean; + children: React.ReactNode; +}) { + return ( +
    +
    + {ic} + {title} + {count} +
    +
    {children}
    +
    + ); +} diff --git a/frontend/components/control/ide/PanZoomStage.tsx b/frontend/components/control/ide/PanZoomStage.tsx new file mode 100644 index 00000000..64efcc75 --- /dev/null +++ b/frontend/components/control/ide/PanZoomStage.tsx @@ -0,0 +1,182 @@ +'use client'; + +/** + * PanZoomStage — 휠 줌 + 드래그 팬 + 드롭 핸들러 + * v3 rich-ui.jsx 의 PanZoomStage 포팅 + */ + +import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { ZoomIn, ZoomOut, Maximize, Hand } from 'lucide-react'; + +interface PanZoomStageProps { + width: number; + height: number; + initialFit?: boolean; + minScale?: number; + maxScale?: number; + onCanvasDrop?: (drop: { x: number; y: number; type: string }) => void; + children: ReactNode | ((ctx: { scale: number }) => ReactNode); +} + +export function PanZoomStage({ + width, height, + initialFit = true, + minScale = 0.25, maxScale = 1.6, + onCanvasDrop, + children, +}: PanZoomStageProps) { + const ref = useRef(null); + const innerRef = useRef(null); + const [pan, setPan] = useState({ x: 0, y: 0 }); + const [scale, setScale] = useState(1); + const [dragging, setDragging] = useState(false); + const [dropHover, setDropHover] = useState(false); + const dragStart = useRef<{ x: number; y: number } | null>(null); + const panStart = useRef<{ x: number; y: number } | null>(null); + const scaleRef = useRef(1); + const panRef = useRef({ x: 0, y: 0 }); + + useEffect(() => { scaleRef.current = scale; }, [scale]); + useEffect(() => { panRef.current = pan; }, [pan]); + + // initial fit + recompute on resize + useEffect(() => { + if (!ref.current) return; + const fit = () => { + const el = ref.current; if (!el) return; + const pw = el.clientWidth, ph = el.clientHeight; + if (initialFit) { + const s = Math.min(pw / width, ph / height, 1); + setScale(s); + setPan({ x: (pw - width * s) / 2, y: (ph - height * s) / 2 }); + } else { + setPan({ x: (pw - width) / 2, y: (ph - height) / 2 }); + } + }; + fit(); + const ro = new ResizeObserver(fit); + ro.observe(ref.current); + return () => ro.disconnect(); + }, [width, height, initialFit]); + + const screenToCanvas = (clientX: number, clientY: number) => { + if (!ref.current) return { x: 0, y: 0 }; + const rect = ref.current.getBoundingClientRect(); + return { + x: (clientX - rect.left - panRef.current.x) / scaleRef.current, + y: (clientY - rect.top - panRef.current.y) / scaleRef.current, + }; + }; + + const onMouseDown = (e: React.MouseEvent) => { + if (e.button !== 0) return; + const target = e.target as HTMLElement; + if (target.closest('[data-pz-node], button, input, select, textarea, a')) return; + setDragging(true); + dragStart.current = { x: e.clientX, y: e.clientY }; + panStart.current = { ...pan }; + e.preventDefault(); + }; + + useEffect(() => { + if (!dragging) return; + const onMove = (e: MouseEvent) => { + if (!dragStart.current || !panStart.current) return; + const dx = e.clientX - dragStart.current.x; + const dy = e.clientY - dragStart.current.y; + setPan({ x: panStart.current.x + dx, y: panStart.current.y + dy }); + }; + const onUp = () => setDragging(false); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + return () => { + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + }; + }, [dragging]); + + const onWheel = (e: React.WheelEvent) => { + if (!ref.current) return; + e.preventDefault(); + const delta = -e.deltaY * 0.0015; + const next = Math.max(minScale, Math.min(maxScale, scale * (1 + delta))); + const rect = ref.current.getBoundingClientRect(); + const cx = e.clientX - rect.left; + const cy = e.clientY - rect.top; + const k = next / scale; + setPan({ x: cx - (cx - pan.x) * k, y: cy - (cy - pan.y) * k }); + setScale(next); + }; + + const onDragOver = (e: React.DragEvent) => { + if (!onCanvasDrop) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + setDropHover(true); + }; + const onDragLeave = () => setDropHover(false); + const onDrop = (e: React.DragEvent) => { + if (!onCanvasDrop) return; + e.preventDefault(); + setDropHover(false); + const type = e.dataTransfer.getData('application/x-ctrl-node-type') + || e.dataTransfer.getData('text/plain'); + if (!type) return; + const { x, y } = screenToCanvas(e.clientX, e.clientY); + onCanvasDrop({ x, y, type }); + }; + + const zoomIn = () => setScale((s) => Math.min(maxScale, s * 1.15)); + const zoomOut = () => setScale((s) => Math.max(minScale, s / 1.15)); + const reset = () => { + if (!ref.current) return; + const pw = ref.current.clientWidth, ph = ref.current.clientHeight; + const s = Math.min(pw / width, ph / height, 1); + setScale(s); + setPan({ x: (pw - width * s) / 2, y: (ph - height * s) / 2 }); + }; + + const childOut = typeof children === 'function' ? children({ scale }) : children; + + return ( + <> +
    +
    + {childOut} +
    +
    +
    + + + + {Math.round(scale * 100)}% +
    +
    + + {onCanvasDrop ? '드래그로 이동 · 휠로 확대/축소 · 팔레트 드롭으로 노드 추가' : '드래그로 이동 · 휠로 확대/축소'} +
    + + ); +} diff --git a/frontend/components/control/ide/RightRail.tsx b/frontend/components/control/ide/RightRail.tsx new file mode 100644 index 00000000..858d2c3f --- /dev/null +++ b/frontend/components/control/ide/RightRail.tsx @@ -0,0 +1,276 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Info, Database, ScrollText, Trash2, Activity, Wrench } from 'lucide-react'; +import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode'; +import { NODE_TYPE_SCHEMAS, type NodeFieldSchema } from '../schemas'; +import { getNodeStats, listNodeComments, type NodeStats, type NodeComment } from '@/lib/api/control'; + +interface RightRailProps { + selectedCard: Record; +} + +export function RightRail({ selectedCard }: RightRailProps) { + const configNodeId = useControlMode((s) => s.configNodeId); + const ruleNodes = useControlMode((s) => s.ruleNodes); + const updateRuleNode = useControlMode((s) => s.updateRuleNode); + const removeRuleNode = useControlMode((s) => s.removeRuleNode); + const setConfigNodeId = useControlMode((s) => s.setConfigNodeId); + + const selectedNode = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null; + + return ( +
    + {/* 섹션 1: 노드 설정 / 카드 정보 */} +
    +
    + {selectedNode ? : } + + {selectedNode ? '노드 설정' : '데이터 인스펙터'} + + + {selectedNode ? selectedNode.id : '—'} + +
    +
    + {selectedNode ? ( + updateRuleNode(selectedNode.id, patch)} + onDelete={() => { removeRuleNode(selectedNode.id); setConfigNodeId(null); }} + /> + ) : ( + + )} +
    +
    + + {/* 섹션 2: 실행 상태 (v3 V3LiveItem 4개 미러) — 실 데이터 없으면 '—' fallback */} + +
    + ); +} + +function ActivitySection() { + // 실 데이터 연결 전: 모든 값 '—' (control.ts 에 getControlActivity API 추가 시 연결) + // TODO: API listControlActivity(cardId) 추가 후 useEffect 로 fetch + const items: Array<{ label: string; value: string; dot?: 'ok' | 'warn' | 'bad' }> = [ + { label: '최근 트리거', value: '—' }, + { label: '오늘 실행', value: '—' }, + { label: '평균 latency', value: '—' }, + { label: '대기 큐', value: '—' }, + ]; + return ( +
    +
    + + 실행 상태 + live +
    +
    +
    + {items.map((it) => ( +
    + {it.label} + + {it.dot && } + {it.value} + +
    + ))} +
    +
    +
    + ); +} + +function NodeInspector({ + node, onChange, onDelete, +}: { + node: Record; + onChange: (patch: Record) => void; + onDelete: () => void; +}) { + const schema: NodeFieldSchema[] = NODE_TYPE_SCHEMAS[node.type] ?? []; + const config: Record = node.config ?? {}; + const def = CTRL_NODE_TYPES[node.type]; + + const [stats, setStats] = useState(null); + const [comments, setComments] = useState([]); + + useEffect(() => { + let alive = true; + getNodeStats(node.id).then((s) => { if (alive) setStats(s); }); + listNodeComments(node.id).then((c) => { if (alive) setComments(c); }); + return () => { alive = false; }; + }, [node.id]); + + return ( + <> +
    + + Inspector + {def?.label ?? node.type} + + + +
    + +
    + {/* node 식별 */} +
    +
    + 노드 ID + {node.id} +
    + {def && ( +
    + 타입 + + {def.icon} {def.label} + +
    + )} +
    + + {/* schema 기반 필드 */} + {schema.length === 0 && ( +
    설정 가능한 필드 없음
    + )} + {schema.map((f) => ( +
    + + {f.select ? ( + + ) : f.multiline ? ( + +
    +
    +
    + +
    +
    + 2 +

    코드 모양 고르기

    + 자주 쓰는 형태를 고르거나, 빈 상태에서 직접 만들 수 있어요 +
    +
    + + + + + + + + + +
    +
    + +
    +
    + 3 +

    어디에 쓸지 정하기

    + 지금 정하거나 나중에 연결할 수 있어요 +
    + +
    + +
    +
    만들면 다음 코드부터 발번 시작 → SO-2026-05-0001
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +

    수주번호 사용 중

    +
    + 생성 2026-03-12 by gbpark + 마지막 수정 2026-05-14 16:22 + 지금까지 142건 발번 +
    +
    +
    + + +
    +
    + +
    +
    + 이 채번이 만드는 코드 + SO-YYYY-MM-#### +
    +
    +
    고정SO
    + - +
    년도2026
    + - +
    05
    + - +
    순번0142
    +
    +
    + 다음에 만들어질 코드: SO-2026-05-0143 + · 매월 1일에 순번 초기화 +
    +
    + +
    +
    +

    코드를 이루는 조각

    + 4개 + 조각을 클릭해서 편집 · 사이에 마우스 올리면 추가 가능 +
    + +
    +
    + +
    +
    + +
    +
    1번고정text
    + SO + × +
    + - +
    +
    2번년도YYYY
    + 2026 + × +
    + - +
    +
    3번MM
    + 05 + × +
    + - +
    +
    4번순번4자리
    + 0143 + × +
    + +
    + +
    + 조각 종류: + + + + + +
    +
    + +
    +
    +
    + 2번 조각 + 날짜 설정 +
    + +
    +
    +
    + +
    + + + + + + +
    +
    +
    + +
    + + + + +
    + 매월 1일 00:00 에 순번이 1 부터 다시 시작 +
    +
    +
    +
    + +
    +
    + 저장하지 않은 변경 1건 있음 +
    +
    + + +
    +
    +
    + + +
    +
    + + + + diff --git a/notes/gbpark/2026-05-18-canonical-data-view-cleanup-report.md b/notes/gbpark/2026-05-18-canonical-data-view-cleanup-report.md new file mode 100644 index 00000000..b15e2573 --- /dev/null +++ b/notes/gbpark/2026-05-18-canonical-data-view-cleanup-report.md @@ -0,0 +1,171 @@ +# 2026-05-18 Canonical Data View Cleanup — Final Report + +Goal 원본: `notes/gbpark/2026-05-18-canonical-data-view-goal.md` + +작업 범위: INVYONE Studio 의 `stats`, `table`, `container`, `chart`, `card-list`, +`grouped-table` 6개 데이터뷰 계열을 input canonical 작업과 동일한 수준으로 정리. +새 생성 경로는 canonical ID 로 수렴, legacy/V2 경로는 migration adapter / domain +preserved / shared dependency 로 명시적 분류. + +--- + +## 1. Summary + +- canonical chart / card-list / grouped-table 을 first-class 진입점에 추가 + (`DynamicComponentRenderer.INVYONE_UNIFIED_IDS`, + `responsiveDefaults.fullWidthComponents`, + `templateMigrate.MAIN_CONTENT_IDS / FULL_WIDTH_IDS / inferPolicy`). +- canonical container (`containerType: "tabs"`) 를 Studio drag/drop, selection, + helper 9 곳 모두 옛 `tabs-widget` / `v2-tabs-widget` 과 동일하게 처리하도록 추가. +- canonical table 을 button data transfer (`ActionTab` / `DataTab` / + `InvLegacyButtonConfigPanel`) 및 `ButtonPrimaryComponent` data provider / + receiver 자동탐색에 인식시킴. +- `RealtimePreviewDynamic.fillParentTypes` 에 canonical table / container / + grouped-table / card-list 추가 (런타임 부모 100% 채움). +- `ScreenNode.tsx` 색상 분기, `InteractiveScreenViewer.tsx` 탭 처리, + `TabsWidget.tsx` inline 테이블 탐색에 canonical 인식 추가. +- legacy `aggregation-widget` 의 `hidden: true` 활성화 (palette 봉인). +- FieldConfig / DataPort 계약 변경 0 건. canonical stats / table / chart / + card-list / grouped-table 의 `dataPorts` 선언 그대로 유지. + +--- + +## 2. Canonical paths updated + +| 파일 | 변경 | +|---|---| +| `frontend/lib/registry/DynamicComponentRenderer.tsx` | `INVYONE_UNIFIED_IDS` 에 `chart` / `card-list` / `grouped-table` 추가. v2- 자동 매핑이 엉뚱한 컴포넌트로 라우팅하지 않도록 차단 | +| `frontend/lib/utils/responsiveDefaults.ts` | `fullWidthComponents` 에 `table` / `grouped-table` / `card-list` 추가 (chart 는 자연스러운 작은 크기도 자주 사용되므로 제외) | +| `frontend/lib/utils/templateMigrate.ts` | `FULL_WIDTH_IDS` 에 `grouped-table`, `MAIN_CONTENT_IDS` 에 `chart` / `card-list`, `inferPolicy` 에 `chart` / `card-list` → `'reflow'` | +| `frontend/lib/registry/components/aggregation-widget/index.ts` | `hidden: true` 활성화. registry 등록은 유지 (옛 화면 alias 라우팅용) | +| `frontend/components/screen/config-panels/button/DataTab.tsx` (×4) | data transfer 후보 매칭에 canonical `"table"` 추가 | +| `frontend/components/screen/config-panels/button-config/ActionTab.tsx` (×4) | 동일 패턴 적용 | +| `frontend/components/v2/config-panels/InvLegacyButtonConfigPanel.tsx` (×2) | 동일 패턴 적용 | +| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | `sourceProvider` 자동탐색 + `targetReceiver` 자동탐색에 canonical `"table"` 추가 | +| `frontend/components/screen/RealtimePreviewDynamic.tsx` | `fillParentTypes` 에 canonical 4 종 추가 | +| `frontend/components/screen/InvyoneStudio.tsx` (×9) | `tabs-widget` / `v2-tabs-widget` 분기 모두에 canonical container (`containerType === "tabs"`) 인식 추가 | +| `frontend/components/screen/ScreenNode.tsx` | 미니어처 색상 분기에 canonical `table` / `grouped-table` / `card-list` 추가 | +| `frontend/components/screen/InteractiveScreenViewer.tsx` | 탭 렌더링 분기에 `isCanonicalTabsContainer` 추가 | +| `frontend/components/screen/widgets/TabsWidget.tsx` | inline 테이블 탐색에 canonical `"table"` 추가 | + +--- + +## 3. Deleted + +폴더 자체 삭제 0 건. 본 cleanup 의 목적은 "삭제" 가 아니라 "분류" 임 (Goal §3 +원칙). 새 생성 경로가 canonical 로만 수렴하고, 옛 ID 는 alias / migration adapter +/ domain shared 로 명시. + +--- + +## 4. Preserved legacy/V2 adapters + +| Component / path | Reason | Next condition for deletion | +|---|---|---| +| `lib/registry/components/aggregation-widget/` | 옛 저장 layout alias → canonical `stats` 로 라우팅. palette hidden 활성화됨 | DB 상 `aggregation-widget` 사용 화면 0 건이 확인되면 폴더 삭제 가능 | +| `lib/registry/components/v2-aggregation-widget/` | items 단위 `dataSource` 매핑이 1:1 아님 (전역 `filters` vs 항목별 `OptionFilter`). 새 생성은 canonical `stats`. palette hidden | items × filters 매핑 어댑터 구현 후 폴더 삭제 가능 | +| `lib/registry/components/v2-status-count/` | `relationColumn` + `parentColumn` 의 부모 row 컨텍스트가 canonical stats DataPort 로 전달되지 않음 (Goal §3.1 stop condition). palette hidden | stats 가 `parentRow(row)` DataPort 를 받도록 확장하거나 OptionFilter `value_type: "field"` 가 외부 row 를 받도록 인프라 보강 후 삭제 | +| `lib/registry/components/table-list/` | `FlowWidget` 이 `SingleTableWithSticky` + `ColumnConfig` 를 직접 import (Goal §8.1) | FlowWidget 을 canonical table 기반으로 마이그레이션하거나 `SingleTableWithSticky` 를 shared 위치로 추출 후 삭제 | +| `lib/registry/components/v2-table-list/` | 옛 저장 layout alias → canonical `table` 로 라우팅. palette hidden | DB 상 `v2-table-list` 사용 화면 0 건 + canonical table 의 column ordering / export / context menu parity 검증 후 | +| `lib/registry/components/split-panel-layout/` `v2-split-panel-layout/` `split-panel-layout2/` | leftPanel / rightPanel master-detail semantics 가 canonical `table.displayMode='split'` 과 직접 대응 안 됨 (`InvyoneStudio` drag/drop, selection sync, nested child 처리 다름). palette hidden | canonical table 의 split 모드가 leftPanel/rightPanel drop / resize / selection sync / nested child 전부 구현 후 삭제 | +| `lib/registry/components/v2-tabs-widget/` `tabs/` | 옛 저장 layout alias → canonical `container` (containerType="tabs"). palette hidden | DB 상 `v2-tabs-widget` 사용 화면 0 건 확인 후 | +| `lib/registry/components/v2-section-card/` `v2-section-paper/` `section-card/` `section-paper/` | alias 로 canonical `container` (containerType="section", sectionVariant) 로 라우팅. palette hidden | canonical container 의 sectionVariant 가 `card` / `paper` parity 검증 후 | +| `lib/registry/components/v2-repeat-container/` `repeat-container/` | canonical `container.containerType="repeater"` skeleton 만 있고 데이터 lookup / 선택 / append 인프라 부족 | canonical container 의 repeater 모드가 dataSourceType / 선택 / append / 인라인 add 완성 후 | +| `lib/registry/components/v2-repeater/` | `basicV2Components` 에 별도 노출 (데이터 조회/선택 special palette item) | canonical container repeater + canonical table multi-select 가 동일 UX 구현 후 | +| `lib/registry/components/accordion-basic/` | alias 로 canonical `container` 라우팅 (다만 container.containerType="accordion" skeleton 만 존재). palette hidden | canonical container accordion 모드 완성 후 | +| `lib/registry/components/conditional-container/` | palette hidden. canonical container.containerType="conditional" skeleton 만 존재 | canonical container conditional 모드 완성 후 | +| `lib/registry/components/modal-repeater-table/` `simple-repeater-table/` `tax-invoice-list/` | business / domain 특화. canonical table parity 부족 | 도메인별 별도 마이그레이션 필요 | +| `lib/schemas/componentConfig.ts` 의 v2-* schema/default | 옛 저장 layout load / save 시 schema validation 용 migration adapter (Goal §4.5) | 옛 저장본을 모두 canonical 로 재저장 후 | +| `lib/utils/layoutV2Converter.ts` (`v2-tabs-widget` / `v2-split-panel-layout` nested defaults) | nested tabs / split layout 의 옛 저장본 변환용 | 옛 layout 데이터 마이그레이션 후 | +| `lib/utils/multilangLabelExtractor.ts` `MultilangSettingsModal.tsx` (`aggregation-widget` i18n) | 옛 i18n 데이터 처리 | i18n 도 canonical stats 기반으로 마이그레이션 시 | +| `lib/registry/hoc/withContainerQuery.css` `app/test-card-responsive/page.tsx` (`v2-aggregation-widget` / `v2-table-list` 참조) | 옛 container query CSS / 반응형 테스트 페이지 | UI 테스트 페이지 cleanup 시 | +| `InvyoneStudio.tsx` 의 `split-panel-layout` / `v2-split-panel-layout` drag/drop 분기 | 옛 화면의 leftPanel/rightPanel drop 처리 (canonical table split 으로 대체 불가) | split-panel-layout 폴더 삭제와 동시에 | +| `components/v2/config-panels/V2AggregationWidgetConfigPanel.tsx` `V2StatusCountConfigPanel.tsx` `V2TableListConfigPanel.tsx` `V2SplitPanelLayoutConfigPanel.tsx` `V2RepeatContainerConfigPanel.tsx` | `getComponentConfigPanel` 직접 import 로 옛 ID 사용 시 노출. canonical 이 우선 사용됨 | 옛 ID 가 0 건이 되면 삭제 | +| `types/v2-components.ts` `types/component-events.ts` `types/screen-management.ts` 의 옛 컴포넌트 type 정의 | 옛 컴포넌트 type signature 유지 | 옛 ID 완전 제거 후 | + +--- + +## 5. Verification + +| Command | Result | +|---|---| +| `git diff --check` | **pass** (whitespace 오류 0 건) | +| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles` | **0 건** (acceptance pass) | +| `rg "EntityPicker\|entity-picker\|EntitySearchModal" frontend/lib/registry/components/input frontend/components/v2/config-panels/InvFieldConfigPanel.tsx` | **0 건** (acceptance pass) | +| `rg "v2-aggregation-widget\|v2-status-count\|aggregation-widget" frontend/lib frontend/components frontend/app frontend/types` | 50 건 — 전부 §4 분류 (alias 라우팅 / hidden / migration adapter / i18n / 테스트 페이지) | +| `rg "v2-table-list\|table-list\|v2-split-panel-layout\|split-panel-layout\|split-panel-layout2" frontend/lib frontend/components frontend/app frontend/types` | 225 건 — 전부 §4 분류 (FlowWidget shared / split master-detail / 옛 type 정의 / alias) | +| `rg "v2-tabs-widget\|tabs-widget\|v2-section-card\|v2-section-paper\|section-card\|section-paper\|v2-repeat-container\|repeat-container\|v2-repeater" frontend/lib frontend/components frontend/app frontend/types` | 164 건 — 전부 §4 분류 (alias / canonical container 인식 추가됨 / domain 보존 / 옛 layout converter) | +| `cd backend-spring && ./gradlew compileJava` | **BUILD SUCCESSFUL** (UP-TO-DATE, backend 변경 0 건) | +| FieldConfig / DataPort 계약 | `frontend/types/invyone-component.ts` 변경 0 건. canonical stats / table / chart / card-list / grouped-table 의 `dataPorts` 선언 그대로 | + +### 5.1 Codex follow-up verification + +Claude Goal 종료 후 Codex가 독립 검증하면서 data transfer 후보 필터의 +false-positive 위험을 보정했다. + +- 기존 변경은 `["table", "table-list", ...].some((t) => type.includes(t))` + 형태였고, `table-search-widget` 같은 검색 컴포넌트도 `"table"` substring + 때문에 후보로 잡힐 수 있었다. +- `DataTab.tsx`, `ActionTab.tsx`, `InvLegacyButtonConfigPanel.tsx` 에 + `isDataTransferComponentType()` helper 를 추가했다. +- canonical `"table"` 은 exact match 로만 허용하고, legacy 호환 문자열 + (`table-list`, `repeater-field-group`, `form-group`, `data-table`) 만 substring + match 를 유지한다. + +추가 확인: + +| Command | Result | +|---|---| +| `git diff --check` | pass | +| input forbidden rg | 0 건 | +| EntityPicker forbidden rg | 0 건 | +| `cd backend-spring && ./gradlew compileJava` | BUILD SUCCESSFUL | +| `npm run lint` | fail — repo 전역 기존 prettier / any / hook rule 이슈 대량 존재. 본 cleanup acceptance 에는 미포함 | +| targeted `npx eslint` on changed frontend files | fail — `InteractiveScreenViewer.tsx` 등 기존 lint 오류가 포함되어 있음. 신규 compile/blocker 는 확인되지 않음 | + +--- + +## 6. Remaining risks + +1. **canonical container repeater / accordion / conditional skeleton**: + 현재 `containerType: "tabs"` / `"section"` 외에는 미구현. 새 화면에서 + container 를 만들고 `containerType="repeater"` 로 변경해도 동작 부족. + palette 에 container 는 한 가지로 노출되므로 사용자가 혼동 가능. + → Phase G.x 추가 작업 필요. + +2. **canonical container default config**: `containerType: "section"` 으로 시작. + tabs 가 더 자주 사용되는 패턴이라면 default 재검토 필요. + +3. **canonical table displayMode='split'** 의 leftPanel / rightPanel 동작 부재: + 옛 `split-panel-layout` 의 master-detail UX (drag/drop, selection sync, + resize, nested child) 가 canonical 에 없음. 새 화면에서 split 모드 사용 시 + 기능 부족. + +4. **v2-aggregation-widget config → canonical StatsConfig 변환 어댑터 없음**: + 옛 저장 화면의 `v2-aggregation-widget` 은 alias 로 canonical stats Renderer + 로 라우팅되지만 config 형식이 다르므로 빈 stats 렌더링 가능. + 메모리 `project_solution_definition_phase` 에 따라 round-trip 호환 미보장. + +5. **`getComponentConfigPanel.tsx` 의 `LEGACY_PANELS`** 에 `chart` / `stats` / + `chart-basic` 포함. `DynamicComponentConfigPanel` 의 fallback 분기에서만 + 사용되고 V2PropertiesPanel 은 `ComponentRegistry.getComponent` 를 우선 + 사용하므로 실제 동작에는 영향 없음. 다만 redundant 코드. + +6. **InvyoneStudio.tsx 8000+ 줄**: 9 곳 canonical container 인식 추가 완료. + 추가 위치 (drag preview, copy/paste, undo/redo 등) 가 발견되면 동일 패턴으로 + 확장 필요. + +--- + +## 7. Acceptance check 요약 (Goal 의 /goal 조건) + +| 조건 | 결과 | +|---|---| +| new Studio creation paths use canonical stats / table / container / chart / card-list / grouped-table | ✅ | +| FieldConfig / DataPort contracts are preserved | ✅ (변경 0 건) | +| v2-input / v2-select / V2InputRenderer / V2SelectRenderer 재도입 X | ✅ (0 건) | +| EntityPicker / EntitySearchModal 재도입 X | ✅ (0 건) | +| `git diff --check` passes | ✅ | +| `cd backend-spring && ./gradlew compileJava` passes | ✅ (BUILD SUCCESSFUL) | +| input forbidden rg checks are 0 | ✅ | +| 남은 stats / table / container legacy/V2 grep matches 가 제거되거나 명시적으로 migration / domain / shared adapters 로 분류 | ✅ (본 보고서 §4) | diff --git a/notes/gbpark/2026-05-18-canonical-data-view-goal.md b/notes/gbpark/2026-05-18-canonical-data-view-goal.md new file mode 100644 index 00000000..5e0e288c --- /dev/null +++ b/notes/gbpark/2026-05-18-canonical-data-view-goal.md @@ -0,0 +1,1089 @@ +# 2026-05-18 Canonical Data View Cleanup - Claude Goal Prompt + +작성 목적: Claude Goal 모드에 그대로 넣어서 `stats`, `table`, `container`, `chart`, `card-list`, `grouped-table` 계열을 input canonical 작업 수준으로 정리하게 만들기 위한 작업 계약서다. + +핵심 결론: +- `input` canonical 작업은 완료된 것으로 본다. `v2-input`, `v2-select`, old 6 input, radio/toggle/file/media 계열은 다시 만들거나 되살리지 않는다. +- 다음 정리 대상은 통계, 테이블, 컨테이너/탭, 그리고 데이터 뷰 컴포넌트다. +- 현재 `stats`, `table`, `container`, `chart`, `card-list`, `grouped-table`은 canonical 구현이 존재하지만, registry, config panel, schema, Studio drag/drop, preview, action/dataflow 경로에 legacy/V2 이름이 많이 남아 있다. +- 이번 Goal의 목적은 단순 폴더 삭제가 아니다. `FieldConfig`와 `DataPort` 계약을 보존하면서 새 화면 생성 경로를 canonical ID로 수렴시키고, old/V2 경로는 흡수 완료, migration adapter, domain/special 보존 중 하나로 명확히 분류하는 것이다. + +--- + +## 1. 현재 확정된 선행 상태 + +### 1.1 Input canonical은 종료 상태 + +근거 문서: +- `notes/gbpark/2026-05-08-input-canonical-migration.md` +- `notes/gbpark/2026-05-12-codex-handoff-input-canonical.md` + +완료 요약: +- `frontend/lib/registry/components/v2-input/` 삭제 완료 +- `frontend/lib/registry/components/v2-select/` 삭제 완료 +- `frontend/components/v2/V2Input.tsx` 삭제 완료 +- `frontend/components/v2/V2Select.tsx` 삭제 완료 +- old 6 input 폴더 삭제 완료 +- `radio-basic`, `toggle-switch`, `slider-basic`, old file/media/image widget 계열 삭제 또는 shared shim화 완료 +- `input`이 text, number, date, datetime, time, daterange, textarea, select, entity code-name, category, checkbox, mask, slider, color, file/image를 담당 + +현재 코드 기준 검증: +```bash +rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" \ + frontend/lib frontend/components frontend/app frontend/types frontend/styles + +rg "EntityPicker|entity-picker|EntitySearchModal" \ + frontend/lib/registry/components/input \ + frontend/components/v2/config-panels/InvFieldConfigPanel.tsx +``` + +기대: +- 둘 다 코드 경로에서는 0건. +- `notes/` 문서 안의 옛 계획 문구는 검색될 수 있다. 코드 cleanup 판단에는 포함하지 않는다. + +절대 금지: +- `v2-input`, `v2-select`, `V2InputRenderer`, `V2SelectRenderer` 재도입 +- canonical `input` 안에 `EntityPicker` 또는 `EntitySearchModal` 재도입 +- DB layout JSON을 SQL로 일괄 마이그레이션하는 방향 + +--- + +## 2. 반드시 보존할 아키텍처 계약 + +원천 파일: `frontend/types/invyone-component.ts` + +### 2.1 FieldConfig + +`FieldConfig`가 유일한 필드 규격이다. table column, form input, search filter가 모두 이 규격 위로 수렴해야 한다. + +핵심 타입: +```ts +export type FieldType = + | 'text' + | 'number' + | 'date' + | 'datetime' + | 'time' + | 'daterange' + | 'select' + | 'entity' + | 'checkbox' + | 'textarea' + | 'file' + | 'code'; +``` + +주의: +- `entity`는 검색 모달이 아니라 참조 테이블 code-name 옵션 source다. +- 명시적인 검색 모달 UI는 `entity-search-input` 같은 explicit 컴포넌트 영역이며 canonical `input`으로 되돌려 넣지 않는다. +- `FieldConfig` 자체를 늘려서 특정 업무 도메인을 해결하지 않는다. 컴포넌트별 표시/동작 옵션은 각 component config로 둔다. + +### 2.2 DataPort + +`DataPort`와 `Connection` 계약은 줄이지 않는다. + +핵심 타입: +```ts +export type DataPortType = 'row' | 'rows' | 'value' | 'params'; + +export type ReservedDataPortName = + | 'searchParams' + | 'refreshTrigger' + | 'selectedRow' + | 'selectedRows' + | 'loadRow' + | 'formData' + | 'savedRow' + | 'clicked'; +``` + +현재 런타임 상태: +- `frontend/lib/dataPort/DataPortBus.ts` +- `frontend/lib/dataPort/runtime.ts` +- `setupConnections(connections, bus)`는 존재한다. +- 그러나 현재 canonical 컴포넌트들은 대부분 `DataPortBus` 직접 publish/subscribe보다 props/callback 방식으로 연결되어 있다. + +작업 원칙: +- `dataPorts` 선언을 삭제하거나 축소하지 않는다. +- Goal 작업 중 DataPortBus 전체 wiring을 완성하지 못하더라도 계약은 유지한다. +- `table`의 `selectedRow/selectedRows`, `searchParams/refreshTrigger` 이름은 유지한다. +- `stats`, `chart`, `card-list`, `grouped-table`의 `data(rows)` input 선언도 유지한다. +- DataPort wiring을 손대는 경우에는 별도 작은 단계로 하고, 기존 props/callback 흐름을 깨지 않는다. + +--- + +## 3. 현재 canonical 컴포넌트 상태 + +### 3.1 `stats` + +주요 파일: +- `frontend/lib/registry/components/stats/index.ts` +- `frontend/lib/registry/components/stats/types.ts` +- `frontend/lib/registry/components/stats/StatsComponent.tsx` +- `frontend/lib/registry/components/stats/use-stats-data.ts` +- `frontend/lib/registry/components/stats/InvStatsConfigPanel.tsx` + +현재 상태: +- canonical id는 `stats`. +- default config는 DB-first다. item에 `value: 0` 같은 mock 값을 박지 않고 `dataSource: { aggregation: "count" }`로 시작한다. +- `StatsItem.dataSource`는 `tableName`, `aggregation`, `columnName`, `filters`를 가진다. +- `filters`는 canonical input의 `OptionFilter`를 재사용한다. +- `useStatsData`는 `/table-management/tables/{tableName}/aggregate` 호출로 값을 가져온다. +- 디자인 모드에서도 preview fetch를 한다. 다만 350ms debounce를 둔다. +- `field`/`user` filter value는 runtime에서 `formData`/`userContext`로 치환한다. +- 실패는 카드 단위 error로 처리해야 한다. + +관련 API: +- `frontend/lib/api/stats.ts` +- `backend-spring/src/main/java/com/erp/controller/TableManagementController.java` +- `backend-spring/src/main/java/com/erp/service/TableManagementService.java` + +지원 backend endpoint: +```text +POST /api/table-management/tables/{tableName}/aggregate +POST /api/table-management/tables/{tableName}/aggregate-group +POST /api/table-management/tables/{tableName}/select-rows +``` + +남은 문제: +- legacy/V2 stats 컴포넌트 폴더가 아직 등록된다. +- `v2-aggregation-widget`, `aggregation-widget`, `v2-status-count`가 renderer/config/schema/alias 경로에 남아 있다. +- `v2-status-count`의 `relationColumn`, `parentColumn` 같은 관계형 상태 카운트 semantics가 canonical `stats`의 `filters`로 완전히 표현 가능한지 검증이 필요하다. + +삭제 후보: +- `frontend/lib/registry/components/aggregation-widget/` +- `frontend/lib/registry/components/v2-aggregation-widget/` +- `frontend/lib/registry/components/v2-status-count/` +- 단, 실제 import와 저장 화면 compatibility 경로를 모두 확인한 뒤 삭제한다. + +보존 가능 조건: +- `v2-status-count`의 관계 필터를 canonical `StatsItem.dataSource.filters`로 안전하게 매핑할 수 없으면 삭제하지 말고 read-only migration adapter 또는 explicit preserved legacy로 분류한다. +- adapter를 남길 경우 새 생성 경로와 palette에서는 반드시 제외한다. + +### 3.2 `table` + +주요 파일: +- `frontend/lib/registry/components/table/index.ts` +- `frontend/lib/registry/components/table/types.ts` +- `frontend/lib/registry/components/table/TableComponent.tsx` +- `frontend/lib/registry/components/table/useTableData.ts` +- `frontend/lib/registry/components/table/InvTableConfigPanel.tsx` + +현재 상태: +- canonical id는 `table`. +- display mode는 `table`, `split`, `grouped`, `pivot`, `card`. +- `TableDefinition.dataPorts`는 다음을 선언한다. +```ts +inputs: [ + { name: "searchParams", type: "params" }, + { name: "refreshTrigger", type: "value" }, +], +outputs: [ + { name: "selectedRow", type: "row" }, + { name: "selectedRows", type: "rows" }, +], +``` +- `useTableData`는 `entityJoinApi.getTableDataWithJoins()`를 사용한다. +- canonical `TableComponent`는 `searchParams` props를 외부 검색 조건으로 받고, 선택 상태는 `onRowSelect`, `onSelectedRowsChange` 같은 callback으로 올린다. +- DataPortBus publish/subscribe는 아직 직접 연결되어 있지 않다. + +남은 문제: +- old `table-list`와 `v2-table-list`가 여전히 크고 기능이 많다. +- `FlowWidget`가 `table-list/SingleTableWithSticky`와 `table-list/types`의 `ColumnConfig`를 직접 import한다. +- `v2-button-primary`, `ActionTab`, `DataTab`, `InvLegacyButtonConfigPanel`, `TabsWidget`, `RealtimePreviewDynamic`, `InvyoneStudio` 등에 `table-list`, `split-panel-layout` 탐색 로직이 남아 있다. +- `componentConfig.ts`에 `v2-table-list`, `v2-split-panel-layout` schema/default가 남아 있다. +- `layoutV2Converter.ts`가 nested `v2-split-panel-layout`에 특수 기본값을 적용한다. +- `responsiveDefaults.ts`는 `table-list`만 full width로 본다. canonical `table`, `grouped-table`, `card-list`, 필요 시 `chart`까지 재검토해야 한다. + +삭제 후보: +- `frontend/lib/registry/components/table-list/` +- `frontend/lib/registry/components/v2-table-list/` +- `frontend/lib/registry/components/split-panel-layout/` +- `frontend/lib/registry/components/v2-split-panel-layout/` +- `frontend/lib/registry/components/split-panel-layout2/` + +단, 삭제 전에 반드시 분류: +- `FlowWidget`의 `SingleTableWithSticky` 의존은 shared internal로 추출하거나, FlowWidget 전용 local component로 복사/이관하거나, 해당 old folder를 explicit shared dependency로 보존한다. +- `modal-repeater-table`, `simple-repeater-table`, `tax-invoice-list`는 table 흡수 대상 문서에 들어 있지만 업무 특화 기능이 많다. 이번 Goal에서 parity가 없으면 삭제하지 말고 explicit/special로 분류한다. +- `split-panel-layout` 계열은 canonical `table displayMode="split"`로 완전히 흡수 가능한지 확인한다. master-detail, resize, selection sync, nested child behavior가 빠져 있으면 adapter 보존이 필요하다. + +### 3.3 `container` + +주요 파일: +- `frontend/lib/registry/components/container/index.ts` +- `frontend/lib/registry/components/container/types.ts` +- `frontend/lib/registry/components/container/ContainerComponent.tsx` +- `frontend/lib/registry/components/container/InvContainerConfigPanel.tsx` + +현재 상태: +- canonical id는 `container`. +- `containerType`은 `tabs`, `section`, `accordion`, `repeater`, `conditional`. +- tabs 모드는 활성 탭의 `components`만 `DynamicComponentRenderer`로 렌더링한다. +- `ContainerTab.components` child shape는 `id`, `componentType`, `componentConfig`, `label`, `size` 중심이다. +- 디자인 모드에서 tab body에 drop-zone data attribute가 있다. + +남은 문제: +- section/accordion/repeater/conditional은 아직 skeleton에 가깝다. +- old tabs와 V2 tabs 경로가 Studio drag/drop, selection, config panel에 남아 있다. +- `layoutV2Converter.ts`가 nested `v2-tabs-widget` 기본값을 별도 처리한다. +- `v2-repeat-container`, `repeat-container`, `v2-repeater`는 반복 데이터 입력/선택 semantics가 있어 단순 container 삭제 대상이 아니다. + +삭제 후보: +- `frontend/lib/registry/components/v2-tabs-widget/` +- `frontend/lib/registry/components/tabs/` +- `frontend/lib/registry/components/v2-section-card/` +- `frontend/lib/registry/components/v2-section-paper/` +- `frontend/lib/registry/components/section-card/` +- `frontend/lib/registry/components/section-paper/` +- `frontend/lib/registry/components/accordion-basic/` +- `frontend/lib/registry/components/v2-repeat-container/` +- `frontend/lib/registry/components/repeat-container/` + +단, 삭제 전에 반드시 분류: +- tabs는 canonical `containerType="tabs"`로 migration 가능해야 한다. +- section card/paper는 `containerType="section"` + `sectionVariant`로 migration 가능해야 한다. +- repeat/repeater는 canonical container skeleton만으로는 부족할 가능성이 높다. parity가 없으면 삭제 금지. + +### 3.4 `chart` + +주요 파일: +- `frontend/lib/registry/components/chart/index.ts` +- `frontend/lib/registry/components/chart/types.ts` +- `frontend/lib/registry/components/chart/ChartComponent.tsx` +- `frontend/lib/registry/components/chart/use-chart-data.ts` +- `frontend/lib/registry/components/chart/InvChartConfigPanel.tsx` + +현재 상태: +- canonical id는 `chart`. +- `dataSource.tableName + groupBy + aggregation`으로 `/aggregate-group` 호출. +- chart type은 bar, horizontalBar, line, donut 중심. +- `dataPorts.inputs = [{ name: "data", type: "rows" }]`. + +남은 문제: +- `DynamicComponentRenderer`의 `INVYONE_UNIFIED_IDS`에 `chart`가 빠져 있다. +- old placeholder `ChartRenderer.tsx` 같은 legacy display 경로와 canonical chart 경로가 혼재할 수 있다. +- `getComponentConfigPanel.tsx`에는 canonical config panel이 잡혀 있다. + +### 3.5 `card-list` + +주요 파일: +- `frontend/lib/registry/components/card-list/index.ts` +- `frontend/lib/registry/components/card-list/types.ts` +- `frontend/lib/registry/components/card-list/CardListComponent.tsx` +- `frontend/lib/registry/components/card-list/InvCardListConfigPanel.tsx` + +현재 상태: +- canonical id는 `card-list`. +- row 목록을 카드 카탈로그처럼 표시한다. +- shared `use-table-rows`를 통해 `/select-rows` 호출. +- `dataPorts.inputs = [{ name: "data", type: "rows" }]`. + +남은 문제: +- config panel의 field mapping 일부는 free text 중심이다. `ColumnPicker`로 바꾸면 좋지만, cleanup보다 우선순위는 낮다. +- `DynamicComponentRenderer`의 `INVYONE_UNIFIED_IDS`에 빠져 있다. + +### 3.6 `grouped-table` + +주요 파일: +- `frontend/lib/registry/components/grouped-table/index.ts` +- `frontend/lib/registry/components/grouped-table/types.ts` +- `frontend/lib/registry/components/grouped-table/GroupedTableComponent.tsx` +- `frontend/lib/registry/components/grouped-table/InvGroupedTableConfigPanel.tsx` + +현재 상태: +- canonical id는 `grouped-table`. +- `/select-rows`로 받은 row를 `groupBy`로 클라이언트 그룹핑한다. +- `dataPorts.inputs = [{ name: "data", type: "rows" }]`. + +남은 문제: +- config panel 일부가 free text 중심이다. +- `DynamicComponentRenderer`의 `INVYONE_UNIFIED_IDS`에 빠져 있다. +- responsive full-width 판단에 빠져 있다. + +--- + +## 4. 현재 legacy/V2 잔여 진입점 + +### 4.1 Registry auto-register + +파일: `frontend/lib/registry/components/index.ts` + +현재 자동 등록되는 legacy/V2 예: +```text +table-list +split-panel-layout +aggregation-widget +repeat-container +tabs/tabs-component +v2-repeater +v2-split-panel-layout +v2-aggregation-widget +v2-table-list +v2-repeat-container +v2-section-card +v2-section-paper +v2-tabs-widget +v2-status-count +``` + +동시에 canonical도 등록된다: +```text +divider +title +button +search +input +stats +chart +card-list +grouped-table +table +container +``` + +정리 목표: +- 새 생성 경로와 palette에는 canonical만 남긴다. +- legacy/V2가 필요하면 runtime migration adapter 또는 explicit preserved domain/special로 분리한다. +- `components/index.ts`에서 무조건 import되는 old renderer는 최종적으로 없애거나, 보존 사유가 주석으로 분명해야 한다. + +### 4.2 Dynamic renderer alias + +파일: `frontend/lib/registry/DynamicComponentRenderer.tsx` + +현재 `INVYONE_UNIFIED_IDS`: +```text +divider, title, button, search, input, stats, table, container +``` + +빠진 canonical: +```text +chart, card-list, grouped-table +``` + +현재 alias: +```text +v2-aggregation-widget -> stats +aggregation-widget -> stats +v2-status-count -> stats +v2-table-list -> table +table-list -> table +v2-tabs-widget -> container +v2-section-card -> container +v2-section-paper -> container +v2-repeat-container -> container +section-card -> container +section-paper -> container +accordion-basic -> container +``` + +정리 목표: +- canonical set에 `chart`, `card-list`, `grouped-table`을 포함한다. +- alias는 삭제 대상이 아니라 migration boundary다. old 저장 화면을 지원할지, 새 솔루션 기준으로 끊을지 정책을 파일 단위로 결정해야 한다. +- input 때처럼 완전 제거 가능한 V2와 달리 table/container는 기능 차이가 커서 alias를 한 번에 날리면 위험하다. + +### 4.3 Config panel loader + +파일: `frontend/lib/utils/getComponentConfigPanel.tsx` + +canonical map: +```text +divider, title, button, search, input, stats, table, container, +chart, card-list, grouped-table +``` + +아직 직접 map에 남은 legacy/V2: +```text +table-list +v2-table-list +tabs-widget +v2-tabs-widget +tabs +v2-tabs +aggregation-widget +v2-aggregation-widget +section-card +v2-section-card +section-paper +v2-section-paper +split-panel-layout +v2-split-panel-layout +repeat-container +v2-repeat-container +``` + +정리 목표: +- alias로 canonical panel을 보여주는 것은 migration boundary로 허용 가능. +- 그러나 새 생성되지 않을 old id의 직접 old config panel import는 제거 후보다. +- 제거 전에는 old config schema가 canonical config로 변환 가능한지 확인한다. + +### 4.4 Template migration + +파일: `frontend/lib/utils/templateMigrate.ts` + +현재 legacy id를 unified id로 정규화한다. + +정리 목표: +- 이 파일은 DB layout SQL migration이 아니라 runtime/view conversion boundary다. +- 무작정 삭제하지 않는다. +- old id를 canonical id로 바꾸는 마지막 방어선으로 둘지, 새 솔루션 기준으로 축소할지 명확히 한다. + +### 4.5 Layout V2 converter와 schema/default + +파일: +- `frontend/lib/schemas/componentConfig.ts` +- `frontend/lib/utils/layoutV2Converter.ts` + +남은 schema/default 예: +```text +v2-table-list +v2-split-panel-layout +v2-repeat-container +v2-aggregation-widget +v2-tabs-widget +v2-repeater +``` + +정리 목표: +- old 컴포넌트가 더 이상 생성/로드되지 않는다면 schema/default 제거. +- old 저장 layout을 runtime에서 읽어야 한다면 schema/default를 migration adapter로 둘 수 있다. +- 삭제할 때는 `getDefaultsByUrl`, `convertV2ToLegacy`, nested tabs/split handling이 깨지지 않는지 확인한다. + +### 4.6 Studio drag/drop and selection + +주요 파일: +- `frontend/components/screen/InvyoneStudio.tsx` +- `frontend/components/screen/panels/ComponentsPanel.tsx` +- `frontend/components/screen/RealtimePreviewDynamic.tsx` +- `frontend/components/screen/InteractiveScreenViewer.tsx` +- `frontend/components/screen/config-panels/button-config/ActionTab.tsx` +- `frontend/components/screen/config-panels/button/DataTab.tsx` + +현재 잔여 문자열: +```text +table-list +v2-table-list +split-panel-layout +v2-split-panel-layout +tabs-widget +v2-tabs-widget +aggregation-widget +v2-aggregation-widget +v2-status-count +``` + +정리 목표: +- 새 drag 생성은 canonical id로만 만든다. +- old id 조건문은 삭제, canonical 추가, 또는 migration adapter로 분류한다. +- tab 내부 drop은 `containerType="tabs"`의 `tabs[].components` shape를 기준으로 한다. +- split panel 내부 drop/selection은 canonical `table displayMode="split"`로 흡수 가능한지 먼저 확인한다. + +--- + +## 5. 목표 end state + +새 화면 생성 기준 basic palette: +```text +input +search +button +title +divider +stats +table +container +chart +card-list +grouped-table +``` + +허용되는 advanced/domain: +```text +v2-bom-tree +v2-bom-item-editor +v2-approval-step +map +v2-shipping-plan-editor +v2-timeline-scheduler +v2-rack-structure +v2-process-work-standard +v2-item-routing +``` + +조건부 보존: +```text +v2-repeater +modal-repeater-table +simple-repeater-table +tax-invoice-list +universal-form-modal +entity-search-input +autocomplete-search-input +selected-items-detail-input +mail-recipient-selector +category-manager +location-swap-selector +related-data-buttons +flow-widget +``` + +보존 조건: +- business/domain/special 컴포넌트로서 canonical table/container와 기능이 다르다. +- 또는 다른 기능이 직접 import하는 shared internal을 포함한다. +- 또는 old 저장 화면 runtime adapter로만 필요하다. + +보존 시 규칙: +- palette 새 생성 경로에서 숨긴다. +- `components/index.ts`에서 자동 등록할지 여부를 재검토한다. +- 보존 이유와 삭제 불가 조건을 최종 보고서에 쓴다. + +--- + +## 6. Claude Goal 실행 프롬프트 + +아래 블록을 Claude Goal에 그대로 넣는다. + +```text +You are working in /Users/gbpark/invyone. + +Goal: +Clean up the INVYONE Studio data-view component system after canonical input migration. Bring stats/table/container/chart/card-list/grouped-table to the same architectural standard as canonical input: new creation paths must use canonical IDs, V2/legacy paths must be absorbed, removed, or explicitly preserved as migration/domain adapters, and FieldConfig/DataPort contracts must be preserved. + +Read this whole file first: +notes/gbpark/2026-05-18-canonical-data-view-goal.md + +Non-negotiable rules: +1. Do not reintroduce v2-input, v2-select, V2InputRenderer, V2SelectRenderer, V2Input.tsx, or V2Select.tsx. +2. Do not put EntityPicker or EntitySearchModal into canonical input. canonical input entity mode is code-name option source only. +3. Do not write DB layout JSON migration SQL. This is new-solution cleanup, not DB data rewrite. +4. Do not shrink FieldConfig or DataPort contracts in frontend/types/invyone-component.ts. +5. Do not delete a legacy/V2 folder just because it has an alias. First prove it has no active imports, or classify it as preserved with a reason. +6. Do not delete domain/special components unless you have implemented equivalent canonical behavior and verified all active references. +7. Prefer the existing codebase patterns: ComponentRegistry, createComponentDefinition, CP panel primitives, ColumnPicker, OptionFilterRow, useDbTables, useStatsData, use-table-rows, aggregate/select backend APIs. +8. Avoid broad rewrites. Work in small phases, run grep after each phase, and keep a final report. + +First, run inventory commands and write down what each match means: + +rg -n "v2-aggregation-widget|v2-status-count|aggregation-widget" \ + frontend/lib frontend/components frontend/app frontend/types + +rg -n "v2-table-list|table-list|v2-split-panel-layout|split-panel-layout|split-panel-layout2" \ + frontend/lib frontend/components frontend/app frontend/types + +rg -n "v2-tabs-widget|tabs-widget|v2-section-card|v2-section-paper|section-card|section-paper|v2-repeat-container|repeat-container|v2-repeater" \ + frontend/lib frontend/components frontend/app frontend/types + +rg -n "INVYONE_UNIFIED_IDS|LEGACY_TO_UNIFIED|CONFIG_PANEL_ALIAS|v2-table-list|v2-aggregation-widget|v2-status-count|v2-tabs-widget|v2-split-panel-layout" \ + frontend/lib/registry frontend/lib/utils frontend/lib/schemas frontend/components/screen + +Do not stop after inventory. Implement the cleanup in phases below. + +Phase 1 - canonical renderer/config entry cleanup: +- Add chart, card-list, grouped-table to canonical/unified routing where needed, especially DynamicComponentRenderer INVYONE_UNIFIED_IDS. +- Ensure ComponentsPanel basic IDs contain only intended canonical basics plus explicitly allowed advanced/domain entries. +- Ensure responsive defaults recognize canonical table/grouped-table/card-list and any other true full-width data view. +- Keep DataPort declarations intact. +- Run grep to confirm no new v2-input/v2-select references. + +Phase 2 - stats cleanup: +- Treat stats as the canonical replacement for v2-aggregation-widget, aggregation-widget, and v2-status-count. +- Check v2-aggregation-widget and aggregation-widget config shapes. Map their items/table/filter concepts into StatsConfig.items[].dataSource where safe. +- Check v2-status-count config. If statusColumn/items/tableName can map to StatsItem.dataSource.filters, implement the adapter. If relationColumn/parentColumn semantics cannot be safely mapped, preserve v2-status-count as a read-only legacy/domain adapter and document why. +- Remove old direct config panel imports and registry auto-register imports only after proving there are no active code imports that need the old implementation. +- Remove old stats folders only if rg proves no active imports remain and runtime alias/migration no longer needs the implementation. +- Final acceptable state for stats: + - New creation uses id "stats". + - Palette does not expose v2-aggregation-widget, aggregation-widget, or v2-status-count. + - Old ids either route to canonical stats or are explicitly preserved with reason. + +Phase 3 - table cleanup: +- Treat table as the canonical replacement for table-list and v2-table-list when behavior is equivalent. +- Compare old table-list/v2-table-list features against canonical table before deleting: selection, multi-select, sort, pagination, column visibility/order, entity label display, export, action buttons, context menus, card mode. +- FlowWidget imports SingleTableWithSticky from table-list. Do not break it. Either extract that shared table view into a neutral shared location, migrate FlowWidget to canonical table internals, or preserve table-list as explicit shared dependency with a written reason. +- Update ActionTab, DataTab, ButtonPrimaryComponent, InvLegacyButtonConfigPanel, RealtimePreviewDynamic, InvyoneStudio, ScreenNode, TabsWidget references so they recognize canonical table, not only table-list. +- For split-panel-layout/v2-split-panel-layout, verify whether canonical table displayMode="split" has equivalent master-detail behavior. If not equivalent, preserve split-panel legacy with reason. If equivalent, migrate creation/config paths to canonical table split mode. +- Do not delete modal-repeater-table, simple-repeater-table, tax-invoice-list, universal-form-modal table sections unless parity is implemented and all imports are updated. These are likely special/business components. +- Final acceptable state for table: + - New creation uses id "table". + - New table config panel is InvTableConfigPanel. + - searchParams and selectedRows/selectedRow flows still work through existing props/callbacks and DataPort declarations remain. + - Remaining table-list/split-panel strings are either migration aliases or explicitly documented preserved dependencies. + +Phase 4 - container/tabs cleanup: +- Treat container as canonical for tabs, section, accordion, repeat, and conditional only where behavior exists. +- For v2-tabs-widget/tabs-widget, migrate creation and drop/selection paths to container with containerType="tabs". +- Ensure tab child shape uses ContainerTab.components with child.componentType and child.componentConfig. +- Update InvyoneStudio tab drop/selection logic to support canonical container as the primary path. +- For section-card/section-paper/v2-section-card/v2-section-paper, map to containerType="section" and sectionVariant card/paper where behavior is equivalent. +- For accordion/repeat/conditional, do not delete old components if canonical container still only has skeleton behavior. Preserve with reason or implement missing behavior first. +- For v2-repeat-container and v2-repeater, verify data lookup/selection/repeater semantics. Do not delete unless canonical repeater behavior is complete. +- Final acceptable state for container: + - New tabs are canonical container tabs. + - Old tabs ids are not palette-created. + - Remaining old container ids are classified. + +Phase 5 - chart/card-list/grouped-table cleanup: +- Ensure chart, card-list, grouped-table are first-class canonical components everywhere a canonical component ID list exists. +- Ensure their config panels are reachable through getComponentConfigPanel. +- Ensure they are visible in the intended palette group. +- Ensure they keep dataPorts input data(rows). +- Do not over-scope into full UX redesign. ColumnPicker improvements are allowed only if low-risk. + +Phase 6 - schema/migration/alias cleanup: +- Review and update these files together: + - frontend/lib/registry/components/index.ts + - frontend/lib/registry/DynamicComponentRenderer.tsx + - frontend/lib/utils/getComponentConfigPanel.tsx + - frontend/lib/utils/templateMigrate.ts + - frontend/lib/schemas/componentConfig.ts + - frontend/lib/utils/layoutV2Converter.ts + - frontend/types/v2-components.ts + - frontend/lib/utils/responsiveDefaults.ts + - frontend/components/screen/panels/ComponentsPanel.tsx + - frontend/components/screen/InvyoneStudio.tsx + - frontend/components/screen/RealtimePreviewDynamic.tsx + - frontend/components/screen/config-panels/button-config/ActionTab.tsx + - frontend/components/screen/config-panels/button/DataTab.tsx + - frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +- Remove direct old renderer/config/schema imports only when the old implementation is no longer needed. +- Keep migration alias only if old saved layouts still need it, and document it as migration-only. + +Phase 7 - verification: +Run: + +git diff --check + +rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" \ + frontend/lib frontend/components frontend/app frontend/types frontend/styles + +rg "EntityPicker|entity-picker|EntitySearchModal" \ + frontend/lib/registry/components/input \ + frontend/components/v2/config-panels/InvFieldConfigPanel.tsx + +rg -n "v2-aggregation-widget|v2-status-count|aggregation-widget" \ + frontend/lib frontend/components frontend/app frontend/types + +rg -n "v2-table-list|table-list|v2-split-panel-layout|split-panel-layout|split-panel-layout2" \ + frontend/lib frontend/components frontend/app frontend/types + +rg -n "v2-tabs-widget|tabs-widget|v2-section-card|v2-section-paper|section-card|section-paper|v2-repeat-container|repeat-container|v2-repeater" \ + frontend/lib frontend/components frontend/app frontend/types + +cd backend-spring && ./gradlew compileJava + +Also run the repo's frontend type/lint command if it exists. If whole-project frontend typecheck fails due pre-existing errors, capture the errors and prove changed files did not introduce new module-not-found or type errors. + +Manual runtime smoke scenarios to preserve: +1. Add a new stats component, configure user_info count, and configure another item with status="재직" count. +2. Add a new table component, load columns from a real table, select one row, then multi-select rows if enabled. +3. Add a new search component and pass its searchParams to the table through the existing supported flow. +4. Add a container tabs component, create two tabs, drop a stats child into one tab and a table child into another tab, switch tabs. +5. Add a chart grouped by a column. +6. Add card-list and grouped-table with tableName, selected fields, groupBy/orderBy where relevant. + +Final report format: +- Summary of canonical entry points changed. +- Files changed. +- Deleted folders/files. +- Legacy/V2 components still preserved and exact reason for each. +- Verification commands and results. +- Remaining risks or explicit next phases. +``` + +--- + +## 7. 작업 세부 설계 + +### 7.1 Stats 설계 + +Target config: +```ts +type StatsConfig = { + orientation?: "horizontal" | "vertical" | "grid"; + style?: "card" | "chip" | "bigNumber"; + columns?: number; + title?: string; + sourceTable?: string; + items?: Array<{ + id?: string; + label: string; + value?: string | number; + icon?: string; + color?: string; + format?: string; + delta?: string; + deltaDirection?: "up" | "down" | "neutral"; + dataSource?: { + tableName?: string; + aggregation?: "count" | "sum" | "avg" | "min" | "max" | "distinctCount"; + columnName?: string; + filters?: OptionFilter[]; + }; + }>; +}; +``` + +Legacy mapping draft: + +| Legacy source | Canonical stats mapping | +|---|---| +| `v2-aggregation-widget.items[]` | `StatsConfig.items[]` | +| item label/title/name | `StatsItem.label` | +| item icon/color | `StatsItem.icon/color` | +| tableName/sourceTable | `StatsItem.dataSource.tableName` or config-level sourceTable fallback | +| aggregation/count/sum/avg | `StatsItem.dataSource.aggregation` | +| column/field | `StatsItem.dataSource.columnName` | +| filters | `StatsItem.dataSource.filters` | +| static value | `StatsItem.value` fallback only | +| `v2-status-count.items[]` | one stat item per status | +| status item value/code | filter `{ column: statusColumn, operator: "=", value }` | +| relationColumn/parentColumn | only map if field/user filter can represent it safely | + +Stop condition: +- If a status-count feature depends on parent selected row or relation context that canonical stats cannot currently receive, preserve `v2-status-count` and document it. + +### 7.2 Table 설계 + +Target config: +```ts +type TableConfig = { + displayMode?: "table" | "split" | "grouped" | "pivot" | "card"; + tableName?: string; + columns?: TableColumn[]; + fields?: FieldConfig[]; + selectionMode?: "none" | "single" | "multiple"; + showCheckbox?: boolean; + pagination?: { enabled?: boolean; pageSize?: number }; + defaultSort?: { column: string; direction: "asc" | "desc" }; + grouped?: { groupBy?: string }; + pivot?: PivotGridProps; + card?: TableCardColumnMapping & TableCardStyleConfig; +}; +``` + +Old table parity checklist: +- tableName/source table load +- server pagination +- sorting +- row selection +- multi selection +- checkbox column +- selectedRows callback +- entity label display +- column order and width +- hidden/visible columns +- card mode +- grouped mode +- pivot mode +- export/download +- row action buttons +- open modal with selected row +- split/master-detail mode +- context menu + +Delete only after: +```bash +rg -n "from \"@/lib/registry/components/table-list|from '@/lib/registry/components/table-list|components/table-list|table-list/" frontend +rg -n "from \"@/lib/registry/components/v2-table-list|components/v2-table-list|v2-table-list/" frontend +``` + +If references remain: +- migrate them to canonical/shared code, or +- preserve the old folder as shared/legacy dependency with explicit comment. + +### 7.3 Container 설계 + +Target config: +```ts +type ContainerConfig = { + containerType?: "tabs" | "section" | "accordion" | "repeater" | "conditional"; + title?: string; + tabs?: Array<{ + id: string; + label: string; + icon?: string; + components?: Array<{ + id: string; + componentType: string; + componentConfig?: Record; + label?: string; + size?: { width?: number | string; height?: number | string }; + }>; + dataSources?: unknown[]; + filters?: unknown[]; + viewType?: string; + }>; + defaultTab?: string; + sectionVariant?: "card" | "paper" | "plain"; + collapsible?: boolean; + defaultCollapsed?: boolean; +}; +``` + +Tabs mapping: + +| Old tabs | Canonical container | +|---|---| +| `v2-tabs-widget` | `container` | +| `tabs-widget` | `container` | +| `config.tabs[]` | `componentConfig.tabs[]` | +| tab child components | `tab.components[]` | +| defaultTab/defaultTabId | `defaultTab` | + +Stop condition: +- If old tabs support nested absolute positioning that canonical tabs does not, keep adapter or implement equivalent before deletion. + +### 7.4 Chart/card-list/grouped-table 설계 + +These are already canonical view components. Treat them as first-class, not as old dashboard placeholders. + +Required paths: +- `ComponentRegistry` registration +- `getComponentConfigPanel` +- `ComponentsPanel` visibility +- `DynamicComponentRenderer` canonical ID set +- responsive default if the component is naturally wide + +Do not confuse: +- admin dashboard `chart` widgets +- POP dashboard chart +- canonical Studio `frontend/lib/registry/components/chart` + +--- + +## 8. 위험 구간 + +### 8.1 `table-list` is not just old UI + +`FlowWidget` imports: +```text +frontend/components/screen/widgets/FlowWidget.tsx + -> frontend/lib/registry/components/table-list/SingleTableWithSticky + -> frontend/lib/registry/components/table-list/types +``` + +So deleting `table-list/` blindly will break FlowWidget. + +### 8.2 Button action config searches old table ids + +Files: +```text +frontend/components/screen/config-panels/button-config/ActionTab.tsx +frontend/components/screen/config-panels/button/DataTab.tsx +frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx +frontend/components/v2/config-panels/InvLegacyButtonConfigPanel.tsx +``` + +They still search for: +```text +table-list +data-table +repeater-field-group +split-panel-layout +screen-split-panel +``` + +Need to add canonical `table` support and avoid breaking selectedRows behavior. + +### 8.3 `container` is not fully equivalent to all old container components + +Current canonical tabs render children. Other modes are not necessarily feature-complete. + +Do not delete: +- `repeat-container` +- `v2-repeat-container` +- `v2-repeater` +- `conditional-container` +- `accordion-basic` + +unless behavior is implemented or no active use remains. + +### 8.4 `componentConfig.ts` and `layoutV2Converter.ts` are load/save boundaries + +Removing schema/default entries can break layout load/save even if renderer imports are gone. + +Before deleting schema/default: +- inspect `getDefaultsByUrl` +- inspect `convertV2ToLegacy` +- inspect nested tabs/split default application +- run smoke load of a layout if possible + +### 8.5 Full-project frontend typecheck may already fail + +Existing known failures have appeared historically around unrelated module aliases and camelCase/snake_case drift. + +If full `tsc` fails: +- capture output +- verify changed files did not add `Cannot find module` errors +- run targeted grep and compile checks +- document residual pre-existing errors + +--- + +## 9. Expected grep end states + +### 9.1 Input + +Must be zero in code paths: +```bash +rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" \ + frontend/lib frontend/components frontend/app frontend/types frontend/styles +``` + +Must be zero in canonical input and InvField panel: +```bash +rg "EntityPicker|entity-picker|EntitySearchModal" \ + frontend/lib/registry/components/input \ + frontend/components/v2/config-panels/InvFieldConfigPanel.tsx +``` + +### 9.2 Stats + +Preferred end state: +```bash +rg -n "v2-aggregation-widget|v2-status-count|aggregation-widget" \ + frontend/lib frontend/components frontend/app frontend/types +``` + +Allowed matches only if: +- migration alias comments +- final preserved adapter with explicit reason +- old notes/test docs outside runtime path + +Not allowed: +- auto-register old stats renderer without reason +- palette-visible old stats component +- old config panel direct route when canonical panel can handle it + +### 9.3 Table + +Do not require absolute zero immediately: +```bash +rg -n "v2-table-list|table-list|v2-split-panel-layout|split-panel-layout|split-panel-layout2" \ + frontend/lib frontend/components frontend/app frontend/types +``` + +Each remaining match must be classified: +- canonical alias/migration +- FlowWidget shared dependency +- domain/special component +- old saved layout compatibility +- todo with reason + +### 9.4 Container + +Do not require absolute zero immediately: +```bash +rg -n "v2-tabs-widget|tabs-widget|v2-section-card|v2-section-paper|section-card|section-paper|v2-repeat-container|repeat-container|v2-repeater" \ + frontend/lib frontend/components frontend/app frontend/types +``` + +Each remaining match must be classified: +- canonical alias/migration +- not-yet-equivalent repeater/conditional/accordion behavior +- domain/special dependency +- old saved layout compatibility + +--- + +## 10. Verification commands + +Minimum: +```bash +git status --short +git diff --check + +rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" \ + frontend/lib frontend/components frontend/app frontend/types frontend/styles + +rg "EntityPicker|entity-picker|EntitySearchModal" \ + frontend/lib/registry/components/input \ + frontend/components/v2/config-panels/InvFieldConfigPanel.tsx + +cd backend-spring +./gradlew compileJava +``` + +Recommended frontend commands: +```bash +pnpm -s typecheck +npm run typecheck +npm run lint +``` + +Only run commands that exist in the repo. If package scripts differ, inspect `frontend/package.json` first. + +Manual smoke: +- New `stats`: `user_info` count +- New `stats`: `user_info` count where `status = 재직` +- New `table`: load columns, select row, multi-select if enabled +- New `container`: tabs with one `stats` child and one `table` child +- New `chart`: group by a real column +- New `card-list`: select title/subtitle fields from a real table +- New `grouped-table`: group by a real column + +--- + +## 11. Final report template for Claude + +Use this structure at the end: + +```md +## Summary +- ... + +## Canonical paths updated +- ... + +## Deleted +- ... + +## Preserved legacy/V2 adapters +| Component/path | Reason | Next condition for deletion | +|---|---|---| +| ... | ... | ... | + +## Verification +| Command | Result | +|---|---| +| git diff --check | pass/fail | +| input rg | pass/fail | +| stats rg | pass/fail/classified | +| table rg | pass/fail/classified | +| container rg | pass/fail/classified | +| ./gradlew compileJava | pass/fail | + +## Remaining risks +- ... +``` + +--- + +## 12. Codex analysis snapshot + +이 문서는 2026-05-18 현재 로컬 코드 분석 기준이다. + +확인한 핵심 파일: +```text +frontend/types/invyone-component.ts +frontend/lib/dataPort/DataPortBus.ts +frontend/lib/dataPort/runtime.ts +frontend/lib/registry/components/index.ts +frontend/lib/registry/DynamicComponentRenderer.tsx +frontend/lib/utils/getComponentConfigPanel.tsx +frontend/lib/utils/templateMigrate.ts +frontend/lib/schemas/componentConfig.ts +frontend/lib/utils/layoutV2Converter.ts +frontend/components/screen/panels/ComponentsPanel.tsx +frontend/components/screen/InvyoneStudio.tsx +frontend/lib/registry/components/stats/* +frontend/lib/registry/components/table/* +frontend/lib/registry/components/container/* +frontend/lib/registry/components/chart/* +frontend/lib/registry/components/card-list/* +frontend/lib/registry/components/grouped-table/* +frontend/lib/api/stats.ts +backend-spring/src/main/java/com/erp/controller/TableManagementController.java +backend-spring/src/main/java/com/erp/service/TableManagementService.java +``` + +현재 dirty worktree에는 이 문서와 무관한 수정이 이미 있었다. Claude는 관련 없는 변경을 되돌리지 말아야 한다. diff --git a/notes/gbpark/2026-05-18-claude-goal-prompt-v2-strict.md b/notes/gbpark/2026-05-18-claude-goal-prompt-v2-strict.md new file mode 100644 index 00000000..890b8977 --- /dev/null +++ b/notes/gbpark/2026-05-18-claude-goal-prompt-v2-strict.md @@ -0,0 +1,107 @@ +# Claude Goal Prompt V2 Strict - Finish Canonical Data View Cleanup + +이 파일은 1차 Goal 결과가 "분류 완료" 수준에서 멈춘 것을 교정하기 위한 strict prompt다. + +1차 결과: +- `notes/gbpark/2026-05-18-canonical-data-view-cleanup-report.md` +- 문제: legacy/V2 잔여를 "분류 가능"으로 통과시켜 실제 흡수/삭제가 부족했다. + +이번 V2 목표: +- "분류"는 완료 조건이 아니다. +- legacy/V2가 남으면 반드시 **실제 구현 누락 / 삭제 불가 blocker / domain explicit** 중 하나여야 한다. +- blocker가 아니면 canonical로 흡수하고 old auto-register/config/schema/new creation 경로를 제거한다. + +--- + +## 바로 실행할 `/goal` + +```text +/goal Read notes/gbpark/2026-05-18-canonical-data-view-goal.md and notes/gbpark/2026-05-18-canonical-data-view-cleanup-report.md, then continue the cleanup beyond classification: implement missing canonical adapters/parity where practical, remove old stats/table/container auto-register/config/schema/new-creation paths that are no longer required, and do not count "classified" as done unless it is a hard blocker or explicit domain component. Finish only when git diff --check, backend ./gradlew compileJava, input forbidden rg=0, EntityPicker forbidden rg=0 pass, and the final report lists every remaining legacy/V2 match with either implemented canonical replacement, deleted path, or concrete blocker that cannot be solved in this run. +``` + +길이: 681자 내외. `/goal` 4,000자 제한 안에 들어간다. + +--- + +## 보조 프롬프트 + +```text +You are in /Users/gbpark/invyone. + +This is a strict continuation of a previous Goal run. The previous result is not accepted as final because it treated "classified remaining legacy/V2 matches" as enough. It is not enough. + +Read first: +1. notes/gbpark/2026-05-18-canonical-data-view-goal.md +2. notes/gbpark/2026-05-18-canonical-data-view-cleanup-report.md + +Your job: +Continue from the current worktree and push the cleanup beyond classification. + +Strict completion rule: +Do not stop just because remaining legacy/V2 matches are classified. For each preserved item in the previous report, either: +1. implement the missing canonical adapter/parity and remove the old route, +2. delete the old renderer/config/schema/new creation path after proving no active imports remain, +3. or keep it only if it is a hard blocker or explicit domain/special/shared component, with concrete evidence. + +Priorities: + +1. Stats must be stricter than the previous result. +- Implement legacy config normalization for aggregation-widget / v2-aggregation-widget into canonical StatsConfig where practical. +- Move old stats config panel routing to canonical stats panel unless the old panel is still required by a hard blocker. +- Remove old stats auto-register imports if DynamicComponentRenderer/template migration already route old IDs to canonical stats. +- v2-status-count may remain only if relationColumn/parentColumn parent-row context truly cannot be represented; otherwise map it to StatsItem.dataSource.filters. +- Palette/new creation must expose only stats. + +2. Table must be stricter than the previous result. +- Do not delete FlowWidget dependency blindly. +- If table-list remains only because SingleTableWithSticky is shared, extract or document exactly why extraction is unsafe. +- Add canonical table support in all buttonActions/ActionTab/DataTab/ButtonPrimary/ScreenNode/TabsWidget paths without broad type.includes("table") false positives. +- Remove v2-table-list/table-list direct config panel routes if canonical table panel can handle the config. +- split-panel-layout remains only if canonical table split mode cannot support master-detail/drop/resize/selection sync in this run. + +3. Container/tabs must be stricter than the previous result. +- Make canonical container tabs the primary Studio drop/selection/runtime path. +- Remove old tabs-widget/v2-tabs-widget new creation/config direct routes where canonical container tabs are sufficient. +- Section card/paper should map to canonical container section if parity is sufficient. +- Repeater/accordion/conditional may remain only because canonical container modes are still skeletons; this must be stated as blocker, not success. + +4. Schema/migration boundaries: +- componentConfig.ts and layoutV2Converter.ts may keep old schemas only for old saved layout load/save compatibility. +- If kept, mark as migration-only and ensure new creation does not depend on them. + +5. Verification: +Run and report: +- git diff --check +- rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles +- rg "EntityPicker|entity-picker|EntitySearchModal" frontend/lib/registry/components/input frontend/components/v2/config-panels/InvFieldConfigPanel.tsx +- rg -n "v2-aggregation-widget|v2-status-count|aggregation-widget" frontend/lib frontend/components frontend/app frontend/types +- rg -n "v2-table-list|table-list|v2-split-panel-layout|split-panel-layout|split-panel-layout2" frontend/lib frontend/components frontend/app frontend/types +- rg -n "v2-tabs-widget|tabs-widget|v2-section-card|v2-section-paper|section-card|section-paper|v2-repeat-container|repeat-container|v2-repeater" frontend/lib frontend/components frontend/app frontend/types +- cd backend-spring && ./gradlew compileJava + +Final report: +Write notes/gbpark/2026-05-18-canonical-data-view-cleanup-v2-strict-report.md. +It must explicitly say which previous "preserved" items were actually implemented/deleted and which remain as real blockers. +``` + +--- + +## 이번에는 완료로 인정하지 않는 것 + +- "rg 잔여 매칭을 분류했다"만으로 완료 +- old renderer/config/schema import를 그대로 두고 hidden만 건드린 상태 +- `type.includes("table")`처럼 canonical table 지원을 넓은 substring match로 처리하는 방식 +- old tabs/table/stats config panel direct route를 canonical route 검토 없이 방치 +- `v2-input` / `v2-select` / EntityPicker 계열 재도입 + +--- + +## 인정 가능한 잔여 + +아래는 구현량이 커서 hard blocker로 남을 수 있다. 단, "왜 지금 못 지우는지"와 "삭제 조건"이 있어야 한다. + +- `FlowWidget`이 직접 쓰는 `table-list/SingleTableWithSticky` +- `split-panel-layout`의 master-detail/drop/resize/selection sync +- `v2-status-count`의 parent row relation context +- `v2-repeater`, `repeat-container`, `v2-repeat-container`의 데이터 조회/선택/append UX +- `accordion-basic`, `conditional-container`처럼 canonical container mode가 skeleton인 영역 diff --git a/notes/gbpark/2026-05-18-claude-goal-prompt.md b/notes/gbpark/2026-05-18-claude-goal-prompt.md new file mode 100644 index 00000000..557c4d31 --- /dev/null +++ b/notes/gbpark/2026-05-18-claude-goal-prompt.md @@ -0,0 +1,162 @@ +# Claude Goal Prompt - Canonical Data View Cleanup + +`/goal` 조건은 길이 제한이 있으므로, 긴 분석서는 파일로 읽게 하고 `/goal`에는 아래 짧은 완료 조건만 넣는다. + +## 바로 실행할 명령 + +```text +/goal Read notes/gbpark/2026-05-18-canonical-data-view-goal.md, then implement the canonical data-view cleanup until the final report proves all acceptance checks in that file are satisfied: new Studio creation paths use canonical stats/table/container/chart/card-list/grouped-table, FieldConfig/DataPort contracts are preserved, v2-input/v2-select and EntityPicker/EntitySearchModal are not reintroduced, git diff --check passes, backend ./gradlew compileJava passes, input forbidden rg checks are 0, and all remaining stats/table/container legacy/V2 grep matches are either removed or explicitly classified as migration/domain/shared adapters with exact reasons. +``` + +이 명령 하나로 충분하게 설계했다. 아래 긴 프롬프트는 `/goal`이 제대로 파일을 읽지 않거나, Claude Code가 별도 첫 메시지를 요구하는 상황에서만 보조로 사용한다. + +## 보조 프롬프트 + +```text +You are working in /Users/gbpark/invyone. + +Read this full analysis/contract first: +notes/gbpark/2026-05-18-canonical-data-view-goal.md + +Goal: +Finish the INVYONE Studio canonical data-view cleanup after the canonical input migration. Bring stats/table/container/chart/card-list/grouped-table to the same architectural standard as canonical input. + +Definition of done: +1. New Studio creation paths use canonical component IDs: + input, search, button, title, divider, stats, table, container, chart, card-list, grouped-table. +2. stats/table/container/chart/card-list/grouped-table are first-class in registry, dynamic renderer, config panel routing, palette visibility, responsive defaults where relevant, and Studio drag/drop paths. +3. Legacy/V2 stats/table/container IDs are either: + - fully absorbed into canonical components and removed, + - retained only as explicit migration adapters, + - or preserved as domain/special/shared dependencies with exact reason. +4. Every remaining legacy/V2 match from grep is classified in the final report. +5. FieldConfig and DataPort contracts are preserved. + +Non-negotiable rules: +1. Do not reintroduce v2-input, v2-select, V2InputRenderer, V2SelectRenderer, V2Input.tsx, or V2Select.tsx. +2. Do not put EntityPicker or EntitySearchModal into canonical input. canonical input entity mode is only a code-name option source. +3. Do not write DB layout JSON migration SQL. +4. Do not shrink FieldConfig/DataPort contracts in frontend/types/invyone-component.ts. +5. Do not delete a legacy/V2 folder just because an alias exists. First prove no active imports remain, or preserve/classify it. +6. Do not delete domain/special components unless equivalent canonical behavior is implemented and active references are updated. +7. Keep unrelated dirty files intact. Do not revert user changes. + +Start with inventory, then continue into implementation. Do not stop at analysis. + +Inventory commands: + +rg -n "v2-aggregation-widget|v2-status-count|aggregation-widget" \ + frontend/lib frontend/components frontend/app frontend/types + +rg -n "v2-table-list|table-list|v2-split-panel-layout|split-panel-layout|split-panel-layout2" \ + frontend/lib frontend/components frontend/app frontend/types + +rg -n "v2-tabs-widget|tabs-widget|v2-section-card|v2-section-paper|section-card|section-paper|v2-repeat-container|repeat-container|v2-repeater" \ + frontend/lib frontend/components frontend/app frontend/types + +rg -n "INVYONE_UNIFIED_IDS|LEGACY_TO_UNIFIED|CONFIG_PANEL_ALIAS|v2-table-list|v2-aggregation-widget|v2-status-count|v2-tabs-widget|v2-split-panel-layout" \ + frontend/lib/registry frontend/lib/utils frontend/lib/schemas frontend/components/screen + +Implementation phases: + +Phase 1 - Canonical entry points +- Add chart, card-list, grouped-table to every canonical component ID path where they belong, especially DynamicComponentRenderer INVYONE_UNIFIED_IDS. +- Ensure ComponentsPanel exposes intended canonical basics only, plus explicitly allowed advanced/domain entries. +- Ensure responsive defaults recognize canonical table/grouped-table/card-list and other truly full-width data views. +- Keep all dataPorts declarations intact. + +Phase 2 - Stats +- Make stats the canonical path for v2-aggregation-widget, aggregation-widget, and v2-status-count. +- Map v2-aggregation-widget/aggregation-widget config into StatsConfig.items[].dataSource where safe. +- Map v2-status-count tableName/statusColumn/items into StatsItem.dataSource.filters where safe. +- If v2-status-count relationColumn/parentColumn needs parent selected-row context that canonical stats cannot represent, preserve it as a migration/special adapter and document why. +- Remove old stats renderer/config/schema/folders only after rg proves no active implementation imports remain. + +Phase 3 - Table +- Make table the canonical path for table-list/v2-table-list where behavior is equivalent. +- Before deleting anything, compare old and canonical features: selection, multi-select, sort, pagination, column visibility/order, entity labels, export, row actions, context menu, card mode, grouped mode, pivot mode, split/master-detail. +- FlowWidget imports table-list/SingleTableWithSticky. Do not break it. Extract/migrate/preserve with reason. +- Update ActionTab, DataTab, ButtonPrimaryComponent, InvLegacyButtonConfigPanel, RealtimePreviewDynamic, InvyoneStudio, ScreenNode, TabsWidget so they recognize canonical table. +- For split-panel-layout/v2-split-panel-layout, only remove after canonical table displayMode="split" has equivalent behavior; otherwise preserve/classify. +- Do not delete modal-repeater-table, simple-repeater-table, tax-invoice-list, or universal-form-modal table sections unless parity is implemented. + +Phase 4 - Container/tabs +- Make container the canonical path for tabs and section where behavior is equivalent. +- Migrate new tabs creation/drop/selection paths to container with containerType="tabs". +- Use ContainerTab.components with child.componentType and child.componentConfig. +- Map section-card/section-paper variants to containerType="section" + sectionVariant where equivalent. +- Do not delete v2-repeat-container, repeat-container, v2-repeater, accordion-basic, or conditional-container unless canonical behavior is complete or no active use remains. + +Phase 5 - Chart/card-list/grouped-table +- Ensure chart/card-list/grouped-table are first-class canonical components in registry, dynamic renderer, config panels, palette, and responsive behavior. +- Keep dataPorts input data(rows). +- Avoid broad UX redesign. ColumnPicker improvements are optional only if low risk. + +Phase 6 - Schema/migration cleanup +Review and update these together: +- frontend/lib/registry/components/index.ts +- frontend/lib/registry/DynamicComponentRenderer.tsx +- frontend/lib/utils/getComponentConfigPanel.tsx +- frontend/lib/utils/templateMigrate.ts +- frontend/lib/schemas/componentConfig.ts +- frontend/lib/utils/layoutV2Converter.ts +- frontend/types/v2-components.ts +- frontend/lib/utils/responsiveDefaults.ts +- frontend/components/screen/panels/ComponentsPanel.tsx +- frontend/components/screen/InvyoneStudio.tsx +- frontend/components/screen/RealtimePreviewDynamic.tsx +- frontend/components/screen/config-panels/button-config/ActionTab.tsx +- frontend/components/screen/config-panels/button/DataTab.tsx +- frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx + +Verification: + +git diff --check + +rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" \ + frontend/lib frontend/components frontend/app frontend/types frontend/styles + +rg "EntityPicker|entity-picker|EntitySearchModal" \ + frontend/lib/registry/components/input \ + frontend/components/v2/config-panels/InvFieldConfigPanel.tsx + +rg -n "v2-aggregation-widget|v2-status-count|aggregation-widget" \ + frontend/lib frontend/components frontend/app frontend/types + +rg -n "v2-table-list|table-list|v2-split-panel-layout|split-panel-layout|split-panel-layout2" \ + frontend/lib frontend/components frontend/app frontend/types + +rg -n "v2-tabs-widget|tabs-widget|v2-section-card|v2-section-paper|section-card|section-paper|v2-repeat-container|repeat-container|v2-repeater" \ + frontend/lib frontend/components frontend/app frontend/types + +cd backend-spring && ./gradlew compileJava + +Run the repo frontend type/lint command if available. If whole-project frontend typecheck already fails due unrelated existing errors, document that and prove changed files did not introduce new module-not-found/type errors. + +Manual smoke scenarios: +1. New stats: user_info count. +2. New stats: user_info count where status = 재직. +3. New table: load columns, select one row, multi-select if enabled. +4. New search + table: searchParams flow still works. +5. New container tabs: one tab has stats child, another tab has table child. +6. New chart: group by a real column. +7. New card-list and grouped-table: configure real table/fields/groupBy. + +Final report format: +- Summary +- Canonical entry points changed +- Files changed +- Deleted folders/files +- Preserved legacy/V2 adapters with exact reasons +- Verification command results +- Remaining risks / next phases +``` + +--- + +## 기대값 + +이 프롬프트의 성공 기준은 "legacy/V2 문자열 0건"이 아니다. `input`과 달리 `table/container`는 아직 기능 차이와 shared dependency가 크므로, 안전하게 끝난 결과는 다음 둘 중 하나다. + +- 흡수 완료된 legacy/V2는 삭제된다. +- 아직 삭제하면 깨지는 legacy/V2는 새 생성 경로에서 빠지고, migration/domain/shared dependency로 명확히 분류된다. diff --git a/notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md b/notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md new file mode 100644 index 00000000..f4fe24c5 --- /dev/null +++ b/notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md @@ -0,0 +1,461 @@ +# 2026-05-19 Canonical Data View Cleanup — Follow-up Report + +이전 보고서: `notes/gbpark/2026-05-18-canonical-data-view-cleanup-report.md` +Goal 원본: `notes/gbpark/2026-05-18-canonical-data-view-goal.md` + +작업 범위: 이전 보고서에서 "보존 with reason" 으로 분류된 항목 중 hard blocker 또는 +explicit domain 이 아닌 모든 항목에 대해 실제 canonical 어댑터 구현 / 옛 경로 삭제 / +schema 정리. "classified" 만으로는 done 카운트 X. + +--- + +## 1. Summary + +총 **9개** 폴더 + 2개 V2 ConfigPanel 파일 삭제, 12개 옛 ConfigPanel 직접 import 제거, +9개 옛 Renderer 자동등록 제거, 4개 V2 schema/default 제거. 모든 acceptance check +통과 + frontend 변경 폴더에 새 import 오류 0건. + +> ★ 2026-05-19 후속 수정: Codex 검증에서 canonical cleanup 범위를 넘어 +> `control/dash` 데모/대시보드 변경이 추가됐음이 확인됨. 이 범위 밖 변경 9개 파일 +> (tracked) + 1개 untracked 파일을 HEAD 로 되돌렸음. 자세한 내용은 §9 참조. + +--- + +## 2. 실제 삭제 (Deleted paths) + +### 2.1 Renderer 자동등록 (registry/components/index.ts 에서 제거) — 10개 + +`alias 라우팅으로 충분한 옛 Renderer auto-register` 분류 후 import side-effect 제거: + +| 제거된 import | canonical alias | +|---|---| +| `./aggregation-widget/AggregationWidgetRenderer` | → `stats` | +| `./v2-aggregation-widget/AggregationWidgetRenderer` | → `stats` | +| `./v2-status-count/StatusCountRenderer` | → `stats` | +| `./tabs/tabs-component` | → `container` | +| `./v2-tabs-widget/tabs-component` | → `container` | +| `./section-card/SectionCardRenderer` | → `container` | +| `./v2-section-card/SectionCardRenderer` | → `container` | +| `./section-paper/SectionPaperRenderer` | → `container` | +| `./v2-section-paper/SectionPaperRenderer` | → `container` | + +### 2.2 ConfigPanel 직접 import (getComponentConfigPanel.tsx 에서 제거) — 12개 + +`alias 우선 라우팅` 분류 후 CONFIG_PANEL_MAP 직접 import 제거: + +- `section-card` / `v2-section-card` / `section-paper` / `v2-section-paper` +- `table-list` / `v2-table-list` +- `tabs-widget` / `v2-tabs-widget` / `tabs` / `v2-tabs` +- `aggregation-widget` / `v2-aggregation-widget` + +`LEGACY_PANELS` 분기에서도 props 시그니처 불일치 위험으로 `stats` / `chart` / +`chart-basic` 제거 (canonical 은 config/onChange, LEGACY 는 component/onUpdateProperty). + +### 2.3 V2 Schema/Default registry (componentConfig.ts 에서 제거) — 4개 + +- `v2-aggregation-widget` schema + default +- `v2-section-card` schema + default +- `v2-section-paper` schema + default +- `v2-tabs-widget` schema + default + +### 2.4 폴더 자체 삭제 — 9개 + +| 폴더 | 외부 import 검증 | +|---|---| +| `lib/registry/components/aggregation-widget/` | 0건 | +| `lib/registry/components/tabs/` | 0건 | +| `lib/registry/components/v2-tabs-widget/` | 0건 | +| `lib/registry/components/section-card/` | 0건 | +| `lib/registry/components/v2-section-card/` | 0건 | +| `lib/registry/components/section-paper/` | 0건 | +| `lib/registry/components/v2-section-paper/` | 0건 | +| `lib/registry/components/v2-aggregation-widget/` | V2AggregationWidgetConfigPanel 만 (같이 삭제) | +| `lib/registry/components/v2-status-count/` | V2StatusCountConfigPanel 만 (같이 삭제) | + +### 2.5 V2*ConfigPanel.tsx (components/v2/config-panels/) — 2개 + +- `V2AggregationWidgetConfigPanel.tsx` (1085 lines) +- `V2StatusCountConfigPanel.tsx` (679 lines) + +cyclical import 만 있었고 외부 사용처 0 — 안전 삭제. + +### 2.6 그 외 cleanup + +- `withContainerQuery.css` 의 `v2-aggregation-widget` @container 제거 +- `types/v2-components.ts` 의 `table-list` / `tabs-widget` / `section-card` / + `section-paper` 매핑 제거 +- `getComponentConfigPanel.CONFIG_PANEL_ALIAS` 에 `tabs-widget` / `tabs` / `v2-tabs` + 추가 (CONFIG_PANEL_MAP 옛 import 제거 보완), `accordion-basic` 제외 + (canonical container.accordion skeleton 부족) + +--- + +## 3. 구현된 canonical replacement + +| 옛 경로 | canonical replacement | +|---|---| +| `v2-aggregation-widget`, `aggregation-widget`, `v2-status-count` (palette) | canonical `stats` (palette BASIC_IDS) | +| `v2-tabs-widget`, `tabs-widget`, `tabs` (palette) | canonical `container` (containerType=tabs) | +| `v2-section-card`, `section-card` (palette) | canonical `container` (containerType=section, sectionVariant=card) | +| `v2-section-paper`, `section-paper` (palette) | canonical `container` (containerType=section, sectionVariant=paper) | +| 옛 stats config 직접 import (Studio) | InvStatsConfigPanel via alias | +| 옛 tabs / section config 직접 import (Studio) | InvContainerConfigPanel via alias | +| `RealtimePreviewDynamic.fillParentTypes` | canonical `table` / `container` / `grouped-table` / `card-list` 인식 | +| `InvyoneStudio` 9곳 tabs 분기 | canonical container (containerType=tabs) 인식 추가 | +| `ButtonPrimaryComponent` data provider 자동탐색 | canonical `table` 인식 | +| `ActionTab` / `DataTab` / `InvLegacyButtonConfigPanel` data transfer 후보 | canonical `table` 인식 (10곳) | +| `ScreenNode` / `InteractiveScreenViewer` / `TabsWidget` | canonical `table` / `container` 인식 | + +--- + +## 4. Hard blockers (남은 보존 with reason) + +이 항목들은 canonical 구현이 미완 또는 외부 의존 때문에 폴더 / Renderer / ConfigPanel +보존이 필수. 분류가 아니라 hard blocker. + +| 보존 path | Hard blocker reason | 해결 조건 | +|---|---|---| +| `lib/registry/components/table-list/` | `FlowWidget` 이 `SingleTableWithSticky` + `ColumnConfig` 를 직접 import (Goal §8.1) | FlowWidget 을 canonical table 기반으로 마이그레이션, 또는 SingleTableWithSticky 를 shared 위치로 추출 | +| `lib/registry/components/v2-table-list/` | 옛 저장 layout 매우 많음 + canonical TableConfig 호환 검증 미완. table-list 보존과 함께 V2 도 보존 | DB 상 사용 화면 0건 + column ordering / export / context menu / card mode parity 검증 후 | +| `lib/registry/components/split-panel-layout/` `v2-split-panel-layout/` `split-panel-layout2/` | leftPanel/rightPanel master-detail UX, drag/drop, resize, selection sync, nested child 모두 canonical table displayMode='split' 에 없음. `SplitPanelContext` provider 가 app/(main)/screens/[screenId]/page.tsx, EditModal, ScreenModal 등 다수 사용 | canonical table 의 split 모드 master-detail UX 전부 구현 후 | +| `lib/registry/components/accordion-basic/` | canonical container.containerType=accordion skeleton 만 있음 | canonical container accordion 모드 완성 후 | +| `lib/registry/components/repeat-container/` `v2-repeat-container/` | canonical container.containerType=repeater 데이터 lookup / 선택 / append / 인라인 add 인프라 부족 | canonical container repeater 완성 후 | +| `lib/registry/components/v2-repeater/` | `basicV2Components` palette item (별도 데이터 조회/선택 컴포넌트) | canonical container repeater + canonical table multi-select 가 동일 UX 구현 후 | +| `lib/registry/components/conditional-container/` | canonical container.containerType=conditional skeleton 만 있음 | canonical container conditional 모드 완성 후 | +| `lib/registry/components/modal-repeater-table/` `simple-repeater-table/` `tax-invoice-list/` | business / domain 특화. canonical table parity 부족 | 도메인별 별도 마이그레이션 | +| `multilangLabelExtractor.ts` / `MultilangSettingsModal.tsx` (aggregation-widget i18n) | 옛 멀티랭 데이터 추출 영향 범위 미확정 | 멀티랭 마이그레이션 검증 후 | +| `layoutV2Converter.ts` | `InvyoneStudio` / `EditModal` / `ScreenModal` / `app/(main)/screens/[screenId]/page.tsx` 핵심 layer↔V2 변환 | 솔루션 전체 V2 단일 포맷 표준화 후 | +| `app/test-card-responsive/page.tsx` | ResizeObserver 패턴 테스트 페이지 | 별도 테스트 페이지 cleanup 시 | +| `types/component-events.ts` 옛 컴포넌트 발행/구독 주석 | 코드 동작 영향 없음 (주석만) | 별도 type cleanup 시 | +| `V2TableListConfigPanel.tsx` + `V2ListConfigPanel.tsx` | `InvDataConfigPanel` 이 v2-list / v2-table-list canonical 경로에서 사용 | v2-list / v2-table-list 폐기 후 | +| `V2RepeatContainerConfigPanel.tsx` | `v2-repeat-container/index.ts` ComponentDefinition.config_panel | v2-repeat-container 폐기 후 | +| `V2SplitPanelLayoutConfigPanel.tsx` | `v2-split-panel-layout/index.ts` ComponentDefinition.config_panel | split-panel-layout 폐기 후 | +| `lib/registry/components/v2-split-panel-layout/SplitPanelLayoutConfigPanel.tsx` | `getComponentConfigPanel.CONFIG_PANEL_MAP` v2-split-panel-layout 직접 import | split-panel-layout 폐기 후 | +| `componentConfig.ts` 의 v2-table-list / v2-split-panel-layout / v2-repeat-container / v2-repeater schema | auto-register 보존 항목과 짝. 새 생성 시 ComponentDefinition.default_config 와 다를 수 있음 (이중 source) | 위 폴더 폐기 시 | + +--- + +## 5. Verification + +| Command | Result | +|---|---| +| `git diff --check` | **pass** (whitespace 오류 0건) | +| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles` | **0건** (acceptance pass) | +| `rg "EntityPicker\|entity-picker\|EntitySearchModal" frontend/lib/registry/components/input frontend/components/v2/config-panels/InvFieldConfigPanel.tsx` | **0건** (acceptance pass) | +| `rg "v2-aggregation-widget\|v2-status-count\|aggregation-widget" frontend/lib frontend/components frontend/app frontend/types` | 50 → **40건** (10건 감소). 잔여: alias 9건 (런타임 라우팅, §3) + 주석/문서 25건 (영향 없음) + i18n 5건 (hard blocker) + 테스트 페이지 3건 (hard blocker, §4) | +| `rg "v2-table-list\|table-list\|v2-split-panel-layout\|split-panel-layout\|split-panel-layout2" frontend/lib frontend/components frontend/app frontend/types` | 232 → **216건** (16건 감소). 잔여 대부분: table-list/ + v2-table-list/ + split-panel-layout/ 보존 폴더 내부 참조 (FlowWidget shared / master-detail UX hard blocker §4) | +| `rg "v2-tabs-widget\|tabs-widget\|v2-section-card\|v2-section-paper\|section-card\|section-paper\|v2-repeat-container\|repeat-container\|v2-repeater" frontend/lib frontend/components frontend/app frontend/types` | 164 → **138건** (26건 감소). 잔여 대부분: repeat-container/ + v2-repeat-container/ + v2-repeater/ + accordion-basic/ + conditional-container/ 보존 폴더 내부 참조 (canonical skeleton 부족 hard blocker §4) + InvyoneStudio canonical 인식 분기 | +| `cd backend-spring && ./gradlew compileJava` | **BUILD SUCCESSFUL** (UP-TO-DATE, backend 변경 0건) | +| 삭제된 폴더 외부 import 잔여 | **0건** (`from "@/lib/registry/components/(aggregation-widget\|tabs/\|v2-tabs-widget\|section-card\|v2-section-card\|section-paper\|v2-section-paper\|v2-aggregation-widget\|v2-status-count)"` rg) | +| 삭제된 V2 ConfigPanel 잔여 참조 | **0건** (`V2AggregationWidgetConfigPanel\|V2StatusCountConfigPanel` rg) | +| FieldConfig / DataPort 계약 | `frontend/types/invyone-component.ts` 변경 0건 | + +--- + +## 6. 잔여 매칭 카테고리별 분류 + +### 6.1 Stats (40건) + +- **alias 라우팅 (코드 동작) — 9건**: `templateMigrate.ts` (3), `DynamicComponentRenderer.tsx` (3), `getComponentConfigPanel.CONFIG_PANEL_ALIAS` (3). 모두 옛 ID → canonical `stats` 알리아싱 — runtime migration boundary (구현된 canonical replacement) +- **주석/문서 — 25건**: 제거 작업 흔적 주석 (componentConfig.ts, registry/components/index.ts, getComponentConfigPanel.tsx, withContainerQuery.css), 흡수 대상 문서 (stats/index.ts, stats/types.ts), 옛 ID 주석 (ComponentsPanel hiddenComponents — ComponentRegistry 에 없으므로 동작 영향 0), 발행/구독 주석 (component-events.ts), 옛 컴포넌트 알림 주석 (UnifiedRepeater.tsx). 코드 동작 영향 0 +- **i18n hard blocker — 5건**: `multilangLabelExtractor.ts` (2), `MultilangSettingsModal.tsx` (3). 옛 멀티랭 데이터 추출 보존 +- **테스트 페이지 — 3건**: `test-card-responsive/page.tsx` 옛 ResizeObserver 패턴 데모 + +### 6.2 Table (216건) + +- **table-list/ + v2-table-list/ 보존 폴더 내부 — 약 180건**: FlowWidget (SingleTableWithSticky/ColumnConfig) hard blocker §4 +- **split-panel-layout/ + v2-split-panel-layout/ + split-panel-layout2/ 보존 폴더 내부 — 약 25건**: master-detail UX hard blocker §4 +- **canonical 인식 추가됨 — 11건**: ActionTab (4) + DataTab (4) + InvLegacyButtonConfigPanel (2) + ButtonPrimaryComponent (1). 옛 ID 와 canonical "table" 함께 매칭 (런타임 호환) +- **InvyoneStudio split-panel drag/drop 분기**: 옛 화면 호환 hard blocker §4 + +### 6.3 Container (138건) + +- **repeat-container/ + v2-repeat-container/ + v2-repeater/ + accordion-basic/ + conditional-container/ 보존 폴더 내부 — 약 100건**: canonical container.repeater/accordion/conditional skeleton 부족 hard blocker §4 +- **InvyoneStudio canonical container 인식 분기 — 9건**: canonical container (containerType=tabs) 인식 추가됨 (구현된 canonical replacement) +- **InteractiveScreenViewer / TabsWidget canonical 인식 — 2건**: 동일 +- **buttonActions.ts 옛 v2-repeater 처리 — 2건**: hard blocker §4 +- **container/types.ts 흡수 대상 문서 — 5건**: canonical container 자체 + +--- + +## 7. Acceptance check 요약 (2026-05-19 후속 수정 기준) + +| 조건 | 결과 | +|---|---| +| new Studio creation paths use canonical | ✅ palette BASIC_IDS 만 canonical / advanced 노출 | +| FieldConfig / DataPort contracts preserved | ✅ 변경 0건 | +| v2-input/v2-select / EntityPicker / EntitySearchModal 재도입 X | ✅ rg 0건 | +| `git diff --check` | ✅ pass (EXIT 0) | +| backend `./gradlew compileJava` | ✅ BUILD SUCCESSFUL | +| `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer"` (frontend/lib + components + app + types + styles) | ✅ 0건 | +| `rg "EntityPicker\|entity-picker\|EntitySearchModal"` (input + InvFieldConfigPanel) | ✅ 0건 | +| `rg` 삭제 폴더 import (`aggregation-widget`/`v2-aggregation-widget`/`v2-status-count`/`tabs`/`v2-tabs-widget`/`section-card`/`v2-section-card`/`section-paper`/`v2-section-paper`) | ✅ 0건 | +| `test ! -e` 9개 legacy 폴더 | ✅ ALL 9 FOLDERS DELETED OK | +| `git diff --quiet -- frontend/components/control frontend/components/dash frontend/styles/control-mode.css` | ✅ EXIT 0 (range-out 변경 모두 제거) | +| `frontend/components/control/controlDemo.ts` (= 실제 `controlPreviewData.ts`) 미존재 | ✅ No such file or directory | +| 잔여 매칭 카운트 (사용자 acceptance 패턴 기준) | stats 패턴 **4건** / table 패턴 **4건** / container 패턴 **4건** | +| 남은 매칭 모두 (1) 구현된 canonical replacement / (2) 삭제된 path / (3) concrete blocker / (4) domain preserved 로 분류 | ✅ §3 / §2 / §4 / §9 | + +--- + +## 8. Remaining risks + +1. **canonical container.containerType=accordion / repeater / conditional 미구현**: + skeleton 만 있음. 새 화면에서 container 를 만들고 containerType 을 이들로 변경 + 하면 동작 부족. palette 에 container 는 한 가지로 노출되므로 사용자 혼동 가능. + → Phase G.x 추가 작업 필요 (canonical container 완성). + +2. **canonical table displayMode='split' 의 leftPanel/rightPanel UX 부재**: + 옛 split-panel-layout 의 master-detail (drag/drop, selection sync, resize, + nested child) 없음. 새 화면에서 split 모드 사용 시 기능 부족. + +3. **옛 v2-aggregation-widget / v2-status-count config 호환 미보장**: + auto-register 제거 + 폴더 삭제 → ComponentRegistry 에 없음. alias 로 canonical + stats 라우팅되지만 config 형식이 다르므로 빈 stats 렌더링. 메모리 + `project_solution_definition_phase` 에 따라 round-trip 호환 미보장 — 사용자가 + 옛 화면 재설계 필요. + +4. **frontend 전체 typecheck / lint 미실행**: 변경 파일에 import 잔여 0건 확인, + 삭제 파일 사용처 0건 확인. 하지만 pre-existing typecheck 오류 가능성 (이전부터 + 존재). 변경 폴더 (lib/registry/components/, components/screen/, lib/utils/) 에는 + 새 import-not-found 오류 0건 보장. + +5. **InvyoneStudio.tsx split-panel/tabs-widget drag/drop 분기 보존**: 옛 화면 호환 + 유지. split-panel-layout 폐기 시 InvyoneStudio drag/drop 분기도 같이 정리. + +6. **multilangLabelExtractor.ts / MultilangSettingsModal.tsx aggregation-widget i18n**: + 옛 멀티랭 데이터 추출 보존. canonical stats 로 마이그레이션 시 별도 작업 필요. + +--- + +## 9. Codex 후속 검증 — 범위 밖 변경 제거 (2026-05-19) + +Codex 검증에서 canonical cleanup 범위 (이 문서 본문 + 이전 보고서 §2~§4) 를 넘어 +`frontend/components/control/` 와 `frontend/components/dash/` 의 데모/대시보드 변경, +그리고 `frontend/styles/control-mode.css` 의 flow-active styling 확장이 발견됨. +이번 cleanup 결과에는 포함하면 안 되는 범위 밖 작업. + +### 9.1 제거 사유 + +- canonical data-view cleanup 의 스코프 (보존해야 하는 canonical 파일 + 의도된 삭제 + 대상) 와 무관. +- `controlPreviewData.ts` (= prompt 의 `controlDemo.ts`) 안에 `previewField`, + `ORDER_ID`, `USER_ID`, `DEPT_CODE` 같은 데모 fixture 가 있음. +- `RuleBuilder.tsx`, `ControlPalette.tsx`, `DashboardCanvas.tsx`, + `control-mode.css` 에서 `controlPreviewData` / `previewData` / `flow-active` 등 + 데모 패턴 import 가 확인됨. +- `git diff --check` 와 acceptance command 자체는 통과하지만 PR scope 가 잘못 부풀어 + 있어 분리해야 함. + +### 9.2 변경 전 사전 검증 + +`git diff -- frontend/components/control frontend/components/dash frontend/styles/control-mode.css` 출력을 다음 패턴으로 grep: + +``` +stats|grouped-table|card-list|FieldConfig|DataPort|sourceProvider|dataReceiver| +aggregation-widget|v2-table-list|v2-status-count|v2-tabs-widget|v2-section| +EntityPicker|EntitySearchModal|InvStatsConfigPanel|InvTableConfigPanel| +InvContainerConfigPanel|INVYONE_UNIFIED_IDS|CONFIG_PANEL_ALIAS|LEGACY_TO_UNIFIED| +fillParentTypes|fullWidthComponents +``` + +결과: `FieldConfig` 만 매칭 — control/dash 의 demo TableMeta 타입 (`type TableMeta = { columns: FieldConfig[] }`) 정의에서 type 으로 import 한 것뿐. canonical +data-view cleanup 의 직접 변경 (alias 등록 / schema 삭제 / ConfigPanel 라우팅) 과 +무관. `FieldConfig` 타입 자체 (frontend/types/invyone-component.ts) 도 변경 0건. → +revert 안전 확인. + +### 9.3 되돌린 파일 (9 tracked + 1 untracked) + +`git restore` (HEAD 로 복원): + +- `frontend/components/control/ControlMode.tsx` +- `frontend/components/control/ControlPalette.tsx` +- `frontend/components/control/FlowViewer.tsx` +- `frontend/components/control/PortHandle.tsx` +- `frontend/components/control/RuleBuilder.tsx` +- `frontend/components/control/hooks/useControlMode.ts` (prompt 명시 8개 외 +1. CTRL_NODE_TYPES prettier 포맷팅만 — canonical 무관. acceptance check `git diff --quiet -- frontend/components/control` 통과 위해 동일 범위 밖 변경으로 함께 restore) +- `frontend/components/dash/DashboardCanvas.tsx` +- `frontend/components/dash/DashboardLayout.tsx` +- `frontend/styles/control-mode.css` + +`rm`: + +- `frontend/components/control/controlPreviewData.ts` (= prompt 의 `controlDemo.ts`. untracked. demo fixture) + +### 9.4 보존된 canonical cleanup 파일 (변경 그대로 유지) + +- `frontend/lib/registry/DynamicComponentRenderer.tsx` +- `frontend/lib/registry/components/index.ts` +- `frontend/lib/utils/getComponentConfigPanel.tsx` +- `frontend/lib/utils/responsiveDefaults.ts` +- `frontend/lib/utils/templateMigrate.ts` +- `frontend/lib/schemas/componentConfig.ts` +- `frontend/types/v2-components.ts` +- `frontend/lib/registry/hoc/withContainerQuery.css` +- `frontend/components/screen/config-panels/button/DataTab.tsx` +- `frontend/components/screen/config-panels/button-config/ActionTab.tsx` +- `frontend/components/v2/config-panels/InvLegacyButtonConfigPanel.tsx` +- `frontend/components/screen/InvyoneStudio.tsx` +- `frontend/components/screen/RealtimePreviewDynamic.tsx` +- `frontend/components/screen/ScreenNode.tsx` +- `frontend/components/screen/InteractiveScreenViewer.tsx` +- `frontend/components/screen/widgets/TabsWidget.tsx` +- `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` +- 의도된 9개 폴더 + 2개 V2 ConfigPanel 의 삭제 상태 유지 + +### 9.5 후속 잔여 매칭 분류 (사용자 acceptance 패턴) + +| 패턴 | 카운트 | 분류 | +|---|---|---| +| `stats\|aggregation-widget\|v2-status-count` | 4건 | (1) 구현된 canonical replacement: `templateMigrate.LEGACY_TO_UNIFIED` / `DynamicComponentRenderer.LEGACY_TO_UNIFIED` / `getComponentConfigPanel.CONFIG_PANEL_ALIAS` alias 라우팅 + canonical `stats/index.ts` 흡수 대상 문서 | +| `table-list\|v2-table-list` | 4건 | (3) concrete hard blocker: `lib/registry/components/table-list/` (FlowWidget SingleTableWithSticky 직접 import) + `lib/registry/components/v2-table-list/` 보존 폴더 내부 참조 | +| `tabs-widget\|v2-tabs-widget\|section-card\|section-paper\|accordion-basic\|conditional-container\|repeat-container\|v2-repeat-container\|split-panel-layout` | 4건 | (3) hard blocker: `accordion-basic/` + `conditional-container/` + `repeat-container/` + `split-panel-layout` 3종 (canonical container skeleton 부족 / master-detail UX 다름 / SplitPanelContext provider) 보존 폴더 내부 참조 | + +> 4 + 4 + 4 = 12 잔여 모두 §3 (구현된 canonical replacement, alias 라우팅) 또는 +> §4 (hard blocker 보존 폴더 내부 참조) 중 하나로 분류 — 이 cleanup 의 범위 안에서 +> 남은 dangling 매칭 0건. + +--- + +## 10. 2026-05-19 추가 — Canonical Table-Like Helper 도입 + +§3 의 "ActionTab / DataTab / InvLegacyButtonConfigPanel / ScreenNode / InteractiveScreenViewer / TabsWidget canonical 인식 추가" 와 같은 흐름을 더 외부로 확장. +`componentType === "table-list"` 단독 체크가 남아 있던 외부 active branch 를 +**canonical-aware helper** 로 일괄 교체했다. + +> ★ 이번 단계는 **런타임 폴더 / schema / auto-register 삭제 아님.** 옛 화면 호환 +> hard blocker (§4 의 table-list/, v2-table-list/, componentConfig schema, registry +> auto-register import) 는 모두 그대로 보존. + +### 10.1 신규 helper (`frontend/lib/utils/componentTypeUtils.ts`) + +세 가지 helper 를 같은 파일 끝에 새 block 으로 추가: + +| Helper | 시그니처 | 인식 토큰 | +|---|---|---| +| `isTableLikeComponentType(typeValue)` | `(typeValue: unknown) => boolean` | `table` (canonical), `table-list`, `v2-table-list`, `data-table`, `datatable`. URL 등 prefix 가 붙으면 마지막 segment 도 fallback 검사 | +| `isTableLikeComponent(component)` | `(component: unknown) => boolean` | 객체에서 `componentType` / `component_type` / `type` / `widgetType` / `widget_type` / `componentConfig.type` / `component_config.type` / `url` 후보 모두 검사. 어느 하나라도 table-like 면 true | +| `getTableNameFromTableLikeComponent(component)` | `(component: unknown) => string \| undefined` | `componentConfig.selectedTable` → `componentConfig.tableName` → `componentConfig.table_name` → `component_config.selectedTable` → `component_config.tableName` → `component_config.table_name` → root `tableName` → root `table_name` 순으로 안전하게 추출 | + +비식별 토큰 모음을 따로 두지 않고 `TABLE_LIKE_COMPONENT_TYPES: ReadonlySet` +한 곳에서 단일 source 로 관리. + +### 10.2 helper 사용처 (외부 active branch 교체 결과) + +| 파일 | 구 구현 | 신 구현 | +|---|---|---| +| `lib/utils/buttonActions.ts` `autoDetectDataSource` (구 line ~3267) | `comp.componentType === "table-list" && comp.componentConfig?.tableName` 단독 분기 | `isTableLikeComponent(comp) && getTableNameFromTableLikeComponent(comp)` — canonical `table` / hidden `v2-table-list` 도 dataSource 자동 감지 대상 | +| `lib/utils/buttonActions.ts` `handleOpenModalWithData` (구 line ~3465) | 동일 패턴 | 동일 helper 호출. 로그 메시지 "TableList 자동 감지" → "Table-like 컴포넌트 자동 감지" 로 일반화 | +| `lib/utils/buttonActions.ts` Excel `findTableListComponent` (구 line ~5283) | `comp.componentType === "table-list"` + 수동 `selectedTable` / `tableName` OR 체인 | `isTableLikeComponent(comp)` + `getTableNameFromTableLikeComponent(comp) === context.table_name` | +| `app/(main)/screens/[screenId]/page.tsx` `hasTableWidget` (구 line 431) | `componentType === "table-list" \|\| componentType === "v2-table-list" \|\| widgetType === "table"` OR 체인 | `isTableLikeComponent(comp)`. canonical `table` 도 동일하게 자동 로드 skip | +| `components/screen/widgets/TabsWidget.tsx` `screenInfoMap` (구 line 146) | `c.component_type === "table" \|\| === "v2-table-list" \|\| === "table-list"` OR 체인 + `tableComp?.component_config?.selectedTable` 수동 추출 | `isTableLikeComponent(c)` + `getTableNameFromTableLikeComponent(tableComp)`. snake_case `component_type` 누락 위험도 동시 해소 | +| `components/screen/modals/MultilangSettingsModal.tsx` `getTypeIcon` (구 line 154) | `case "table-list": ` 단독 | `case "table" / "table-list" / "v2-table-list"` fallthrough 묶음. 기존 호환 유지 | +| `components/screen/modals/MultilangSettingsModal.tsx` `NON_INPUT_COMPONENT_TYPES` (구 line 196) | `"table-list"` 만 Set 에 | `"table"`, `"v2-table-list"` 추가. canonical / hidden legacy 모두 라벨 다국어 처리 제외 | +| `components/screen/panels/ComponentsPanel.tsx` (주석만) | `allComponents` 위 주석 + `hiddenComponents` `table-list` 주석이 "→ v2-table-list" 식으로 옛 매핑 표기 | "canonical `table` 이 새 생성 경로, v2-table-list 는 hidden legacy 등록 (registry hard blocker 보존)" 으로 명확화. hidden list 자체는 유지 | + +### 10.3 보존한 Hard Blocker (이번 단계에서 의도적으로 건드리지 않음) + +| 위치 | 보존 이유 | +|---|---| +| `lib/registry/components/v2-table-list/**`, `lib/registry/components/table-list/**` | §4 와 동일. 옛 저장 화면에서 `componentType: "v2-table-list"` / `"table-list"` 를 직접 가리키는 layout JSON 다수 — 런타임 렌더 미보장 시 화면 깨짐 | +| `lib/registry/components/index.ts` 의 `table-list` / `v2-table-list` auto-register import | ComponentRegistry 등록이 빠지면 옛 화면 로드 실패. 삭제 금지 | +| `lib/schemas/componentConfig.ts` 의 `v2-table-list` schema / default | old layout load compatibility (`componentConfig` schema 검증을 통과해야 함). 삭제 금지 | +| `buttonActions.ts` 의 `split-panel-layout` 분기 (line ~3293, ~3477) | table-like 와는 별개 컴포넌트. `componentConfig.leftPanel.tableName` 라는 고유 경로 사용. helper 적용 대상 아님 → §4 와 동일하게 보존 | +| `frontend/components/control/**`, `frontend/components/dash/**`, `frontend/styles/control-mode.css` | 본 cleanup 범위 외 (§9 의 control/dash revert 와 동일 정책) | +| 금지 토큰 (`v2-input`, `v2-select`, `V2InputRenderer`, `V2SelectRenderer`, `EntityPicker`, `entity-picker`, `EntitySearchModal`) 재도입 금지 | 본 작업에서 재도입 0건 (§10.4 acceptance) | + +### 10.4 Acceptance (이번 단계) + +| 검증 항목 | 명령 | 결과 | +|---|---|---| +| Whitespace clean | `git diff --check` | ✅ pass (출력 없음) | +| 금지 토큰 (input canonical) | `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles` | ✅ 0건 | +| EntityPicker 잔재 | `rg "EntityPicker\|entity-picker\|EntitySearchModal" frontend/lib/registry/components/input frontend/components/v2/config-panels/InvFieldConfigPanel.tsx` | ✅ 0건 | +| 3개 파일 `componentType === "table-list"` 단독 분기 | `rg 'componentType === "table-list"' frontend/lib/utils/buttonActions.ts "frontend/app/(main)/screens/[screenId]/page.tsx" frontend/components/screen/widgets/TabsWidget.tsx` | ✅ 0건 | +| backend-spring 컴파일 | `cd backend-spring && ./gradlew compileJava` | ✅ BUILD SUCCESSFUL | +| 변경 파일 TS 신규 에러 | `npx tsc --noEmit` — 본 단계에서 손댄 line 들 | ✅ 0건 신규 에러 (helper 추가 line, OR 체인 교체 line 모두 깨끗. 기존 에러는 본 cleanup 범위 외라 유지) | + +### 10.5 변경 파일 요약 (이번 단계) + +``` +M frontend/lib/utils/componentTypeUtils.ts (+helper block 100여 줄) +M frontend/lib/utils/buttonActions.ts (3곳 helper 호출로 교체, +1 import) +M frontend/app/(main)/screens/[screenId]/page.tsx (hasTableWidget 한 줄 helper 호출, +1 import) +M frontend/components/screen/widgets/TabsWidget.tsx (screenInfoMap helper 호출, +1 import) +M frontend/components/screen/modals/MultilangSettingsModal.tsx (icon/Set 분류만) +M frontend/components/screen/panels/ComponentsPanel.tsx (주석 정리만) +M notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md (본 §10) +``` + +### 10.6 후속 작업 후보 (이번 범위 밖) + +- v2-table-list / table-list 폴더의 실제 폐기 — old layout 마이그레이션이 끝난 뒤 + §4 hard blocker 와 함께 별도 phase 로 진행 +- `componentConfig.ts` schema / default 의 v2-table-list 제거 — 동일 조건 +- `buttonActions.ts` 의 `split-panel-layout` 분기 — canonical table.displayMode='split' + master-detail UX 완성 후 §4 와 함께 마이그레이션 +- 기존 page.tsx / buttonActions.ts 의 기존 TS 에러 (snake_case vs camelCase 타입 불일치) + 정리 — 본 cleanup 과 무관한 별도 트랙 + +--- + +## 11. 2026-05-19 추가 (2차) — Helper 확산 (button/preview/backend) + +§10 에서 도입한 helper 를 **외부 active branch 8 frontend + 2 backend** 까지 확산. +런타임 보존 폴더 / schema / auto-register 는 그대로 유지하고 active 분기만 교체. + +### 11.1 변경 파일 (10개) + +| 파일 | 변경 요지 | +|---|---| +| `frontend/components/screen/config-panels/button/DataTab.tsx` | 로컬 `isDataTransferComponentType` 정의를 `isTableLikeComponentType` 기반으로 정리. table-like 외 `repeater-field-group` / `form-group` 만 별도 `DATA_TRANSFER_EXTRA_PATTERNS` 로 보존 | +| `frontend/components/screen/config-panels/button-config/ActionTab.tsx` | 동일한 로컬 helper 정리 + `compType === "table-list"` 단독 분기 2곳 (`autoDetect` modal source 감지, `openModalWithData` 소스 감지) 을 `isTableLikeComponent` + `getTableNameFromTableLikeComponent` 로 교체. `compId === "table-list"` OR 체인 (테이블명 폴백) 도 `isTableLikeComponentType(compId)` 로 정리. `split-panel-layout` / `screen-split-panel` / `split-panel-layout2` 는 별개 컴포넌트라 그대로 보존 | +| `frontend/components/v2/config-panels/InvLegacyButtonConfigPanel.tsx` | 동일한 로컬 `isDataTransferComponentType` 정리 | +| `frontend/components/screen/ScreenNode.tsx` | `componentKind === "table-list"` 포함 OR 체인을 `isTableLikeComponentType(componentKind)` + `TABLE_LIKE_EXTRA_KINDS` (grouped-table / card-list / data-grid) 로 정리. 미니어처 색상 결정용 | +| `frontend/components/screen/RealtimePreviewDynamic.tsx` | `fillParentTypes` 배열의 `table` / `table-list` / `v2-table-list` 를 helper 로 통합 + 그 외 split/tabs layout 만 명시 목록 유지. `componentConfig.type === "table-list"` 단독 분기 2곳 (size.height 최소 200 보장, 기본 200 fallback) 도 helper 호출로 교체 | +| `frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx` | `provider.component_type === "table-list"` 단독 탐색 → `isTableLikeComponentType(provider.component_type)` | +| `frontend/lib/registry/components/v2-button-primary/ButtonPrimaryComponent.tsx` | 동일한 패턴 + DataReceiver 자동 탐색의 `rt === "table" \|\| === "table-list" \|\| === "data-table"` OR 체인 → helper 한 호출 | +| `frontend/lib/registry/components/universal-form-modal/UniversalFormModalConfigPanel.tsx` | `compType === "table-list" \|\| === "interactive-data-table"` OR 체인 → `isTableLikeComponentType(compType) \|\| compType === "interactive-data-table"` | +| `backend-spring/src/main/java/com/erp/service/ScreenGroupService.java` | `private static int countTableLikeWidgets(Map)` 헬퍼 신규 — `table` + `table-list` + `v2-table-list` 합산. `screenType = "grid"` 추론 분기 2곳 (`extractScreenLayout` / `getScreenSummary`) 모두 helper 사용. 셋 중 어느 것이든 있으면 grid | +| `backend-spring/src/main/resources/mapper/screenGroup.xml` | `selectSavableScreensForGroup` 의 `NOT EXISTS` 서브쿼리에서 `componentType = 'table-list'` 단독을 `IN ('table', 'table-list', 'v2-table-list')` 로 확장. 체크박스 활성 판정도 legacy `componentConfig.checkbox.enabled` 와 canonical `componentConfig.showCheckbox` 를 SQL OR 로 구분 처리 | + +### 11.2 보존한 Hard Blocker (이번 단계에서 의도적으로 건드리지 않음) + +| 위치 | 보존 이유 | +|---|---| +| `lib/registry/components/v2-table-list/**`, `lib/registry/components/table-list/**` | §4 / §10.3 와 동일. 옛 저장 layout JSON 의 직접 type 지목 — 폴더 삭제/대형 수정 시 화면 깨짐 | +| `lib/schemas/componentConfig.ts` 의 v2-table-list schema / default | old layout config 검증 통과 hard blocker. 삭제 금지 | +| `lib/registry/components/repeat-container/**` 의 `dataSourceType = "table-list"` enum | **컴포넌트 type 이 아니라 데이터 소스 모드 enum 값**. helper 적용 대상 아님. 같은 문자열이지만 도메인이 다름 → 이번 범위에서 건드리지 않음 | +| `frontend/components/v2/config-panels/InvDataConfigPanel.tsx` 의 `v2-table-list` 참조 | old layout config hard blocker. `InvDataConfigPanel` 이 v2-list / v2-table-list canonical 경로에서 사용 (§4 참조). 보존 | +| `ActionTab.tsx` / `RealtimePreviewDynamic.tsx` 의 `split-panel-layout` / `split-panel-layout2` / `screen-split-panel` / `v2-tab-container` / `tabs-widget` 등 분기 | table-like 와 별개 컴포넌트. `componentConfig.leftPanel.*` / `componentConfig.tabs.*` 등 고유 path 사용. helper 적용 대상 아님 | +| `frontend/components/control/**`, `frontend/components/dash/**`, `frontend/styles/control-mode.css` | §9 정책과 동일. 본 cleanup 범위 외 | +| 금지 토큰 (`v2-input`, `v2-select`, `V2InputRenderer`, `V2SelectRenderer`, `EntityPicker`, `entity-picker`, `EntitySearchModal`) | 본 작업에서 재도입 0건 | + +### 11.3 Acceptance (이번 단계) + +| 검증 항목 | 명령 | 결과 | +|---|---|---| +| Whitespace clean | `git diff --check` | ✅ pass (출력 없음) | +| 금지 토큰 (input canonical) | `rg "v2-input\|v2-select\|V2InputRenderer\|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles` | ✅ 0건 | +| EntityPicker 잔재 | `rg "EntityPicker\|entity-picker\|EntitySearchModal" frontend/lib/registry/components/input frontend/components/v2/config-panels/InvFieldConfigPanel.tsx` | ✅ 0건 | +| 10개 수정 대상 파일 `componentType === "table-list"` 단독 분기 | `rg 'componentType === "table-list"\|component_type === "table-list"'` (수정 대상 8개 frontend 파일 한정) | ✅ 0건 | +| backend-spring 컴파일 | `cd backend-spring && ./gradlew compileJava` | ✅ BUILD SUCCESSFUL | +| 변경 파일 TS 신규 에러 | `npx tsc --noEmit` — 본 단계 손댄 line | ✅ 0건 신규 에러 (helper 추가/교체 line 모두 깨끗. 기존 에러는 §10.4 와 동일하게 본 범위 외라 유지) | + +### 11.4 helper 사용 사이트 누적 (§10 + §11) + +| Helper | 사용처 | +|---|---| +| `isTableLikeComponentType(typeValue)` | `componentTypeUtils.ts` 내부 (`isTableLikeComponent` 의 부품), `DataTab.tsx`, `ActionTab.tsx`, `InvLegacyButtonConfigPanel.tsx`, `ScreenNode.tsx`, `RealtimePreviewDynamic.tsx`, `ButtonPrimaryComponent.tsx` (×2), `UniversalFormModalConfigPanel.tsx` | +| `isTableLikeComponent(component)` | `buttonActions.ts` (×3), `screens/[screenId]/page.tsx`, `TabsWidget.tsx`, `ActionTab.tsx` (×2) | +| `getTableNameFromTableLikeComponent(component)` | `buttonActions.ts` (×3), `TabsWidget.tsx`, `ActionTab.tsx` (×2) | + +### 11.5 후속 작업 후보 (이번 범위 밖) + +- canonical table 의 `showCheckbox` config 실제 검증 — `screenGroup.xml` 의 OR 분기가 + canonical 화면 SQL 결과에 미치는 영향 확인 (실 데이터 검증 필요) +- `ActionTab.tsx` 의 `v2-list` 분기 — 별도 컴포넌트이므로 v2-list 폐기 시 같이 정리 +- `ScreenNode.TABLE_LIKE_EXTRA_KINDS` (grouped-table / card-list / data-grid) — canonical + 컴포넌트군 정착 후 `TABLE_LIKE_COMPONENT_TYPES` 본체로 흡수 검토 +- `repeat-container` 의 `dataSourceType = "table-list"` enum naming — 도메인 분리 위해 + `tableList` → `legacyTableList` 등으로 rename 검토 (별도 트랙) diff --git a/notes/gbpark/2026-05-19-claude-cleanup-scope-fix-prompt.md b/notes/gbpark/2026-05-19-claude-cleanup-scope-fix-prompt.md new file mode 100644 index 00000000..f406dc8c --- /dev/null +++ b/notes/gbpark/2026-05-19-claude-cleanup-scope-fix-prompt.md @@ -0,0 +1,210 @@ +# 2026-05-19 Claude Prompt — Canonical Data View Cleanup Scope Fix + +아래 내용을 Claude Code 에 일반 프롬프트로 붙여넣는다. `/goal` 명령은 쓰지 않는다. + +--- + +## Prompt + +너는 이 repo 에서 이미 진행된 `canonical data-view cleanup` 결과를 최종 수습하는 역할이다. + +중요: 이번 작업은 새로운 대규모 기능 구현이 아니다. 현재 diff 중 `stats/table/container/chart/card-list/grouped-table` canonical 정리 범위는 보존하되, 범위 밖 변경을 제거하고 검증 가능한 상태로 만드는 것이 목표다. + +작업 전제: + +- Workspace: `/Users/gbpark/invyone` +- 원 설계 문서: + - `notes/gbpark/2026-05-18-canonical-data-view-goal.md` + - `notes/gbpark/2026-05-18-claude-goal-prompt-v2-strict.md` +- 현재 후속 보고서: + - `notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md` +- Codex 1차 검증 결과: + - acceptance check 자체는 통과했다. + - 하지만 Claude 가 canonical cleanup 범위를 넘어 `control/dash` 데모 기능을 추가했다. + - 이 범위 밖 변경은 이번 cleanup 결과에 포함하면 안 된다. + +절대 규칙: + +1. `/goal` 을 사용하지 말고, 이 프롬프트 범위 안에서 한 번에 처리한다. +2. `input` canonical 작업의 결과를 되돌리거나 훼손하지 않는다. +3. `FieldConfig`, `DataPort`, `sourceProvider`, `dataReceiver`, `dbTable` 관련 계약을 깨지 않는다. +4. `v2-input`, `v2-select`, `V2InputRenderer`, `V2SelectRenderer`, `EntityPicker`, `EntitySearchModal` 을 재도입하지 않는다. +5. legacy/V2 삭제 또는 alias 정리는 “동작 경로가 canonical 으로 수렴하는 경우”에만 유지한다. +6. 범위 밖 변경은 새 feature 로 인정하지 않는다. 이번 cleanup diff 에서 제거하거나, 제거가 위험하면 정확한 이유를 보고하고 중단한다. +7. 사용자가 만든 것일 수 있는 unrelated backend/notes 변경은 임의로 되돌리지 않는다. + +이번 작업에서 보존해야 하는 canonical cleanup 범위: + +- canonical data-view 관련 registry/config/schema/type 정리 + - `frontend/lib/registry/DynamicComponentRenderer.tsx` + - `frontend/lib/registry/components/index.ts` + - `frontend/lib/utils/getComponentConfigPanel.tsx` + - `frontend/lib/utils/responsiveDefaults.ts` + - `frontend/lib/utils/templateMigrate.ts` + - `frontend/lib/schemas/componentConfig.ts` + - `frontend/types/v2-components.ts` + - `frontend/lib/registry/hoc/withContainerQuery.css` +- input 작업 후속 data-transfer 타입 판정 정리 + - `frontend/components/screen/config-panels/button/DataTab.tsx` + - `frontend/components/screen/config-panels/button-config/ActionTab.tsx` + - `frontend/components/v2/config-panels/InvLegacyButtonConfigPanel.tsx` +- canonical data-view 신규/기존 연결 + - `frontend/lib/registry/components/stats/**` + - `frontend/lib/registry/components/table/**` + - `frontend/lib/registry/components/container/**` + - `frontend/lib/registry/components/chart/**` + - `frontend/lib/registry/components/card-list/**` + - `frontend/lib/registry/components/grouped-table/**` +- 의도된 삭제 대상 + - `frontend/lib/registry/components/aggregation-widget/**` + - `frontend/lib/registry/components/v2-aggregation-widget/**` + - `frontend/lib/registry/components/v2-status-count/**` + - `frontend/lib/registry/components/tabs/**` + - `frontend/lib/registry/components/v2-tabs-widget/**` + - `frontend/lib/registry/components/section-card/**` + - `frontend/lib/registry/components/v2-section-card/**` + - `frontend/lib/registry/components/section-paper/**` + - `frontend/lib/registry/components/v2-section-paper/**` + - `frontend/components/v2/config-panels/V2AggregationWidgetConfigPanel.tsx` + - `frontend/components/v2/config-panels/V2StatusCountConfigPanel.tsx` + +이번 작업에서 제거해야 하는 범위 밖 변경: + +- `frontend/components/control/ControlMode.tsx` +- `frontend/components/control/ControlPalette.tsx` +- `frontend/components/control/FlowViewer.tsx` +- `frontend/components/control/PortHandle.tsx` +- `frontend/components/control/RuleBuilder.tsx` +- `frontend/components/control/controlDemo.ts` +- `frontend/components/dash/DashboardCanvas.tsx` +- `frontend/components/dash/DashboardLayout.tsx` +- `frontend/styles/control-mode.css` + +위 파일들은 canonical data-view cleanup 과 무관한 제어모드 데모/대시보드 변경이다. 이번 작업에서는 포함하지 않는다. tracked 파일은 현재 branch 의 HEAD 상태로 되돌리고, untracked `controlDemo.ts` 는 삭제한다. 단, 되돌리기 전에 `git diff -- ` 로 확인했을 때 canonical data-view cleanup 과 직접 관련된 변경이 섞여 있으면 되돌리지 말고 보고한다. + +진행 순서: + +1. 현재 상태를 먼저 확인한다. + + ```bash + git status --short + git diff --name-status + git diff --stat + ``` + +2. 범위 밖 변경을 확인한다. + + ```bash + git diff -- frontend/components/control frontend/components/dash frontend/styles/control-mode.css + git status --short -- frontend/components/control frontend/components/dash frontend/styles/control-mode.css + ``` + + 확인 기준: + - `controlDemo.ts`, demo table/meta, `createDemoRuleGraph`, `CONTROL_DEMO_TABLES`, `onLoadDemo`, flow-active styling 같은 변경은 범위 밖이다. + - 해당 변경은 이번 cleanup 에 포함하지 않는다. + +3. 범위 밖 변경만 제거한다. + + 안전 조건: + - 제거 대상은 위 “이번 작업에서 제거해야 하는 범위 밖 변경” 목록으로 제한한다. + - 다른 파일은 revert 하지 않는다. + - repo 전체 reset, checkout, clean 금지. + + 예시: + + ```bash + git restore -- frontend/components/control/ControlMode.tsx \ + frontend/components/control/ControlPalette.tsx \ + frontend/components/control/FlowViewer.tsx \ + frontend/components/control/PortHandle.tsx \ + frontend/components/control/RuleBuilder.tsx \ + frontend/components/dash/DashboardCanvas.tsx \ + frontend/components/dash/DashboardLayout.tsx \ + frontend/styles/control-mode.css + rm frontend/components/control/controlDemo.ts + ``` + + `rm` 은 위 untracked 파일 하나에만 사용한다. + +4. report 를 정정한다. + + `notes/gbpark/2026-05-19-canonical-data-view-cleanup-followup.md` 를 업데이트한다. + + 반드시 반영할 내용: + - summary 의 “7개 폴더” vs 실제 “9개 폴더” 불일치를 고친다. + - Codex 검증에서 범위 밖 `control/dash` 변경이 발견되어 제거했다는 사실을 기록한다. + - 제거한 범위 밖 파일 목록을 기록한다. + - acceptance command 와 결과를 최신 상태로 다시 기록한다. + - 잔여 매칭은 hard blocker/domain 으로만 남아야 하며, 각 보존 사유를 명시한다. + +5. 필수 acceptance check 를 다시 실행한다. + + ```bash + git diff --check + rg "v2-input|v2-select|V2InputRenderer|V2SelectRenderer" frontend/lib frontend/components frontend/app frontend/types frontend/styles --glob '!**/node_modules/**' + rg "EntityPicker|entity-picker|EntitySearchModal" frontend/lib/registry/components/input frontend/components/v2/config-panels/InvFieldConfigPanel.tsx --glob '!**/node_modules/**' + rg -n "from .*components/(aggregation-widget|v2-aggregation-widget|v2-status-count|tabs|v2-tabs-widget|section-card|v2-section-card|section-paper|v2-section-paper)|import\\(.*components/(aggregation-widget|v2-aggregation-widget|v2-status-count|tabs|v2-tabs-widget|section-card|v2-section-card|section-paper|v2-section-paper)" frontend --glob '!**/node_modules/**' + test ! -e frontend/lib/registry/components/aggregation-widget + test ! -e frontend/lib/registry/components/v2-aggregation-widget + test ! -e frontend/lib/registry/components/v2-status-count + test ! -e frontend/lib/registry/components/tabs + test ! -e frontend/lib/registry/components/v2-tabs-widget + test ! -e frontend/lib/registry/components/section-card + test ! -e frontend/lib/registry/components/v2-section-card + test ! -e frontend/lib/registry/components/section-paper + test ! -e frontend/lib/registry/components/v2-section-paper + git diff --quiet -- frontend/components/control frontend/components/dash frontend/styles/control-mode.css + git status --short -- frontend/components/control/controlDemo.ts + ``` + + backend compile: + + ```bash + cd backend-spring + ./gradlew compileJava + ``` + + 잔여 카운트도 다시 계산한다. + + ```bash + rg -n "stats|aggregation-widget|v2-status-count" frontend/lib frontend/components frontend/app frontend/types --glob '!**/node_modules/**' | wc -l + rg -n "table-list|v2-table-list" frontend/lib frontend/components frontend/app frontend/types --glob '!**/node_modules/**' | wc -l + rg -n "tabs-widget|v2-tabs-widget|section-card|section-paper|accordion-basic|conditional-container|repeat-container|v2-repeat-container|split-panel-layout" frontend/lib frontend/components frontend/app frontend/types --glob '!**/node_modules/**' | wc -l + ``` + + 주의: + - `rg` 가 0건이어야 하는 check 는 exit code 1 이 정상일 수 있다. 출력이 0건이면 pass 로 기록한다. + - 전체 `npm run lint` 는 기존 repo-wide lint 부채 때문에 이번 acceptance 조건이 아니다. 실행하지 않아도 된다. 실행했다면 기존 오류와 이번 변경 오류를 구분해서 보고한다. + +6. 마지막으로 다음을 보고한다. + + - canonical cleanup diff 는 유지됐는가 + - 범위 밖 `control/dash` 변경은 제거됐는가 + - deleted legacy folder/import 는 실제로 사라졌는가 + - input forbidden / EntityPicker forbidden 은 0건인가 + - backend compile 결과 + - 잔여 `stats/table/container` 매칭 수와 보존 사유 + - 수정한 파일 목록 + +완료 기준: + +- `git diff --check` pass +- input forbidden rg 출력 0건 +- EntityPicker forbidden rg 출력 0건 +- 삭제된 legacy component folder 가 실제로 존재하지 않음 +- 삭제된 legacy component folder 로 import 하는 active code 0건 +- `./gradlew compileJava` BUILD SUCCESSFUL +- `git diff --quiet -- frontend/components/control frontend/components/dash frontend/styles/control-mode.css` pass +- `frontend/components/control/controlDemo.ts` 가 untracked/존재 상태로 남아 있지 않음 +- follow-up report 의 folder deletion count 불일치가 수정됨 +- 남은 legacy/V2 매칭은 구현 canonical, 삭제 path, concrete hard blocker, domain preserved 중 하나로 분류됨 + +금지: + +- repo 전체 `git reset`, `git checkout .`, `git clean -fd` 금지 +- unrelated backend 파일 revert 금지 +- `input`/`EntityPicker` 관련 옛 경로 재도입 금지 +- `control/dash` 데모 기능을 별도 feature 라고 주장하며 남겨두기 금지 +- acceptance 실패를 “분류 가능”만으로 통과 처리 금지 + +최종 답변은 짧게, 실행한 command 결과 중심으로 작성한다. diff --git a/notes/gbpark/2026-05-19-control-mockup/index.html b/notes/gbpark/2026-05-19-control-mockup/index.html new file mode 100644 index 00000000..6294c59d --- /dev/null +++ b/notes/gbpark/2026-05-19-control-mockup/index.html @@ -0,0 +1,1272 @@ + + + + +INVYONE — 제어 모드 시연용 mockup + + + + +
    +
    + +
    +
    📊인사 대시보드
    +
    📦영업 현황
    +
    🏭생산 KPI
    +
    +
    + + + + + +
    +
    + +
    +
    +
    메뉴
    + + + + +
    관리
    +
    👥사용자
    +
    🛡권한
    +
    설정
    +
    + +
    + +
    + +
    + 👤 + 인사 정보 + USER_INFO +
    +
    +
    +
    +
    전체
    8
    +
    재직
    7
    +
    휴직
    1
    +
    겸직
    0
    +
    +
    + + + + + +
    + + + + + + + + +
    사번ID이름부서상태
    1001dskim김대성해외영업부active
    1003ghmyeong명건희해외영업부active
    1004hjjeong정혜진해외영업부active
    1005chpark박창현해외영업부active
    +
    +
    + +
    + +
    + 📝 + 사원 등록 + FORM +
    +
    +
    +
    +
    +
    +
    +
    + +
    + +
    + 📈 + 월별 매출 + CHART +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    ⚡ 제어할 카드를 클릭하세요
    + + +
    +
    +
    + +
    + + + + + From 895cb48ee0c6ca45060ebe5f252710620acb4e65 Mon Sep 17 00:00:00 2001 From: johngreen Date: Thu, 21 May 2026 19:21:13 +0900 Subject: [PATCH 05/11] =?UTF-8?q?docs(claude):=20UI/=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EC=A0=9C=EC=95=88=20=EC=8B=9C=20ASCII=20B?= =?UTF-8?q?efore/After=20=EA=B7=B8=EB=A6=BC=20=EC=9D=98=EB=AC=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 컨벤션에 § 💬 추가. 글로만 설명하면 사용자가 이해 못 함 — 변경 전/후 두 그림을 무조건 그려서 보여줘야 함. 코드 인용 (file:line, CSS class) 최소화하고 영문/SQL/전문용어 풀어쓰기. 3줄 패턴 (무슨 일 / 사용자 영향 / 어떻게 고치는지) 권장. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 54b32c82..08ded1be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,82 @@ + +# 절대 규칙: 검증 없는 주장 금지 + +내가 출력하는 모든 발언은 근거가 있어야 한다. 근거가 없으면 그 말을 하지 않는다. 위로·추정·일반론·"보통 그렇다"로 채우지 않는다. + +## 위반 사례 (절대 하지 말 것) +- "100명 중 5명도 안 된다" 같은 통계를 출처 없이 만들어내기 +- "통과 확률 70~80%" 같은 수치를 추정으로 제시하기 +- "보통", "일반적으로", "대부분" 으로 시작하는 일반론 +- 본인이 검증 안 한 SDK/API 동작을 단정적으로 설명하기 +- 위로·격려를 위해 사실이 아닌 것을 끼워넣기 + +## 발화 전 자기 검증 +한 문장이라도 출력하기 전에 다음을 확인: +1. **출처가 있는가?** — 코드(파일:라인), 명령 결과, 공식 문서, 사용자가 준 정보, 도구 호출 결과 중 하나 +2. **출처가 없다면 추정인가?** — 추정이면 명시적으로 "추정이지만…" 또는 "확인 안 됐지만…" 으로 시작 +3. **추정도 근거가 없으면?** — 말하지 않는다. "모릅니다" 또는 "확인이 필요합니다" 라고 한다 + +## 모를 때의 정답 +- 검색·문서 조회·코드 읽기로 확인 가능하면 확인부터 한다 +- 확인이 불가능하면 "모릅니다" 가 정답. 그럴듯한 답을 만들지 않는다 +- 사용자 의사결정에 영향을 주는 사실일수록 더 엄격하게 적용 + +## 어겼을 때 +사용자가 "그 근거 뭐야" 라고 묻거나 잘못된 사실을 지적하면: +- 즉시 인정. "맞습니다. 그 수치 제가 지어냈습니다." 같이 명시적으로 시인 +- 변명·재포장 금지 +- 무엇이 검증된 사실이고 무엇이 추정/날조였는지 다시 분리해서 제시 + + +# 💬 사용자에게 설명할 때 — 그림으로 (★ 중요) + +UI 변경 제안, 디자인 토론, 코드 구조 설명 등을 할 때는 **반드시 변경 전/후를 ASCII 표나 도식으로 그려서** 보여준다. 글로만 설명하면 사용자가 이해 못 한다. + +## 원칙 + +1. **변경 제안은 무조건 Before / After 두 그림** +2. **코드 인용 (file:line, 변수명, CSS class) 최소화** — 결론과 시각적 영향 위주 +3. **평어, 한국어, 짧은 문장** +4. **영문/SQL/전문용어 풀어쓰기** — "grid template" 대신 "표 컬럼 배치", "stopPropagation" 대신 "클릭이 위로 새는 거 막기" +5. **3줄 패턴 권장** — 무슨 일 / 사용자한테 보이는 영향 / 어떻게 고치는지 + +## 나쁜 예시 ❌ + +> "ColumnGrid.tsx:93-103 의 `grid-cols-[4px_140px_1fr_100px_160px_40px]` 를 5컬럼으로 축소하고, 라벨 셀에 sub-line 을 추가하면 entity/code/numbering 의 메타가 inline 으로..." + +(사용자: "뭐라는지 모르겠어") + +## 좋은 예시 ⭕ + +> **지금 모양:** +> ``` +> 라벨·컬럼명 │ 참조/설정 │ 타입 +> 거래처명 │ — │ 텍스트 ← 빈 칸 +> 거래처ID │ customer_mng → ... │ 테이블참조 +> ``` +> +> **바꿔서:** +> ``` +> 라벨·컬럼명 │ 타입 +> 거래처명 │ 텍스트 +> 거래처ID │ 테이블참조 +> → customer_mng.id ← 정보 있을 때만 작게 밑에 +> ``` + +## 옵션 제시할 땐 표로 + +``` +| 옵션 | 핵심 | 단점 | +| A안 | 이름만 바꾸기 | 가장 가벼움 | +| B안 | 그룹을 잘게 쪼개기 | 그룹 수 늘어남 | +``` + +## 우선 순위 +- 첫 시도에 글만 쓰지 말 것. 그림부터 그리고 글은 짧게 보충. +- 사용자가 "무슨 말인지 모르겠어" 하면 → 더 분해해서 다시 그림 그리기. 글 길어지면 더 헷갈림. + +--- + # INVYONE — Claude 작업 컨벤션 이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다. From 30ebb14023b668cadecdc1a8cb7c7a49daf6ed00 Mon Sep 17 00:00:00 2001 From: johngreen Date: Thu, 21 May 2026 19:21:30 +0900 Subject: [PATCH 06/11] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20=EC=9E=85=EB=A0=A5=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20lucide=20=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=20=E2=80=94=20letter/symbol/emoji=20=ED=98=BC?= =?UTF-8?q?=EC=9E=AC=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존엔 iconChar 문자열에 'T'(글자) / '#'(기호) / '{}'(코드) / '📎'(emoji) 식으로 일관성 없게 표시. 카드 그리드에서 시각 노이즈 큼. - TypeColorConfig.iconChar(string) → Icon(LucideIcon) 으로 교체 - 13개 입력 타입 전부 lucide 아이콘 매핑 (Type/Hash/Calendar/Braces/Link2 등) - FALLBACK_TYPE_CONFIG export 해서 TypeOverviewStrip 의 legacy/unknown 도 같은 인터페이스 따름 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/table-type/ColumnDetailPanel.tsx | 12 ++-- .../admin/table-type/TypeOverviewStrip.tsx | 15 ++--- frontend/components/admin/table-type/types.ts | 57 ++++++++++++++----- 3 files changed, 52 insertions(+), 32 deletions(-) diff --git a/frontend/components/admin/table-type/ColumnDetailPanel.tsx b/frontend/components/admin/table-type/ColumnDetailPanel.tsx index 8b365576..d1556a11 100644 --- a/frontend/components/admin/table-type/ColumnDetailPanel.tsx +++ b/frontend/components/admin/table-type/ColumnDetailPanel.tsx @@ -183,12 +183,12 @@ export function ColumnDetailPanel({ isLegacy && "cursor-not-allowed", )} > - - {conf.iconChar} - + { const length = ratio * circumference; const dashArray = `${length} ${circumference - length}`; const dashOffset = -offset; offset += length; - const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" }); + const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || FALLBACK_TYPE_CONFIG); return { type, dashArray, @@ -112,7 +105,7 @@ export function TypeOverviewStrip({ .filter((type) => (counts[type] || 0) > 0) .sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0)) .map((type) => { - const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted", label: type }; + const conf = INPUT_TYPE_COLORS[type] || { ...FALLBACK_TYPE_CONFIG, label: type }; const isActive = activeFilter === null || activeFilter === type; return (
    ) : ( - <> - - setSelectedColumn((prev) => (prev === c ? null : c))} - onColumnChange={(columnName, field, value) => { - if (field === "is_unique") { - const currentColumn = columns.find((c) => c.column_name === columnName); - if (currentColumn) { - handleUniqueToggle(columnName, currentColumn.is_unique || "NO"); + + + + + 컬럼 + + + + 참조 + {(() => { + const refCount = columns.filter((c) => + ["entity", "code", "category", "numbering"].includes(c.input_type), + ).length; + return refCount > 0 ? ( + + {refCount} + + ) : null; + })()} + + + + + + setSelectedColumn((prev) => (prev === c ? null : c))} + onColumnChange={(columnName, field, value) => { + if (field === "is_unique") { + const currentColumn = columns.find((c) => c.column_name === columnName); + if (currentColumn) { + handleUniqueToggle(columnName, currentColumn.is_unique || "NO"); + } + return; } - return; - } - if (field === "is_nullable") { - const currentColumn = columns.find((c) => c.column_name === columnName); - if (currentColumn) { - handleNullableToggle(columnName, currentColumn.is_nullable || "YES"); + if (field === "is_nullable") { + const currentColumn = columns.find((c) => c.column_name === columnName); + if (currentColumn) { + handleNullableToggle(columnName, currentColumn.is_nullable || "YES"); + } + return; } - return; + const idx = columns.findIndex((c) => c.column_name === columnName); + if (idx >= 0) handleColumnChange(idx, field, value); + }} + constraints={constraints} + typeFilter={typeFilter} + getColumnIndexState={getColumnIndexState} + onPkToggle={handlePkToggle} + onIndexToggle={(columnName, checked) => + handleIndexToggle(columnName, "index", checked) } - const idx = columns.findIndex((c) => c.column_name === columnName); - if (idx >= 0) handleColumnChange(idx, field, value); - }} - constraints={constraints} - typeFilter={typeFilter} - getColumnIndexState={getColumnIndexState} - onPkToggle={handlePkToggle} - onIndexToggle={(columnName, checked) => - handleIndexToggle(columnName, "index", checked) - } - onDeleteColumn={handleDeleteColumnClick} - tables={tables} - referenceTableColumns={referenceTableColumns} - /> - + onDeleteColumn={handleDeleteColumnClick} + tables={tables} + referenceTableColumns={referenceTableColumns} + /> + + + + setSelectedColumn((prev) => (prev === c ? null : c))} + /> + + )} )} diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx index e2f5610e..bbd76f4b 100644 --- a/frontend/components/admin/table-type/ColumnGrid.tsx +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -92,11 +92,10 @@ export function ColumnGrid({
    라벨 · 컬럼명 - 참조/설정 타입 PK / NN / IDX / UQ @@ -142,7 +141,7 @@ export function ColumnGrid({ }} className={cn( "grid min-h-12 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors", - "grid-cols-[4px_140px_1fr_100px_160px_40px]", + "grid-cols-[4px_1fr_100px_160px_40px]", "bg-card border-transparent hover:border-border hover:shadow-sm", isSelected && "border-primary/30 bg-primary/5 shadow-sm", )} @@ -159,66 +158,6 @@ export function ColumnGrid({
    - {/* 참조/설정 칩 */} -
    - {column.input_type === "entity" && column.reference_table && column.reference_table !== "none" && ( - <> - { - const t = tables.find((tb) => tb.table_name === column.reference_table); - return t?.display_name && t.display_name !== t.table_name - ? `${t.display_name} (${column.reference_table})` - : column.reference_table; - })() - : column.reference_table - } - > - {column.reference_table} - - - { - const refCols = referenceTableColumns[column.reference_table]; - const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? "")); - return c?.display_name && c.display_name !== c.column_name - ? `${c.display_name} (${column.reference_column})` - : column.reference_column ?? "—"; - })() - : column.reference_column ?? "—" - } - > - {column.reference_column || "—"} - - - )} - {column.input_type === "code" && ( - - {column.code_info ?? "—"} · {column.default_value ?? ""} - - )} - {column.input_type === "numbering" && column.numbering_rule_id && ( - - {column.numbering_rule_id} - - )} - {column.input_type !== "entity" && - column.input_type !== "code" && - column.input_type !== "numbering" && - (column.default_value ? ( - {column.default_value} - ) : ( - - ))} -
    - {/* 타입 뱃지 */}
    diff --git a/frontend/components/admin/table-type/ReferenceListView.tsx b/frontend/components/admin/table-type/ReferenceListView.tsx new file mode 100644 index 00000000..5bca69e5 --- /dev/null +++ b/frontend/components/admin/table-type/ReferenceListView.tsx @@ -0,0 +1,223 @@ +"use client"; + +import React, { useMemo } from "react"; +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/utils"; +import { Database, FolderTree, Hash, Link2, FileCode2 } from "lucide-react"; +import type { ColumnTypeInfo, TableInfo } from "./types"; +import { INPUT_TYPE_COLORS } from "./types"; +import type { ReferenceTableColumn } from "@/lib/api/entityJoin"; + +export interface ReferenceListViewProps { + columns: ColumnTypeInfo[]; + tables?: TableInfo[]; + referenceTableColumns?: Record; + onSelectColumn?: (columnName: string) => void; + selectedColumn?: string | null; +} + +type RefKind = "entity" | "code" | "category" | "numbering"; + +const KIND_META: Record< + RefKind, + { icon: React.FC<{ className?: string }>; label: string; color: string; bgColor: string } +> = { + entity: { icon: Link2, label: "테이블 참조", color: "text-violet-600", bgColor: "bg-violet-50" }, + code: { icon: FileCode2, label: "공통코드", color: "text-emerald-600", bgColor: "bg-emerald-50" }, + category: { icon: FolderTree, label: "카테고리", color: "text-teal-600", bgColor: "bg-teal-50" }, + numbering: { icon: Hash, label: "채번", color: "text-orange-600", bgColor: "bg-orange-50" }, +}; + +function getRefKind(col: ColumnTypeInfo): RefKind | null { + const t = col.input_type; + if (t === "entity" || t === "code" || t === "category" || t === "numbering") return t; + return null; +} + +export function ReferenceListView({ + columns, + tables, + referenceTableColumns, + onSelectColumn, + selectedColumn = null, +}: ReferenceListViewProps) { + const grouped = useMemo(() => { + const groups: Record = { + entity: [], + code: [], + category: [], + numbering: [], + }; + for (const col of columns) { + const kind = getRefKind(col); + if (kind) groups[kind].push(col); + } + return groups; + }, [columns]); + + const totalRefs = + grouped.entity.length + grouped.code.length + grouped.category.length + grouped.numbering.length; + + if (totalRefs === 0) { + return ( +
    +
    + + 이 테이블에는 참조 컬럼이 없어요. +
    +
    + ); + } + + return ( +
    + {/* 헤더 */} +
    + + 소스 컬럼 + 참조 종류 + 참조 대상 +
    + + {/* 그룹별 행 */} +
    + {(["entity", "code", "category", "numbering"] as const).map((kind) => { + const list = grouped[kind]; + if (list.length === 0) return null; + const meta = KIND_META[kind]; + const KindIcon = meta.icon; + return ( +
    +
    + + + {meta.label} + + + {list.length} + +
    + {list.map((column) => { + const typeConf = INPUT_TYPE_COLORS[column.input_type || "text"] || INPUT_TYPE_COLORS.text; + const isSelected = selectedColumn === column.column_name; + return ( +
    onSelectColumn?.(column.column_name)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onSelectColumn?.(column.column_name); + } + }} + className={cn( + "grid min-h-10 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors", + "bg-card border-transparent hover:border-border hover:shadow-sm", + isSelected && "border-primary/30 bg-primary/5 shadow-sm", + )} + style={{ gridTemplateColumns: "4px 220px 110px 1fr" }} + > + {/* 색상바 */} +
    + + {/* 소스 컬럼명 */} +
    +
    + {column.display_name && column.display_name !== column.column_name + ? `${column.display_name} (${column.column_name})` + : column.column_name} +
    +
    + + {/* 참조 종류 칩 */} +
    + + {meta.label} +
    + + {/* 참조 대상 */} +
    + {kind === "entity" && column.reference_table && column.reference_table !== "none" ? ( + <> + { + const t = tables.find((tb) => tb.table_name === column.reference_table); + return t?.display_name && t.display_name !== t.table_name + ? `${t.display_name} (${column.reference_table})` + : column.reference_table; + })() + : column.reference_table + } + > + {column.reference_table} + + + { + const refCols = referenceTableColumns[column.reference_table]; + const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? "")); + return c?.display_name && c.display_name !== c.column_name + ? `${c.display_name} (${column.reference_column})` + : column.reference_column ?? "—"; + })() + : column.reference_column ?? "—" + } + > + {column.reference_column || "—"} + + + ) : kind === "code" ? ( + column.code_info ? ( + + 코드: {column.code_info} + + ) : ( + — (코드 그룹 미지정) + ) + ) : kind === "category" ? ( + column.category_ref ? ( + + 카테고리: {column.category_ref} + + ) : column.category_menus && column.category_menus.length > 0 ? ( + + 카테고리 메뉴 {column.category_menus.length}개 + + ) : ( + — (카테고리 미지정) + ) + ) : kind === "numbering" ? ( + column.numbering_rule_id ? ( + + 채번: {column.numbering_rule_id} + + ) : ( + — (채번 규칙 미지정) + ) + ) : ( + + )} +
    +
    + ); + })} +
    + ); + })} +
    +
    + ); +} From cf5f7ef9af835edf6b997f6b4d2f667ea45507bd Mon Sep 17 00:00:00 2001 From: johngreen Date: Fri, 22 May 2026 12:31:59 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20=EC=BB=AC=EB=9F=BC=20=ED=83=AD=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=ED=97=A4=EB=8D=94=20=EC=82=AC=EC=9A=A9=EC=9E=90/?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=202=EA=B7=B8=EB=A3=B9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC=20=E2=80=94=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=20=ED=83=AD=EA=B3=BC=20=EC=A4=91=EB=B3=B5=20=ED=95=B4=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #24 에서 컬럼/참조 탭은 분리했지만 ColumnGrid 의 기존 3그룹 (기본/참조/메타) 헤더가 그대로 남아있어 "참조 정보" 그룹이 컬럼 탭에서도 보이고 참조 탭에서도 보이는 시각적 중복 발생. DBeaver 의 Columns 탭처럼 모든 컬럼은 컬럼 탭에서 보여야 함 (참조 컬럼 포함). 단, "참조 정보" 그룹 헤더는 참조 탭이 같은 역할을 하므로 제거. - "기본 정보" + "참조 정보" → "사용자 컬럼" 으로 합침 - "메타 정보" → "시스템 컬럼" 으로 이름만 평이하게 - 참조 컬럼은 평범한 한 행으로 표시 (타입 칩으로 종류는 식별) - 미사용 Layers 아이콘 import 제거 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/table-type/ColumnGrid.tsx | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/frontend/components/admin/table-type/ColumnGrid.tsx b/frontend/components/admin/table-type/ColumnGrid.tsx index bbd76f4b..fa8e1131 100644 --- a/frontend/components/admin/table-type/ColumnGrid.tsx +++ b/frontend/components/admin/table-type/ColumnGrid.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useMemo } from "react"; -import { MoreHorizontal, Database, Layers, FileStack, Trash2 } from "lucide-react"; +import { MoreHorizontal, Database, FileStack, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -47,11 +47,10 @@ function getIndexState( return { isPk, hasIndex }; } -/** 그룹 헤더 라벨 */ -const GROUP_LABELS: Record; label: string }> = { - basic: { icon: FileStack, label: "기본 정보" }, - reference: { icon: Layers, label: "참조 정보" }, - meta: { icon: Database, label: "메타 정보" }, +/** 그룹 헤더 라벨 — 참조 컬럼은 별도 "참조" 탭에서 보여주므로 컬럼 탭에서는 사용자/시스템 2그룹으로만 분류 */ +const GROUP_LABELS: Record<"user" | "system", { icon: React.FC<{ className?: string }>; label: string }> = { + user: { icon: FileStack, label: "사용자 컬럼" }, + system: { icon: Database, label: "시스템 컬럼" }, }; export function ColumnGrid({ @@ -73,20 +72,19 @@ export function ColumnGrid({ [constraints, externalGetIndexState], ); - /** typeFilter 적용 후 그룹별로 정렬 */ + /** typeFilter 적용 후 사용자/시스템 그룹으로 분류 (참조 컬럼은 참조 탭으로 분리됐으므로 사용자 컬럼에 합침) */ const filteredAndGrouped = useMemo(() => { const filtered = typeFilter != null ? columns.filter((c) => (c.input_type || "text") === typeFilter) : columns; - const groups = { basic: [] as ColumnTypeInfo[], reference: [] as ColumnTypeInfo[], meta: [] as ColumnTypeInfo[] }; + const groups = { user: [] as ColumnTypeInfo[], system: [] as ColumnTypeInfo[] }; for (const col of filtered) { - const group = getColumnGroup(col); - groups[group].push(col); + const g = getColumnGroup(col) === "meta" ? "system" : "user"; + groups[g].push(col); } return groups; }, [columns, typeFilter]); - const totalFiltered = - filteredAndGrouped.basic.length + filteredAndGrouped.reference.length + filteredAndGrouped.meta.length; + const totalFiltered = filteredAndGrouped.user.length + filteredAndGrouped.system.length; return (
    @@ -107,7 +105,7 @@ export function ColumnGrid({ {typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."}
    ) : ( - (["basic", "reference", "meta"] as const).map((groupKey) => { + (["user", "system"] as const).map((groupKey) => { const list = filteredAndGrouped[groupKey]; if (list.length === 0) return null; const { icon: Icon, label } = GROUP_LABELS[groupKey]; From 7dbeccc1822882fc6ee0e870bb3639da90781e79 Mon Sep 17 00:00:00 2001 From: johngreen Date: Fri, 22 May 2026 13:09:08 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat(=ED=85=8C=EC=9D=B4=EB=B8=94=ED=83=80?= =?UTF-8?q?=EC=9E=85):=20=EC=99=80=EC=9D=B4=EB=93=9C=20=EB=AA=A8=EB=8B=88?= =?UTF-8?q?=ED=84=B0=EC=97=90=EC=84=9C=20=EC=9A=B0=EC=B8=A1=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8C=A8=EB=84=90=20=EA=B3=A0=EC=A0=95=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=E2=80=94=20=EC=8A=AC=EB=9D=BC=EC=9D=B4=EB=93=9C=20?= =?UTF-8?q?in=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존엔 컬럼 행 클릭해야 우측 패널이 슬라이드로 들어옴. 와이드 모니터에선 우측에 큰 빈 공간이 남는데도 패널이 숨어있어 화면이 흔들려 보임 → 일관성 ↓ 와이드(xl 이상)에선 패널을 일반 flex child 로 고정해서 항상 표시. 좁은 화면은 기존 오버레이 슬라이드 유지 (가운데 영역 좁아지는 것 방지). - page.tsx: 우측 패널 컨테이너에 xl: 상태로 relative + 슬라이드 무력화 - ColumnDetailPanel: 컬럼 선택 안 한 상태일 때 "컬럼을 선택해주세요" 빈 상태 안내 UI 추가 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../(main)/admin/systemMng/tableMngList/page.tsx | 5 ++++- .../admin/table-type/ColumnDetailPanel.tsx | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 4118f898..93228eb1 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -1772,11 +1772,14 @@ export default function TableManagementPage() { )}
    - {/* 우측: 상세 패널 (overlay slide-in/out — 가운데 본문 위에 부드럽게 등장) */} + {/* 우측: 상세 패널 + - 와이드 모니터 (xl 이상): 항상 보이는 고정 3-pane + - 좁은 화면: 기존처럼 슬라이드 in 오버레이 */}
    +
    + +
    +

    컬럼을 선택해주세요

    +

    + 좌측 그리드에서 컬럼을 선택하면 여기에 상세 설정이 표시됩니다. +

    +
    + ); + } return (
    From 4031fe8b601d49bfb3c78f8e26ebfe81de46d6bb Mon Sep 17 00:00:00 2001 From: johngreen Date: Fri, 22 May 2026 13:21:01 +0900 Subject: [PATCH 10/11] chore: retrigger CI (gradle plugin download timed out) Co-Authored-By: Claude Opus 4.7 (1M context) From 623387702994e72dcbd9db204a9e442c4269c756 Mon Sep 17 00:00:00 2001 From: johngreen Date: Fri, 22 May 2026 14:03:52 +0900 Subject: [PATCH 11/11] =?UTF-8?q?style(=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=ED=83=80=EC=9E=85):=20=ED=83=AD=20=EC=8A=A4=ED=83=80=EC=9D=BC?= =?UTF-8?q?=20Google=20=EC=8B=9D=20underline=20=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=E2=80=94=20=EA=B0=80=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=E2=86=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 shadcn 기본 Tabs 가 둥근 알약 (segmented control) 스타일이라 탭이 아니라 토글처럼 보임. 사용자가 "탭이 눈에 잘 안 띈다" 지적. Gmail/Drive/GitHub/Vercel 공통 패턴인 underline 스타일로 변경: - TabsList: 전체 폭 + 하단 1px 구분선, 배경/padding 제거 - TabsTrigger: 평면 + 활성 시 2px primary 밑줄 + 글자 색 강조, 비활성은 muted - 글자 / 아이콘 크기 한 단계 ↑ (text-xs→text-sm, h-3.5→h-4) - 비활성에도 transparent border-b-2 줘서 layout shift 방지 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/systemMng/tableMngList/page.tsx | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index 93228eb1..c1612719 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -1695,20 +1695,34 @@ export default function TableManagementPage() {
    ) : ( - - - + + + 컬럼 - - + + 참조 {(() => { const refCount = columns.filter((c) => ["entity", "code", "category", "numbering"].includes(c.input_type), ).length; return refCount > 0 ? ( - + {refCount} ) : null;