diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 7a87d1a0..84a8729c 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -3,6 +3,10 @@ "agent-orchestrator": { "command": "node", "args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"] + }, + "Framelink Figma MCP": { + "command": "npx", + "args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"] } } } diff --git a/.gitignore b/.gitignore index a771d2c9..1dbaa5f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Claude Code (로컬 전용 - Git 제외) +.claude/ + # Dependencies node_modules/ npm-debug.log* @@ -286,4 +289,12 @@ uploads/ *.hwp *.hwpx -claude.md \ No newline at end of file +claude.md + +# AI 에이전트 테스트 산출물 +*-test-screenshots/ +*-screenshots/ +*-test.mjs + +# 개인 작업 문서 (popdocs) +popdocs/ \ No newline at end of file diff --git a/PLAN.MD b/PLAN.MD index 0eff7965..45468fa4 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,139 +1,548 @@ -# 프로젝트: V2/V2 컴포넌트 설정 스키마 정비 +# 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성 -## 개요 - -레거시 컴포넌트를 제거하고, V2/V2 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 한 곳에서 관리한다. - -## 핵심 기능 - -1. [x] 레거시 컴포넌트 스키마 제거 -2. [x] V2 컴포넌트 overrides 스키마 정의 (16개) -3. [x] V2 컴포넌트 overrides 스키마 정의 (9개) -4. [x] componentConfig.ts 한 파일에서 통합 관리 - -## 정의된 V2 컴포넌트 (18개) - -- v2-table-list, v2-button-primary, v2-text-display -- v2-split-panel-layout, v2-section-card, v2-section-paper -- v2-divider-line, v2-repeat-container, v2-rack-structure -- v2-numbering-rule, v2-category-manager, v2-pivot-grid -- v2-location-swap-selector, v2-aggregation-widget -- v2-card-display, v2-table-search-widget, v2-tabs-widget -- v2-v2-repeater - -## 정의된 V2 컴포넌트 (9개) - -- v2-input, v2-select, v2-date -- v2-list, v2-layout, v2-group -- v2-media, v2-biz, v2-hierarchy - -## 테스트 계획 - -### 1단계: 기본 기능 - -- [x] V2 레이아웃 저장 시 컴포넌트별 overrides 스키마 검증 통과 -- [x] V2 컴포넌트 기본값과 스키마가 매칭됨 - -### 2단계: 에러 케이스 - -- [x] 잘못된 overrides 입력 시 Zod 검증 실패 처리 (safeParse + console.warn + graceful fallback) -- [x] 누락된 기본값 컴포넌트 저장 시 안전한 기본값 적용 (레지스트리 조회 → 빈 객체) - -## 에러 처리 계획 - -- 스키마 파싱 실패 시 로그/에러 메시지 표준화 -- 기본값 누락 시 안전한 fallback 적용 - -## 진행 상태 - -- [x] 레거시 컴포넌트 제거 완료 -- [x] V2/V2 스키마 정의 완료 -- [x] 한 파일 통합 관리 완료 - -# 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후) - -## 개요 - -채번/카테고리에서 `menu_objid` 의존성 제거 완료 후, 화면 복제 기능을 새 DB 구조에 맞게 수정하고 테스트합니다. - -## 핵심 변경사항 - -### DB 구조 변경 (완료) - -- 채번규칙: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반 -- 카테고리: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반 -- 복제 순서 의존성 문제 해결 - -### 복제 옵션 정리 (완료) - -- [x] **삭제**: 코드 카테고리 + 코드 복사 옵션 -- [x] **삭제**: 연쇄관계 설정 복사 옵션 -- [x] **이름 변경**: "카테고리 매핑 + 값 복사" → "카테고리 값 복사" - -### 현재 복제 옵션 (3개) - -1. **채번 규칙 복사** - 채번규칙 복제 -2. **카테고리 값 복사** - 카테고리 값 복제 (table_column_category_values) -3. **테이블 타입관리 입력타입 설정 복사** - table_type_columns 복제 +> **작성일**: 2026-02-10 +> **상태**: 코딩 완료 (방어 로직 패치 포함) +> **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성 --- -## 테스트 계획 +## 1. 문제 요약 -### 1. 화면 간 연결 복제 테스트 +pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가. -- [ ] 수주관리 1번→2번→3번→4번 화면 연결 상태에서 복제 -- [ ] 복제 후 연결 관계가 유지되는지 확인 -- [ ] 각 화면의 고유 키값이 새로운 화면을 참조하도록 변경되는지 확인 - -### 2. 제어관리 복제 테스트 - -- [ ] 다른 회사로 제어관리 복제 -- [ ] 복제된 플로우 스텝/연결이 정상 작동하는지 확인 - -### 3. 추가 옵션 복제 테스트 - -- [ ] 채번규칙 복사 정상 작동 확인 -- [ ] 카테고리 값 복사 정상 작동 확인 -- [ ] 테이블 타입관리 입력타입 설정 복사 정상 작동 확인 - -### 4. 기본 복제 테스트 - -- [ ] 단일 화면 복제 (모달 포함) -- [ ] 그룹 전체 복제 (재귀적) -- [ ] 메뉴 동기화 정상 작동 +| # | 문제 | 심각도 | 영향 | +|---|------|--------|------| +| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 | +| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 | +| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 | +| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 | --- -## 관련 파일 +## 2. 수정 대상 파일 (2개) -- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달 -- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴 -- `backend-node/src/services/screenManagementService.ts` - 복제 서비스 -- `backend-node/src/services/numberingRuleService.ts` - 채번규칙 서비스 -- `docs/DB_STRUCTURE_DIAGRAM.md` - DB 구조 문서 +### 파일 A: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx` -## 진행 상태 +**변경 유형**: 설정 UI 추가 3건 + +#### 변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래) + +집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가. + +**추가할 위치**: `{/* 집계 함수 + 대상 컬럼 */}` 블록 다음, `{/* 자동 새로고침 */}` 블록 이전 + +**추가할 코드** (약 50줄): + +```tsx +{/* 그룹핑 (차트용 X축 분류) */} +{dataSource.aggregation && ( +
+ 차트에서 X축 카테고리로 사용됩니다 +
++ 그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용 +
++ 카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다 +
+ )} ++ 다른 테이블의 카테고리 값 참조 시 입력 +
+화면 데이터가 없습니다.
@@ -1282,7 +1288,7 @@ export const ScreenModal: React.FC- 규칙 사이에 들어갈 문자입니다 -
-규칙을 추가하여 코드를 구성하세요
데이터 바인딩
-- Phase 4에서 구현 예정 -
-연결 없음
++ 이 컴포넌트는 다른 컴포넌트와 연결할 수 없습니다 +
+연결 수정
+ +새 연결 추가
+ )} + + {/* 보내는 값 */} +필터할 컬럼
+ + {dbColumnsLoading ? ( +화면 표시 컬럼
+ {displayColumns.map((col) => ( +데이터 전용 컬럼
+ {dataOnlyColumns.map((col) => ( ++ {filterColumns.length}개 컬럼 중 하나라도 일치하면 표시 +
+ )} + + {/* 필터 방식 */} +필터 방식
+ ++ {r.description} +
+ )} +연결된 소스
+ {incoming.map((conn) => { + const sourceComp = allComponents.find( + (c) => c.id === conn.sourceComponent + ); + return ( ++ 아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요. +
+ )} +화면 데이터가 없습니다.
diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index e2143e8e..05d228f4 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -571,8 +571,38 @@ export const InteractiveScreenViewerDynamic: React.FC테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트
++ 레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다 +
타겟 테이블에 저장될 필드명
+추가 데이터가 저장될 타겟 테이블 컬럼
- 소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다. + 여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.
- {!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable ? ( + {!config.action?.dataTransfer?.targetTable ? (먼저 소스 테이블과 타겟 테이블을 선택하세요.
+먼저 타겟 테이블을 선택하세요.
- 매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다. + 매핑 그룹이 없습니다. 소스 테이블을 추가하세요.
소스 테이블을 먼저 선택하세요.
+ ) : activeRules.length === 0 ? ( +매핑 없음 (동일 필드명 자동 매핑)
+ ) : ( + activeRules.map((rule: any, rIdx: number) => { + const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`; + const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`; + return ( +{column.tableLabel || column.tableName}
-{column.tableLabel || column.tableName}
++ {config.detailTable + ? allTables.find((t) => t.tableName === config.detailTable)?.displayName || config.detailTable + : "미설정"} +
+ {config.detailTable && config.foreignKey && ( ++ FK: {config.foreignKey} → {currentTableName || "메인 테이블"}.id +
+ )} ++ 메인 FK와 부모-자식 계층 FK를 선택하세요 +
+ + {fkCandidateColumns.length > 0 ? ( ++ {loadingColumns ? "로딩 중..." : "디테일 테이블을 먼저 선택하세요"} +
++ 트리 노드에 표시할 품목 정보의 소스 엔티티 +
+ + {entityColumns.length > 0 ? ( + + ) : ( ++ {loadingColumns + ? "로딩 중..." + : !config.detailTable + ? "디테일 테이블을 먼저 선택하세요" + : "엔티티 타입 컬럼이 없습니다"} +
+선택된 엔티티
+참조 테이블: {config.dataSource.sourceTable}
+FK 컬럼: {config.dataSource.foreignKey}
++ BOM 변경 이력과 버전 관리에 사용할 테이블을 선택하세요 +
+ +{currentTableName}
++ 컬럼 {detailTableColumns.length}개 / 엔티티 {entityColumns.length}개 +
++ 트리 노드에 표시할 소스/디테일 컬럼을 선택하세요 +
+ + {/* 소스 테이블 컬럼 (표시용) */} + {config.dataSource?.sourceTable && ( + <> +로딩 중...
+ ) : sourceTableColumns.length === 0 ? ( +컬럼 정보가 없습니다
+ ) : ( +로딩 중...
+ ) : displayableColumns.length === 0 ? ( +컬럼 정보가 없습니다
+ ) : ( ++ {targetTable} 테이블에서 옵션을 불러올 때 적용할 조건 +
+ + {loadingColumns && ( ++ 필터 조건이 없습니다 +
+ )} + +테이블
+{config.categoryTable || tableName || "-"}
+컬럼
+{config.categoryColumn || columnName || "-"}
+화면 로드 시 자동 선택될 카테고리 값
++ 카테고리 값이 없습니다. 테이블 카테고리 관리에서 값을 추가해주세요. +
+ )} +테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.
)} + + {config.entityTable && entityColumns.length > 0 && ( ++ 같은 폼에 참조 테이블({config.entityTable})의 컬럼이 배치되어 있으면, 엔티티 선택 시 해당 필드가 자동으로 + 채워집니다. +
++ 데이터 바인딩 값이 없을 때 표시할 기본 이미지 +
|
return (
{/* 요약 정보 */}
diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx
index d0f9d5aa..aee70dd2 100644
--- a/frontend/lib/registry/components/table-list/TableListComponent.tsx
+++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx
@@ -781,6 +781,7 @@ export const TableListComponent: React.FC
+
{values.map((val, idx) => {
const categoryData = mapping?.[val];
const displayLabel = categoryData?.label || val;
@@ -4316,7 +4330,7 @@ export const TableListComponent: React.FC
+
+ {selectedItems.size > 0 && (
+
0 && "ml-2 border-l-2 border-l-primary/20",
+ isDragOver && "border-primary bg-primary/5 border-dashed",
)}
style={{ marginLeft: `${indentPx}px` }}
+ draggable
+ onDragStart={(e) => onDragStart(e, node.tempId)}
+ onDragOver={(e) => onDragOver(e, node.tempId)}
+ onDrop={(e) => onDrop(e, node.tempId)}
>
- {/* 드래그 핸들 */}
+ {renderCell(col)}
+
+ ))}
- {/* 품목구분 셀렉트 */}
-
-
- {/* 하위 추가 버튼 */}
@@ -659,23 +1135,36 @@ export function BomItemEditorComponent({
BOM 하위 품목 편집기
);
}
- const dummyRows = [
- { depth: 0, code: "ASM-001", name: "본체 조립", type: "조립", qty: "1" },
- { depth: 1, code: "PRT-010", name: "프레임", type: "구매", qty: "2" },
- { depth: 1, code: "PRT-011", name: "커버", type: "구매", qty: "1" },
- { depth: 0, code: "ASM-002", name: "전장 조립", type: "조립", qty: "1" },
- { depth: 1, code: "PRT-020", name: "PCB 보드", type: "구매", qty: "3" },
- ];
+ const visibleColumns = (cfg.columns || []).filter((c: any) => c.visible !== false);
+
+ const DUMMY_DATA: Record- 트리 구조로 하위 품목을 관리합니다 + 설정 패널에서 테이블과 컬럼을 지정하세요
- {/* 헤더 */}
하위 품목 구성
- {dummyRows.map((row, i) => (
- 0 && "border-l-2 border-l-primary/20",
- i === 0 && "bg-accent/30",
- )}
- style={{ marginLeft: `${row.depth * 20}px` }}
- >
-
);
@@ -784,19 +1286,33 @@ export function BomItemEditorComponent({
- {col.key === "quantity" || col.title === "수량"
- ? row.qty
- : ""}
-
- ))}
-
-
-
+ {/* 테이블 형태 미리보기 - config.columns 순서 그대로 */}
+
-
-
-
-
+ {visibleColumns.length === 0 ? (
+
+
- ))}
+ ) : (
+ + 컬럼 탭에서 표시할 컬럼을 선택하세요 +
{/* 헤더 */}
-
{/* 트리 목록 */}
- 하위 품목 구성-+ 하위 품목 구성 + {hasChanges && (미저장)} ++
+
+
{loading ? (
로딩 중...
diff --git a/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx
new file mode 100644
index 00000000..cfff4a0c
--- /dev/null
+++ b/frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Loader2 } from "lucide-react";
+import { apiClient } from "@/lib/api/client";
+
+interface BomDetailEditModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ node: Record
-
-
-
-
-
-
-
-
-
-
+ {/* 헤더 (실제 화면과 동일 구조) */}
+
);
}
- // 선택 안 된 상태
+ // ─── 미선택 상태 ───
+
if (!selectedBomId) {
return (
-
+
+
+ {/* 툴바 (실제 화면과 동일 구조) */}
+
+
+
+
+
+
+ BOM 상세정보+ + 제품 + +
+ 품목코드 SAMPLE-001
+ 기준수량 1
+
+
+ BOM 구성
+ 2
+
+
+ {/* 테이블 */}
+ {configuredColumns.length === 0 ? (
+
+ {showHistory && (
+
+
+ 트리
+ 레벨
+
+ {features.showExpandAll !== false && (
+
+
+ )}
+
+
+ ) : (
+ 컬럼 미설정 +설정 패널 > 컬럼 탭에서 표시할 컬럼을 선택하세요 +
+
+ )}
-
- 좌측에서 BOM을 선택하세요 -선택한 BOM의 구성 정보가 트리로 표시됩니다 +
+
);
}
+ // ─── 트리 평탄화 ───
+
+ const flattenedRows = useMemo(() => {
+ const rows: { node: BomTreeNode; depth: number }[] = [];
+ const traverse = (nodes: BomTreeNode[], depth: number) => {
+ for (const node of nodes) {
+ rows.push({ node, depth });
+ if (node.children.length > 0 && expandedNodes.has(node.id)) {
+ traverse(node.children, depth + 1);
+ }
+ }
+ };
+ traverse(treeData, 0);
+ return rows;
+ }, [treeData, expandedNodes]);
+
+ // 레벨 뷰용: 전체 노드 평탄화 (expand 상태 무관)
+ const allFlattenedRows = useMemo(() => {
+ const rows: { node: BomTreeNode; depth: number }[] = [];
+ const traverse = (nodes: BomTreeNode[], depth: number) => {
+ for (const node of nodes) {
+ rows.push({ node, depth });
+ if (node.children.length > 0) traverse(node.children, depth + 1);
+ }
+ };
+ traverse(treeData, 0);
+ return rows;
+ }, [treeData]);
+
+ const maxDepth = useMemo(() => {
+ return allFlattenedRows.reduce((max, r) => Math.max(max, r.depth), 0);
+ }, [allFlattenedRows]);
+
+ const visibleRows = viewMode === "level" ? allFlattenedRows : flattenedRows;
+ const levelColumnsForView = useMemo(() => {
+ return Array.from({ length: maxDepth + 1 }, (_, i) => i);
+ }, [maxDepth]);
+
+ // 레벨 뷰에서 "level" 컬럼을 제외한 데이터 컬럼
+ const dataColumnsForLevelView = useMemo(() => {
+ return displayColumns.filter((c) => c.key !== "level");
+ }, [displayColumns]);
+
+ // 트리/레벨 뷰 전환 시 데이터 열 위치 고정을 위한 공통 접두 영역 너비
+ const prefixAreaWidth = useMemo(() => {
+ const treeIconWidth = Math.max(52, maxDepth * INDENT_PX + 44);
+ const levelColsWidth = (maxDepth + 1) * 30;
+ return Math.max(treeIconWidth, levelColsWidth);
+ }, [maxDepth]);
+
+ // ─── 메인 렌더링 ───
+
return (
-
+
BOM을 선택해주세요 +좌측 목록에서 BOM을 선택하면 구성이 표시됩니다
+
{/* 헤더 정보 */}
- {headerInfo && (
-
-
-
)}
- {/* 트리 툴바 */}
- {headerInfo.item_name || "-"}- - {headerInfo.bom_number || "-"} - - +
+
- {headerInfo.status === "active" ? "사용" : "미사용"}
-
-
-
- 품목코드: {headerInfo.item_code || "-"}
- 구분: {getItemTypeLabel(headerInfo.item_type)}
- 기준수량: {headerInfo.base_qty || "1"} {headerInfo.unit || ""}
- 버전: v{headerInfo.version || "1.0"} (차수 {headerInfo.revision || "1"})
+
+
+
+
+ + {headerInfo.item_name || "-"} ++ + {getItemTypeLabel(headerInfo.item_type)} + + + {headerInfo.status === "active" ? "사용" : "미사용"} + +
+ 품목코드 {headerInfo.item_code || "-"}
+ 기준수량 {headerInfo.base_qty || "1"}
+ 버전 v{headerInfo.version || "1"} (차수 {headerInfo.revision || "1"})
+
+
- BOM 구성
-
- {treeData.length}건
+ {/* 툴바 */}
+
+ BOM 구성
+
+ {allFlattenedRows.length}
-
-
- {/* 트리 컨텐츠 */}
-
+ {showHistory && (
+
+
+ {features.showExpandAll !== false && (
+
+
+ )}
+ {/* 테이블 */}
+
) : (
items.map((item) => (
-
{loading ? (
-
- {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */}
+ {/* 확인 다이얼로그 */}
-
);
}
-/**
- * 트리 노드 행 (재귀 렌더링)
- */
-interface TreeNodeRowProps {
- node: BomTreeNode;
- depth: number;
- expandedNodes: Set로딩 중...
+
+
+ ) : displayColumns.length === 0 ? (
+
+
) : treeData.length === 0 ? (
- 표시할 컬럼이 설정되지 않았습니다 +디자인 모드에서 컬럼을 추가하세요
-
+
+ {/* 품목 수정 모달 */}
+ 등록된 하위 품목이 없습니다 +
+
+ ) : viewMode === "level" ? (
+ /* ═══ 레벨 뷰 ═══ */
+ 등록된 하위 품목이 없습니다
- {treeData.map((node) => (
-
+ /* ═══ 트리 뷰 ═══ */
+
{
- onSelect(node.id);
- if (hasChildren) onToggle(node.id);
- }}
- >
- {/* 펼치기/접기 화살표 */}
-
- {hasChildren ? (
- isExpanded ? (
-
-
- {/* 하위 노드 재귀 렌더링 */}
- {hasChildren && isExpanded && (
-
-
- {node.child_item_name || "-"}
-
-
- {node.child_item_code || ""}
-
-
- {getItemTypeLabel(node.child_item_type)}
-
-
-
- {/* 수량/단위 */}
-
-
- 수량: {node.quantity || "0"} {node.unit || ""}
-
- {node.loss_rate && node.loss_rate !== "0" && (
-
- 로스: {node.loss_rate}%
-
- )}
-
-
- {node.children.map((child) => (
-
- )}
- >
- );
-}
+export default BomTreeComponent;
diff --git a/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx
new file mode 100644
index 00000000..d36bfe6e
--- /dev/null
+++ b/frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx
@@ -0,0 +1,253 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Loader2, Plus, Trash2, Download, ShieldCheck } from "lucide-react";
+import { cn } from "@/lib/utils";
+import { apiClient } from "@/lib/api/client";
+
+interface BomVersion {
+ id: string;
+ version_name: string;
+ revision: number;
+ status: string;
+ created_by: string;
+ created_date: string;
+}
+
+interface BomVersionModalProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ bomId: string | null;
+ tableName?: string;
+ detailTable?: string;
+ onVersionLoaded?: () => void;
+}
+
+const STATUS_STYLE: Record
- {/* 파일 업로드 영역 - 높이 축소 */}
- {!isDesignMode && (
+ {/* 파일 업로드 영역 - readonly/disabled이면 숨김 */}
+ {!isDesignMode && !config.readonly && !config.disabled && (
@@ -487,8 +486,8 @@ export const FileManagerModal: React.FC {
- if (!config.disabled && !isDesignMode) {
- fileInputRef.current?.click();
- }
+ fileInputRef.current?.click();
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -267,7 +265,6 @@ export const FileManagerModal: React.FC
- {/* 좌측: 이미지 미리보기 (확대/축소 가능) */}
- }
+ {/* 좌측: 이미지 미리보기 (확대/축소 가능) - showPreview가 false면 숨김 */}
+ {(config.showPreview !== false) && }
- {/* 우측: 파일 목록 (고정 너비) */}
-
{/* 확대/축소 컨트롤 */}
{selectedFile && previewImageUrl && (
+
@@ -369,10 +366,10 @@ export const FileManagerModal: React.FC
)}
-
+ {/* 우측: 파일 목록 - showFileList가 false면 숨김, showPreview가 false면 전체 너비 */}
+ {(config.showFileList !== false) &&
+ 업로드된 파일@@ -404,7 +401,7 @@ export const FileManagerModal: React.FC- {formatFileSize(file.fileSize)} • {file.fileExt.toUpperCase()} + {config.showFileSize !== false && <>{formatFileSize(file.fileSize)} • >}{file.fileExt.toUpperCase()}
@@ -434,19 +431,21 @@ export const FileManagerModal: React.FC
)}
+
+ );
+ }
+
+ return (
+
+
+ + 품목별 라우팅 관리 + ++ 품목 선택 - 라우팅 버전 - 공정 순서 + +
+
+ );
+}
diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx
new file mode 100644
index 00000000..f6fefd2e
--- /dev/null
+++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingConfigPanel.tsx
@@ -0,0 +1,780 @@
+"use client";
+
+import React, { useState, useEffect, useCallback } from "react";
+import { Plus, Trash2, Check, ChevronsUpDown } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { cn } from "@/lib/utils";
+import { ItemRoutingConfig, ProcessColumnDef } from "./types";
+import { defaultConfig } from "./config";
+
+interface TableInfo {
+ tableName: string;
+ displayName?: string;
+}
+
+interface ColumnInfo {
+ columnName: string;
+ displayName?: string;
+ dataType?: string;
+}
+
+interface ScreenInfo {
+ screenId: number;
+ screenName: string;
+ screenCode: string;
+}
+
+// 테이블 셀렉터 Combobox
+function TableSelector({
+ value,
+ onChange,
+ tables,
+ loading,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ tables: TableInfo[];
+ loading: boolean;
+}) {
+ const [open, setOpen] = useState(false);
+ const selected = tables.find((t) => t.tableName === value);
+
+ return (
+
+ {/* 좌측 패널: 품목 목록 */}
+
+
+ {/* 삭제 확인 다이얼로그 */}
+
+
+
+ {/* 우측 패널: 버전 + 공정 */}
+
+
+
+ {/* 검색 */}
+ + {config.leftPanelTitle || "품목 목록"} ++
+ setSearchText(e.target.value)}
+ onKeyDown={handleSearchKeyDown}
+ placeholder="품목명/품번 검색"
+ className="h-8 text-xs"
+ />
+
+
+ {/* 품목 리스트 */}
+
+ {items.length === 0 ? (
+
+
+
+ ) : (
+ + {loading ? "로딩 중..." : "품목이 없습니다"} + +
+ {items.map((item) => {
+ const itemCode =
+ item[config.dataSource.itemCodeColumn] || item.item_code || item.item_number;
+ const itemName =
+ item[config.dataSource.itemNameColumn] || item.item_name;
+ const isSelected = selectedItemCode === itemCode;
+
+ return (
+
+ )}
+
+
+ {itemName} +{itemCode} +
+ {selectedItemCode ? (
+ <>
+ {/* 헤더: 선택된 품목 + 버전 추가 */}
+
+
+
+
+ {/* 버전 선택 버튼들 */}
+ {versions.length > 0 ? (
+
+
+ {!config.readonly && (
+ {selectedItemName}+{selectedItemCode} +
+ 버전:
+ {versions.map((ver) => {
+ const isActive = selectedVersionId === ver.id;
+ const isDefault = ver.is_default === true;
+ return (
+
+ ) : (
+
+
+ );
+ })}
+
+
+ )}
+
+ {/* 공정 테이블 */}
+ {selectedVersionId ? (
+ + 라우팅 버전이 없습니다. 버전을 추가해주세요. + +
+ {/* 공정 테이블 헤더 */}
+
+ ) : (
+ versions.length > 0 && (
+
+
+
+ {/* 테이블 */}
+ + {config.rightPanelTitle || "공정 순서"} ({details.length}건) ++ {!config.readonly && ( +
+ {details.length === 0 ? (
+
+
+
+ ) : (
+ + {loading ? "로딩 중..." : "등록된 공정이 없습니다"} + +
+
+ )
+ )}
+ >
+ ) : (
+ + 라우팅 버전을 선택해주세요 + +
+
+ )}
+ + 좌측에서 품목을 선택하세요 + ++ 품목을 선택하면 라우팅 버전별 공정 순서를 관리할 수 있습니다 + ++ 해당 버전에 포함된 모든 공정 정보도 함께 삭제됩니다. + > + )} +
+
+ {t.displayName || t.tableName}
+
+ {t.displayName && (
+
+ {t.tableName}
+
+ )}
+
+
+
+ {c.displayName || c.columnName}
+
+ {c.displayName && (
+
+ {c.columnName}
+
+ )}
+
+
+ {s.screenName}
+
+ {s.screenCode} (ID: {s.screenId})
+
+
+
+
+ );
+}
diff --git a/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx b/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx
new file mode 100644
index 00000000..7a9fa624
--- /dev/null
+++ b/frontend/lib/registry/components/v2-item-routing/ItemRoutingRenderer.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import React from "react";
+import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
+import { V2ItemRoutingDefinition } from "./index";
+import { ItemRoutingComponent } from "./ItemRoutingComponent";
+
+export class ItemRoutingRenderer extends AutoRegisteringComponentRenderer {
+ static componentDefinition = V2ItemRoutingDefinition;
+
+ render(): React.ReactElement {
+ const { formData, isPreview, config, tableName } = this.props as Record<
+ string,
+ unknown
+ >;
+
+ return (
+ 품목별 라우팅 설정+ + {/* 데이터 소스 설정 */} ++ 데이터 소스 + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 모달 연동 + +
+
+
+
+
+
+
+
+
+
+
+
+ + 공정 테이블 컬럼 + +
+ {config.processColumns.map((col, idx) => (
+
+
+
+ ))}
+ UI 설정 + +
+
+ update({ splitRatio: Number(e.target.value) })}
+ min={20}
+ max={60}
+ className="mt-1 h-8 w-20 text-xs"
+ />
+
+
+
+
+ update({ leftPanelTitle: e.target.value })}
+ className="mt-1 h-8 text-xs"
+ />
+
+
+
+ update({ rightPanelTitle: e.target.value })}
+ className="mt-1 h-8 text-xs"
+ />
+
+
+
+
+ update({ versionAddButtonText: e.target.value })}
+ className="mt-1 h-8 text-xs"
+ />
+
+
+
+ update({ processAddButtonText: e.target.value })}
+ className="mt-1 h-8 text-xs"
+ />
+
+
+
+
+
+
+
+
+
{/* 품목 헤더 */}
- 왼쪽에서 항목을 선택하세요 @@ -58,25 +44,60 @@ export function WorkItemDetailList({ const getTypeLabel = (value?: string) => detailTypes.find((t) => t.value === value)?.label || value || "-"; - const handleAdd = () => { - if (!newData.content?.trim()) return; - onCreateDetail({ - ...newData, - sort_order: details.length + 1, - }); - setNewData({ - detail_type: detailTypes[0]?.value || "", - content: "", - is_required: "N", - sort_order: 0, - }); - setIsAdding(false); + const handleOpenAdd = () => { + setModalMode("add"); + setEditTarget(null); + setModalOpen(true); }; - const handleSaveEdit = (id: string) => { - onUpdateDetail(id, editData); - setEditingId(null); - setEditData({}); + const handleOpenEdit = (detail: WorkItemDetail) => { + setModalMode("edit"); + setEditTarget(detail); + setModalOpen(true); + }; + + const handleSubmit = (data: Partial{idx + 1} |
-
- |
)}
| |||||||||||||||||||||||||||||||||
| - {details.length + 1} - | -
- |
- - - setNewData((prev) => ({ - ...prev, - content: e.target.value, - })) - } - onKeyDown={(e) => e.key === "Enter" && handleAdd()} - className="h-7 text-xs" - /> - | -
- |
-
-
-
- |
-
상세 항목이 없습니다. "상세 추가" 버튼을 클릭하여 추가하세요. @@ -375,6 +205,16 @@ export function WorkItemDetailList({
+ 프리셋 변경 시 외형과 액션이 자동 설정됩니다 +
+ )} + + {/* 외형 설정 */} ++ 프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택 +
+ )} ++ 기본:{" "} + {DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]} +
++ 메인 액션 성공 후 순차 실행할 후속 동작 +
+ )} + + {actions.map((fa, idx) => ( ++ 데이터 소스를 설정해주세요. +
+{error}
+데이터가 없습니다.
++ {config.scrollDirection === "horizontal" + ? "격자로 배치, 가로 스크롤" + : "격자로 배치, 세로 스크롤"} +
++ {maxColumns === 1 + ? "현재 모드에서 열 최대 1 (모드 변경 시 자동 적용)" + : "모드 변경 시 열/행 자동 적용 / 열 최대 2"} +
++ 먼저 테이블 탭에서 테이블을 선택하세요 +
+테이블을 먼저 선택해주세요
+데이터 소스 탭에서 테이블을 선택하세요
++ 카드 헤더 왼쪽에 표시될 코드 (예: ITEM032) +
++ 카드 헤더 오른쪽에 표시될 제목 (예: 너트 M10) +
++ 이미지가 없는 항목에 표시될 기본 이미지 +
++ DB에서 이미지 URL을 가져올 컬럼. URL이 없으면 기본 이미지 사용 +
++ 본문에 표시할 필드를 추가하세요 +
++ 설정 시 각 카드 행의 해당 컬럼 값이 숫자패드 최대값으로 사용됨 (예: unreceived_qty) +
++ 입력값을 저장할 DB 컬럼 (현재는 로컬 상태만 유지) +
++ 사용 가능: 컬럼명, $input (입력값), +, -, *, / +
++ 클릭하면 계산식에 추가됩니다 +
++ 다른 테이블과 조인하여 추가 컬럼을 사용할 수 있습니다 +
++ 필터 조건을 추가하여 데이터를 필터링할 수 있습니다 +
++ 담기 클릭 시 이동할 POP 화면의 screenId +
++ PascalCase로 입력 (ShoppingCart, Package, Truck 등) +
+ )} ++ 데이터 소스를 설정하세요 +
++ 차트에서 X축 카테고리로 사용됩니다 +
+ {subType === "chart" && !dataSource.aggregation?.groupBy?.length && ( ++ 차트 모드에서는 그룹핑(X축)을 설정해야 의미 있는 차트가 표시됩니다 +
+ )} ++ 먼저 메인 테이블을 선택하세요 +
+ )} + + {joins.map((join, index) => ( +계산식 설정
+ + {/* 값 목록 */} + {formula.values.map((fv, index) => ( ++ 수식에 정의되지 않은 변수가 있습니다 +
+ )} ++ X축: 그룹핑(X축)에서 선택한 컬럼 자동 적용 / Y축: 집계 결과(value) 자동 적용 +
++ 카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다 +
+ )} ++ 각 셀을 클릭하여 아이템을 배정하세요. +/- 버튼으로 행/열을 + 추가/삭제할 수 있습니다. +
+ + {/* 배정된 아이템별 스타일 설정 */} + {onUpdateItem && (() => { + const assignedItemIds = ensuredCells + .map((c) => c.itemId) + .filter((id): id is string => !!id); + const uniqueIds = [...new Set(assignedItemIds)]; + const assignedItems = uniqueIds + .map((id) => items.find((i) => i.id === id)) + .filter((i): i is DashboardItem => !!i); + + if (assignedItems.length === 0) return null; + + return ( +
+ 페이지를 추가하면 각 페이지에 독립적인 그리드 레이아웃을
+ 설정할 수 있습니다.
+
+ 페이지가 없으면 아이템이 하나씩 슬라이드됩니다.
+
+ {item.label} +
+ )} + + {/* 차트 영역 */} ++ {item.label} +
+ )} + + {/* 게이지 SVG - 높이/너비 모두 반응형 */} ++ 목표: {abbreviateNumber(target)} +
+ )} ++ {item.label} +
+ )} + + {/* 메인 값 - @container 반응형 */} + {visibility.showValue && ( ++ {item.formula?.values.map((v) => v.label).join(" / ")} +
+ )} ++ {item.label} +
+ )} + + {/* 총합 - @container 반응형 */} + {visibility.showValue && ( ++ {abbreviateNumber(total)} +
+ )} + + {/* 카테고리별 건수 */} ++ {visibility.showUnit && item.kpiConfig?.unit + ? `단위: ${item.kpiConfig.unit}` + : ""} +
+ )} ++ Windows: Win + . / Mac: Ctrl + Cmd + Space +
+ + {/* 배경 그라디언트 설정 */} +{error}
} + + {/* 또는 URL 직접 입력 */} + onUpdate({ + ...config, + imageConfig: { + imageUrl: e.target.value, + // URL 입력 시 임시 파일 제거 + tempDataUrl: undefined, + tempFileName: undefined, + fileObjid: undefined, + } + })} + placeholder="또는 URL 직접 입력..." + className="h-8 text-xs" + disabled={isTemp} + /> + + {/* 현재 이미지 미리보기 + 크기 조절 */} + {hasImage && ( ++ {config.imageConfig.tempFileName} +
+ )} + + onUpdate({ ...config, imageScale: Number(e.target.value) })} + className="w-full" + /> + {isTemp && ( ++ ※ 화면 저장 시 서버에 업로드됩니다. +
+ )} +| + {getColLabel(col)} + | + ))} +
|---|
+ 토글은 추가 설정이 없습니다. ON/OFF 값이 바로 전달됩니다. +
++ {cfg.inputType} 타입의 상세 설정은 후속 구현 예정입니다. +
++ 입력 후 대기 시간. 0이면 즉시 발행 (권장: 300~500) +
+옵션이 없습니다. 아래 버튼으로 추가하세요.
+ )} + {options.map((opt, i) => ( ++ "직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현) +
+ )} ++ 테이블: 표 형태 / 아이콘: 아이콘 카드 형태 +
++ 비워두면 기본 컬럼명이 사용됩니다 +
++ 포함: 어디든 일치 / 시작: 앞에서 일치 / 같음: 정확히 일치 +
++ 모달 상단에 초성(가나다) / 알파벳(ABC) 필터 탭 표시 +
++ 선택 후 검색 입력란에 표시될 값 (예: 회사명) +
++ 연결된 리스트를 필터할 때 사용할 값 (예: 회사코드) +
+