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 24814953..ff1bb5b5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Claude Code (로컬 전용 - Git 제외) +.claude/ + # Dependencies node_modules/ npm-debug.log* @@ -293,4 +296,12 @@ uploads/ claude.md -.cursor/mcp.json \ No newline at end of file +.cursor/mcp.json + +# 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 && ( +
+ + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {columns.map((col) => ( + { + const current = dataSource.aggregation?.groupBy ?? []; + const isSelected = current.includes(col.name); + const newGroupBy = isSelected + ? current.filter((g) => g !== col.name) + : [...current, col.name]; + onChange({ + ...dataSource, + aggregation: { + ...dataSource.aggregation!, + groupBy: newGroupBy.length > 0 ? newGroupBy : undefined, + }, + }); + setGroupByOpen(false); + }} + className="text-xs" + > + + {col.name} + ({col.type}) + + ))} + + + + + +

+ 차트에서 X축 카테고리로 사용됩니다 +

+
+)} +``` + +**필요한 state 추가** (DataSourceEditor 내부, 기존 state 옆): + +```tsx +const [groupByOpen, setGroupByOpen] = useState(false); +``` + +#### 변경 A-2: 차트 타입 설정에 xAxisColumn/yAxisColumn 추가 (라인 1187~1212 부근) + +**추가할 위치**: `{item.subType === "chart" && (` 블록 내부, 차트 유형 Select 다음 + +**추가할 코드** (약 30줄): + +```tsx +{/* X축 컬럼 */} +
+ + + onUpdate({ + ...item, + chartConfig: { + ...item.chartConfig, + chartType: item.chartConfig?.chartType ?? "bar", + xAxisColumn: e.target.value || undefined, + }, + }) + } + placeholder="groupBy 컬럼명 (비우면 자동)" + className="h-8 text-xs" + /> +

+ 그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용 +

+
+``` + +#### 변경 A-3: 통계 카드 타입에 카테고리 설정 UI 추가 (라인 1272 부근, 게이지 설정 다음) + +**추가할 위치**: `{item.subType === "gauge" && (` 블록 다음에 새 블록 추가 + +**추가할 코드** (약 100줄): `StatCategoryEditor` 인라인 블록 + +```tsx +{item.subType === "stat-card" && ( +
+
+ + +
+ + {(item.statConfig?.categories ?? []).map((cat, catIdx) => ( +
+
+ { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { ...cat, label: e.target.value }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="라벨 (예: 수주)" + className="h-6 flex-1 text-xs" + /> + { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { ...cat, color: e.target.value || undefined }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="#색상코드" + className="h-6 w-20 text-xs" + /> + +
+ {/* 필터 조건: 컬럼 / 연산자 / 값 */} +
+ { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { + ...cat, + filter: { ...cat.filter, column: e.target.value }, + }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="컬럼" + className="h-6 w-20 text-[10px]" + /> + + { + const newCats = [...(item.statConfig?.categories ?? [])]; + newCats[catIdx] = { + ...cat, + filter: { ...cat.filter, value: e.target.value }, + }; + onUpdate({ + ...item, + statConfig: { ...item.statConfig, categories: newCats }, + }); + }} + placeholder="값" + className="h-6 flex-1 text-[10px]" + /> +
+
+ ))} + + {(item.statConfig?.categories ?? []).length === 0 && ( +

+ 카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다 +

+ )} +
+)} +``` + +--- + +### 파일 B: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx` + +**변경 유형**: 데이터 처리 로직 수정 2건 + +#### 변경 B-1: 차트 xAxisColumn 자동 설정 (라인 276~283 부근) + +차트에 groupBy가 있지만 xAxisColumn이 설정되지 않은 경우, 첫 번째 groupBy 컬럼을 자동으로 xAxisColumn에 반영. + +**현재 코드** (라인 276~283): +```tsx +case "chart": + return ( + + ); +``` + +**변경 코드**: +```tsx +case "chart": { + // groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정 + const chartItem = { ...item }; + if ( + item.dataSource.aggregation?.groupBy?.length && + !item.chartConfig?.xAxisColumn + ) { + chartItem.chartConfig = { + ...chartItem.chartConfig, + chartType: chartItem.chartConfig?.chartType ?? "bar", + xAxisColumn: item.dataSource.aggregation.groupBy[0], + }; + } + return ( + + ); +} +``` + +#### 변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297) + +**현재 코드** (버그): +```tsx +case "stat-card": { + const categoryData: Record = {}; + if (item.statConfig?.categories) { + for (const cat of item.statConfig.categories) { + categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값 + } + } + return ( + + ); +} +``` + +**변경 코드**: +```tsx +case "stat-card": { + const categoryData: Record = {}; + if (item.statConfig?.categories) { + for (const cat of item.statConfig.categories) { + if (cat.filter.column && cat.filter.value) { + // 카테고리 필터로 rows 필터링 + const filtered = itemData.rows.filter((row) => { + const cellValue = String(row[cat.filter.column] ?? ""); + const filterValue = String(cat.filter.value ?? ""); + switch (cat.filter.operator) { + case "=": + return cellValue === filterValue; + case "!=": + return cellValue !== filterValue; + case "like": + return cellValue.toLowerCase().includes(filterValue.toLowerCase()); + default: + return cellValue === filterValue; + } + }); + categoryData[cat.label] = filtered.length; + } else { + categoryData[cat.label] = itemData.rows.length; + } + } + } + return ( + + ); +} +``` + +**주의**: 이 방식은 rows에 전체 데이터가 있어야 작동함. 통계 카드의 dataSource에 집계 함수를 설정하지 않고(또는 groupBy를 사용하여) 원시 rows를 가져와야 한다. + +--- + +## 3. 구현 순서 (의존성 기반) + +| 순서 | 작업 | 파일 | 의존성 | 상태 | +|------|------|------|--------|------| +| 1 | A-1: DataSourceEditor에 groupBy UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | +| 2 | A-2: 차트 xAxisColumn 입력 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | +| 3 | A-3: 통계 카드 카테고리 설정 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] | +| 4 | B-1: 차트 xAxisColumn 자동 보정 로직 | PopDashboardComponent.tsx | 순서 1 | [x] | +| 5 | B-2: 통계 카드 카테고리별 필터 적용 | PopDashboardComponent.tsx | 순서 3 | [x] | +| 6 | 린트 검사 | 전체 | 순서 1~5 | [x] | +| 7 | C-1: SQL 빌더 방어 로직 (빈 컬럼/테이블 차단) | dataFetcher.ts | 없음 | [x] | +| 8 | C-2: refreshInterval 최소값 강제 (5초) | PopDashboardComponent.tsx | 없음 | [x] | +| 9 | 브라우저 테스트 | - | 순서 1~8 | [ ] | + +순서 1, 2, 3은 서로 독립이므로 병렬 가능. +순서 4는 순서 1의 groupBy 값이 있어야 의미 있음. +순서 5는 순서 3의 카테고리 설정이 있어야 의미 있음. +순서 7, 8은 백엔드 부하 방지를 위한 방어 패치. + +--- + +## 4. 사전 충돌 검사 결과 + +### 새로 추가할 식별자 목록 + +| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 | +|--------|------|-----------|-----------|-----------| +| `groupByOpen` | state (boolean) | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 | +| `setGroupByOpen` | state setter | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 | +| `chartItem` | const (DashboardItem) | PopDashboardComponent.tsx renderSingleItem 내부 | 동일 함수 내부 | 충돌 없음 | + +**Grep 검색 결과** (전체 pop-dashboard 폴더): +- `groupByOpen`: 0건 - 충돌 없음 +- `setGroupByOpen`: 0건 - 충돌 없음 +- `groupByColumns`: 0건 - 충돌 없음 +- `chartItem`: 0건 - 충돌 없음 +- `StatCategoryEditor`: 0건 - 충돌 없음 +- `loadCategoryData`: 0건 - 충돌 없음 + +### 기존 타입/함수 재사용 목록 + +| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 | +|------------|-----------|------------------------| +| `DataSourceConfig.aggregation.groupBy` | types.ts 라인 155 | A-1 UI에서 읽기/쓰기 | +| `ChartItemConfig.xAxisColumn` | types.ts 라인 248 | A-2 UI, B-1 자동 보정 | +| `StatCategory` | types.ts 라인 261 | A-3 카테고리 편집 | +| `StatCardConfig.categories` | types.ts 라인 268 | A-3 UI에서 읽기/쓰기 | +| `FilterOperator` | types.ts (import 이미 존재) | A-3 카테고리 필터 Select | +| `columns` (state) | PopDashboardConfig.tsx DataSourceEditor 내부 | A-1 groupBy 컬럼 목록 | + +**사용처 있는데 정의 누락된 항목: 없음** + +--- + +## 5. 에러 함정 경고 + +### 함정 1: 차트에 groupBy만 설정하고 xAxisColumn을 비우면 +ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `{status: "수주", value: 79}` 형태. +`name` 키가 없으므로 X축이 빈 채로 렌더링됨. +**B-1의 자동 보정 로직이 필수**. 순서 4를 빠뜨리면 차트가 깨짐. + +### 함정 2: 통계 카드에 집계 함수를 설정하면 +집계(COUNT 등)가 설정되면 rows에 `[{value: 87}]` 하나만 들어옴. +카테고리별 필터링이 작동하려면 **집계 함수를 "없음"으로 두거나, groupBy를 설정**해야 개별 행이 rows에 포함됨. +통계 카드에서는 **집계를 사용하지 않는 것이 올바른 사용법**. +설정 가이드 문서에 이 점을 명시해야 함. + +### 함정 3: PopDashboardConfig.tsx의 import 누락 +현재 `FilterOperator`는 이미 import되어 있음 (라인 54). +`StatCategory`는 직접 사용하지 않고 `item.statConfig.categories` 구조로 접근하므로 import 불필요. +**새로운 import 추가 필요 없음.** + +### 함정 4: 통계 카드 카테고리 필터에서 숫자 비교 +`String(row[col])` vs `String(filter.value)` 비교이므로, 숫자 컬럼도 문자열로 비교됨. +`"100" === "100"`은 정상 동작하지만, `"100.00" !== "100"`이 될 수 있음. +현재 대부분 컬럼이 varchar이므로 문제없지만, numeric 컬럼 사용 시 주의. + +### 함정 5: DataSourceEditor의 columns state 타이밍 +`groupByOpen` Popover에서 `columns` 배열을 사용하는데, 테이블 선택 직후 columns가 아직 로딩 중일 수 있음. +기존 코드에서 `loadingCols` 상태로 버튼을 disabled 처리하고 있으므로 문제없음. + +--- + +## 6. 검증 방법 + +### 차트 (BUG-1, BUG-2) +1. 아이템 추가 > "차트" 선택 +2. 테이블: `sales_order_mng`, 집계: COUNT, 컬럼: `id`, 그룹핑: `status` +3. 차트 유형: 막대 차트 +4. 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1 + +### 통계 카드 (BUG-3, BUG-4) +1. 아이템 추가 > "통계 카드" 선택 +2. 테이블: `sales_order_mng`, **집계: 없음** (중요!) +3. 카테고리 추가: + - "수주" / status / = / 수주 + - "진행중" / status / = / 진행중 + - "완료" / status / = / 완료 +4. 기대 결과: 수주 79, 진행중 7, 완료 1 + +--- + +## 이전 완료 계획 (아카이브) + +
+POP 뷰어 스크롤 수정 (완료) + +- [x] 라인 185: overflow-hidden 제거 +- [x] 라인 266: overflow-auto 공통 적용 +- [x] 라인 275: 일반 모드 min-h-full 추가 +- [x] 린트 검사 통과 + +
+ +
+POP 뷰어 실제 컴포넌트 렌더링 (완료) + +- [x] 뷰어 페이지에 레지스트리 초기화 import 추가 +- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체 +- [x] 린트 검사 통과 + +
+ +
+V2/V2 컴포넌트 설정 스키마 정비 (완료) + +- [x] 레거시 컴포넌트 스키마 제거 +- [x] V2 컴포넌트 overrides 스키마 정의 (16개) +- [x] V2 컴포넌트 overrides 스키마 정의 (9개) +- [x] componentConfig.ts 한 파일에서 통합 관리 + +
+ +
+화면 복제 기능 개선 (진행 중) - [완료] DB 구조 개편 (menu_objid 의존성 제거) -- [완료] 복제 옵션 정리 (코드카테고리/연쇄관계 삭제, 이름 변경) -- [완료] 화면 간 연결 복제 버그 수정 (targetScreenId 매핑 추가) +- [완료] 복제 옵션 정리 +- [완료] 화면 간 연결 복제 버그 수정 - [대기] 화면 간 연결 복제 테스트 - [대기] 제어관리 복제 테스트 - [대기] 추가 옵션 복제 테스트 ---- - -## 수정 이력 - -### 2026-01-26: 버튼 targetScreenId 매핑 버그 수정 - -**문제**: 그룹 복제 시 버튼의 `targetScreenId`가 새 화면으로 매핑되지 않음 - -- 수주관리 1→2→3→4 화면 복제 시 연결이 깨지는 문제 - -**수정 파일**: `backend-node/src/services/screenManagementService.ts` - -- `updateTabScreenReferences` 함수에 `targetScreenId` 처리 로직 추가 -- 쿼리에 `targetScreenId` 검색 조건 추가 -- 문자열/숫자 타입 모두 처리 +
diff --git a/POPUPDATE_2.md b/POPUPDATE_2.md new file mode 100644 index 00000000..85e20af2 --- /dev/null +++ b/POPUPDATE_2.md @@ -0,0 +1,696 @@ +# POP 컴포넌트 정의서 v8.0 + +## POP 헌법 (공통 규칙) + +### 제1조. 컴포넌트의 정의 + +- 컴포넌트란 디자이너가 그리드에 배치하는 것이다 +- 그리드에 배치하지 않는 것은 컴포넌트가 아니다 + +### 제2조. 컴포넌트의 독립성 + +- 모든 컴포넌트는 독립적으로 동작한다 +- 컴포넌트는 다른 컴포넌트의 존재를 직접 알지 못한다 (이벤트 버스로만 통신) + +### 제3조. 데이터의 자유 + +- 모든 컴포넌트는 자신의 테이블 + 외부 테이블을 자유롭게 조인할 수 있다 +- 컬럼별로 read/write/readwrite/hidden을 개별 설정할 수 있다 +- 보유 데이터 중 원하는 컬럼만 골라서 저장할 수 있다 + +### 제4조. 통신의 규칙 + +- 컴포넌트 간 통신은 반드시 이벤트 버스(usePopEvent)를 통한다 +- 컴포넌트가 다른 컴포넌트를 직접 참조하거나 호출하지 않는다 +- 이벤트는 화면 단위로 격리된다 (다른 POP 화면의 이벤트를 받지 않는다) +- 같은 화면 안에서는 이벤트를 통해 자유롭게 데이터를 주고받을 수 있다 + +### 제5조. 역할의 분리 + +- 조회용 입력(pop-search)과 저장용 입력(pop-field)은 다른 컴포넌트다 +- 이동/실행(pop-icon)과 값 선택 후 반환(pop-lookup)은 다른 컴포넌트다 +- 자주 쓰는 패턴은 하나로 합치되, 흐름 자체는 강제하고 보이는 방식만 옵션으로 제공한다 + +### 제6조. 시스템 설정도 컴포넌트다 + +- 프로필, 테마, 대시보드 보이기/숨기기 같은 시스템 설정도 컴포넌트(pop-system)로 만든다 +- 디자이너가 pop-system을 배치하지 않으면 해당 화면에 설정 기능이 없다 +- 이를 통해 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정한다 + +### 제7조. 디자이너의 권한 + +- 디자이너는 컴포넌트를 배치하고 설정한다 +- 디자이너는 사용자에게 커스텀을 허용할지 말지 결정한다 (userConfigurable) +- 디자이너가 "사용자 커스텀 허용 = OFF"로 설정하면, 사용자는 변경할 수 없다 +- 컴포넌트의 옵션 설정(어떻게 저장하고 어떻게 조회하는지 등)은 디자이너가 결정한다 + +### 제8조. 컴포넌트의 구성 + +- 모든 컴포넌트는 3개 파일로 구성된다: 실제 컴포넌트, 디자인 미리보기, 설정 패널 +- 모든 컴포넌트는 레지스트리에 등록해야 디자이너에 나타난다 +- 모든 컴포넌트 인스턴스는 userConfigurable, displayName 공통 속성을 가진다 + +### 제9조. 모달 화면의 설계 + +- 모달은 인라인(컴포넌트 설정만으로 구성)과 외부 참조(별도 POP 화면 연결) 두 가지 방식이 있다 +- 단순한 목록 선택은 인라인 모달을 사용한다 (설정만으로 완결) +- 복잡한 검색/필터가 필요하거나 여러 곳에서 재사용하는 모달은 별도 POP 화면을 만들어 참조한다 +- 모달 안의 화면도 동일한 POP 컴포넌트 시스템으로 구성된다 (같은 그리드, 같은 컴포넌트) +- 모달 화면의 layout_data는 기존 screen_layouts_pop 테이블에 저장한다 (DB 변경 불필요) + +--- + +## 현재 상태 + +- 그리드 시스템 (v5.2): 완성 +- 컴포넌트 레지스트리: 완성 (PopComponentRegistry.ts) +- 구현 완료: `pop-text` 1개 (pop-text.tsx) +- 기존 `components-spec.md`는 v4 기준이라 갱신 필요 + +## 아키텍처 개요 + +```mermaid +graph TB + subgraph designer [디자이너] + Palette[컴포넌트 팔레트] + Grid[CSS Grid 캔버스] + ConfigPanel[속성 설정 패널] + end + + subgraph registry [레지스트리] + Registry[PopComponentRegistry] + end + + subgraph infra [공통 인프라] + DataSource[useDataSource 훅] + EventBus[usePopEvent 훅] + ActionRunner[usePopAction 훅] + end + + subgraph components [9개 컴포넌트] + Text[pop-text - 완성] + Dashboard[pop-dashboard] + Table[pop-table] + Button[pop-button] + Icon[pop-icon] + Search[pop-search] + Field[pop-field] + Lookup[pop-lookup] + System[pop-system] + end + + subgraph backend [기존 백엔드 API] + DataAPI[dataApi - 동적 CRUD] + DashAPI[dashboardApi - 통계 쿼리] + CodeAPI[commonCodeApi - 공통코드] + NumberAPI[numberingRuleApi - 채번] + end + + Palette --> Grid + Grid --> ConfigPanel + ConfigPanel --> Registry + + Registry --> components + components --> infra + infra --> backend + EventBus -.->|컴포넌트 간 통신| components + System -.->|보이기/숨기기 제어| components +``` + +--- + +## 공통 인프라 (모든 컴포넌트가 공유) + +### 핵심 원칙: 모든 컴포넌트는 데이터를 자유롭게 다룬다 + +1. **데이터 전달**: 모든 컴포넌트는 자신이 보유한 데이터를 다른 컴포넌트에 전달/수신 가능 +2. **테이블 조인**: 자신의 테이블 + 외부 테이블 자유롭게 조인하여 데이터 구성 +3. **컬럼별 CRUD 제어**: 컬럼 단위로 "조회만" / "저장 대상" / "숨김"을 개별 설정 가능 +4. **선택적 저장**: 보유 데이터 중 원하는 컬럼만 골라서 저장/수정/삭제 가능 + +### 공통 인스턴스 속성 (모든 컴포넌트 배치 시 설정 가능) + +디자이너가 컴포넌트를 그리드에 배치할 때 설정하는 공통 속성: + +- `userConfigurable`: boolean - 사용자가 이 컴포넌트를 숨길 수 있는지 (개인 설정 패널에 노출) +- `displayName`: string - 개인 설정 패널에 보여줄 이름 (예: "금일 생산실적") + +### 1. DataSourceConfig (데이터 소스 설정 타입) + +모든 데이터 연동 컴포넌트가 사용하는 표준 설정 구조: + +- `tableName`: 대상 테이블 +- `columns`: 컬럼 바인딩 목록 (ColumnBinding 배열) +- `filters`: 필터 조건 배열 +- `sort`: 정렬 설정 +- `aggregation`: 집계 함수 (count, sum, avg, min, max) +- `joins`: 테이블 조인 설정 (JoinConfig 배열) +- `refreshInterval`: 자동 새로고침 주기 (초) +- `limit`: 조회 건수 제한 + +### 1-1. ColumnBinding (컬럼별 읽기/쓰기 제어) + +각 컬럼이 컴포넌트에서 어떤 역할을 하는지 개별 설정: + +- `columnName`: 컬럼명 +- `sourceTable`: 소속 테이블 (조인된 외부 테이블 포함) +- `mode`: "read" | "write" | "readwrite" | "hidden" + - read: 조회만 (화면에 표시하되 저장 안 함) + - write: 저장 대상 (사용자 입력 -> DB 저장) + - readwrite: 조회 + 저장 모두 + - hidden: 내부 참조용 (화면에 안 보이지만 다른 컴포넌트에 전달 가능) +- `label`: 화면 표시 라벨 +- `defaultValue`: 기본값 + +예시: 발주 품목 카드에서 5개 컬럼 중 3개만 저장 + +``` +columns: [ + { columnName: "item_code", sourceTable: "order_items", mode: "read" }, + { columnName: "item_name", sourceTable: "item_info", mode: "read" }, + { columnName: "inbound_qty", sourceTable: "order_items", mode: "readwrite" }, + { columnName: "warehouse", sourceTable: "order_items", mode: "write" }, + { columnName: "memo", sourceTable: "order_items", mode: "write" }, +] +``` + +### 1-2. JoinConfig (테이블 조인 설정) + +외부 테이블과 자유롭게 조인: + +- `targetTable`: 조인할 외부 테이블명 +- `joinType`: "inner" | "left" | "right" +- `on`: 조인 조건 { sourceColumn, targetColumn } +- `columns`: 가져올 컬럼 목록 + +### 2. useDataSource 훅 + +DataSourceConfig를 받아서 기존 API를 호출하고 결과를 반환: + +- 로딩/에러/데이터 상태 관리 +- 자동 새로고침 타이머 +- 필터 변경 시 자동 재조회 +- 기존 `dataApi`, `dashboardApi` 활용 +- **CRUD 함수 제공**: save(data), update(id, data), delete(id) + - ColumnBinding의 mode가 "write" 또는 "readwrite"인 컬럼만 저장 대상에 포함 + - "read" 컬럼은 저장 시 자동 제외 + +### 3. usePopEvent 훅 (이벤트 버스 - 데이터 전달 포함) + +컴포넌트 간 통신 (단순 이벤트 + 데이터 페이로드): + +- `publish(eventName, payload)`: 이벤트 발행 +- `subscribe(eventName, callback)`: 이벤트 구독 +- `getSharedData(key)`: 공유 데이터 직접 읽기 +- `setSharedData(key, value)`: 공유 데이터 직접 쓰기 +- 화면 단위 스코프 (다른 POP 화면과 격리) + +### 4. PopActionConfig (액션 설정 타입) + +모든 컴포넌트가 사용할 수 있는 액션 표준 구조: + +- `type`: "navigate" | "modal" | "save" | "delete" | "api" | "event" | "refresh" +- `navigate`: { screenId, url } +- `modal`: { mode, title, screenId, inlineConfig, modalSize } + - mode: "inline" (설정만으로 구성) | "screen-ref" (별도 화면 참조) + - title: 모달 제목 + - screenId: mode가 "screen-ref"일 때 참조할 POP 화면 ID + - inlineConfig: mode가 "inline"일 때 사용할 DataSourceConfig + 표시 설정 + - modalSize: { width, height } 모달 크기 +- `save`: { targetColumns } +- `delete`: { confirmMessage } +- `api`: { method, endpoint, body } +- `event`: { eventName, payload } +- `refresh`: { targetComponents } + +--- + +## 컴포넌트 정의 (9개) + +### 1. pop-text (완성) + +- **한 줄 정의**: 보여주기만 함 +- **카테고리**: display +- **역할**: 정적 표시 전용 (이벤트 없음) +- **서브타입**: text, datetime, image, title +- **데이터**: 없음 (정적 콘텐츠) +- **이벤트**: 발행 없음, 수신 없음 +- **설정**: 내용, 폰트 크기/굵기, 좌우/상하 정렬, 이미지 URL/맞춤/크기, 날짜 포맷 빌더 + +### 2. pop-dashboard (신규 - 2026-02-09 토의 결과 반영) + +- **한 줄 정의**: 여러 집계 아이템을 묶어서 다양한 방식으로 보여줌 +- **카테고리**: display +- **역할**: 숫자 데이터를 집계/계산하여 시각화. 하나의 컴포넌트 안에 여러 집계 아이템을 담는 컨테이너 +- **구조**: 1개 pop-dashboard = 여러 DashboardItem의 묶음. 각 아이템은 독립적으로 데이터 소스/서브타입/보이기숨기기 설정 가능 +- **서브타입** (아이템별로 선택, 한 묶음에 혼합 가능): + - kpi-card: 숫자 + 단위 + 라벨 + 증감 표시 + - chart: 막대/원형/라인 차트 + - gauge: 게이지 (목표 대비 달성률) + - stat-card: 통계 카드 (건수 + 대기 + 링크) +- **표시 모드** (디자이너가 선택): + - arrows: 좌우 버튼으로 아이템 넘기기 + - auto-slide: 전광판처럼 자동 전환 (터치 시 멈춤, 일정 시간 후 재개) + - grid: 컴포넌트 영역 내부를 행/열로 쪼개서 여러 아이템 동시 표시 (디자이너가 각 아이템 위치 직접 지정) + - scroll: 좌우 또는 상하 스와이프 +- **데이터**: 각 아이템별 독립 DataSourceConfig (조인/집계 자유) +- **계산식 지원**: "생산량/총재고량", "출고량/현재고량" 같은 복합 표현 가능 + - 값 A, B를 각각 다른 테이블/집계로 설정 + - 표시 형태: 분수(1,234/5,678), 퍼센트(21.7%), 비율(1,234:5,678) +- **CRUD**: 주로 읽기. 목표값 수정 등 필요 시 write 컬럼으로 저장 가능 +- **이벤트**: + - 수신: filter_changed, data_ready + - 발행: kpi_clicked (아이템 클릭 시 상세 데이터 전달) +- **설정**: 데이터 소스(드롭다운 기반 쉬운 집계), 집계 함수, 계산식, 라벨, 단위, 색상 구간, 차트 타입, 새로고침 주기, 목표값, 표시 모드, 아이템별 보이기/숨기기 +- **보이기/숨기기**: 각 아이템별로 pop-system에서 개별 on/off 가능 (userConfigurable) +- **기존 POP 대시보드 폐기**: `frontend/components/pop/dashboard/` 폴더 전체를 이 컴포넌트로 대체 예정 (Phase 1~3 완료 후) + +#### pop-dashboard 데이터 구조 + +``` +PopDashboardConfig { + items: DashboardItem[] // 아이템 목록 (각각 독립 설정) + displayMode: "arrows" | "auto-slide" | "grid" | "scroll" + autoSlideInterval: number // 자동 슬라이드 간격(초) + gridLayout: { columns: number, rows: number } // 행열 그리드 설정 + showIndicator: boolean // 페이지 인디케이터 표시 + gap: number // 아이템 간 간격 +} + +DashboardItem { + id: string + label: string // pop-system에서 보이기/숨기기용 이름 + visible: boolean // 보이기/숨기기 + subType: "kpi-card" | "chart" | "gauge" | "stat-card" + dataSource: DataSourceConfig // 각 아이템별 독립 데이터 소스 + + // 행열 그리드 모드에서의 위치 (디자이너가 직접 지정) + gridPosition: { col: number, row: number, colSpan: number, rowSpan: number } + + // 계산식 (선택사항) + formula?: { + enabled: boolean + values: [ + { id: "A", dataSource: DataSourceConfig, label: "생산량" }, + { id: "B", dataSource: DataSourceConfig, label: "총재고량" }, + ] + expression: string // "A / B", "A + B", "A / B * 100" + displayFormat: "value" | "fraction" | "percent" | "ratio" + } + + // 서브타입별 설정 + kpiConfig?: { unit, colorRanges, showTrend, trendPeriod } + chartConfig?: { chartType, xAxis, yAxis, colors } + gaugeConfig?: { min, max, target, colorRanges } + statConfig?: { categories, showLink } +} +``` + +#### 설정 패널 흐름 (드롭다운 기반 쉬운 집계) + +``` +1. [+ 아이템 추가] 버튼 클릭 +2. 서브타입 선택: kpi-card / chart / gauge / stat-card +3. 데이터 모드 선택: [단일 집계] 또는 [계산식] + + [단일 집계] + - 테이블 선택 (table-schema API로 목록) + - 조인할 테이블 추가 (선택사항) + - 컬럼 선택 → 집계 함수 선택 (합계/건수/평균/최소/최대) + - 필터 조건 추가 + + [계산식] (예: 생산량/총재고량) + - 값 A: 테이블 -> 컬럼 -> 집계함수 + - 값 B: 테이블 -> 컬럼 -> 집계함수 (다른 테이블도 가능) + - 계산식: A / B + - 표시 형태: 분수 / 퍼센트 / 비율 + +4. 라벨, 단위, 색상 등 외형 설정 +5. 행열 그리드 위치 설정 (grid 모드일 때) +``` + +### 3. pop-table (신규 - 가장 복잡) + +- **한 줄 정의**: 데이터 목록을 보여주고 편집함 +- **카테고리**: display +- **역할**: 데이터 목록 표시 + 편집 (카드형/테이블형) +- **서브타입**: + - card-list: 카드 형태 + - table-list: 테이블 형태 (행/열 장부) +- **데이터**: DataSourceConfig (조인/컬럼별 읽기쓰기 자유) +- **CRUD**: useDataSource의 save/update/delete 사용. write/readwrite 컬럼만 자동 추출 +- **카드 템플릿** (card-list 전용): 카드 내부 미니 그리드로 요소 배치, 요소별 데이터 바인딩 +- **이벤트**: + - 수신: filter_changed, refresh, data_ready + - 발행: row_selected, row_action, save_complete, delete_complete +- **설정**: 데이터 소스, 표시 모드, 카드 템플릿, 컬럼 정의, 행 선택 방식, 페이징, 정렬, 인라인 편집 여부 + +### 4. pop-button (신규) + +- **한 줄 정의**: 누르면 액션 실행 (저장, 삭제 등) +- **카테고리**: action +- **역할**: 액션 실행 (저장, 삭제, API 호출, 모달 열기 등) +- **데이터**: 이벤트로 수신한 데이터를 액션에 활용 +- **CRUD**: 버튼 클릭 시 수신 데이터 기반으로 save/update/delete 실행 +- **이벤트**: + - 수신: data_ready, row_selected + - 발행: save_complete, delete_complete 등 +- **설정**: 라벨, 아이콘, 크기, 스타일, 액션 설정(PopActionConfig), 확인 다이얼로그, 로딩 상태 + +### 5. pop-icon (신규) + +- **한 줄 정의**: 누르면 어딘가로 이동 (돌아오는 값 없음) +- **카테고리**: action +- **역할**: 네비게이션 (화면 이동, URL 이동) +- **데이터**: 없음 +- **이벤트**: 없음 (네비게이션은 이벤트가 아닌 직접 실행) +- **설정**: 아이콘 종류(lucide-icon), 라벨, 배경색/그라디언트, 크기, 클릭 액션(PopActionConfig), 뱃지 표시 +- **pop-lookup과의 차이**: pop-icon은 이동/실행만 함. 값을 선택해서 돌려주지 않음 + +### 6. pop-search (신규) + +- **한 줄 정의**: 조건을 입력해서 다른 컴포넌트를 조회/필터링 +- **카테고리**: input +- **역할**: 다른 컴포넌트에 필터 조건 전달 + 자체 데이터 조회 +- **서브타입**: + - text-search: 텍스트 검색 + - date-range: 날짜 범위 + - select-filter: 드롭다운 선택 (공통코드 연동) + - combo-filter: 복합 필터 (여러 조건 조합) +- **실행 방식**: auto(값 변경 즉시) 또는 button(검색 버튼 클릭 시) +- **데이터**: 공통코드/카테고리 API로 선택 항목 조회 +- **이벤트**: + - 수신: 없음 + - 발행: filter_changed (필터 값 변경 시) +- **설정**: 필터 타입, 대상 컬럼, 공통코드 연결, 플레이스홀더, 실행 방식(auto/button), 발행할 이벤트 이름 +- **pop-field와의 차이**: pop-search 입력값은 조회용(DB에 안 들어감). pop-field 입력값은 저장용(DB에 들어감) + +### 7. pop-field (신규) + +- **한 줄 정의**: 저장할 값을 입력 +- **카테고리**: input +- **역할**: 단일 데이터 입력 (폼 필드) - 입력한 값이 DB에 저장되는 것이 목적 +- **서브타입**: + - text: 텍스트 입력 + - number: 숫자 입력 (수량, 금액) + - date: 날짜 선택 + - select: 드롭다운 선택 + - numpad: 큰 숫자패드 (현장용) +- **데이터**: DataSourceConfig (선택적) + - select 옵션을 DB에서 조회 가능 + - ColumnBinding으로 입력값의 저장 대상 테이블/컬럼 지정 +- **CRUD**: 자체 저장은 보통 하지 않음. value_changed 이벤트로 pop-button 등에 전달 +- **이벤트**: + - 수신: set_value (외부에서 값 설정) + - 발행: value_changed (값 + 컬럼명 + 모드 정보) +- **설정**: 입력 타입, 라벨, 플레이스홀더, 필수 여부, 유효성 검증, 최소/최대값, 단위 표시, 바인딩 컬럼 + +### 8. pop-lookup (신규) + +- **한 줄 정의**: 모달에서 값을 골라서 반환 +- **카테고리**: input +- **역할**: 필드를 클릭하면 모달이 열리고, 목록에서 선택하면 값이 반환되는 컴포넌트 +- **서브타입 (모달 안 표시 방식)**: + - card: 카드형 목록 + - table: 테이블형 목록 + - icon-grid: 아이콘 그리드 (참조 화면의 거래처 선택처럼) +- **동작 흐름**: 필드 클릭 -> 모달 열림 -> 목록에서 선택 -> 모달 닫힘 -> 필드에 값 표시 + 이벤트 발행 +- **데이터**: DataSourceConfig (모달 안 목록의 데이터 소스) +- **이벤트**: + - 수신: set_value (외부에서 값 초기화) + - 발행: value_selected (선택한 레코드 전체 데이터 전달), filter_changed (선택 값을 필터로 전달) +- **설정**: 라벨, 플레이스홀더, 데이터 소스, 모달 표시 방식(card/table/icon-grid), 표시 컬럼(모달 목록에 보여줄 컬럼), 반환 컬럼(선택 시 돌려줄 값), 발행할 이벤트 이름 +- **pop-icon과의 차이**: pop-icon은 이동/실행만 하고 값이 안 돌아옴. pop-lookup은 값을 골라서 돌려줌 +- **pop-search와의 차이**: pop-search는 텍스트/날짜/드롭다운으로 필터링. pop-lookup은 모달을 열어서 목록에서 선택 + +#### pop-lookup 모달 화면 설계 방식 + +pop-lookup이 열리는 모달의 내부 화면은 **두 가지 방식** 중 선택할 수 있다: + +**방식 A: 인라인 모달 (기본)** +- pop-lookup 컴포넌트의 설정 패널에서 직접 모달 내부 화면을 구성 +- DataSourceConfig + 표시 컬럼 + 검색 필터 설정만으로 동작 +- 별도 화면 생성 없이 컴포넌트 설정만으로 완결 +- 적합한 경우: 단순 목록 선택 (거래처 목록, 품목 목록 등) + +**방식 B: 외부 화면 참조 (고급)** +- 별도의 POP 화면(screen_id)을 모달로 연결 +- 모달 안에서 검색/필터/테이블 등 복잡한 화면을 디자이너로 자유롭게 구성 +- 여러 pop-lookup에서 같은 모달 화면을 재사용 가능 +- 적합한 경우: 복잡한 검색/필터가 필요한 선택 화면, 여러 화면에서 공유하는 모달 + +**설정 구조:** + +``` +modalConfig: { + mode: "inline" | "screen-ref" + + // mode = "inline"일 때 사용 + dataSource: DataSourceConfig + displayColumns: ColumnBinding[] + searchFilter: { enabled: boolean, targetColumns: string[] } + modalSize: { width: number, height: number } + + // mode = "screen-ref"일 때 사용 + screenId: number // 참조할 POP 화면 ID + returnMapping: { // 모달 화면에서 선택된 값을 어떻게 매핑할지 + sourceColumn: string // 모달 화면에서 반환하는 컬럼 + targetField: string // pop-lookup 필드에 표시할 값 + }[] + modalSize: { width: number, height: number } +} +``` + +**기존 시스템과의 호환성 (검증 완료):** + +| 항목 | 현재 상태 | pop-lookup 지원 여부 | +|------|-----------|---------------------| +| DB: layout_data JSONB | 유연한 JSON 구조 | modalConfig를 layout_data에 저장 가능 (스키마 변경 불필요) | +| DB: screen_layouts_pop 테이블 | screen_id + company_code 기반 | 모달 화면도 별도 screen_id로 저장 가능 | +| 프론트: TabsWidget | screenId로 외부 화면 참조 지원 | 같은 패턴으로 모달에서 외부 화면 로드 가능 | +| 프론트: detectLinkedModals API | 연결된 모달 화면 감지 기능 있음 | 화면 간 참조 관계 추적에 활용 가능 | +| 백엔드: saveLayoutPop/getLayoutPop | POP 전용 저장/조회 API 있음 | 모달 화면도 동일 API로 저장/조회 가능 | +| 레이어 시스템 | layer_id 기반 다중 레이어 지원 | 모달 내부 레이아웃을 레이어로 관리 가능 | + +**DB 마이그레이션 불필요**: layout_data가 JSONB이므로 modalConfig를 컴포넌트 overrides에 포함하면 됨. +**백엔드 변경 불필요**: 기존 saveLayoutPop/getLayoutPop API가 그대로 사용 가능. +**프론트엔드 참고 패턴**: TabsWidget의 screenId 참조 방식을 그대로 차용. + +### 9. pop-system (신규) + +- **한 줄 정의**: 시스템 설정을 하나로 통합한 컴포넌트 (프로필, 테마, 보이기/숨기기) +- **카테고리**: system +- **역할**: 사용자 개인 설정 기능을 제공하는 통합 컴포넌트 +- **내부 포함 기능**: + - 프로필 표시 (사용자명, 부서) + - 테마 선택 (기본/다크/블루/그린) + - 대시보드 보이기/숨기기 체크박스 (같은 화면의 userConfigurable=true 컴포넌트를 자동 수집) + - 하단 메뉴 보이기/숨기기 + - 드래그앤드롭으로 순서 변경 +- **디자이너가 설정하는 것**: 크기(그리드에서 차지하는 영역), 내부 라벨/아이콘 크기와 위치 +- **사용자가 하는 것**: 체크박스로 컴포넌트 보이기/숨기기, 테마 선택, 순서 변경 +- **데이터**: 같은 화면의 layout_data에서 컴포넌트 목록을 자동 수집 +- **저장**: 사용자별 설정을 localStorage에 저장 (데스크탑 패턴 따름) +- **이벤트**: + - 수신: 없음 + - 발행: visibility_changed (컴포넌트 보이기/숨기기 변경 시), theme_changed (테마 변경 시) +- **설정**: 내부 라벨 크기, 아이콘 크기, 위치 정도만 +- **특이사항**: + - 디자이너가 이 컴포넌트를 배치하지 않으면 해당 화면에 개인 설정 기능이 없다 + - 디자이너가 "이 화면에 설정 기능을 넣을지 말지"를 직접 결정하는 구조 + - 메인 홈에는 배치, 업무 화면(입고 등)에는 안 배치하는 식으로 사용 + +--- + +## 컴포넌트 간 통신 예시 + +### 예시 1: 검색 -> 필터 연동 + +```mermaid +sequenceDiagram + participant Search as pop-search + participant Dashboard as pop-dashboard + participant Table as pop-table + + Note over Search: 사용자가 창고 WH01 선택 + Search->>Dashboard: filter_changed + Search->>Table: filter_changed + Note over Dashboard: DataSource 재조회 + Note over Table: DataSource 재조회 +``` + +### 예시 2: 데이터 전달 + 선택적 저장 + +```mermaid +sequenceDiagram + participant Table as pop-table + participant Field as pop-field + participant Button as pop-button + + Note over Table: 사용자가 발주 행 선택 + Table->>Field: row_selected + Table->>Button: row_selected + Note over Field: 사용자가 qty를 500으로 입력 + Field->>Button: value_changed + Note over Button: 사용자가 저장 클릭 + Note over Button: write/readwrite 컬럼만 추출하여 저장 + Button->>Table: save_complete + Note over Table: 데이터 새로고침 +``` + +### 예시 3: pop-lookup 거래처 선택 -> 품목 조회 + +```mermaid +sequenceDiagram + participant Lookup as pop-lookup + participant Table as pop-table + + Note over Lookup: 사용자가 거래처 필드 클릭 + Note over Lookup: 모달 열림 - 거래처 목록 표시 + Note over Lookup: 사용자가 대한금속 선택 + Note over Lookup: 모달 닫힘 - 필드에 대한금속 표시 + Lookup->>Table: filter_changed { company: "대한금속" } + Note over Table: company=대한금속 필터로 재조회 + Note over Table: 발주 품목 3건 표시 +``` + +### 예시 4: pop-lookup 인라인 모달 vs 외부 화면 참조 + +```mermaid +sequenceDiagram + participant User as 사용자 + participant Lookup as pop-lookup (거래처) + participant Modal as 모달 + + Note over User,Modal: [방식 A: 인라인 모달] + User->>Lookup: 거래처 필드 클릭 + Lookup->>Modal: 인라인 모달 열림 (DataSourceConfig 기반) + Note over Modal: supplier 테이블에서 목록 조회 + Note over Modal: 테이블형 목록 표시 + User->>Modal: "대한금속" 선택 + Modal->>Lookup: value_selected { supplier_code: "DH001", name: "대한금속" } + Note over Lookup: 필드에 "대한금속" 표시 + + Note over User,Modal: [방식 B: 외부 화면 참조] + User->>Lookup: 거래처 필드 클릭 + Lookup->>Modal: 모달 열림 (screenId=42 화면 로드) + Note over Modal: 별도 POP 화면 렌더링 + Note over Modal: pop-search(검색) + pop-table(목록) 등 배치된 컴포넌트 동작 + User->>Modal: 검색 후 "대한금속" 선택 + Modal->>Lookup: returnMapping 기반으로 값 반환 + Note over Lookup: 필드에 "대한금속" 표시 +``` + +### 예시 5: 컬럼별 읽기/쓰기 분리 동작 + +5개 컬럼이 있는 발주 화면: + +- item_code (read) -> 화면에 표시, 저장 안 함 +- item_name (read, 조인) -> item_info 테이블에서 가져옴, 저장 안 함 +- inbound_qty (readwrite) -> 화면에 표시 + 사용자 수정 + 저장 +- warehouse (write) -> 사용자 입력 + 저장 +- memo (write) -> 사용자 입력 + 저장 + +저장 API 호출 시: `{ inbound_qty: 500, warehouse: "WH01", memo: "긴급" }` 만 전달 +조회 API 호출 시: 5개 컬럼 전부 + 조인된 item_name까지 조회 + +--- + +## 구현 우선순위 + +- Phase 0 (공통 인프라): ColumnBinding, JoinConfig, DataSourceConfig 타입, useDataSource 훅 (CRUD 포함), usePopEvent 훅 (데이터 전달 포함), PopActionConfig 타입 +- Phase 1 (기본 표시): pop-dashboard (4개 서브타입 전부 + 멀티 아이템 컨테이너 + 4개 표시 모드 + 계산식) +- Phase 2 (기본 액션): pop-button, pop-icon +- Phase 3 (데이터 목록): pop-table (테이블형부터, 카드형은 후순위) +- Phase 4 (입력/연동): pop-search, pop-field, pop-lookup +- Phase 5 (고도화): pop-table 카드 템플릿 +- Phase 6 (시스템): pop-system (프로필, 테마, 대시보드 보이기/숨기기 통합) + +### Phase 1 상세 변경 (2026-02-09 토의 결정) + +기존 계획에서 "KPI 카드 우선"이었으나, 토의 결과 **4개 서브타입 전부를 Phase 1에서 구현**으로 변경: +- kpi-card, chart, gauge, stat-card 모두 Phase 1 +- 멀티 아이템 컨테이너 (arrows, auto-slide, grid, scroll) +- 계산식 지원 (formula) +- 드롭다운 기반 쉬운 집계 설정 +- 기존 `frontend/components/pop/dashboard/` 폴더는 Phase 1 완료 후 폐기/삭제 + +### 백엔드 API 현황 (호환성 점검 완료) + +기존 백엔드에 이미 구현되어 있어 새로 만들 필요 없는 API: + +| API | 용도 | 비고 | +|-----|------|------| +| `dataApi.getTableData()` | 동적 테이블 조회 | 페이징, 검색, 정렬, 필터 | +| `dataApi.getJoinedData()` | 2개 테이블 조인 | Entity 조인, 필터링, 중복제거 | +| `entityJoinApi.getTableDataWithJoins()` | Entity 조인 전용 | ID->이름 자동 변환 | +| `dataApi.createRecord/updateRecord/deleteRecord()` | 동적 CRUD | - | +| `dataApi.upsertGroupedRecords()` | 그룹 UPSERT | - | +| `dashboardApi.executeQuery()` | SELECT SQL 직접 실행 | 집계/복합조인용 | +| `dashboardApi.getTableSchema()` | 테이블/컬럼 목록 | 설정 패널 드롭다운용 | + +**백엔드 신규 개발 불필요** - 기존 API만으로 모든 데이터 연동 가능 + +### useDataSource의 API 선택 전략 + +``` +단순 조회 (조인/집계 없음) -> dataApi.getTableData() 또는 entityJoinApi +2개 테이블 조인 -> dataApi.getJoinedData() +3개+ 테이블 조인 또는 집계 -> DataSourceConfig를 SQL로 변환 -> dashboardApi.executeQuery() +CRUD -> dataApi.createRecord/updateRecord/deleteRecord() +``` + +### POP 전용 훅 분리 (2026-02-09 결정) + +데스크탑과의 완전 분리를 위해 POP 전용 훅은 별도 폴더: +- `frontend/hooks/pop/usePopEvent.ts` (POP 전용) +- `frontend/hooks/pop/useDataSource.ts` (POP 전용) + +## 기존 시스템 호환성 검증 결과 (v8.0 추가) + +v8.0에서 추가된 모달 설계 방식에 대해 기존 시스템과의 호환성을 검증한 결과: + +### DB 스키마 (변경 불필요) + +| 테이블 | 현재 구조 | 호환성 | +|--------|-----------|--------| +| screen_layouts_v2 | layout_data JSONB + screen_id + company_code + layer_id | modalConfig를 컴포넌트 overrides에 포함하면 됨 | +| screen_layouts_pop | 동일 구조 (POP 전용) | 모달 화면도 별도 screen_id로 저장 가능 | + +- layout_data가 JSONB 타입이므로 어떤 JSON 구조든 저장 가능 +- 모달 화면을 별도 screen_id로 만들어도 기존 UNIQUE(screen_id, company_code, layer_id) 제약조건과 충돌 없음 +- DB 마이그레이션 불필요 + +### 백엔드 API (변경 불필요) + +| API | 엔드포인트 | 호환성 | +|-----|-----------|--------| +| POP 레이아웃 저장 | POST /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 저장 | +| POP 레이아웃 조회 | GET /api/screen-management/screens/:screenId/layout-pop | 모달 화면도 동일 API로 조회 | +| 연결 모달 감지 | detectLinkedModals(screenId) | 화면 간 참조 관계 추적에 활용 | + +### 프론트엔드 (참고 패턴 존재) + +| 기존 기능 | 위치 | 활용 방안 | +|-----------|------|-----------| +| TabsWidget screenId 참조 | frontend/components/screen/widgets/TabsWidget.tsx | 같은 패턴으로 모달에서 외부 화면 로드 | +| TabsConfigPanel | frontend/components/screen/config-panels/TabsConfigPanel.tsx | pop-lookup 설정 패널의 모달 화면 선택 UI 참조 | +| ScreenDesigner 탭 내부 컴포넌트 | frontend/components/screen/ScreenDesigner.tsx | 모달 내부 컴포넌트 편집 패턴 참조 | + +### 결론 + +- DB 마이그레이션: 불필요 +- 백엔드 변경: 불필요 +- 프론트엔드: pop-lookup 컴포넌트 구현 시 기존 TabsWidget의 screenId 참조 패턴을 그대로 차용 +- 새로운 API: 불필요 (기존 saveLayoutPop/getLayoutPop로 충분) + +## 참고 파일 + +- 레지스트리: `frontend/lib/registry/PopComponentRegistry.ts` +- 기존 텍스트 컴포넌트: `frontend/lib/registry/pop-components/pop-text.tsx` +- 공통 스타일 타입: `frontend/lib/registry/pop-components/types.ts` +- POP 타입 정의: `frontend/components/pop/designer/types/pop-layout.ts` +- 기존 스펙 (v4): `popdocs/components-spec.md` +- 탭 위젯 (모달 참조 패턴): `frontend/components/screen/widgets/TabsWidget.tsx` +- POP 레이아웃 API: `frontend/lib/api/screen.ts` (saveLayoutPop, getLayoutPop) +- 백엔드 화면관리: `backend-node/src/controllers/screenManagementController.ts` diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 00000000..3aa75278 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,46 @@ +# 프로젝트 상태 추적 + +> **최종 업데이트**: 2026-02-11 + +--- + +## 현재 진행 중 + +### pop-dashboard 스타일 정리 +**상태**: 코딩 완료, 브라우저 확인 대기 +**계획서**: [popdocs/PLAN.md](./popdocs/PLAN.md) +**내용**: 글자 크기 커스텀 제거 + 라벨 정렬만 유지 + stale closure 수정 + +--- + +## 다음 작업 + +| 순서 | 작업 | 상태 | +|------|------|------| +| 1 | 브라우저 확인 (라벨 정렬 동작, 반응형 자동 크기, 미리보기 반영) | [ ] 대기 | +| 2 | 게이지/통계카드 테스트 시나리오 동작 확인 | [ ] 대기 | +| 3 | Phase 2 계획 수립 (pop-button, pop-icon) | [ ] 대기 | + +--- + +## 완료된 작업 (최근) + +| 날짜 | 작업 | 비고 | +|------|------|------| +| 2026-02-11 | 대시보드 스타일 정리 | FONT_SIZE_PX/글자 크기 Select 삭제, ItemStyleConfig -> labelAlign만, stale closure 수정 | +| 2026-02-10 | 디자이너 캔버스 UX 개선 | 헤더 제거, 실제 데이터 렌더링, 컴포넌트 목록 | +| 2026-02-10 | 차트/게이지/네비게이션/정렬 디자인 개선 | CartesianGrid, abbreviateNumber, 오버레이 화살표/인디케이터 | +| 2026-02-10 | 대시보드 4가지 아이템 모드 완성 | groupBy UI, xAxisColumn, 통계카드 카테고리, 필터 버그 수정 | +| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 | +| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent | +| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 | + +--- + +## 알려진 이슈 + +| # | 이슈 | 심각도 | 상태 | +|---|------|--------|------| +| 1 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 | +| 2 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 | +| 3 | 기존 저장 데이터의 `itemStyle.align`이 `labelAlign`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 | diff --git a/backend-node/README.md b/backend-node/README.md index a2d34209..84bff2a1 100644 --- a/backend-node/README.md +++ b/backend-node/README.md @@ -1,4 +1,4 @@ -# PLM System Backend - Node.js + TypeScript +re# PLM System Backend - Node.js + TypeScript Java Spring Boot에서 Node.js + TypeScript로 리팩토링된 PLM 시스템 백엔드입니다. diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index cba61f24..26d99e7b 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -105,6 +105,7 @@ import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRou import scheduleRoutes from "./routes/scheduleRoutes"; // 스케줄 자동 생성 import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리 import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회 +import bomRoutes from "./routes/bomRoutes"; // BOM 이력/버전 관리 import roleRoutes from "./routes/roleRoutes"; // 권한 그룹 관리 import departmentRoutes from "./routes/departmentRoutes"; // 부서 관리 import tableCategoryValueRoutes from "./routes/tableCategoryValueRoutes"; // 카테고리 값 관리 @@ -289,6 +290,7 @@ app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 app.use("/api/schedule", scheduleRoutes); // 스케줄 자동 생성 app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리 app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회 +app.use("/api/bom", bomRoutes); // BOM 이력/버전 관리 app.use("/api/roles", roleRoutes); // 권한 그룹 관리 app.use("/api/departments", departmentRoutes); // 부서 관리 app.use("/api/table-categories", tableCategoryValueRoutes); // 카테고리 값 관리 diff --git a/backend-node/src/controllers/bomController.ts b/backend-node/src/controllers/bomController.ts new file mode 100644 index 00000000..8355b148 --- /dev/null +++ b/backend-node/src/controllers/bomController.ts @@ -0,0 +1,148 @@ +/** + * BOM 이력/버전 관리 컨트롤러 + */ + +import { Request, Response } from "express"; +import { logger } from "../utils/logger"; +import * as bomService from "../services/bomService"; + +// ─── 이력 (History) ───────────────────────────── + +export async function getBomHistory(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const tableName = (req.query.tableName as string) || undefined; + + const data = await bomService.getBomHistory(bomId, companyCode, tableName); + res.json({ success: true, data }); + } catch (error: any) { + logger.error("BOM 이력 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function addBomHistory(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const changedBy = (req as any).user?.userName || (req as any).user?.userId || ""; + + const { change_type, change_description, revision, version, tableName } = req.body; + if (!change_type) { + res.status(400).json({ success: false, message: "change_type은 필수입니다" }); + return; + } + + const result = await bomService.addBomHistory(bomId, companyCode, { + change_type, + change_description, + revision, + version, + changed_by: changedBy, + }, tableName); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 이력 등록 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── BOM 헤더 조회 (entity join 포함) ───────────────────────── + +export async function getBomHeader(req: Request, res: Response) { + try { + const { bomId } = req.params; + const tableName = (req.query.tableName as string) || undefined; + + const data = await bomService.getBomHeader(bomId, tableName); + if (!data) { + res.status(404).json({ success: false, message: "BOM을 찾을 수 없습니다" }); + return; + } + res.json({ success: true, data }); + } catch (error: any) { + logger.error("BOM 헤더 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +// ─── 버전 (Version) ───────────────────────────── + +export async function getBomVersions(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const tableName = (req.query.tableName as string) || undefined; + + const result = await bomService.getBomVersions(bomId, companyCode, tableName); + res.json({ + success: true, + data: result.versions, + currentVersionId: result.currentVersionId, + }); + } catch (error: any) { + logger.error("BOM 버전 목록 조회 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function createBomVersion(req: Request, res: Response) { + try { + const { bomId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const createdBy = (req as any).user?.userName || (req as any).user?.userId || ""; + const { tableName, detailTable } = req.body || {}; + + const result = await bomService.createBomVersion(bomId, companyCode, createdBy, tableName, detailTable); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 버전 생성 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function loadBomVersion(req: Request, res: Response) { + try { + const { bomId, versionId } = req.params; + const companyCode = (req as any).user?.companyCode || "*"; + const { tableName, detailTable } = req.body || {}; + + const result = await bomService.loadBomVersion(bomId, versionId, companyCode, tableName, detailTable); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 버전 불러오기 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function activateBomVersion(req: Request, res: Response) { + try { + const { bomId, versionId } = req.params; + const { tableName } = req.body || {}; + + const result = await bomService.activateBomVersion(bomId, versionId, tableName); + res.json({ success: true, data: result }); + } catch (error: any) { + logger.error("BOM 버전 사용 확정 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} + +export async function deleteBomVersion(req: Request, res: Response) { + try { + const { bomId, versionId } = req.params; + const tableName = (req.query.tableName as string) || undefined; + const detailTable = (req.query.detailTable as string) || undefined; + + const deleted = await bomService.deleteBomVersion(bomId, versionId, tableName, detailTable); + if (!deleted) { + res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" }); + return; + } + res.json({ success: true }); + } catch (error: any) { + logger.error("BOM 버전 삭제 실패", { error: error.message }); + res.status(500).json({ success: false, message: error.message }); + } +} diff --git a/backend-node/src/controllers/entitySearchController.ts b/backend-node/src/controllers/entitySearchController.ts index bbc42568..3ece2ce7 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -115,7 +115,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re export async function getEntityOptions(req: AuthenticatedRequest, res: Response) { try { const { tableName } = req.params; - const { value = "id", label = "name" } = req.query; + const { value = "id", label = "name", fields } = req.query; // tableName 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -167,9 +167,21 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) ? `WHERE ${whereConditions.join(" AND ")}` : ""; + // autoFill용 추가 컬럼 처리 + let extraColumns = ""; + if (fields && typeof fields === "string") { + const requestedFields = fields.split(",").map((f) => f.trim()).filter(Boolean); + const validExtra = requestedFields.filter( + (f) => existingColumns.has(f) && f !== valueColumn && f !== effectiveLabelColumn + ); + if (validExtra.length > 0) { + extraColumns = ", " + validExtra.map((f) => `"${f}"`).join(", "); + } + } + // 쿼리 실행 (최대 500개) const query = ` - SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label + SELECT ${valueColumn} as value, ${effectiveLabelColumn} as label${extraColumns} FROM ${tableName} ${whereClause} ORDER BY ${effectiveLabelColumn} ASC @@ -184,6 +196,7 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) labelColumn: effectiveLabelColumn, companyCode, rowCount: result.rowCount, + extraFields: extraColumns ? true : false, }); res.json({ diff --git a/backend-node/src/controllers/processWorkStandardController.ts b/backend-node/src/controllers/processWorkStandardController.ts index 7c38d6d9..e72f6b9f 100644 --- a/backend-node/src/controllers/processWorkStandardController.ts +++ b/backend-node/src/controllers/processWorkStandardController.ts @@ -39,16 +39,18 @@ export async function getItemsWithRouting(req: AuthenticatedRequest, res: Respon if (search) params.push(`%${search}%`); const query = ` - SELECT DISTINCT + SELECT i.id, i.${nameColumn} AS item_name, - i.${codeColumn} AS item_code + i.${codeColumn} AS item_code, + COUNT(rv.id) AS routing_count FROM ${tableName} i - INNER JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} + LEFT JOIN ${routingTable} rv ON rv.${routingFkColumn} = i.${codeColumn} AND rv.company_code = i.company_code WHERE i.company_code = $1 ${searchCondition} - ORDER BY i.${codeColumn} + GROUP BY i.id, i.${nameColumn}, i.${codeColumn}, i.created_date + ORDER BY i.created_date DESC NULLS LAST `; const result = await getPool().query(query, params); @@ -82,10 +84,10 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R // 라우팅 버전 목록 const versionsQuery = ` - SELECT id, version_name, description, created_date + SELECT id, version_name, description, created_date, COALESCE(is_default, false) AS is_default FROM ${routingVersionTable} WHERE ${routingFkColumn} = $1 AND company_code = $2 - ORDER BY created_date DESC + ORDER BY is_default DESC, created_date DESC `; const versionsResult = await getPool().query(versionsQuery, [ itemCode, @@ -127,6 +129,92 @@ export async function getRoutingsWithProcesses(req: AuthenticatedRequest, res: R } } +// ============================================================ +// 기본 버전 설정 +// ============================================================ + +/** + * 라우팅 버전을 기본 버전으로 설정 + * 같은 품목의 다른 버전은 기본 해제 + */ +export async function setDefaultVersion(req: AuthenticatedRequest, res: Response) { + const pool = getPool(); + const client = await pool.connect(); + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { versionId } = req.params; + const { + routingVersionTable = "item_routing_version", + routingFkColumn = "item_code", + } = req.body; + + await client.query("BEGIN"); + + const versionResult = await client.query( + `SELECT ${routingFkColumn} AS item_code FROM ${routingVersionTable} WHERE id = $1 AND company_code = $2`, + [versionId, companyCode] + ); + + if (versionResult.rowCount === 0) { + await client.query("ROLLBACK"); + return res.status(404).json({ success: false, message: "버전을 찾을 수 없습니다" }); + } + + const itemCode = versionResult.rows[0].item_code; + + await client.query( + `UPDATE ${routingVersionTable} SET is_default = false WHERE ${routingFkColumn} = $1 AND company_code = $2`, + [itemCode, companyCode] + ); + + await client.query( + `UPDATE ${routingVersionTable} SET is_default = true WHERE id = $1 AND company_code = $2`, + [versionId, companyCode] + ); + + await client.query("COMMIT"); + + logger.info("기본 버전 설정", { companyCode, versionId, itemCode }); + return res.json({ success: true, message: "기본 버전이 설정되었습니다" }); + } catch (error: any) { + await client.query("ROLLBACK"); + logger.error("기본 버전 설정 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } finally { + client.release(); + } +} + +/** + * 기본 버전 해제 + */ +export async function unsetDefaultVersion(req: AuthenticatedRequest, res: Response) { + try { + const companyCode = req.user?.companyCode; + if (!companyCode) { + return res.status(401).json({ success: false, message: "인증 필요" }); + } + + const { versionId } = req.params; + const { routingVersionTable = "item_routing_version" } = req.body; + + await getPool().query( + `UPDATE ${routingVersionTable} SET is_default = false WHERE id = $1 AND company_code = $2`, + [versionId, companyCode] + ); + + logger.info("기본 버전 해제", { companyCode, versionId }); + return res.json({ success: true, message: "기본 버전이 해제되었습니다" }); + } catch (error: any) { + logger.error("기본 버전 해제 실패", { error: error.message }); + return res.status(500).json({ success: false, message: error.message }); + } +} + // ============================================================ // 작업 항목 CRUD // ============================================================ @@ -330,7 +418,10 @@ export async function getWorkItemDetails(req: AuthenticatedRequest, res: Respons const { workItemId } = req.params; const query = ` - SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, created_date + SELECT id, work_item_id, detail_type, content, is_required, sort_order, remark, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields, + created_date FROM process_work_item_detail WHERE work_item_id = $1 AND company_code = $2 ORDER BY sort_order, created_date @@ -355,7 +446,11 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo return res.status(401).json({ success: false, message: "인증 필요" }); } - const { work_item_id, detail_type, content, is_required, sort_order, remark } = req.body; + const { + work_item_id, detail_type, content, is_required, sort_order, remark, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields, + } = req.body; if (!work_item_id || !content) { return res.status(400).json({ @@ -375,8 +470,10 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo const query = ` INSERT INTO process_work_item_detail - (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING * `; @@ -389,6 +486,15 @@ export async function createWorkItemDetail(req: AuthenticatedRequest, res: Respo sort_order || 0, remark || null, writer, + inspection_code || null, + inspection_method || null, + unit || null, + lower_limit || null, + upper_limit || null, + duration_minutes || null, + input_type || null, + lookup_target || null, + display_fields || null, ]); logger.info("작업 항목 상세 생성", { companyCode, id: result.rows[0].id }); @@ -410,7 +516,11 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo } const { id } = req.params; - const { detail_type, content, is_required, sort_order, remark } = req.body; + const { + detail_type, content, is_required, sort_order, remark, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields, + } = req.body; const query = ` UPDATE process_work_item_detail @@ -419,6 +529,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo is_required = COALESCE($3, is_required), sort_order = COALESCE($4, sort_order), remark = COALESCE($5, remark), + inspection_code = $8, + inspection_method = $9, + unit = $10, + lower_limit = $11, + upper_limit = $12, + duration_minutes = $13, + input_type = $14, + lookup_target = $15, + display_fields = $16, updated_date = NOW() WHERE id = $6 AND company_code = $7 RETURNING * @@ -432,6 +551,15 @@ export async function updateWorkItemDetail(req: AuthenticatedRequest, res: Respo remark, id, companyCode, + inspection_code || null, + inspection_method || null, + unit || null, + lower_limit || null, + upper_limit || null, + duration_minutes || null, + input_type || null, + lookup_target || null, + display_fields || null, ]); if (result.rowCount === 0) { @@ -544,8 +672,10 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { for (const detail of item.details) { await client.query( `INSERT INTO process_work_item_detail - (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + (company_code, work_item_id, detail_type, content, is_required, sort_order, remark, writer, + inspection_code, inspection_method, unit, lower_limit, upper_limit, + duration_minutes, input_type, lookup_target, display_fields) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`, [ companyCode, workItemId, @@ -555,6 +685,15 @@ export async function saveAll(req: AuthenticatedRequest, res: Response) { detail.sort_order || 0, detail.remark || null, writer, + detail.inspection_code || null, + detail.inspection_method || null, + detail.unit || null, + detail.lower_limit || null, + detail.upper_limit || null, + detail.duration_minutes || null, + detail.input_type || null, + detail.lookup_target || null, + detail.display_fields || null, ] ); } diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 320ab74b..8cd9f770 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -921,14 +921,51 @@ export async function addTableData( } } + // 회사별 NOT NULL 소프트 제약조건 검증 + const notNullViolations = await tableManagementService.validateNotNullConstraints( + tableName, + data, + companyCode || "*" + ); + if (notNullViolations.length > 0) { + res.status(400).json({ + success: false, + message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`, + error: { + code: "NOT_NULL_VIOLATION", + details: notNullViolations, + }, + }); + return; + } + + // 회사별 UNIQUE 소프트 제약조건 검증 + const uniqueViolations = await tableManagementService.validateUniqueConstraints( + tableName, + data, + companyCode || "*" + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + error: { + code: "UNIQUE_VIOLATION", + details: uniqueViolations, + }, + }); + return; + } + // 데이터 추가 - await tableManagementService.addTableData(tableName, data); + const result = await tableManagementService.addTableData(tableName, data); - logger.info(`테이블 데이터 추가 완료: ${tableName}`); + logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`); - const response: ApiResponse = { + const response: ApiResponse<{ id: string | null }> = { success: true, message: "테이블 데이터를 성공적으로 추가했습니다.", + data: { id: result.insertedId }, }; res.status(201).json(response); @@ -1003,6 +1040,45 @@ export async function editTableData( } const tableManagementService = new TableManagementService(); + const companyCode = req.user?.companyCode || "*"; + + // 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상) + const notNullViolations = await tableManagementService.validateNotNullConstraints( + tableName, + updatedData, + companyCode + ); + if (notNullViolations.length > 0) { + res.status(400).json({ + success: false, + message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`, + error: { + code: "NOT_NULL_VIOLATION", + details: notNullViolations, + }, + }); + return; + } + + // 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외) + const excludeId = originalData?.id ? String(originalData.id) : undefined; + const uniqueViolations = await tableManagementService.validateUniqueConstraints( + tableName, + updatedData, + companyCode, + excludeId + ); + if (uniqueViolations.length > 0) { + res.status(400).json({ + success: false, + message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`, + error: { + code: "UNIQUE_VIOLATION", + details: uniqueViolations, + }, + }); + return; + } // 데이터 수정 await tableManagementService.editTableData( @@ -1693,6 +1769,7 @@ export async function getCategoryColumnsByCompany( let columnsResult; // 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회 + // category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함) if (companyCode === "*") { const columnsQuery = ` SELECT DISTINCT @@ -1712,15 +1789,15 @@ export async function getCategoryColumnsByCompany( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = '*' + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery); - logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", { + logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", { rowCount: columnsResult.rows.length }); } else { - // 일반 회사: 해당 회사의 카테고리 컬럼만 조회 const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", @@ -1739,11 +1816,12 @@ export async function getCategoryColumnsByCompany( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = $1 + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery, [companyCode]); - logger.info("✅ 회사별 카테고리 컬럼 조회 완료", { + logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", { companyCode, rowCount: columnsResult.rows.length }); @@ -1804,13 +1882,10 @@ export async function getCategoryColumnsByMenu( const { getPool } = await import("../database/db"); const pool = getPool(); - // 🆕 table_type_columns에서 직접 input_type = 'category'인 컬럼들을 조회 - // category_column_mapping 대신 table_type_columns 기준으로 조회 - logger.info("🔍 table_type_columns 기반 카테고리 컬럼 조회", { menuObjid, companyCode }); - + // table_type_columns에서 input_type = 'category' 컬럼 조회 + // category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함) let columnsResult; - // 최고 관리자인 경우 모든 회사의 카테고리 컬럼 조회 if (companyCode === "*") { const columnsQuery = ` SELECT DISTINCT @@ -1830,15 +1905,15 @@ export async function getCategoryColumnsByMenu( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = '*' + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery); - logger.info("✅ 최고 관리자: 전체 카테고리 컬럼 조회 완료", { + logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", { rowCount: columnsResult.rows.length }); } else { - // 일반 회사: 해당 회사의 카테고리 컬럼만 조회 const columnsQuery = ` SELECT DISTINCT ttc.table_name AS "tableName", @@ -1857,11 +1932,12 @@ export async function getCategoryColumnsByMenu( ON ttc.table_name = tl.table_name WHERE ttc.input_type = 'category' AND ttc.company_code = $1 + AND (ttc.category_ref IS NULL OR ttc.category_ref = '') ORDER BY ttc.table_name, ttc.column_name `; columnsResult = await pool.query(columnsQuery, [companyCode]); - logger.info("✅ 회사별 카테고리 컬럼 조회 완료", { + logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", { companyCode, rowCount: columnsResult.rows.length }); @@ -2616,8 +2692,22 @@ export async function toggleTableIndex( logger.info(`인덱스 ${action}: ${indexName} (${indexType})`); if (action === "create") { + let indexColumns = `"${columnName}"`; + + // 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장) + if (indexType === "unique") { + const hasCompanyCode = await query( + `SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + if (hasCompanyCode.length > 0) { + indexColumns = `"company_code", "${columnName}"`; + logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`); + } + } + const uniqueClause = indexType === "unique" ? "UNIQUE " : ""; - const sql = `CREATE ${uniqueClause}INDEX "${indexName}" ON "public"."${tableName}" ("${columnName}")`; + const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`; logger.info(`인덱스 생성: ${sql}`); await query(sql); } else if (action === "drop") { @@ -2638,22 +2728,55 @@ export async function toggleTableIndex( } catch (error: any) { logger.error("인덱스 토글 오류:", error); - // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 안내 - const errorMsg = error.message?.includes("duplicate key") - ? "중복 데이터가 있어 UNIQUE 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요." - : "인덱스 설정 중 오류가 발생했습니다."; + const errMsg = error.message || ""; + let userMessage = "인덱스 설정 중 오류가 발생했습니다."; + let duplicates: any[] = []; + + // 중복 데이터로 인한 UNIQUE 인덱스 생성 실패 + if ( + errMsg.includes("could not create unique index") || + errMsg.includes("duplicate key") + ) { + const { columnName, tableName } = { ...req.params, ...req.body }; + try { + duplicates = await query( + `SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10` + ); + } catch { + try { + duplicates = await query( + `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10` + ); + } catch { /* 중복 조회 실패 시 무시 */ } + } + + const dupDetails = duplicates.length > 0 + ? duplicates.map((d: any) => { + const company = d.company_code ? `[${d.company_code}] ` : ""; + return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`; + }).join(", ") + : ""; + + userMessage = dupDetails + ? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}` + : `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`; + } res.status(500).json({ success: false, - message: errorMsg, - error: error instanceof Error ? error.message : "Unknown error", + message: userMessage, + error: errMsg, + duplicates, }); } } /** - * NOT NULL 토글 + * NOT NULL 토글 (회사별 소프트 제약조건) * PUT /api/table-management/tables/:tableName/columns/:columnName/nullable + * + * DB 레벨 ALTER TABLE 대신 table_type_columns.is_nullable을 회사별로 관리한다. + * 멀티테넌시 환경에서 회사 A는 NOT NULL, 회사 B는 NULL 허용이 가능하다. */ export async function toggleColumnNullable( req: AuthenticatedRequest, @@ -2662,6 +2785,7 @@ export async function toggleColumnNullable( try { const { tableName, columnName } = req.params; const { nullable } = req.body; + const companyCode = req.user?.companyCode || "*"; if (!tableName || !columnName || typeof nullable !== "boolean") { res.status(400).json({ @@ -2671,18 +2795,54 @@ export async function toggleColumnNullable( return; } - if (nullable) { - // NOT NULL 해제 - const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" DROP NOT NULL`; - logger.info(`NOT NULL 해제: ${sql}`); - await query(sql); - } else { - // NOT NULL 설정 - const sql = `ALTER TABLE "public"."${tableName}" ALTER COLUMN "${columnName}" SET NOT NULL`; - logger.info(`NOT NULL 설정: ${sql}`); - await query(sql); + // is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL + const isNullableValue = nullable ? "Y" : "N"; + + if (!nullable) { + // NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인 + const hasCompanyCode = await query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + if (hasCompanyCode.length > 0) { + const nullCheckQuery = companyCode === "*" + ? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL` + : `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`; + const nullCheckParams = companyCode === "*" ? [] : [companyCode]; + + const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams); + const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10); + + if (nullCount > 0) { + logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, { + companyCode, + nullCount, + }); + + res.status(400).json({ + success: false, + message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`, + }); + return; + } + } } + // table_type_columns에 회사별 is_nullable 설정 UPSERT + await query( + `INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + ON CONFLICT (table_name, column_name, company_code) + DO UPDATE SET is_nullable = $3, updated_date = NOW()`, + [tableName, columnName, isNullableValue, companyCode] + ); + + logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, { + companyCode, + }); + res.status(200).json({ success: true, message: nullable @@ -2692,14 +2852,95 @@ export async function toggleColumnNullable( } catch (error: any) { logger.error("NOT NULL 토글 오류:", error); - // NULL 데이터가 있는 컬럼에 NOT NULL 설정 시 안내 - const errorMsg = error.message?.includes("contains null values") - ? "해당 컬럼에 NULL 값이 있어 NOT NULL 설정이 불가합니다. NULL 데이터를 먼저 정리해주세요." - : "NOT NULL 설정 중 오류가 발생했습니다."; - res.status(500).json({ success: false, - message: errorMsg, + message: "NOT NULL 설정 중 오류가 발생했습니다.", + error: error instanceof Error ? error.message : "Unknown error", + }); + } +} + +/** + * UNIQUE 토글 (회사별 소프트 제약조건) + * PUT /api/table-management/tables/:tableName/columns/:columnName/unique + * + * DB 레벨 인덱스 대신 table_type_columns.is_unique를 회사별로 관리한다. + * 저장 시 앱 레벨에서 중복 검증을 수행한다. + */ +export async function toggleColumnUnique( + req: AuthenticatedRequest, + res: Response +): Promise { + try { + const { tableName, columnName } = req.params; + const { unique } = req.body; + const companyCode = req.user?.companyCode || "*"; + + if (!tableName || !columnName || typeof unique !== "boolean") { + res.status(400).json({ + success: false, + message: "tableName, columnName, unique(boolean)이 필요합니다.", + }); + return; + } + + const isUniqueValue = unique ? "Y" : "N"; + + if (unique) { + // UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인 + const hasCompanyCode = await query<{ column_name: string }>( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + if (hasCompanyCode.length > 0) { + const dupQuery = companyCode === "*" + ? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10` + : `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`; + const dupParams = companyCode === "*" ? [] : [companyCode]; + + const dupResult = await query(dupQuery, dupParams); + + if (dupResult.length > 0) { + const dupDetails = dupResult + .map((d: any) => `"${d[columnName]}" (${d.cnt}건)`) + .join(", "); + + res.status(400).json({ + success: false, + message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`, + }); + return; + } + } + } + + // table_type_columns에 회사별 is_unique 설정 UPSERT + await query( + `INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date) + VALUES ($1, $2, $3, $4, NOW(), NOW()) + ON CONFLICT (table_name, column_name, company_code) + DO UPDATE SET is_unique = $3, updated_date = NOW()`, + [tableName, columnName, isUniqueValue, companyCode] + ); + + logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, { + companyCode, + }); + + res.status(200).json({ + success: true, + message: unique + ? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.` + : `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`, + }); + } catch (error: any) { + logger.error("UNIQUE 토글 오류:", error); + + res.status(500).json({ + success: false, + message: "UNIQUE 설정 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error", }); } diff --git a/backend-node/src/routes/bomRoutes.ts b/backend-node/src/routes/bomRoutes.ts new file mode 100644 index 00000000..f6e3ee62 --- /dev/null +++ b/backend-node/src/routes/bomRoutes.ts @@ -0,0 +1,27 @@ +/** + * BOM 이력/버전 관리 라우트 + */ + +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import * as bomController from "../controllers/bomController"; + +const router = Router(); + +router.use(authenticateToken); + +// BOM 헤더 (entity join 포함) +router.get("/:bomId/header", bomController.getBomHeader); + +// 이력 +router.get("/:bomId/history", bomController.getBomHistory); +router.post("/:bomId/history", bomController.addBomHistory); + +// 버전 +router.get("/:bomId/versions", bomController.getBomVersions); +router.post("/:bomId/versions", bomController.createBomVersion); +router.post("/:bomId/versions/:versionId/load", bomController.loadBomVersion); +router.post("/:bomId/versions/:versionId/activate", bomController.activateBomVersion); +router.delete("/:bomId/versions/:versionId", bomController.deleteBomVersion); + +export default router; diff --git a/backend-node/src/routes/processWorkStandardRoutes.ts b/backend-node/src/routes/processWorkStandardRoutes.ts index 0c052007..7630b359 100644 --- a/backend-node/src/routes/processWorkStandardRoutes.ts +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -14,6 +14,10 @@ router.use(authenticateToken); router.get("/items", ctrl.getItemsWithRouting); router.get("/items/:itemCode/routings", ctrl.getRoutingsWithProcesses); +// 기본 버전 설정/해제 +router.put("/versions/:versionId/set-default", ctrl.setDefaultVersion); +router.put("/versions/:versionId/unset-default", ctrl.unsetDefaultVersion); + // 작업 항목 CRUD router.get("/routing-detail/:routingDetailId/work-items", ctrl.getWorkItems); router.post("/work-items", ctrl.createWorkItem); diff --git a/backend-node/src/routes/tableManagementRoutes.ts b/backend-node/src/routes/tableManagementRoutes.ts index d02a5615..a8964e99 100644 --- a/backend-node/src/routes/tableManagementRoutes.ts +++ b/backend-node/src/routes/tableManagementRoutes.ts @@ -32,6 +32,7 @@ import { setTablePrimaryKey, // 🆕 PK 설정 toggleTableIndex, // 🆕 인덱스 토글 toggleColumnNullable, // 🆕 NOT NULL 토글 + toggleColumnUnique, // 🆕 UNIQUE 토글 } from "../controllers/tableManagementController"; const router = express.Router(); @@ -161,6 +162,12 @@ router.post("/tables/:tableName/indexes", toggleTableIndex); */ router.put("/tables/:tableName/columns/:columnName/nullable", toggleColumnNullable); +/** + * UNIQUE 토글 + * PUT /api/table-management/tables/:tableName/columns/:columnName/unique + */ +router.put("/tables/:tableName/columns/:columnName/unique", toggleColumnUnique); + /** * 테이블 존재 여부 확인 * GET /api/table-management/tables/:tableName/exists diff --git a/backend-node/src/services/bomService.ts b/backend-node/src/services/bomService.ts new file mode 100644 index 00000000..89da38a9 --- /dev/null +++ b/backend-node/src/services/bomService.ts @@ -0,0 +1,292 @@ +/** + * BOM 이력 및 버전 관리 서비스 + * 행(Row) 기반 버전 관리: bom_detail.version_id로 버전별 데이터 분리 + */ + +import { query, queryOne, transaction } from "../database/db"; +import { logger } from "../utils/logger"; + +function safeTableName(name: string, fallback: string): string { + if (!name || !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) return fallback; + return name; +} + +// ─── 이력 (History) ───────────────────────────── + +export async function getBomHistory(bomId: string, companyCode: string, tableName?: string) { + const table = safeTableName(tableName || "", "bom_history"); + const sql = companyCode === "*" + ? `SELECT * FROM ${table} WHERE bom_id = $1 ORDER BY changed_date DESC` + : `SELECT * FROM ${table} WHERE bom_id = $1 AND company_code = $2 ORDER BY changed_date DESC`; + const params = companyCode === "*" ? [bomId] : [bomId, companyCode]; + return query(sql, params); +} + +export async function addBomHistory( + bomId: string, + companyCode: string, + data: { + revision?: string; + version?: string; + change_type: string; + change_description?: string; + changed_by?: string; + }, + tableName?: string, +) { + const table = safeTableName(tableName || "", "bom_history"); + const sql = ` + INSERT INTO ${table} (bom_id, revision, version, change_type, change_description, changed_by, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `; + return queryOne(sql, [ + bomId, + data.revision || null, + data.version || null, + data.change_type, + data.change_description || null, + data.changed_by || null, + companyCode, + ]); +} + +// ─── 버전 (Version) ───────────────────────────── + +// ─── BOM 헤더 조회 (entity join 포함) ───────────────────────────── + +export async function getBomHeader(bomId: string, tableName?: string) { + const table = safeTableName(tableName || "", "bom"); + const sql = ` + SELECT b.*, + i.item_name, i.item_number, i.division as item_type, i.unit + FROM ${table} b + LEFT JOIN item_info i ON b.item_id = i.id + WHERE b.id = $1 + LIMIT 1 + `; + return queryOne>(sql, [bomId]); +} + +export async function getBomVersions(bomId: string, companyCode: string, tableName?: string) { + const table = safeTableName(tableName || "", "bom_version"); + const dTable = "bom_detail"; + + // 버전 목록 + 각 버전별 디테일 건수 + 현재 활성 버전 ID + const sql = companyCode === "*" + ? `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count + FROM ${table} v WHERE v.bom_id = $1 ORDER BY v.created_date DESC` + : `SELECT v.*, (SELECT COUNT(*) FROM ${dTable} d WHERE d.version_id = v.id) as detail_count + FROM ${table} v WHERE v.bom_id = $1 AND v.company_code = $2 ORDER BY v.created_date DESC`; + const params = companyCode === "*" ? [bomId] : [bomId, companyCode]; + const versions = await query(sql, params); + + // bom.current_version_id도 함께 반환 + const bomRow = await queryOne<{ current_version_id: string }>( + `SELECT current_version_id FROM bom WHERE id = $1`, [bomId], + ); + + return { + versions, + currentVersionId: bomRow?.current_version_id || null, + }; +} + +/** + * 새 버전 생성: 현재 활성 버전의 bom_detail 행을 복사하여 새 version_id로 INSERT + */ +export async function createBomVersion( + bomId: string, companyCode: string, createdBy: string, + versionTableName?: string, detailTableName?: string, +) { + const vTable = safeTableName(versionTableName || "", "bom_version"); + const dTable = safeTableName(detailTableName || "", "bom_detail"); + + return transaction(async (client) => { + const bomRow = await client.query(`SELECT * FROM bom WHERE id = $1`, [bomId]); + if (bomRow.rows.length === 0) throw new Error("BOM을 찾을 수 없습니다"); + const bomData = bomRow.rows[0]; + + // 다음 버전 번호 결정 + const lastVersion = await client.query( + `SELECT version_name FROM ${vTable} WHERE bom_id = $1 ORDER BY created_date DESC LIMIT 1`, + [bomId], + ); + let nextVersionNum = 1; + if (lastVersion.rows.length > 0) { + const parsed = parseFloat(lastVersion.rows[0].version_name); + if (!isNaN(parsed)) nextVersionNum = Math.floor(parsed) + 1; + } + const versionName = `${nextVersionNum}.0`; + + // 새 버전 레코드 생성 (snapshot_data 없이) + const insertSql = ` + INSERT INTO ${vTable} (bom_id, version_name, revision, status, created_by, company_code) + VALUES ($1, $2, $3, 'developing', $4, $5) + RETURNING * + `; + const newVersion = await client.query(insertSql, [ + bomId, + versionName, + bomData.revision ? parseInt(bomData.revision, 10) || 0 : 0, + createdBy, + companyCode, + ]); + const newVersionId = newVersion.rows[0].id; + + // 현재 활성 버전의 bom_detail 행을 복사 + const sourceVersionId = bomData.current_version_id; + if (sourceVersionId) { + const sourceDetails = await client.query( + `SELECT * FROM ${dTable} WHERE bom_id = $1 AND version_id = $2 ORDER BY parent_detail_id NULLS FIRST, id`, + [bomId, sourceVersionId], + ); + + // old ID → new ID 매핑 (parent_detail_id 유지) + const oldToNew: Record = {}; + for (const d of sourceDetails.rows) { + const insertResult = await client.query( + `INSERT INTO ${dTable} (bom_id, version_id, parent_detail_id, child_item_id, quantity, unit, process_type, loss_rate, remark, level, base_qty, revision, seq_no, writer, company_code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id`, + [ + bomId, + newVersionId, + d.parent_detail_id ? (oldToNew[d.parent_detail_id] || null) : null, + d.child_item_id, + d.quantity, + d.unit, + d.process_type, + d.loss_rate, + d.remark, + d.level, + d.base_qty, + d.revision, + d.seq_no, + d.writer, + companyCode, + ], + ); + oldToNew[d.id] = insertResult.rows[0].id; + } + + logger.info("BOM 버전 생성 - 디테일 복사 완료", { + bomId, versionName, sourceVersionId, copiedCount: sourceDetails.rows.length, + }); + } + + // BOM 헤더의 version과 current_version_id 갱신 + await client.query( + `UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`, + [versionName, newVersionId, bomId], + ); + + logger.info("BOM 버전 생성 완료", { bomId, versionName, newVersionId, companyCode }); + return newVersion.rows[0]; + }); +} + +/** + * 버전 불러오기: bom_detail 삭제/복원 없이 current_version_id만 전환 + */ +export async function loadBomVersion( + bomId: string, versionId: string, companyCode: string, + versionTableName?: string, _detailTableName?: string, +) { + const vTable = safeTableName(versionTableName || "", "bom_version"); + + return transaction(async (client) => { + const verRow = await client.query( + `SELECT * FROM ${vTable} WHERE id = $1 AND bom_id = $2`, + [versionId, bomId], + ); + if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다"); + + const versionName = verRow.rows[0].version_name; + + // BOM 헤더의 version과 current_version_id만 전환 + await client.query( + `UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`, + [versionName, versionId, bomId], + ); + + logger.info("BOM 버전 불러오기 완료", { bomId, versionId, versionName }); + return { restored: true, versionName }; + }); +} + +/** + * 사용 확정: 선택 버전을 active로 변경 + current_version_id 갱신 + */ +export async function activateBomVersion(bomId: string, versionId: string, tableName?: string) { + const table = safeTableName(tableName || "", "bom_version"); + + return transaction(async (client) => { + const verRow = await client.query( + `SELECT version_name FROM ${table} WHERE id = $1 AND bom_id = $2`, + [versionId, bomId], + ); + if (verRow.rows.length === 0) throw new Error("버전을 찾을 수 없습니다"); + + // 기존 active -> inactive + await client.query( + `UPDATE ${table} SET status = 'inactive' WHERE bom_id = $1 AND status = 'active'`, + [bomId], + ); + // 선택한 버전 -> active + await client.query( + `UPDATE ${table} SET status = 'active' WHERE id = $1`, + [versionId], + ); + // BOM 헤더 갱신 + const versionName = verRow.rows[0].version_name; + await client.query( + `UPDATE bom SET version = $1, current_version_id = $2 WHERE id = $3`, + [versionName, versionId, bomId], + ); + + logger.info("BOM 버전 사용 확정", { bomId, versionId, versionName }); + return { activated: true, versionName }; + }); +} + +/** + * 버전 삭제: 해당 version_id의 bom_detail 행도 함께 삭제 + */ +export async function deleteBomVersion( + bomId: string, versionId: string, + tableName?: string, detailTableName?: string, +) { + const table = safeTableName(tableName || "", "bom_version"); + const dTable = safeTableName(detailTableName || "", "bom_detail"); + + return transaction(async (client) => { + // active 상태 버전은 삭제 불가 + const checkResult = await client.query( + `SELECT status FROM ${table} WHERE id = $1 AND bom_id = $2`, + [versionId, bomId], + ); + if (checkResult.rows.length === 0) throw new Error("버전을 찾을 수 없습니다"); + if (checkResult.rows[0].status === "active") { + throw new Error("사용중인 버전은 삭제할 수 없습니다"); + } + + // 해당 버전의 bom_detail 행 삭제 + const deleteDetails = await client.query( + `DELETE FROM ${dTable} WHERE bom_id = $1 AND version_id = $2`, + [bomId, versionId], + ); + + // 버전 레코드 삭제 + const deleteVersion = await client.query( + `DELETE FROM ${table} WHERE id = $1 AND bom_id = $2 RETURNING id`, + [versionId, bomId], + ); + + logger.info("BOM 버전 삭제", { + bomId, versionId, + deletedDetails: deleteDetails.rowCount, + }); + + return deleteVersion.rows.length > 0; + }); +} diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index a8765d18..2888a1f3 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -14,6 +14,35 @@ interface NumberingRulePart { autoConfig?: any; manualConfig?: any; generatedValue?: string; + separatorAfter?: string; +} + +/** + * 파트 배열에서 autoConfig.separatorAfter를 파트 레벨로 추출 + */ +function extractSeparatorAfterFromParts(parts: any[]): any[] { + return parts.map((part) => { + if (part.autoConfig?.separatorAfter !== undefined) { + part.separatorAfter = part.autoConfig.separatorAfter; + } + return part; + }); +} + +/** + * 파트별 개별 구분자를 사용하여 코드 결합 + * 마지막 파트의 separatorAfter는 무시됨 + */ +function joinPartsWithSeparators(partValues: string[], sortedParts: any[], globalSeparator: string): string { + let result = ""; + partValues.forEach((val, idx) => { + result += val; + if (idx < partValues.length - 1) { + const sep = sortedParts[idx].separatorAfter ?? sortedParts[idx].autoConfig?.separatorAfter ?? globalSeparator; + result += sep; + } + }); + return result; } interface NumberingRuleConfig { @@ -141,7 +170,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info(`채번 규칙 목록 조회 완료: ${result.rows.length}개`, { @@ -274,7 +303,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } return result.rows; @@ -381,7 +410,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("✅ 규칙 파트 조회 성공", { ruleId: rule.ruleId, @@ -517,7 +546,7 @@ class NumberingRuleService { companyCode === "*" ? rule.companyCode : companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info(`화면용 채번 규칙 조회 완료: ${result.rows.length}개`, { @@ -633,7 +662,7 @@ class NumberingRuleService { } const partsResult = await pool.query(partsQuery, partsParams); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); return rule; } @@ -708,17 +737,25 @@ class NumberingRuleService { manual_config AS "manualConfig" `; + // auto_config에 separatorAfter 포함 + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + const partResult = await client.query(insertPartQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); - parts.push(partResult.rows[0]); + const savedPart = partResult.rows[0]; + // autoConfig에서 separatorAfter를 추출하여 파트 레벨로 이동 + if (savedPart.autoConfig?.separatorAfter !== undefined) { + savedPart.separatorAfter = savedPart.autoConfig.separatorAfter; + } + parts.push(savedPart); } await client.query("COMMIT"); @@ -820,17 +857,23 @@ class NumberingRuleService { manual_config AS "manualConfig" `; + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + const partResult = await client.query(insertPartQuery, [ ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); - parts.push(partResult.rows[0]); + const savedPart = partResult.rows[0]; + if (savedPart.autoConfig?.separatorAfter !== undefined) { + savedPart.separatorAfter = savedPart.autoConfig.separatorAfter; + } + parts.push(savedPart); } } @@ -1053,7 +1096,8 @@ class NumberingRuleService { } })); - const previewCode = parts.join(rule.separator || ""); + const sortedRuleParts = rule.parts.sort((a: any, b: any) => a.order - b.order); + const previewCode = joinPartsWithSeparators(parts, sortedRuleParts, rule.separator || ""); logger.info("코드 미리보기 생성", { ruleId, previewCode, @@ -1164,8 +1208,8 @@ class NumberingRuleService { } })); - const separator = rule.separator || ""; - const previewTemplate = previewParts.join(separator); + const sortedPartsForTemplate = rule.parts.sort((a: any, b: any) => a.order - b.order); + const previewTemplate = joinPartsWithSeparators(previewParts, sortedPartsForTemplate, rule.separator || ""); // 사용자 입력 코드에서 수동 입력 부분 추출 // 예: 템플릿 "R-____-XXX", 사용자입력 "R-MYVALUE-012" → "MYVALUE" 추출 @@ -1382,7 +1426,8 @@ class NumberingRuleService { } })); - const allocatedCode = parts.join(rule.separator || ""); + const sortedPartsForAlloc = rule.parts.sort((a: any, b: any) => a.order - b.order); + const allocatedCode = joinPartsWithSeparators(parts, sortedPartsForAlloc, rule.separator || ""); // 순번이 있는 경우에만 증가 const hasSequence = rule.parts.some( @@ -1541,7 +1586,7 @@ class NumberingRuleService { rule.ruleId, companyCode === "*" ? rule.companyCode : companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } logger.info("[테스트] 채번 규칙 목록 조회 완료", { @@ -1634,7 +1679,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("테이블+컬럼 기반 채번 규칙 조회 성공 (테스트)", { ruleId: rule.ruleId, @@ -1754,12 +1799,14 @@ class NumberingRuleService { auto_config, manual_config, company_code, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) `; + const autoConfigWithSep = { ...(part.autoConfig || {}), separatorAfter: part.separatorAfter ?? "-" }; + await client.query(partInsertQuery, [ config.ruleId, part.order, part.partType, part.generationMethod, - JSON.stringify(part.autoConfig || {}), + JSON.stringify(autoConfigWithSep), JSON.stringify(part.manualConfig || {}), companyCode, ]); @@ -1914,7 +1961,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("카테고리 조건 매칭 채번 규칙 찾음", { ruleId: rule.ruleId, @@ -1973,7 +2020,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); logger.info("기본 채번 규칙 찾음 (카테고리 조건 없음)", { ruleId: rule.ruleId, @@ -2056,7 +2103,7 @@ class NumberingRuleService { rule.ruleId, companyCode, ]); - rule.parts = partsResult.rows; + rule.parts = extractSeparatorAfterFromParts(partsResult.rows); } return result.rows; diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 6e0f3944..791940ec 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -199,7 +199,15 @@ export class TableManagementService { cl.input_type as "cl_input_type", COALESCE(ttc.detail_settings::text, cl.detail_settings::text, '') as "detailSettings", COALESCE(ttc.description, cl.description, '') as "description", - c.is_nullable as "isNullable", + CASE + WHEN COALESCE(ttc.is_nullable, cl.is_nullable) IS NOT NULL + THEN CASE WHEN COALESCE(ttc.is_nullable, cl.is_nullable) = 'N' THEN 'NO' ELSE 'YES' END + ELSE c.is_nullable + END as "isNullable", + CASE + WHEN COALESCE(ttc.is_unique, cl.is_unique) = 'Y' THEN 'YES' + ELSE 'NO' + END as "isUnique", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -241,7 +249,15 @@ export class TableManagementService { COALESCE(cl.input_type, 'direct') as "inputType", COALESCE(cl.detail_settings::text, '') as "detailSettings", COALESCE(cl.description, '') as "description", - c.is_nullable as "isNullable", + CASE + WHEN cl.is_nullable IS NOT NULL + THEN CASE WHEN cl.is_nullable = 'N' THEN 'NO' ELSE 'YES' END + ELSE c.is_nullable + END as "isNullable", + CASE + WHEN cl.is_unique = 'Y' THEN 'YES' + ELSE 'NO' + END as "isUnique", CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END as "isPrimaryKey", c.column_default as "defaultValue", c.character_maximum_length as "maxLength", @@ -502,8 +518,8 @@ export class TableManagementService { table_name, column_name, column_label, input_type, detail_settings, code_category, code_value, reference_table, reference_column, display_column, display_order, is_visible, is_nullable, - company_code, created_date, updated_date - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, NOW(), NOW()) + company_code, category_ref, created_date, updated_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'Y', $13, $14, NOW(), NOW()) ON CONFLICT (table_name, column_name, company_code) DO UPDATE SET column_label = COALESCE(EXCLUDED.column_label, table_type_columns.column_label), @@ -516,6 +532,7 @@ export class TableManagementService { display_column = COALESCE(EXCLUDED.display_column, table_type_columns.display_column), display_order = COALESCE(EXCLUDED.display_order, table_type_columns.display_order), is_visible = COALESCE(EXCLUDED.is_visible, table_type_columns.is_visible), + category_ref = EXCLUDED.category_ref, updated_date = NOW()`, [ tableName, @@ -531,6 +548,7 @@ export class TableManagementService { settings.displayOrder || 0, settings.isVisible !== undefined ? settings.isVisible : true, companyCode, + settings.categoryRef || null, ] ); @@ -1599,7 +1617,8 @@ export class TableManagementService { tableName, columnName, actualValue, - paramIndex + paramIndex, + operator ); case "entity": @@ -1612,7 +1631,14 @@ export class TableManagementService { ); default: - // 기본 문자열 검색 (actualValue 사용) + // operator에 따라 정확 일치 또는 부분 일치 검색 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(actualValue)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${actualValue}%`], @@ -1626,10 +1652,19 @@ export class TableManagementService { ); // 오류 시 기본 검색으로 폴백 let fallbackValue = value; + let fallbackOperator = "contains"; if (typeof value === "object" && value !== null && "value" in value) { fallbackValue = value.value; + fallbackOperator = value.operator || "contains"; } + if (fallbackOperator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(fallbackValue)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${fallbackValue}%`], @@ -1776,7 +1811,8 @@ export class TableManagementService { tableName: string, columnName: string, value: any, - paramIndex: number + paramIndex: number, + operator: string = "contains" ): Promise<{ whereClause: string; values: any[]; @@ -1786,7 +1822,14 @@ export class TableManagementService { const codeTypeInfo = await this.getCodeTypeInfo(tableName, columnName); if (!codeTypeInfo.isCodeType || !codeTypeInfo.codeCategory) { - // 코드 타입이 아니면 기본 검색 + // 코드 타입이 아니면 operator에 따라 검색 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(value)], + paramCount: 1, + }; + } return { whereClause: `${columnName}::text ILIKE $${paramIndex}`, values: [`%${value}%`], @@ -1794,6 +1837,15 @@ export class TableManagementService { }; } + // select 필터(equals)인 경우 정확한 코드값 매칭만 수행 + if (operator === "equals") { + return { + whereClause: `${columnName}::text = $${paramIndex}`, + values: [String(value)], + paramCount: 1, + }; + } + if (typeof value === "string" && value.trim() !== "") { // 코드값 또는 코드명으로 검색 return { @@ -2431,6 +2483,154 @@ export class TableManagementService { return value; } + /** + * 회사별 NOT NULL 소프트 제약조건 검증 + * table_type_columns.is_nullable = 'N'인 컬럼에 NULL/빈값이 들어오면 위반 목록을 반환한다. + */ + async validateNotNullConstraints( + tableName: string, + data: Record, + companyCode: string + ): Promise { + try { + // 회사별 설정 우선, 없으면 공통(*) 설정 사용 + const notNullColumns = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_nullable = 'N' + AND ttc.company_code = $2`, + [tableName, companyCode] + ); + + // 회사별 설정이 없으면 공통 설정 확인 + if (notNullColumns.length === 0 && companyCode !== "*") { + const globalNotNull = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_nullable = 'N' + AND ttc.company_code = '*' + AND NOT EXISTS ( + SELECT 1 FROM table_type_columns ttc2 + WHERE ttc2.table_name = ttc.table_name + AND ttc2.column_name = ttc.column_name + AND ttc2.company_code = $2 + )`, + [tableName, companyCode] + ); + notNullColumns.push(...globalNotNull); + } + + if (notNullColumns.length === 0) return []; + + const violations: string[] = []; + for (const col of notNullColumns) { + const value = data[col.column_name]; + // NULL, undefined, 빈 문자열을 NOT NULL 위반으로 처리 + if (value === null || value === undefined || value === "") { + violations.push(col.column_label); + } + } + + return violations; + } catch (error) { + logger.error(`NOT NULL 검증 오류: ${tableName}`, error); + return []; + } + } + + /** + * 회사별 UNIQUE 소프트 제약조건 검증 + * table_type_columns.is_unique = 'Y'인 컬럼에 중복 값이 들어오면 위반 목록을 반환한다. + * @param excludeId 수정 시 자기 자신은 제외 + */ + async validateUniqueConstraints( + tableName: string, + data: Record, + companyCode: string, + excludeId?: string + ): Promise { + try { + // 회사별 설정 우선, 없으면 공통(*) 설정 사용 + let uniqueColumns = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_unique = 'Y' + AND ttc.company_code = $2`, + [tableName, companyCode] + ); + + // 회사별 설정이 없으면 공통 설정 확인 + if (uniqueColumns.length === 0 && companyCode !== "*") { + const globalUnique = await query<{ column_name: string; column_label: string }>( + `SELECT + ttc.column_name, + COALESCE(ttc.column_label, ttc.column_name) as column_label + FROM table_type_columns ttc + WHERE ttc.table_name = $1 + AND ttc.is_unique = 'Y' + AND ttc.company_code = '*' + AND NOT EXISTS ( + SELECT 1 FROM table_type_columns ttc2 + WHERE ttc2.table_name = ttc.table_name + AND ttc2.column_name = ttc.column_name + AND ttc2.company_code = $2 + )`, + [tableName, companyCode] + ); + uniqueColumns = globalUnique; + } + + if (uniqueColumns.length === 0) return []; + + const violations: string[] = []; + for (const col of uniqueColumns) { + const value = data[col.column_name]; + if (value === null || value === undefined || value === "") continue; + + // 해당 회사 내에서 같은 값이 이미 존재하는지 확인 + const hasCompanyCode = await query( + `SELECT column_name FROM information_schema.columns + WHERE table_name = $1 AND column_name = 'company_code'`, + [tableName] + ); + + let dupQuery: string; + let dupParams: any[]; + + if (hasCompanyCode.length > 0 && companyCode !== "*") { + dupQuery = excludeId + ? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 AND id != $3 LIMIT 1` + : `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND company_code = $2 LIMIT 1`; + dupParams = excludeId ? [value, companyCode, excludeId] : [value, companyCode]; + } else { + dupQuery = excludeId + ? `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 AND id != $2 LIMIT 1` + : `SELECT 1 FROM "${tableName}" WHERE "${col.column_name}" = $1 LIMIT 1`; + dupParams = excludeId ? [value, excludeId] : [value]; + } + + const dupResult = await query(dupQuery, dupParams); + if (dupResult.length > 0) { + violations.push(`${col.column_label} (${value})`); + } + } + + return violations; + } catch (error) { + logger.error(`UNIQUE 검증 오류: ${tableName}`, error); + return []; + } + } + /** * 테이블에 데이터 추가 * @returns 무시된 컬럼 정보 (디버깅용) @@ -2438,7 +2638,7 @@ export class TableManagementService { async addTableData( tableName: string, data: Record - ): Promise<{ skippedColumns: string[]; savedColumns: string[] }> { + ): Promise<{ skippedColumns: string[]; savedColumns: string[]; insertedId: string | null }> { try { logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`); logger.info(`추가할 데이터:`, data); @@ -2551,19 +2751,21 @@ export class TableManagementService { const insertQuery = ` INSERT INTO "${tableName}" (${columnNames}) VALUES (${placeholders}) + RETURNING id `; logger.info(`실행할 쿼리: ${insertQuery}`); logger.info(`쿼리 파라미터:`, values); - await query(insertQuery, values); + const insertResult = await query(insertQuery, values) as any[]; + const insertedId = insertResult?.[0]?.id ?? null; - logger.info(`테이블 데이터 추가 완료: ${tableName}`); + logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${insertedId}`); - // 무시된 컬럼과 저장된 컬럼 정보 반환 return { skippedColumns, savedColumns: existingColumns, + insertedId, }; } catch (error) { logger.error(`테이블 데이터 추가 오류: ${tableName}`, error); @@ -4353,7 +4555,8 @@ export class TableManagementService { END as "detailSettings", ttc.is_nullable as "isNullable", ic.data_type as "dataType", - ttc.company_code as "companyCode" + ttc.company_code as "companyCode", + ttc.category_ref as "categoryRef" FROM table_type_columns ttc LEFT JOIN information_schema.columns ic ON ttc.table_name = ic.table_name AND ttc.column_name = ic.column_name @@ -4430,20 +4633,24 @@ export class TableManagementService { } const inputTypes: ColumnTypeInfo[] = rawInputTypes.map((col) => { - const baseInfo = { + const baseInfo: any = { tableName: tableName, columnName: col.columnName, displayName: col.displayName, dataType: col.dataType || "varchar", inputType: col.inputType, detailSettings: col.detailSettings, - description: "", // 필수 필드 추가 - isNullable: col.isNullable === "Y" ? "Y" : "N", // 🔥 FIX: string 타입으로 변환 + description: "", + isNullable: col.isNullable === "Y" ? "Y" : "N", isPrimaryKey: false, displayOrder: 0, isVisible: true, }; + if (col.categoryRef) { + baseInfo.categoryRef = col.categoryRef; + } + // 카테고리 타입인 경우 categoryMenus 추가 if ( col.inputType === "category" && diff --git a/docs/BOM_개발_현황.md b/docs/BOM_개발_현황.md new file mode 100644 index 00000000..45c33546 --- /dev/null +++ b/docs/BOM_개발_현황.md @@ -0,0 +1,278 @@ +# BOM 관리 시스템 개발 현황 + +## 1. 개요 + +BOM(Bill of Materials) 관리 시스템은 제품의 구성 부품을 계층적으로 관리하는 기능입니다. +V2 컴포넌트 기반으로 구현되어 있으며, 설정 패널을 통해 모든 기능을 동적으로 구성할 수 있습니다. + +--- + +## 2. 아키텍처 + +### 2.1 전체 구조 + +``` +[프론트엔드] [백엔드] [데이터베이스] +v2-bom-tree (트리 뷰) ──── /api/bom ────── bomService.ts ────── bom, bom_detail +v2-bom-item-editor ──── /api/table-management ──────────── bom_history, bom_version +V2BomTreeConfigPanel (설정 패널) +``` + +### 2.2 관련 파일 목록 + +#### 프론트엔드 + +| 파일 | 설명 | +|------|------| +| `frontend/lib/registry/components/v2-bom-tree/BomTreeComponent.tsx` | BOM 트리/레벨 뷰 메인 컴포넌트 | +| `frontend/lib/registry/components/v2-bom-tree/BomVersionModal.tsx` | 버전 관리 모달 | +| `frontend/lib/registry/components/v2-bom-tree/BomHistoryModal.tsx` | 이력 관리 모달 | +| `frontend/lib/registry/components/v2-bom-tree/BomDetailEditModal.tsx` | BOM 항목 수정 모달 | +| `frontend/lib/registry/components/v2-bom-tree/BomTreeRenderer.tsx` | 트리 렌더러 | +| `frontend/lib/registry/components/v2-bom-tree/index.ts` | 컴포넌트 정의 (v2-bom-tree) | +| `frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx` | BOM 트리 설정 패널 | +| `frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx` | BOM 항목 편집기 (에디터 모드) | + +#### 백엔드 + +| 파일 | 설명 | +|------|------| +| `backend-node/src/routes/bomRoutes.ts` | BOM API 라우트 정의 | +| `backend-node/src/controllers/bomController.ts` | BOM 컨트롤러 (이력/버전) | +| `backend-node/src/services/bomService.ts` | BOM 서비스 (비즈니스 로직) | + +#### 데이터베이스 + +| 파일 | 설명 | +|------|------| +| `db/migrations/062_create_bom_history_version_tables.sql` | 이력/버전 테이블 DDL | + +--- + +## 3. 데이터베이스 스키마 + +### 3.1 bom (BOM 헤더) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | VARCHAR (UUID) | PK | +| item_id | VARCHAR | 완제품 품목 ID (item_info FK) | +| bom_name | VARCHAR | BOM 명칭 | +| version | VARCHAR | 현재 사용중인 버전명 | +| revision | VARCHAR | 차수 | +| base_qty | NUMERIC | 기준수량 | +| unit | VARCHAR | 단위 | +| remark | TEXT | 비고 | +| company_code | VARCHAR | 회사 코드 (멀티테넌시) | + +### 3.2 bom_detail (BOM 상세 - 자식 품목) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | VARCHAR (UUID) | PK | +| bom_id | VARCHAR | BOM 헤더 FK | +| parent_detail_id | VARCHAR | 부모 detail FK (NULL = 1레벨) | +| child_item_id | VARCHAR | 자식 품목 ID (item_info FK) | +| quantity | NUMERIC | 구성수량 (소요량) | +| unit | VARCHAR | 단위 | +| process_type | VARCHAR | 공정구분 (제조/외주 등) | +| loss_rate | NUMERIC | 손실율 | +| level | INTEGER | 레벨 | +| base_qty | NUMERIC | 기준수량 | +| revision | VARCHAR | 차수 | +| remark | TEXT | 비고 | +| company_code | VARCHAR | 회사 코드 | + +### 3.3 bom_history (BOM 이력) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | VARCHAR (UUID) | PK | +| bom_id | VARCHAR | BOM 헤더 FK | +| revision | VARCHAR | 차수 | +| version | VARCHAR | 버전 | +| change_type | VARCHAR | 변경구분 (등록/수정/추가/삭제) | +| change_description | TEXT | 변경내용 | +| changed_by | VARCHAR | 변경자 | +| changed_date | TIMESTAMP | 변경일시 | +| company_code | VARCHAR | 회사 코드 | + +### 3.4 bom_version (BOM 버전) + +| 컬럼 | 타입 | 설명 | +|------|------|------| +| id | VARCHAR (UUID) | PK | +| bom_id | VARCHAR | BOM 헤더 FK | +| version_name | VARCHAR | 버전명 (1.0, 2.0 ...) | +| revision | INTEGER | 생성 시점의 차수 | +| status | VARCHAR | 상태 (developing / active / inactive) | +| snapshot_data | JSONB | 스냅샷 (bom 헤더 + bom_detail 전체) | +| created_by | VARCHAR | 생성자 | +| created_date | TIMESTAMP | 생성일시 | +| company_code | VARCHAR | 회사 코드 | + +--- + +## 4. API 명세 + +### 4.1 이력 API + +| Method | Path | 설명 | +|--------|------|------| +| GET | `/api/bom/:bomId/history` | 이력 목록 조회 | +| POST | `/api/bom/:bomId/history` | 이력 등록 | + +**Query Params**: `tableName` (설정 패널에서 지정한 이력 테이블명, 기본값: `bom_history`) + +### 4.2 버전 API + +| Method | Path | 설명 | +|--------|------|------| +| GET | `/api/bom/:bomId/versions` | 버전 목록 조회 | +| POST | `/api/bom/:bomId/versions` | 신규 버전 생성 | +| POST | `/api/bom/:bomId/versions/:versionId/load` | 버전 불러오기 (데이터 복원) | +| POST | `/api/bom/:bomId/versions/:versionId/activate` | 버전 사용 확정 | +| DELETE | `/api/bom/:bomId/versions/:versionId` | 버전 삭제 | + +**Body/Query**: `tableName`, `detailTable` (설정 패널에서 지정한 테이블명) + +--- + +## 5. 버전 관리 구조 + +### 5.1 핵심 원리 + +**각 버전은 생성 시점의 BOM 전체 구조(헤더 + 모든 디테일)를 JSONB 스냅샷으로 저장합니다.** + +``` +버전 1.0 (active) + └─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] } + +버전 2.0 (developing) + └─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] } + +버전 3.0 (inactive) + └─ snapshot_data: { bom: {...}, details: [{...}, {...}, ...] } +``` + +### 5.2 버전 상태 (status) + +| 상태 | 설명 | +|------|------| +| `developing` | 개발중 - 신규 생성 시 기본 상태 | +| `active` | 사용중 - "사용 확정" 후 운영 상태 | +| `inactive` | 사용중지 - 이전에 active였다가 다른 버전이 확정된 경우 | + +### 5.3 버전 워크플로우 + +``` +[현재 BOM 데이터] + │ + ▼ +신규 버전 생성 ───► 버전 N.0 (status: developing) + │ + ├── 불러오기: 해당 스냅샷의 데이터로 현재 BOM을 복원 + │ (status 변경 없음, BOM 헤더 version 변경 없음) + │ + ├── 사용 확정: status → active, + │ 기존 active 버전 → inactive, + │ BOM 헤더의 version 필드 갱신 + │ + └── 삭제: active 상태가 아닌 경우만 삭제 가능 +``` + +### 5.4 불러오기 vs 사용 확정 + +| 동작 | 불러오기 (Load) | 사용 확정 (Activate) | +|------|----------------|---------------------| +| BOM 데이터 복원 | O (detail 전체 교체) | X | +| BOM 헤더 업데이트 | O (base_qty, unit 등) | version 필드만 | +| 버전 status 변경 | X | active로 변경 | +| 기존 active 비활성화 | X | O (→ inactive) | +| BOM 목록 새로고침 | O (refreshTable) | O (refreshTable) | + +--- + +## 6. 설정 패널 구성 + +`V2BomTreeConfigPanel.tsx`에서 아래 항목을 설정할 수 있습니다: + +### 6.1 기본 탭 + +| 설정 항목 | 설명 | 기본값 | +|-----------|------|--------| +| 디테일 테이블 | BOM 상세 데이터 테이블 | `bom_detail` | +| 외래키 | BOM 헤더와의 연결 키 | `bom_id` | +| 부모키 | 부모-자식 관계 키 | `parent_detail_id` | +| 이력 테이블 | BOM 변경 이력 테이블 | `bom_history` | +| 버전 테이블 | BOM 버전 관리 테이블 | `bom_version` | +| 이력 기능 표시 | 이력 버튼 노출 여부 | `true` | +| 버전 기능 표시 | 버전 버튼 노출 여부 | `true` | + +### 6.2 컬럼 탭 + +- 소스 테이블 (bom/item_info 등)에서 표시할 컬럼 선택 +- 디테일 테이블에서 표시할 컬럼 선택 +- 컬럼 순서 드래그앤드롭 +- 컬럼별 라벨, 너비, 정렬 설정 + +--- + +## 7. 뷰 모드 + +### 7.1 트리 뷰 (기본) + +- 계층적 들여쓰기로 부모-자식 관계 표현 +- 레벨별 시각 구분: + - **0레벨 (가상 루트)**: 파란색 배경 + 파란 좌측 바 + - **1레벨**: 흰색 배경 + 초록 좌측 바 + - **2레벨**: 연회색 배경 + 주황 좌측 바 + - **3레벨 이상**: 진회색 배경 + 보라 좌측 바 +- 펼침/접힘 (정전개/역전개) + +### 7.2 레벨 뷰 + +- 평면 테이블 형태로 표시 +- "레벨0", "레벨1", "레벨2" ... 컬럼에 체크마크로 계층 표시 +- 같은 레벨별 배경색 구분 적용 + +--- + +## 8. 주요 기능 목록 + +| 기능 | 상태 | 설명 | +|------|------|------| +| BOM 트리 표시 | 완료 | 계층적 트리 뷰 + 레벨 뷰 | +| BOM 항목 편집 | 완료 | 더블클릭으로 수정 모달 (0레벨: bom, 하위: bom_detail) | +| 이력 관리 | 완료 | 변경 이력 조회/등록 모달 | +| 버전 관리 | 완료 | 버전 생성/불러오기/사용 확정/삭제 | +| 설정 패널 | 완료 | 테이블/컬럼/기능 동적 설정 | +| 디자인 모드 프리뷰 | 완료 | 실제 화면과 일치하는 디자인 모드 표시 | +| 컬럼 크기 조절 | 완료 | 헤더 드래그로 컬럼 너비 변경 | +| 텍스트 말줄임 | 완료 | 긴 텍스트 `...` 처리 | +| 레벨별 시각 구분 | 완료 | 배경색 + 좌측 컬러 바 | +| 정전개/역전개 | 완료 | 전체 펼침/접기 토글 | +| 좌우 스크롤 | 완료 | 컬럼 크기가 커질 때 수평 스크롤 | +| BOM 목록 자동 새로고침 | 완료 | 버전 불러오기/확정 후 좌측 패널 자동 리프레시 | +| BOM 하위 품목 저장 | 완료 | BomItemEditorComponent에서 직접 INSERT/UPDATE/DELETE | +| 차수 (Revision) 자동 증가 | 미구현 | BOM 변경 시 헤더 revision 자동 +1 | + +--- + +## 9. 보안 고려사항 + +- **SQL 인젝션 방지**: `safeTableName()` 함수로 테이블명 검증 (`^[a-zA-Z_][a-zA-Z0-9_]*$`) +- **멀티테넌시**: 모든 API에서 `company_code` 필터링 적용 +- **최고 관리자**: `company_code = "*"` 시 전체 데이터 조회 가능 +- **인증**: `authenticateToken` 미들웨어로 모든 라우트 보호 + +--- + +## 10. 향후 개선 사항 + +- [ ] 차수(Revision) 자동 증가 구현 (BOM 헤더 레벨) +- [ ] 버전 비교 기능 (두 버전 간 diff) +- [ ] BOM 복사 기능 +- [ ] 이력 자동 등록 (수정/저장 시 자동으로 이력 생성) +- [ ] Excel 내보내기/가져오기 +- [ ] BOM 유효성 검증 (순환참조 방지 등) diff --git a/docs/screen-implementation-guide/01_master-data/item-info.md b/docs/screen-implementation-guide/01_master-data/item-info.md index b0ddd9e0..223eed42 100644 --- a/docs/screen-implementation-guide/01_master-data/item-info.md +++ b/docs/screen-implementation-guide/01_master-data/item-info.md @@ -8,7 +8,7 @@ ## ⚠️ 문서 사용 안내 -> **이 문서는 "품목정보" 화면의 구현 예시입니다.** + > > ### 📌 중요: JSON 데이터는 참고용입니다! > diff --git a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx index cf89df73..d5c41e6a 100644 --- a/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx +++ b/frontend/app/(main)/admin/systemMng/tableMngList/page.tsx @@ -63,6 +63,7 @@ interface ColumnTypeInfo { detailSettings: string; description: string; isNullable: string; + isUnique: string; defaultValue?: string; maxLength?: number; numericPrecision?: number; @@ -72,9 +73,10 @@ interface ColumnTypeInfo { referenceTable?: string; referenceColumn?: string; displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 - categoryMenus?: number[]; // 🆕 Category 타입: 선택된 2레벨 메뉴 OBJID 배열 - hierarchyRole?: "large" | "medium" | "small"; // 🆕 계층구조 역할 - numberingRuleId?: string; // 🆕 Numbering 타입: 채번규칙 ID + categoryMenus?: number[]; + hierarchyRole?: "large" | "medium" | "small"; + numberingRuleId?: string; + categoryRef?: string | null; } interface SecondLevelMenu { @@ -382,10 +384,12 @@ export default function TableManagementPage() { return { ...col, - inputType: col.inputType || "text", // 기본값: text - numberingRuleId, // 🆕 채번규칙 ID - categoryMenus: col.categoryMenus || [], // 카테고리 메뉴 매핑 정보 - hierarchyRole, // 계층구조 역할 + inputType: col.inputType || "text", + isUnique: col.isUnique || "NO", + numberingRuleId, + categoryMenus: col.categoryMenus || [], + hierarchyRole, + categoryRef: col.categoryRef || null, }; }); @@ -668,15 +672,16 @@ export default function TableManagementPage() { } const columnSetting = { - columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) - columnLabel: column.displayName, // 사용자가 입력한 표시명 + columnName: column.columnName, + columnLabel: column.displayName, inputType: column.inputType || "text", detailSettings: finalDetailSettings, codeCategory: column.codeCategory || "", codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", referenceColumn: column.referenceColumn || "", - displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명 + displayColumn: column.displayColumn || "", + categoryRef: column.categoryRef || null, }; // console.log("저장할 컬럼 설정:", columnSetting); @@ -703,9 +708,9 @@ export default function TableManagementPage() { length: column.categoryMenus?.length || 0, }); - if (column.inputType === "category") { - // 1. 먼저 기존 매핑 모두 삭제 - console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", { + if (column.inputType === "category" && !column.categoryRef) { + // 참조가 아닌 자체 카테고리만 메뉴 매핑 처리 + console.log("기존 카테고리 메뉴 매핑 삭제 시작:", { tableName: selectedTable, columnName: column.columnName, }); @@ -864,8 +869,8 @@ export default function TableManagementPage() { } return { - columnName: column.columnName, // 실제 DB 컬럼명 (변경 불가) - columnLabel: column.displayName, // 사용자가 입력한 표시명 + columnName: column.columnName, + columnLabel: column.displayName, inputType: column.inputType || "text", detailSettings: finalDetailSettings, description: column.description || "", @@ -873,7 +878,8 @@ export default function TableManagementPage() { codeValue: column.codeValue || "", referenceTable: column.referenceTable || "", referenceColumn: column.referenceColumn || "", - displayColumn: column.displayColumn || "", // 🎯 Entity 조인에서 표시할 컬럼명 + displayColumn: column.displayColumn || "", + categoryRef: column.categoryRef || null, }; }); @@ -886,8 +892,8 @@ export default function TableManagementPage() { ); if (response.data.success) { - // 🆕 Category 타입 컬럼들의 메뉴 매핑 처리 - const categoryColumns = columns.filter((col) => col.inputType === "category"); + // 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외) + const categoryColumns = columns.filter((col) => col.inputType === "category" && !col.categoryRef); console.log("📥 전체 저장: 카테고리 컬럼 확인", { totalColumns: columns.length, @@ -1091,9 +1097,9 @@ export default function TableManagementPage() { } }; - // 인덱스 토글 핸들러 + // 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨) const handleIndexToggle = useCallback( - async (columnName: string, indexType: "index" | "unique", checked: boolean) => { + async (columnName: string, indexType: "index", checked: boolean) => { if (!selectedTable) return; const action = checked ? "create" : "drop"; try { @@ -1122,14 +1128,41 @@ export default function TableManagementPage() { const hasIndex = constraints.indexes.some( (idx) => !idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, ); - const hasUnique = constraints.indexes.some( - (idx) => idx.isUnique && idx.columns.length === 1 && idx.columns[0] === columnName, - ); - return { isPk, hasIndex, hasUnique }; + return { isPk, hasIndex }; }, [constraints], ); + // UNIQUE 토글 핸들러 (앱 레벨 소프트 제약조건 - NOT NULL과 동일 패턴) + const handleUniqueToggle = useCallback( + async (columnName: string, currentIsUnique: string) => { + if (!selectedTable) return; + const isCurrentlyUnique = currentIsUnique === "YES"; + const newUnique = !isCurrentlyUnique; + try { + const response = await apiClient.put( + `/table-management/tables/${selectedTable}/columns/${columnName}/unique`, + { unique: newUnique }, + ); + if (response.data.success) { + toast.success(response.data.message); + setColumns((prev) => + prev.map((col) => + col.columnName === columnName + ? { ...col, isUnique: newUnique ? "YES" : "NO" } + : col, + ), + ); + } else { + toast.error(response.data.message || "UNIQUE 설정 실패"); + } + } catch (error: any) { + toast.error(error?.response?.data?.message || "UNIQUE 설정 중 오류가 발생했습니다."); + } + }, + [selectedTable], + ); + // NOT NULL 토글 핸들러 const handleNullableToggle = useCallback( async (columnName: string, currentIsNullable: string) => { @@ -1662,7 +1695,30 @@ export default function TableManagementPage() { )} )} - {/* 카테고리 타입: 메뉴 종속성 제거됨 - 테이블/컬럼 단위로 관리 */} + {/* 카테고리 타입: 참조 설정 */} + {column.inputType === "category" && ( +
+ + { + const val = e.target.value || null; + setColumns((prev) => + prev.map((c) => + c.columnName === column.columnName + ? { ...c, categoryRef: val } + : c + ) + ); + }} + placeholder="테이블명.컬럼명" + className="h-8 text-xs" + /> +

+ 다른 테이블의 카테고리 값 참조 시 입력 +

+
+ )} {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */} {column.inputType === "entity" && ( <> @@ -2029,12 +2085,12 @@ export default function TableManagementPage() { aria-label={`${column.columnName} 인덱스 설정`} /> - {/* UQ 체크박스 */} + {/* UQ 체크박스 (앱 레벨 소프트 제약조건) */}
- handleIndexToggle(column.columnName, "unique", checked as boolean) + checked={column.isUnique === "YES"} + onCheckedChange={() => + handleUniqueToggle(column.columnName, column.isUnique) } aria-label={`${column.columnName} 유니크 설정`} /> diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 95305aaf..160883ad 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -87,10 +87,12 @@ function ScreenViewPage() { // 🆕 조건부 컨테이너 높이 추적 (컴포넌트 ID → 높이) const [conditionalContainerHeights, setConditionalContainerHeights] = useState>({}); - // 🆕 레이어 시스템 지원 + // 레이어 시스템 지원 const [conditionalLayers, setConditionalLayers] = useState([]); - // 🆕 조건부 영역(Zone) 목록 + // 조건부 영역(Zone) 목록 const [zones, setZones] = useState([]); + // 데이터 전달에 의해 강제 활성화된 레이어 ID 목록 + const [forceActivatedLayerIds, setForceActivatedLayerIds] = useState([]); // 편집 모달 상태 const [editModalOpen, setEditModalOpen] = useState(false); @@ -378,11 +380,51 @@ function ScreenViewPage() { } }); - return newActiveIds; - }, [formData, conditionalLayers, layout]); + // 강제 활성화된 레이어 ID 병합 + for (const forcedId of forceActivatedLayerIds) { + if (!newActiveIds.includes(forcedId)) { + newActiveIds.push(forcedId); + } + } - // 🆕 메인 테이블 데이터 자동 로드 (단일 레코드 폼) - // 화면의 메인 테이블에서 사용자 회사 코드로 데이터를 조회하여 폼에 자동 채움 + return newActiveIds; + }, [formData, conditionalLayers, layout, forceActivatedLayerIds]); + + // 데이터 전달에 의한 레이어 강제 활성화 이벤트 리스너 + useEffect(() => { + const handleActivateLayer = (e: Event) => { + const { componentId, targetLayerId } = (e as CustomEvent).detail || {}; + if (!componentId && !targetLayerId) return; + + // targetLayerId가 직접 지정된 경우 + if (targetLayerId) { + setForceActivatedLayerIds((prev) => + prev.includes(targetLayerId) ? prev : [...prev, targetLayerId], + ); + console.log(`🔓 [레이어 강제 활성화] layerId: ${targetLayerId}`); + return; + } + + // componentId로 해당 컴포넌트가 속한 레이어를 찾아 활성화 + for (const layer of conditionalLayers) { + const found = layer.components.some((comp) => comp.id === componentId); + if (found) { + setForceActivatedLayerIds((prev) => + prev.includes(layer.id) ? prev : [...prev, layer.id], + ); + console.log(`🔓 [레이어 강제 활성화] componentId: ${componentId} → layerId: ${layer.id}`); + return; + } + } + }; + + window.addEventListener("activateLayerForComponent", handleActivateLayer); + return () => { + window.removeEventListener("activateLayerForComponent", handleActivateLayer); + }; + }, [conditionalLayers]); + + // 메인 테이블 데이터 자동 로드 (단일 레코드 폼) useEffect(() => { const loadMainTableData = async () => { if (!screen || !layout || !layout.components || !companyCode) { diff --git a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx index f578b30e..861795b5 100644 --- a/frontend/app/(pop)/pop/screens/[screenId]/page.tsx +++ b/frontend/app/(pop)/pop/screens/[screenId]/page.tsx @@ -24,7 +24,9 @@ import { GRID_BREAKPOINTS, detectGridMode, } from "@/components/pop/designer/types/pop-layout"; -import PopRenderer from "@/components/pop/designer/renderers/PopRenderer"; +// POP 컴포넌트 자동 등록 (레지스트리 초기화 - PopRenderer보다 먼저 import) +import "@/lib/registry/pop-components"; +import PopViewerWithModals from "@/components/pop/viewer/PopViewerWithModals"; import { useResponsiveModeWithOverride, type DeviceType, @@ -144,6 +146,28 @@ function PopScreenViewPage() { } }, [screenId]); + // 뷰어 모드에서도 컴포넌트 크기 변경 지원 (더보기 등) + const handleRequestResize = React.useCallback((componentId: string, newRowSpan: number, newColSpan?: number) => { + setLayout((prev) => { + const comp = prev.components[componentId]; + if (!comp) return prev; + return { + ...prev, + components: { + ...prev.components, + [componentId]: { + ...comp, + position: { + ...comp.position, + rowSpan: newRowSpan, + ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), + }, + }, + }, + }; + }); + }, []); + const currentDevice = DEVICE_SIZES[deviceType][isLandscape ? "landscape" : "portrait"]; const hasComponents = Object.keys(layout.components).length > 0; @@ -180,7 +204,7 @@ function PopScreenViewPage() { -
+
{/* 상단 툴바 (프리뷰 모드에서만) */} {isPreviewMode && (
@@ -261,7 +285,7 @@ function PopScreenViewPage() { )} {/* POP 화면 컨텐츠 */} -
+
{/* 현재 모드 표시 (일반 모드) */} {!isPreviewMode && (
@@ -270,7 +294,7 @@ function PopScreenViewPage() { )}
); })()} diff --git a/frontend/components/auth/AuthGuard.tsx b/frontend/components/auth/AuthGuard.tsx index efb8cd25..3b3eb182 100644 --- a/frontend/components/auth/AuthGuard.tsx +++ b/frontend/components/auth/AuthGuard.tsx @@ -3,6 +3,7 @@ import { useEffect, ReactNode } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/useAuth"; +import { AuthLogger } from "@/lib/authLogger"; import { Loader2 } from "lucide-react"; interface AuthGuardProps { @@ -41,11 +42,13 @@ export function AuthGuard({ } if (requireAuth && !isLoggedIn) { + AuthLogger.log("AUTH_GUARD_BLOCK", `인증 필요하지만 비로그인 상태 → ${redirectTo} 리다이렉트`); router.push(redirectTo); return; } if (requireAdmin && !isAdmin) { + AuthLogger.log("AUTH_GUARD_BLOCK", `관리자 권한 필요하지만 일반 사용자 → ${redirectTo} 리다이렉트`); router.push(redirectTo); return; } diff --git a/frontend/components/common/ExcelUploadModal.tsx b/frontend/components/common/ExcelUploadModal.tsx index 4797a34a..81b5ed61 100644 --- a/frontend/components/common/ExcelUploadModal.tsx +++ b/frontend/components/common/ExcelUploadModal.tsx @@ -942,17 +942,22 @@ export const ExcelUploadModal: React.FC = ({ continue; } - // 채번 적용: 각 행마다 채번 API 호출 (신규 등록 시에만, 자동 감지된 채번 규칙 사용) + // 채번 적용: 엑셀에 값이 없거나 빈 값이면 채번 규칙으로 자동 생성, 값이 있으면 그대로 사용 if (hasNumbering && numberingInfo && uploadMode === "insert" && !shouldUpdate) { - try { - const { apiClient } = await import("@/lib/api/client"); - const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`); - const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code; - if (numberingResponse.data?.success && generatedCode) { - dataToSave[numberingInfo.columnName] = generatedCode; + const existingValue = dataToSave[numberingInfo.columnName]; + const hasExcelValue = existingValue !== undefined && existingValue !== null && String(existingValue).trim() !== ""; + + if (!hasExcelValue) { + try { + const { apiClient } = await import("@/lib/api/client"); + const numberingResponse = await apiClient.post(`/numbering-rules/${numberingInfo.numberingRuleId}/allocate`); + const generatedCode = numberingResponse.data?.data?.generatedCode || numberingResponse.data?.data?.code; + if (numberingResponse.data?.success && generatedCode) { + dataToSave[numberingInfo.columnName] = generatedCode; + } + } catch (numError) { + console.error("채번 오류:", numError); } - } catch (numError) { - console.error("채번 오류:", numError); } } diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 16dd5afc..a79f26e3 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -25,6 +25,7 @@ import { useSplitPanelContext } from "@/contexts/SplitPanelContext"; import { ActiveTabProvider } from "@/contexts/ActiveTabContext"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; interface ScreenModalState { isOpen: boolean; @@ -1025,6 +1026,10 @@ export const ScreenModal: React.FC = ({ className }) => {
) : screenData ? ( +
= ({ className }) => {
+
) : (

화면 데이터가 없습니다.

diff --git a/frontend/components/numbering-rule/NumberingRuleCard.tsx b/frontend/components/numbering-rule/NumberingRuleCard.tsx index d1444d4e..e9731017 100644 --- a/frontend/components/numbering-rule/NumberingRuleCard.tsx +++ b/frontend/components/numbering-rule/NumberingRuleCard.tsx @@ -25,7 +25,7 @@ export const NumberingRuleCard: React.FC = ({ isPreview = false, }) => { return ( - +
diff --git a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx index 9320f00e..8b521fe0 100644 --- a/frontend/components/numbering-rule/NumberingRuleDesigner.tsx +++ b/frontend/components/numbering-rule/NumberingRuleDesigner.tsx @@ -62,9 +62,9 @@ export const NumberingRuleDesigner: React.FC = ({ const [editingLeftTitle, setEditingLeftTitle] = useState(false); const [editingRightTitle, setEditingRightTitle] = useState(false); - // 구분자 관련 상태 - const [separatorType, setSeparatorType] = useState("-"); - const [customSeparator, setCustomSeparator] = useState(""); + // 구분자 관련 상태 (개별 파트 사이 구분자) + const [separatorTypes, setSeparatorTypes] = useState>({}); + const [customSeparators, setCustomSeparators] = useState>({}); // 카테고리 조건 관련 상태 - 모든 카테고리를 테이블.컬럼 단위로 조회 interface CategoryOption { @@ -192,48 +192,68 @@ export const NumberingRuleDesigner: React.FC = ({ } }, [currentRule, onChange]); - // currentRule이 변경될 때 구분자 상태 동기화 + // currentRule이 변경될 때 파트별 구분자 상태 동기화 useEffect(() => { - if (currentRule) { - const sep = currentRule.separator ?? "-"; - // 빈 문자열이면 "none" - if (sep === "") { - setSeparatorType("none"); - setCustomSeparator(""); - return; - } - // 미리 정의된 구분자인지 확인 (none, custom 제외) - const predefinedOption = SEPARATOR_OPTIONS.find( - opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep - ); - if (predefinedOption) { - setSeparatorType(predefinedOption.value); - setCustomSeparator(""); - } else { - // 직접 입력된 구분자 - setSeparatorType("custom"); - setCustomSeparator(sep); - } + if (currentRule && currentRule.parts.length > 0) { + const newSepTypes: Record = {}; + const newCustomSeps: Record = {}; + + currentRule.parts.forEach((part) => { + const sep = part.separatorAfter ?? currentRule.separator ?? "-"; + if (sep === "") { + newSepTypes[part.order] = "none"; + newCustomSeps[part.order] = ""; + } else { + const predefinedOption = SEPARATOR_OPTIONS.find( + opt => opt.value !== "custom" && opt.value !== "none" && opt.displayValue === sep + ); + if (predefinedOption) { + newSepTypes[part.order] = predefinedOption.value; + newCustomSeps[part.order] = ""; + } else { + newSepTypes[part.order] = "custom"; + newCustomSeps[part.order] = sep; + } + } + }); + + setSeparatorTypes(newSepTypes); + setCustomSeparators(newCustomSeps); } - }, [currentRule?.ruleId]); // ruleId가 변경될 때만 실행 (규칙 선택/생성 시) + }, [currentRule?.ruleId]); - // 구분자 변경 핸들러 - const handleSeparatorChange = useCallback((type: SeparatorType) => { - setSeparatorType(type); + // 개별 파트 구분자 변경 핸들러 + const handlePartSeparatorChange = useCallback((partOrder: number, type: SeparatorType) => { + setSeparatorTypes(prev => ({ ...prev, [partOrder]: type })); if (type !== "custom") { const option = SEPARATOR_OPTIONS.find(opt => opt.value === type); const newSeparator = option?.displayValue ?? ""; - setCurrentRule((prev) => prev ? { ...prev, separator: newSeparator } : null); - setCustomSeparator(""); + setCustomSeparators(prev => ({ ...prev, [partOrder]: "" })); + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => + part.order === partOrder ? { ...part, separatorAfter: newSeparator } : part + ), + }; + }); } }, []); - // 직접 입력 구분자 변경 핸들러 - const handleCustomSeparatorChange = useCallback((value: string) => { - // 최대 2자 제한 + // 개별 파트 직접 입력 구분자 변경 핸들러 + const handlePartCustomSeparatorChange = useCallback((partOrder: number, value: string) => { const trimmedValue = value.slice(0, 2); - setCustomSeparator(trimmedValue); - setCurrentRule((prev) => prev ? { ...prev, separator: trimmedValue } : null); + setCustomSeparators(prev => ({ ...prev, [partOrder]: trimmedValue })); + setCurrentRule((prev) => { + if (!prev) return null; + return { + ...prev, + parts: prev.parts.map((part) => + part.order === partOrder ? { ...part, separatorAfter: trimmedValue } : part + ), + }; + }); }, []); const handleAddPart = useCallback(() => { @@ -250,6 +270,7 @@ export const NumberingRuleDesigner: React.FC = ({ partType: "text", generationMethod: "auto", autoConfig: { textValue: "CODE" }, + separatorAfter: "-", }; setCurrentRule((prev) => { @@ -257,6 +278,10 @@ export const NumberingRuleDesigner: React.FC = ({ return { ...prev, parts: [...prev.parts, newPart] }; }); + // 새 파트의 구분자 상태 초기화 + setSeparatorTypes(prev => ({ ...prev, [newPart.order]: "-" })); + setCustomSeparators(prev => ({ ...prev, [newPart.order]: "" })); + toast.success(`규칙 ${newPart.order}가 추가되었습니다`); }, [currentRule, maxRules]); @@ -573,42 +598,6 @@ export const NumberingRuleDesigner: React.FC = ({
- {/* 두 번째 줄: 구분자 설정 */} -
-
- - -
- {separatorType === "custom" && ( -
- - handleCustomSeparatorChange(e.target.value)} - className="h-9" - placeholder="최대 2자" - maxLength={2} - /> -
- )} -

- 규칙 사이에 들어갈 문자입니다 -

-
@@ -625,15 +614,48 @@ export const NumberingRuleDesigner: React.FC = ({

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

) : ( -
+
{currentRule.parts.map((part, index) => ( - handleUpdatePart(part.order, updates)} - onDelete={() => handleDeletePart(part.order)} - isPreview={isPreview} - /> + +
+ handleUpdatePart(part.order, updates)} + onDelete={() => handleDeletePart(part.order)} + isPreview={isPreview} + /> + {/* 카드 하단에 구분자 설정 (마지막 파트 제외) */} + {index < currentRule.parts.length - 1 && ( +
+ 뒤 구분자 + + {separatorTypes[part.order] === "custom" && ( + handlePartCustomSeparatorChange(part.order, e.target.value)} + className="h-6 w-14 text-center text-[10px]" + placeholder="2자" + maxLength={2} + /> + )} +
+ )} +
+
))}
)} diff --git a/frontend/components/numbering-rule/NumberingRulePreview.tsx b/frontend/components/numbering-rule/NumberingRulePreview.tsx index a9179959..eff551a1 100644 --- a/frontend/components/numbering-rule/NumberingRulePreview.tsx +++ b/frontend/components/numbering-rule/NumberingRulePreview.tsx @@ -17,75 +17,71 @@ export const NumberingRulePreview: React.FC = ({ return "규칙을 추가해주세요"; } - const parts = config.parts - .sort((a, b) => a.order - b.order) - .map((part) => { - if (part.generationMethod === "manual") { - return part.manualConfig?.value || "XXX"; + const sortedParts = config.parts.sort((a, b) => a.order - b.order); + + const partValues = sortedParts.map((part) => { + if (part.generationMethod === "manual") { + return part.manualConfig?.value || "XXX"; + } + + const autoConfig = part.autoConfig || {}; + + switch (part.partType) { + case "sequence": { + const length = autoConfig.sequenceLength || 3; + const startFrom = autoConfig.startFrom || 1; + return String(startFrom).padStart(length, "0"); } - - const autoConfig = part.autoConfig || {}; - - switch (part.partType) { - // 1. 순번 (자동 증가) - case "sequence": { - const length = autoConfig.sequenceLength || 3; - const startFrom = autoConfig.startFrom || 1; - return String(startFrom).padStart(length, "0"); - } - - // 2. 숫자 (고정 자릿수) - case "number": { - const length = autoConfig.numberLength || 4; - const value = autoConfig.numberValue || 0; - return String(value).padStart(length, "0"); - } - - // 3. 날짜 - case "date": { - const format = autoConfig.dateFormat || "YYYYMMDD"; - - // 컬럼 기준 생성인 경우 placeholder 표시 - if (autoConfig.useColumnValue && autoConfig.sourceColumnName) { - // 형식에 맞는 placeholder 반환 - switch (format) { - case "YYYY": return "[YYYY]"; - case "YY": return "[YY]"; - case "YYYYMM": return "[YYYYMM]"; - case "YYMM": return "[YYMM]"; - case "YYYYMMDD": return "[YYYYMMDD]"; - case "YYMMDD": return "[YYMMDD]"; - default: return "[DATE]"; - } - } - - // 현재 날짜 기준 생성 - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - + case "number": { + const length = autoConfig.numberLength || 4; + const value = autoConfig.numberValue || 0; + return String(value).padStart(length, "0"); + } + case "date": { + const format = autoConfig.dateFormat || "YYYYMMDD"; + if (autoConfig.useColumnValue && autoConfig.sourceColumnName) { switch (format) { - case "YYYY": return String(year); - case "YY": return String(year).slice(-2); - case "YYYYMM": return `${year}${month}`; - case "YYMM": return `${String(year).slice(-2)}${month}`; - case "YYYYMMDD": return `${year}${month}${day}`; - case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; - default: return `${year}${month}${day}`; + case "YYYY": return "[YYYY]"; + case "YY": return "[YY]"; + case "YYYYMM": return "[YYYYMM]"; + case "YYMM": return "[YYMM]"; + case "YYYYMMDD": return "[YYYYMMDD]"; + case "YYMMDD": return "[YYMMDD]"; + default: return "[DATE]"; } } - - // 4. 문자 - case "text": - return autoConfig.textValue || "TEXT"; - - default: - return "XXX"; + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + switch (format) { + case "YYYY": return String(year); + case "YY": return String(year).slice(-2); + case "YYYYMM": return `${year}${month}`; + case "YYMM": return `${String(year).slice(-2)}${month}`; + case "YYYYMMDD": return `${year}${month}${day}`; + case "YYMMDD": return `${String(year).slice(-2)}${month}${day}`; + default: return `${year}${month}${day}`; + } } - }); + case "text": + return autoConfig.textValue || "TEXT"; + default: + return "XXX"; + } + }); - return parts.join(config.separator || ""); + // 파트별 개별 구분자로 결합 + const globalSep = config.separator ?? "-"; + let result = ""; + partValues.forEach((val, idx) => { + result += val; + if (idx < partValues.length - 1) { + const sep = sortedParts[idx].separatorAfter ?? globalSep; + result += sep; + } + }); + return result; }, [config]); if (compact) { diff --git a/frontend/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx index 7753a992..2edefb3a 100644 --- a/frontend/components/pop/designer/PopCanvas.tsx +++ b/frontend/components/pop/designer/PopCanvas.tsx @@ -13,8 +13,12 @@ import { GAP_PRESETS, GRID_BREAKPOINTS, DEFAULT_COMPONENT_GRID_SIZE, + PopModalDefinition, + ModalSizePreset, + MODAL_SIZE_PRESETS, + resolveModalWidth, } from "./types/pop-layout"; -import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff } from "lucide-react"; +import { ZoomIn, ZoomOut, Maximize2, Smartphone, Tablet, Lock, RotateCcw, AlertTriangle, EyeOff, Monitor, ChevronDown, ChevronUp } from "lucide-react"; import { useDrag } from "react-dnd"; import { Button } from "@/components/ui/button"; import { @@ -112,6 +116,16 @@ interface PopCanvasProps { onLockLayout?: () => void; onResetOverride?: (mode: GridMode) => void; onChangeGapPreset?: (preset: GapPreset) => void; + /** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */ + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; + /** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */ + previewPageIndex?: number; + /** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */ + activeCanvasId?: string; + /** 캔버스 전환 콜백 */ + onActiveCanvasChange?: (canvasId: string) => void; + /** 모달 정의 업데이트 콜백 */ + onUpdateModal?: (modalId: string, updates: Partial) => void; } // ======================================== @@ -135,7 +149,43 @@ export default function PopCanvas({ onLockLayout, onResetOverride, onChangeGapPreset, + onRequestResize, + previewPageIndex, + activeCanvasId = "main", + onActiveCanvasChange, + onUpdateModal, }: PopCanvasProps) { + // 모달 탭 데이터 + const modalTabs = useMemo(() => { + const tabs: { id: string; label: string }[] = [{ id: "main", label: "메인화면" }]; + if (layout.modals?.length) { + for (const modal of layout.modals) { + const numbering = modal.id.replace("modal-", ""); + tabs.push({ id: modal.id, label: `모달화면 ${numbering}` }); + } + } + return tabs; + }, [layout.modals]); + + // activeCanvasId에 따라 렌더링할 layout 분기 + const activeLayout = useMemo((): PopLayoutDataV5 => { + if (activeCanvasId === "main") return layout; + const modal = layout.modals?.find(m => m.id === activeCanvasId); + if (!modal) return layout; // fallback + return { + ...layout, + gridConfig: modal.gridConfig, + components: modal.components, + overrides: modal.overrides, + }; + }, [layout, activeCanvasId]); + + // 현재 활성 모달 정의 (모달 캔버스일 때만) + const activeModal = useMemo(() => { + if (activeCanvasId === "main") return null; + return layout.modals?.find(m => m.id === activeCanvasId) || null; + }, [layout.modals, activeCanvasId]); + // 줌 상태 const [canvasScale, setCanvasScale] = useState(0.8); @@ -162,12 +212,12 @@ export default function PopCanvas({ const adjustedGap = Math.round(breakpoint.gap * gapMultiplier); const adjustedPadding = Math.max(8, Math.round(breakpoint.padding * gapMultiplier)); - // 숨김 컴포넌트 ID 목록 - const hiddenComponentIds = layout.overrides?.[currentMode]?.hidden || []; + // 숨김 컴포넌트 ID 목록 (activeLayout 기반) + const hiddenComponentIds = activeLayout.overrides?.[currentMode]?.hidden || []; // 동적 캔버스 높이 계산 (컴포넌트 배치 기반) const dynamicCanvasHeight = useMemo(() => { - const visibleComps = Object.values(layout.components).filter( + const visibleComps = Object.values(activeLayout.components).filter( comp => !hiddenComponentIds.includes(comp.id) ); @@ -186,7 +236,7 @@ export default function PopCanvas({ const height = totalRows * (breakpoint.rowHeight + adjustedGap) + adjustedPadding * 2; return Math.max(MIN_CANVAS_HEIGHT, height); - }, [layout.components, layout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]); + }, [activeLayout.components, activeLayout.overrides, currentMode, hiddenComponentIds, breakpoint.rowHeight, adjustedGap, adjustedPadding]); // 그리드 라벨 계산 (동적 행 수) const gridLabels = useMemo(() => { @@ -300,7 +350,7 @@ export default function PopCanvas({ }; // 현재 모드에서의 유효 위치들로 중첩 검사 - const effectivePositions = getAllEffectivePositions(layout, currentMode); + const effectivePositions = getAllEffectivePositions(activeLayout, currentMode); const existingPositions = Array.from(effectivePositions.values()); const hasOverlap = existingPositions.some(pos => @@ -346,7 +396,7 @@ export default function PopCanvas({ const dragItem = item as DragItemMoveComponent & { fromHidden?: boolean }; // 현재 모드에서의 유효 위치들 가져오기 - const effectivePositions = getAllEffectivePositions(layout, currentMode); + const effectivePositions = getAllEffectivePositions(activeLayout, currentMode); // 이동 중인 컴포넌트의 현재 유효 위치에서 colSpan/rowSpan 가져오기 // 검토 필요(ReviewPanel에서 클릭)나 숨김 컴포넌트는 effectivePositions에 없으므로 원본 사용 @@ -398,42 +448,42 @@ export default function PopCanvas({ canDrop: monitor.canDrop(), }), }), - [onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding] + [onDropComponent, onMoveComponent, onUnhideComponent, breakpoint, layout, activeLayout, currentMode, canvasScale, customWidth, adjustedGap, adjustedPadding] ); drop(canvasRef); - // 빈 상태 체크 - const isEmpty = Object.keys(layout.components).length === 0; + // 빈 상태 체크 (activeLayout 기반) + const isEmpty = Object.keys(activeLayout.components).length === 0; - // 숨김 처리된 컴포넌트 객체 목록 (hiddenComponentIds는 라인 166에서 정의됨) + // 숨김 처리된 컴포넌트 객체 목록 const hiddenComponents = useMemo(() => { return hiddenComponentIds - .map(id => layout.components[id]) + .map(id => activeLayout.components[id]) .filter(Boolean); - }, [hiddenComponentIds, layout.components]); + }, [hiddenComponentIds, activeLayout.components]); // 표시되는 컴포넌트 목록 (숨김 제외) const visibleComponents = useMemo(() => { - return Object.values(layout.components).filter( + return Object.values(activeLayout.components).filter( comp => !hiddenComponentIds.includes(comp.id) ); - }, [layout.components, hiddenComponentIds]); + }, [activeLayout.components, hiddenComponentIds]); // 검토 필요 컴포넌트 목록 const reviewComponents = useMemo(() => { return visibleComponents.filter(comp => { - const hasOverride = !!layout.overrides?.[currentMode]?.positions?.[comp.id]; + const hasOverride = !!activeLayout.overrides?.[currentMode]?.positions?.[comp.id]; return needsReview(currentMode, hasOverride); }); - }, [visibleComponents, layout.overrides, currentMode]); + }, [visibleComponents, activeLayout.overrides, currentMode]); // 검토 패널 표시 여부 (12칸 모드가 아니고, 검토 필요 컴포넌트가 있을 때) const showReviewPanel = currentMode !== "tablet_landscape" && reviewComponents.length > 0; // 12칸 모드가 아닐 때만 패널 표시 // 숨김 패널: 숨김 컴포넌트가 있거나, 그리드에 컴포넌트가 있을 때 드롭 영역으로 표시 - const hasGridComponents = Object.keys(layout.components).length > 0; + const hasGridComponents = Object.keys(activeLayout.components).length > 0; const showHiddenPanel = currentMode !== "tablet_landscape" && (hiddenComponents.length > 0 || hasGridComponents); const showRightPanel = showReviewPanel || showHiddenPanel; @@ -573,6 +623,32 @@ export default function PopCanvas({
+ {/* 모달 탭 바 (모달이 1개 이상 있을 때만 표시) */} + {modalTabs.length > 1 && ( +
+ {modalTabs.map(tab => ( + + ))} +
+ )} + + {/* 모달 사이즈 설정 패널 (모달 캔버스 활성 시) */} + {activeModal && ( + onUpdateModal?.(activeModal.id, updates)} + /> + )} + {/* 캔버스 영역 */}
)}
@@ -969,3 +1047,278 @@ function HiddenItem({
); } + +// ======================================== +// 모달 사이즈 설정 패널 +// ======================================== + +const SIZE_PRESET_ORDER: ModalSizePreset[] = ["sm", "md", "lg", "xl", "full"]; + +const MODE_LABELS: { mode: GridMode; label: string; icon: typeof Smartphone; width: number }[] = [ + { mode: "mobile_portrait", label: "모바일 세로", icon: Smartphone, width: 375 }, + { mode: "mobile_landscape", label: "모바일 가로", icon: Smartphone, width: 667 }, + { mode: "tablet_portrait", label: "태블릿 세로", icon: Tablet, width: 768 }, + { mode: "tablet_landscape", label: "태블릿 가로", icon: Monitor, width: 1024 }, +]; + +function ModalSizeSettingsPanel({ + modal, + currentMode, + onUpdate, +}: { + modal: PopModalDefinition; + currentMode: GridMode; + onUpdate: (updates: Partial) => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const sizeConfig = modal.sizeConfig || { default: "md" }; + const usePerMode = !!sizeConfig.modeOverrides && Object.keys(sizeConfig.modeOverrides).length > 0; + + const currentModeInfo = MODE_LABELS.find(m => m.mode === currentMode)!; + const currentModeWidth = currentModeInfo.width; + const currentModalWidth = resolveModalWidth( + { default: sizeConfig.default, modeOverrides: sizeConfig.modeOverrides as Record | undefined }, + currentMode, + currentModeWidth, + ); + + const handleDefaultChange = (preset: ModalSizePreset) => { + onUpdate({ + sizeConfig: { + ...sizeConfig, + default: preset, + }, + }); + }; + + const handleTogglePerMode = () => { + if (usePerMode) { + onUpdate({ + sizeConfig: { + default: sizeConfig.default, + }, + }); + } else { + onUpdate({ + sizeConfig: { + ...sizeConfig, + modeOverrides: { + mobile_portrait: sizeConfig.default, + mobile_landscape: sizeConfig.default, + tablet_portrait: sizeConfig.default, + tablet_landscape: sizeConfig.default, + }, + }, + }); + } + }; + + const handleModeChange = (mode: GridMode, preset: ModalSizePreset) => { + onUpdate({ + sizeConfig: { + ...sizeConfig, + modeOverrides: { + ...sizeConfig.modeOverrides, + [mode]: preset, + }, + }, + }); + }; + + return ( +
+ {/* 헤더 (항상 표시) */} + + + {/* 펼침 영역 */} + {isExpanded && ( +
+ {/* 기본 사이즈 선택 */} +
+ 모달 사이즈 +
+ {SIZE_PRESET_ORDER.map(preset => { + const info = MODAL_SIZE_PRESETS[preset]; + return ( + + ); + })} +
+
+ + {/* 모드별 개별 설정 토글 */} +
+ 모드별 개별 사이즈 + +
+ + {/* 모드별 설정 */} + {usePerMode && ( +
+ {MODE_LABELS.map(({ mode, label, icon: Icon }) => { + const modePreset = sizeConfig.modeOverrides?.[mode] ?? sizeConfig.default; + return ( +
+
+ + {label} +
+
+ {SIZE_PRESET_ORDER.map(preset => ( + + ))} +
+
+ ); + })} +
+ )} + + {/* 캔버스 축소판 미리보기 */} + +
+ )} +
+ ); +} + +// ======================================== +// 모달 사이즈 썸네일 미리보기 (캔버스 축소판 + 모달 오버레이) +// ======================================== + +function ModalThumbnailPreview({ + sizeConfig, + currentMode, +}: { + sizeConfig: { default: ModalSizePreset; modeOverrides?: Partial> }; + currentMode: GridMode; +}) { + const PREVIEW_WIDTH = 260; + const ASPECT_RATIO = 0.65; + + const modeInfo = MODE_LABELS.find(m => m.mode === currentMode)!; + const modeWidth = modeInfo.width; + const modeHeight = modeWidth * ASPECT_RATIO; + + const scale = PREVIEW_WIDTH / modeWidth; + const previewHeight = Math.round(modeHeight * scale); + + const modalWidth = resolveModalWidth( + { default: sizeConfig.default, modeOverrides: sizeConfig.modeOverrides as Record | undefined }, + currentMode, + modeWidth, + ); + const scaledModalWidth = Math.min(Math.round(modalWidth * scale), PREVIEW_WIDTH); + const isFull = modalWidth >= modeWidth; + const scaledModalHeight = isFull ? previewHeight : Math.round(previewHeight * 0.75); + const Icon = modeInfo.icon; + + return ( +
+
+ 미리보기 +
+ + {modeInfo.label} +
+
+ +
+ {/* 반투명 배경 오버레이 (모달이 열렸을 때의 딤 효과) */} +
+ + {/* 모달 영역 (가운데 정렬, FULL이면 전체 채움) */} +
+
+ 모달 +
+
+ + {/* 하단 수치 표시 */} +
+ {isFull ? "FULL" : `${modalWidth}px`} / {modeWidth}px +
+
+
+ ); +} diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx index 8bcc8f3a..902eb9a9 100644 --- a/frontend/components/pop/designer/PopDesigner.tsx +++ b/frontend/components/pop/designer/PopDesigner.tsx @@ -28,11 +28,15 @@ import { createEmptyPopLayoutV5, isV5Layout, addComponentToV5Layout, + createComponentDefinitionV5, GRID_BREAKPOINTS, + PopModalDefinition, + PopDataConnection, } from "./types/pop-layout"; import { getAllEffectivePositions } from "./utils/gridUtils"; import { screenApi } from "@/lib/api/screen"; import { ScreenDefinition } from "@/types/screen"; +import { PopDesignerContext } from "./PopDesignerContext"; // ======================================== // Props @@ -51,6 +55,7 @@ export default function PopDesigner({ onBackToList, onScreenUpdate, }: PopDesignerProps) { + // ======================================== // 레이아웃 상태 // ======================================== @@ -69,13 +74,24 @@ export default function PopDesigner({ // 선택 상태 const [selectedComponentId, setSelectedComponentId] = useState(null); + // 대시보드 페이지 미리보기 인덱스 (-1 = 기본 모드) + const [previewPageIndex, setPreviewPageIndex] = useState(-1); + // 그리드 모드 (4개 프리셋) const [currentMode, setCurrentMode] = useState("tablet_landscape"); - // 선택된 컴포넌트 - const selectedComponent: PopComponentDefinitionV5 | null = selectedComponentId - ? layout.components[selectedComponentId] || null - : null; + // 모달 캔버스 활성 상태 ("main" 또는 모달 ID) + const [activeCanvasId, setActiveCanvasId] = useState("main"); + + // 선택된 컴포넌트 (activeCanvasId에 따라 메인 또는 모달에서 조회) + const selectedComponent: PopComponentDefinitionV5 | null = (() => { + if (!selectedComponentId) return null; + if (activeCanvasId === "main") { + return layout.components[selectedComponentId] || null; + } + const modal = layout.modals?.find(m => m.id === activeCanvasId); + return modal?.components[selectedComponentId] || null; + })(); // ======================================== // 히스토리 관리 @@ -206,52 +222,169 @@ export default function PopDesigner({ (type: PopComponentType, position: PopGridPosition) => { const componentId = `comp_${idCounter}`; setIdCounter((prev) => prev + 1); - const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`); - setLayout(newLayout); - saveToHistory(newLayout); + + if (activeCanvasId === "main") { + // 메인 캔버스 + const newLayout = addComponentToV5Layout(layout, componentId, type, position, `${type} ${idCounter}`); + setLayout(newLayout); + saveToHistory(newLayout); + } else { + // 모달 캔버스 + setLayout(prev => { + const comp = createComponentDefinitionV5(componentId, type, position, `${type} ${idCounter}`); + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + return { ...m, components: { ...m.components, [componentId]: comp } }; + }), + }; + saveToHistory(newLayout); + return newLayout; + }); + } setSelectedComponentId(componentId); setHasChanges(true); }, - [idCounter, layout, saveToHistory] + [idCounter, layout, saveToHistory, activeCanvasId] ); const handleUpdateComponent = useCallback( (componentId: string, updates: Partial) => { - const existingComponent = layout.components[componentId]; - if (!existingComponent) return; + // 함수적 업데이트로 stale closure 방지 + setLayout((prev) => { + if (activeCanvasId === "main") { + // 메인 캔버스 + const existingComponent = prev.components[componentId]; + if (!existingComponent) return prev; - const newLayout = { - ...layout, - components: { - ...layout.components, - [componentId]: { - ...existingComponent, - ...updates, - }, - }, - }; - setLayout(newLayout); - saveToHistory(newLayout); + const newLayout = { + ...prev, + components: { + ...prev.components, + [componentId]: { ...existingComponent, ...updates }, + }, + }; + saveToHistory(newLayout); + return newLayout; + } else { + // 모달 캔버스 + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const existing = m.components[componentId]; + if (!existing) return m; + return { + ...m, + components: { + ...m.components, + [componentId]: { ...existing, ...updates }, + }, + }; + }), + }; + saveToHistory(newLayout); + return newLayout; + } + }); setHasChanges(true); }, - [layout, saveToHistory] + [saveToHistory, activeCanvasId] + ); + + // ======================================== + // 연결 CRUD + // ======================================== + + const handleAddConnection = useCallback( + (conn: Omit) => { + setLayout((prev) => { + const newId = `conn_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const newConnection: PopDataConnection = { ...conn, id: newId }; + const prevConnections = prev.dataFlow?.connections || []; + const newLayout: PopLayoutDataV5 = { + ...prev, + dataFlow: { + ...prev.dataFlow, + connections: [...prevConnections, newConnection], + }, + }; + saveToHistory(newLayout); + return newLayout; + }); + setHasChanges(true); + }, + [saveToHistory] + ); + + const handleUpdateConnection = useCallback( + (connectionId: string, conn: Omit) => { + setLayout((prev) => { + const prevConnections = prev.dataFlow?.connections || []; + const newLayout: PopLayoutDataV5 = { + ...prev, + dataFlow: { + ...prev.dataFlow, + connections: prevConnections.map((c) => + c.id === connectionId ? { ...conn, id: connectionId } : c + ), + }, + }; + saveToHistory(newLayout); + return newLayout; + }); + setHasChanges(true); + }, + [saveToHistory] + ); + + const handleRemoveConnection = useCallback( + (connectionId: string) => { + setLayout((prev) => { + const prevConnections = prev.dataFlow?.connections || []; + const newLayout: PopLayoutDataV5 = { + ...prev, + dataFlow: { + ...prev.dataFlow, + connections: prevConnections.filter((c) => c.id !== connectionId), + }, + }; + saveToHistory(newLayout); + return newLayout; + }); + setHasChanges(true); + }, + [saveToHistory] ); const handleDeleteComponent = useCallback( (componentId: string) => { - const newComponents = { ...layout.components }; - delete newComponents[componentId]; - - const newLayout = { - ...layout, - components: newComponents, - }; - setLayout(newLayout); - saveToHistory(newLayout); + setLayout(prev => { + if (activeCanvasId === "main") { + const newComponents = { ...prev.components }; + delete newComponents[componentId]; + const newLayout = { ...prev, components: newComponents }; + saveToHistory(newLayout); + return newLayout; + } else { + const newLayout = { + ...prev, + modals: (prev.modals || []).map(m => { + if (m.id !== activeCanvasId) return m; + const newComps = { ...m.components }; + delete newComps[componentId]; + return { ...m, components: newComps }; + }), + }; + saveToHistory(newLayout); + return newLayout; + } + }); setSelectedComponentId(null); setHasChanges(true); }, - [layout, saveToHistory] + [saveToHistory, activeCanvasId] ); const handleMoveComponent = useCallback( @@ -357,6 +490,56 @@ export default function PopDesigner({ [layout, saveToHistory] ); + // 컴포넌트가 자신의 rowSpan/colSpan을 동적으로 변경 요청 (CardList 확장 등) + const handleRequestResize = useCallback( + (componentId: string, newRowSpan: number, newColSpan?: number) => { + const component = layout.components[componentId]; + if (!component) return; + + const newPosition = { + ...component.position, + rowSpan: newRowSpan, + ...(newColSpan !== undefined ? { colSpan: newColSpan } : {}), + }; + + // 기본 모드(tablet_landscape)인 경우: 원본 position 직접 수정 + if (currentMode === "tablet_landscape") { + const newLayout = { + ...layout, + components: { + ...layout.components, + [componentId]: { + ...component, + position: newPosition, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + } else { + // 다른 모드인 경우: 오버라이드에 저장 + const newLayout = { + ...layout, + overrides: { + ...layout.overrides, + [currentMode]: { + ...layout.overrides?.[currentMode], + positions: { + ...layout.overrides?.[currentMode]?.positions, + [componentId]: newPosition, + }, + }, + }, + }; + setLayout(newLayout); + saveToHistory(newLayout); + setHasChanges(true); + } + }, + [layout, currentMode, saveToHistory] + ); + // ======================================== // Gap 프리셋 관리 // ======================================== @@ -471,6 +654,59 @@ export default function PopDesigner({ setHasChanges(true); }, [layout, currentMode, saveToHistory]); + // ======================================== + // 모달 캔버스 관리 + // ======================================== + + /** 모달 ID 자동 생성 (계층적: modal-1, modal-1-1, modal-1-1-1) */ + const generateModalId = useCallback((parentCanvasId: string): string => { + const modals = layout.modals || []; + if (parentCanvasId === "main") { + const rootModals = modals.filter(m => !m.parentId); + return `modal-${rootModals.length + 1}`; + } + const prefix = parentCanvasId.replace("modal-", ""); + const children = modals.filter(m => m.parentId === parentCanvasId); + return `modal-${prefix}-${children.length + 1}`; + }, [layout.modals]); + + /** 모달 캔버스 생성하고 해당 탭으로 전환 */ + const createModalCanvas = useCallback((buttonComponentId: string, title: string): string => { + const modalId = generateModalId(activeCanvasId); + const newModal: PopModalDefinition = { + id: modalId, + parentId: activeCanvasId === "main" ? undefined : activeCanvasId, + title: title || "새 모달", + sourceButtonId: buttonComponentId, + gridConfig: { ...layout.gridConfig }, + components: {}, + }; + setLayout(prev => ({ + ...prev, + modals: [...(prev.modals || []), newModal], + })); + setHasChanges(true); + setActiveCanvasId(modalId); + return modalId; + }, [generateModalId, activeCanvasId, layout.gridConfig]); + + /** 모달 정의 업데이트 (제목, sizeConfig 등) */ + const handleUpdateModal = useCallback((modalId: string, updates: Partial) => { + setLayout(prev => ({ + ...prev, + modals: (prev.modals || []).map(m => + m.id === modalId ? { ...m, ...updates } : m + ), + })); + setHasChanges(true); + }, []); + + /** 특정 캔버스로 전환 */ + const navigateToCanvas = useCallback((canvasId: string) => { + setActiveCanvasId(canvasId); + setSelectedComponentId(null); + }, []); + // ======================================== // 뒤로가기 // ======================================== @@ -553,6 +789,14 @@ export default function PopDesigner({ // 렌더링 // ======================================== return ( +
{/* 헤더 */} @@ -637,6 +881,11 @@ export default function PopDesigner({ onLockLayout={handleLockLayout} onResetOverride={handleResetOverride} onChangeGapPreset={handleChangeGapPreset} + onRequestResize={handleRequestResize} + previewPageIndex={previewPageIndex} + activeCanvasId={activeCanvasId} + onActiveCanvasChange={navigateToCanvas} + onUpdateModal={handleUpdateModal} /> @@ -652,10 +901,21 @@ export default function PopDesigner({ ? (updates) => handleUpdateComponent(selectedComponentId, updates) : undefined } + allComponents={Object.values(layout.components)} + onSelectComponent={setSelectedComponentId} + selectedComponentId={selectedComponentId} + previewPageIndex={previewPageIndex} + onPreviewPage={setPreviewPageIndex} + connections={layout.dataFlow?.connections || []} + onAddConnection={handleAddConnection} + onUpdateConnection={handleUpdateConnection} + onRemoveConnection={handleRemoveConnection} + modals={layout.modals} />
+
); } diff --git a/frontend/components/pop/designer/PopDesignerContext.tsx b/frontend/components/pop/designer/PopDesignerContext.tsx new file mode 100644 index 00000000..8af42d64 --- /dev/null +++ b/frontend/components/pop/designer/PopDesignerContext.tsx @@ -0,0 +1,35 @@ +/** + * PopDesignerContext - 디자이너 전역 컨텍스트 + * + * ConfigPanel 등 하위 컴포넌트에서 디자이너 레벨 동작을 트리거하기 위한 컨텍스트. + * 예: pop-button 설정 패널에서 "모달 캔버스 생성" 버튼 클릭 시 + * 디자이너의 activeCanvasId를 변경하고 새 모달을 생성. + * + * Provider: PopDesigner.tsx + * Consumer: pop-button ConfigPanel (ModalCanvasButton) + */ + +"use client"; + +import { createContext, useContext } from "react"; + +export interface PopDesignerContextType { + /** 새 모달 캔버스 생성하고 해당 탭으로 전환 (모달 ID 반환) */ + createModalCanvas: (buttonComponentId: string, title: string) => string; + /** 특정 캔버스(메인 또는 모달)로 전환 */ + navigateToCanvas: (canvasId: string) => void; + /** 현재 활성 캔버스 ID ("main" 또는 모달 ID) */ + activeCanvasId: string; + /** 현재 선택된 컴포넌트 ID */ + selectedComponentId: string | null; +} + +export const PopDesignerContext = createContext(null); + +/** + * 디자이너 컨텍스트 사용 훅 + * 뷰어 모드에서는 null 반환 (Provider 없음) + */ +export function usePopDesignerContext(): PopDesignerContextType | null { + return useContext(PopDesignerContext); +} diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx index ddb7ac79..8a5fa621 100644 --- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx +++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx @@ -7,21 +7,23 @@ import { PopGridPosition, GridMode, GRID_BREAKPOINTS, - PopComponentType, } from "../types/pop-layout"; import { Settings, - Database, + Link2, Eye, Grid3x3, MoveHorizontal, MoveVertical, + Layers, } from "lucide-react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { PopDataConnection, PopModalDefinition } from "../types/pop-layout"; +import ConnectionEditor from "./ConnectionEditor"; // ======================================== // Props @@ -36,14 +38,41 @@ interface ComponentEditorPanelProps { onUpdateComponent?: (updates: Partial) => void; /** 추가 className */ className?: string; + /** 그리드에 배치된 모든 컴포넌트 */ + allComponents?: PopComponentDefinitionV5[]; + /** 컴포넌트 선택 콜백 */ + onSelectComponent?: (componentId: string) => void; + /** 현재 선택된 컴포넌트 ID */ + selectedComponentId?: string | null; + /** 대시보드 페이지 미리보기 인덱스 */ + previewPageIndex?: number; + /** 페이지 미리보기 요청 콜백 */ + onPreviewPage?: (pageIndex: number) => void; + /** 데이터 흐름 연결 목록 */ + connections?: PopDataConnection[]; + /** 연결 추가 콜백 */ + onAddConnection?: (conn: Omit) => void; + /** 연결 수정 콜백 */ + onUpdateConnection?: (connectionId: string, conn: Omit) => void; + /** 연결 삭제 콜백 */ + onRemoveConnection?: (connectionId: string) => void; + /** 모달 정의 목록 (설정 패널에 전달) */ + modals?: PopModalDefinition[]; } // ======================================== // 컴포넌트 타입별 라벨 // ======================================== -const COMPONENT_TYPE_LABELS: Record = { +const COMPONENT_TYPE_LABELS: Record = { + "pop-sample": "샘플", + "pop-text": "텍스트", + "pop-icon": "아이콘", + "pop-dashboard": "대시보드", + "pop-card-list": "카드 목록", "pop-field": "필드", "pop-button": "버튼", + "pop-string-list": "리스트 목록", + "pop-search": "검색", "pop-list": "리스트", "pop-indicator": "인디케이터", "pop-scanner": "스캐너", @@ -61,6 +90,16 @@ export default function ComponentEditorPanel({ currentMode, onUpdateComponent, className, + allComponents, + onSelectComponent, + selectedComponentId, + previewPageIndex, + onPreviewPage, + connections, + onAddConnection, + onUpdateConnection, + onRemoveConnection, + modals, }: ComponentEditorPanelProps) { const breakpoint = GRID_BREAKPOINTS[currentMode]; @@ -97,8 +136,8 @@ export default function ComponentEditorPanel({
{/* 탭 */} - - + + 위치 @@ -111,14 +150,51 @@ export default function ComponentEditorPanel({ 표시 - - - 데이터 + + + 연결 {/* 위치 탭 */} - + + {/* 배치된 컴포넌트 목록 */} + {allComponents && allComponents.length > 0 && ( +
+
+ + + 배치된 컴포넌트 ({allComponents.length}) + +
+
+ {allComponents.map((comp) => { + const label = comp.label + || COMPONENT_TYPE_LABELS[comp.type] + || comp.type; + const isActive = comp.id === selectedComponentId; + return ( + + ); + })} +
+
+
+ )} {/* 설정 탭 */} - + {/* 표시 탭 */} - + - {/* 데이터 탭 */} - - + {/* 연결 탭 */} + +
@@ -313,9 +400,13 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate interface ComponentSettingsFormProps { component: PopComponentDefinitionV5; onUpdate?: (updates: Partial) => void; + currentMode?: GridMode; + previewPageIndex?: number; + onPreviewPage?: (pageIndex: number) => void; + modals?: PopModalDefinition[]; } -function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) { +function ComponentSettingsForm({ component, onUpdate, currentMode, previewPageIndex, onPreviewPage, modals }: ComponentSettingsFormProps) { // PopComponentRegistry에서 configPanel 가져오기 const registeredComp = PopComponentRegistry.getComponent(component.type); const ConfigPanel = registeredComp?.configPanel; @@ -344,6 +435,11 @@ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormPro ) : (
@@ -419,20 +515,3 @@ function VisibilityForm({ component, onUpdate }: VisibilityFormProps) { ); } -// ======================================== -// 데이터 바인딩 플레이스홀더 -// ======================================== - -function DataBindingPlaceholder() { - return ( -
-
- -

데이터 바인딩

-

- Phase 4에서 구현 예정 -

-
-
- ); -} diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx index 05db0aab..42b1ee06 100644 --- a/frontend/components/pop/designer/panels/ComponentPalette.tsx +++ b/frontend/components/pop/designer/panels/ComponentPalette.tsx @@ -3,7 +3,7 @@ import { useDrag } from "react-dnd"; import { cn } from "@/lib/utils"; import { PopComponentType } from "../types/pop-layout"; -import { Square, FileText } from "lucide-react"; +import { Square, FileText, MousePointer, BarChart3, LayoutGrid, MousePointerClick, List, Search } from "lucide-react"; import { DND_ITEM_TYPES } from "../constants"; // 컴포넌트 정의 @@ -27,6 +27,42 @@ const PALETTE_ITEMS: PaletteItem[] = [ icon: FileText, description: "텍스트, 시간, 이미지 표시", }, + { + type: "pop-icon", + label: "아이콘", + icon: MousePointer, + description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)", + }, + { + type: "pop-dashboard", + label: "대시보드", + icon: BarChart3, + description: "KPI, 차트, 게이지, 통계 집계", + }, + { + type: "pop-card-list", + label: "카드 목록", + icon: LayoutGrid, + description: "테이블 데이터를 카드 형태로 표시", + }, + { + type: "pop-button", + label: "버튼", + icon: MousePointerClick, + description: "액션 버튼 (저장/삭제/API/모달)", + }, + { + type: "pop-string-list", + label: "리스트 목록", + icon: List, + description: "테이블 데이터를 리스트/카드로 표시", + }, + { + type: "pop-search", + label: "검색", + icon: Search, + description: "조건 입력 (텍스트/날짜/선택/모달)", + }, ]; // 드래그 가능한 컴포넌트 아이템 diff --git a/frontend/components/pop/designer/panels/ConnectionEditor.tsx b/frontend/components/pop/designer/panels/ConnectionEditor.tsx new file mode 100644 index 00000000..2e92d602 --- /dev/null +++ b/frontend/components/pop/designer/panels/ConnectionEditor.tsx @@ -0,0 +1,623 @@ +"use client"; + +import React from "react"; +import { ArrowRight, Link2, Unlink2, Plus, Trash2, Pencil, X, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + PopComponentDefinitionV5, + PopDataConnection, +} from "../types/pop-layout"; +import { + PopComponentRegistry, + type ComponentConnectionMeta, +} from "@/lib/registry/PopComponentRegistry"; +import { getTableColumns } from "@/lib/api/tableManagement"; + +// ======================================== +// Props +// ======================================== + +interface ConnectionEditorProps { + component: PopComponentDefinitionV5; + allComponents: PopComponentDefinitionV5[]; + connections: PopDataConnection[]; + onAddConnection?: (conn: Omit) => void; + onUpdateConnection?: (connectionId: string, conn: Omit) => void; + onRemoveConnection?: (connectionId: string) => void; +} + +// ======================================== +// ConnectionEditor +// ======================================== + +export default function ConnectionEditor({ + component, + allComponents, + connections, + onAddConnection, + onUpdateConnection, + onRemoveConnection, +}: ConnectionEditorProps) { + const registeredComp = PopComponentRegistry.getComponent(component.type); + const meta = registeredComp?.connectionMeta; + + const outgoing = connections.filter( + (c) => c.sourceComponent === component.id + ); + const incoming = connections.filter( + (c) => c.targetComponent === component.id + ); + + const hasSendable = meta?.sendable && meta.sendable.length > 0; + const hasReceivable = meta?.receivable && meta.receivable.length > 0; + + if (!hasSendable && !hasReceivable) { + return ( +
+
+ +

연결 없음

+

+ 이 컴포넌트는 다른 컴포넌트와 연결할 수 없습니다 +

+
+
+ ); + } + + return ( +
+ {hasSendable && ( + + )} + + {hasReceivable && ( + + )} +
+ ); +} + +// ======================================== +// 대상 컴포넌트에서 정보 추출 +// ======================================== + +/** 화면에 표시 중인 컬럼만 추출 */ +function extractDisplayColumns(comp: PopComponentDefinitionV5 | undefined): string[] { + if (!comp?.config) return []; + const cfg = comp.config as Record; + const cols: string[] = []; + + if (Array.isArray(cfg.listColumns)) { + (cfg.listColumns as Array<{ columnName?: string }>).forEach((c) => { + if (c.columnName && !cols.includes(c.columnName)) cols.push(c.columnName); + }); + } + + if (Array.isArray(cfg.selectedColumns)) { + (cfg.selectedColumns as string[]).forEach((c) => { + if (!cols.includes(c)) cols.push(c); + }); + } + + return cols; +} + +/** 대상 컴포넌트의 데이터소스 테이블명 추출 */ +function extractTableName(comp: PopComponentDefinitionV5 | undefined): string { + if (!comp?.config) return ""; + const cfg = comp.config as Record; + const ds = cfg.dataSource as { tableName?: string } | undefined; + return ds?.tableName || ""; +} + +// ======================================== +// 보내기 섹션 +// ======================================== + +interface SendSectionProps { + component: PopComponentDefinitionV5; + meta: ComponentConnectionMeta; + allComponents: PopComponentDefinitionV5[]; + outgoing: PopDataConnection[]; + onAddConnection?: (conn: Omit) => void; + onUpdateConnection?: (connectionId: string, conn: Omit) => void; + onRemoveConnection?: (connectionId: string) => void; +} + +function SendSection({ + component, + meta, + allComponents, + outgoing, + onAddConnection, + onUpdateConnection, + onRemoveConnection, +}: SendSectionProps) { + const [editingId, setEditingId] = React.useState(null); + + return ( +
+ + + {/* 기존 연결 목록 */} + {outgoing.map((conn) => ( +
+ {editingId === conn.id ? ( + { + onUpdateConnection?.(conn.id, data); + setEditingId(null); + }} + onCancel={() => setEditingId(null)} + submitLabel="수정" + /> + ) : ( +
+ + {conn.label || `${conn.sourceOutput} -> ${conn.targetInput}`} + + + {onRemoveConnection && ( + + )} +
+ )} +
+ ))} + + {/* 새 연결 추가 */} + onAddConnection?.(data)} + submitLabel="연결 추가" + /> +
+ ); +} + +// ======================================== +// 연결 폼 (추가/수정 공용) +// ======================================== + +interface ConnectionFormProps { + component: PopComponentDefinitionV5; + meta: ComponentConnectionMeta; + allComponents: PopComponentDefinitionV5[]; + initial?: PopDataConnection; + onSubmit: (data: Omit) => void; + onCancel?: () => void; + submitLabel: string; +} + +function ConnectionForm({ + component, + meta, + allComponents, + initial, + onSubmit, + onCancel, + submitLabel, +}: ConnectionFormProps) { + const [selectedOutput, setSelectedOutput] = React.useState( + initial?.sourceOutput || meta.sendable[0]?.key || "" + ); + const [selectedTargetId, setSelectedTargetId] = React.useState( + initial?.targetComponent || "" + ); + const [selectedTargetInput, setSelectedTargetInput] = React.useState( + initial?.targetInput || "" + ); + const [filterColumns, setFilterColumns] = React.useState( + initial?.filterConfig?.targetColumns || + (initial?.filterConfig?.targetColumn ? [initial.filterConfig.targetColumn] : []) + ); + const [filterMode, setFilterMode] = React.useState< + "equals" | "contains" | "starts_with" | "range" + >(initial?.filterConfig?.filterMode || "contains"); + + const targetCandidates = allComponents.filter((c) => { + if (c.id === component.id) return false; + const reg = PopComponentRegistry.getComponent(c.type); + return reg?.connectionMeta?.receivable && reg.connectionMeta.receivable.length > 0; + }); + + const targetComp = selectedTargetId + ? allComponents.find((c) => c.id === selectedTargetId) + : null; + + const targetMeta = targetComp + ? PopComponentRegistry.getComponent(targetComp.type)?.connectionMeta + : null; + + // 화면에 표시 중인 컬럼 + const displayColumns = React.useMemo( + () => extractDisplayColumns(targetComp || undefined), + [targetComp] + ); + + // DB 테이블 전체 컬럼 (비동기 조회) + const tableName = React.useMemo( + () => extractTableName(targetComp || undefined), + [targetComp] + ); + const [allDbColumns, setAllDbColumns] = React.useState([]); + const [dbColumnsLoading, setDbColumnsLoading] = React.useState(false); + + React.useEffect(() => { + if (!tableName) { + setAllDbColumns([]); + return; + } + let cancelled = false; + setDbColumnsLoading(true); + getTableColumns(tableName).then((res) => { + if (cancelled) return; + if (res.success && res.data?.columns) { + setAllDbColumns(res.data.columns.map((c) => c.columnName)); + } else { + setAllDbColumns([]); + } + setDbColumnsLoading(false); + }); + return () => { cancelled = true; }; + }, [tableName]); + + // 표시 컬럼과 데이터 전용 컬럼 분리 + const displaySet = React.useMemo(() => new Set(displayColumns), [displayColumns]); + const dataOnlyColumns = React.useMemo( + () => allDbColumns.filter((c) => !displaySet.has(c)), + [allDbColumns, displaySet] + ); + const hasAnyColumns = displayColumns.length > 0 || dataOnlyColumns.length > 0; + + const toggleColumn = (col: string) => { + setFilterColumns((prev) => + prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col] + ); + }; + + const handleSubmit = () => { + if (!selectedOutput || !selectedTargetId || !selectedTargetInput) return; + + onSubmit({ + sourceComponent: component.id, + sourceField: "", + sourceOutput: selectedOutput, + targetComponent: selectedTargetId, + targetField: "", + targetInput: selectedTargetInput, + filterConfig: + filterColumns.length > 0 + ? { + targetColumn: filterColumns[0], + targetColumns: filterColumns, + filterMode, + } + : undefined, + label: buildConnectionLabel( + component, + selectedOutput, + allComponents.find((c) => c.id === selectedTargetId), + selectedTargetInput, + filterColumns + ), + }); + + if (!initial) { + setSelectedTargetId(""); + setSelectedTargetInput(""); + setFilterColumns([]); + } + }; + + return ( +
+ {onCancel && ( +
+

연결 수정

+ +
+ )} + {!onCancel && ( +

새 연결 추가

+ )} + + {/* 보내는 값 */} +
+ 보내는 값 + +
+ + {/* 받는 컴포넌트 */} +
+ 받는 컴포넌트 + +
+ + {/* 받는 방식 */} + {targetMeta && ( +
+ 받는 방식 + +
+ )} + + {/* 필터 설정 */} + {selectedTargetInput && ( +
+

필터할 컬럼

+ + {dbColumnsLoading ? ( +
+ + 컬럼 조회 중... +
+ ) : hasAnyColumns ? ( +
+ {/* 표시 컬럼 그룹 */} + {displayColumns.length > 0 && ( +
+

화면 표시 컬럼

+ {displayColumns.map((col) => ( +
+ toggleColumn(col)} + /> + +
+ ))} +
+ )} + + {/* 데이터 전용 컬럼 그룹 */} + {dataOnlyColumns.length > 0 && ( +
+ {displayColumns.length > 0 && ( +
+ )} +

데이터 전용 컬럼

+ {dataOnlyColumns.map((col) => ( +
+ toggleColumn(col)} + /> + +
+ ))} +
+ )} +
+ ) : ( + setFilterColumns(e.target.value ? [e.target.value] : [])} + placeholder="컬럼명 입력" + className="h-7 text-xs" + /> + )} + + {filterColumns.length > 0 && ( +

+ {filterColumns.length}개 컬럼 중 하나라도 일치하면 표시 +

+ )} + + {/* 필터 방식 */} +
+

필터 방식

+ +
+
+ )} + + {/* 제출 버튼 */} + +
+ ); +} + +// ======================================== +// 받기 섹션 (읽기 전용) +// ======================================== + +interface ReceiveSectionProps { + component: PopComponentDefinitionV5; + meta: ComponentConnectionMeta; + allComponents: PopComponentDefinitionV5[]; + incoming: PopDataConnection[]; +} + +function ReceiveSection({ + component, + meta, + allComponents, + incoming, +}: ReceiveSectionProps) { + return ( +
+ + +
+ {meta.receivable.map((r) => ( +
+ {r.label} + {r.description && ( +

+ {r.description} +

+ )} +
+ ))} +
+ + {incoming.length > 0 ? ( +
+

연결된 소스

+ {incoming.map((conn) => { + const sourceComp = allComponents.find( + (c) => c.id === conn.sourceComponent + ); + return ( +
+ + + {sourceComp?.label || conn.sourceComponent} + +
+ ); + })} +
+ ) : ( +

+ 아직 연결된 소스가 없습니다. 보내는 컴포넌트에서 연결을 설정하세요. +

+ )} +
+ ); +} + +// ======================================== +// 유틸 +// ======================================== + +function buildConnectionLabel( + source: PopComponentDefinitionV5, + _outputKey: string, + target: PopComponentDefinitionV5 | undefined, + _inputKey: string, + columns?: string[] +): string { + const srcLabel = source.label || source.id; + const tgtLabel = target?.label || target?.id || "?"; + const colInfo = columns && columns.length > 0 + ? ` [${columns.join(", ")}]` + : ""; + return `${srcLabel} -> ${tgtLabel}${colInfo}`; +} diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx index b0299813..88100d27 100644 --- a/frontend/components/pop/designer/renderers/PopRenderer.tsx +++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx @@ -48,12 +48,18 @@ interface PopRendererProps { onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; /** 컴포넌트 크기 조정 완료 (히스토리 저장용) */ onComponentResizeEnd?: (componentId: string) => void; + /** 컴포넌트가 자신의 rowSpan/colSpan 변경을 요청 (CardList 확장 등) */ + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; /** Gap 오버라이드 (Gap 프리셋 적용된 값) */ overrideGap?: number; /** Padding 오버라이드 (Gap 프리셋 적용된 값) */ overridePadding?: number; /** 추가 className */ className?: string; + /** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */ + currentScreenId?: number; + /** 대시보드 페이지 미리보기 인덱스 */ + previewPageIndex?: number; } // ======================================== @@ -62,6 +68,13 @@ interface PopRendererProps { const COMPONENT_TYPE_LABELS: Record = { "pop-sample": "샘플", + "pop-text": "텍스트", + "pop-icon": "아이콘", + "pop-dashboard": "대시보드", + "pop-card-list": "카드 목록", + "pop-button": "버튼", + "pop-string-list": "리스트 목록", + "pop-search": "검색", }; // ======================================== @@ -80,9 +93,12 @@ export default function PopRenderer({ onComponentMove, onComponentResize, onComponentResizeEnd, + onRequestResize, overrideGap, overridePadding, className, + currentScreenId, + previewPageIndex, }: PopRendererProps) { const { gridConfig, components, overrides } = layout; @@ -110,18 +126,27 @@ export default function PopRenderer({ return Math.max(10, maxRowEnd + 3); }, [components, overrides, mode, hiddenIds]); - // CSS Grid 스타일 (행 높이 강제 고정: 셀 크기 = 컴포넌트 크기의 기준) + // CSS Grid 스타일 + // 디자인 모드: 행 높이 고정 (정밀한 레이아웃 편집) + // 뷰어 모드: minmax(rowHeight, auto) (컴포넌트가 컨텐츠에 맞게 확장 가능) + const rowTemplate = isDesignMode + ? `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)` + : `repeat(${dynamicRowCount}, minmax(${breakpoint.rowHeight}px, auto))`; + const autoRowHeight = isDesignMode + ? `${breakpoint.rowHeight}px` + : `minmax(${breakpoint.rowHeight}px, auto)`; + const gridStyle = useMemo((): React.CSSProperties => ({ display: "grid", gridTemplateColumns: `repeat(${breakpoint.columns}, 1fr)`, - gridTemplateRows: `repeat(${dynamicRowCount}, ${breakpoint.rowHeight}px)`, - gridAutoRows: `${breakpoint.rowHeight}px`, + gridTemplateRows: rowTemplate, + gridAutoRows: autoRowHeight, gap: `${finalGap}px`, padding: `${finalPadding}px`, minHeight: "100%", backgroundColor: "#ffffff", position: "relative", - }), [breakpoint, finalGap, finalPadding, dynamicRowCount]); + }), [breakpoint, finalGap, finalPadding, dynamicRowCount, rowTemplate, autoRowHeight]); // 그리드 가이드 셀 생성 (동적 행 수) const gridCells = useMemo(() => { @@ -248,15 +273,17 @@ export default function PopRenderer({ onComponentMove={onComponentMove} onComponentResize={onComponentResize} onComponentResizeEnd={onComponentResizeEnd} + onRequestResize={onRequestResize} + previewPageIndex={previewPageIndex} /> ); } - // 뷰어 모드: 드래그 없는 일반 렌더링 + // 뷰어 모드: 드래그 없는 일반 렌더링 (overflow visible로 컨텐츠 확장 허용) return (
); @@ -291,6 +320,8 @@ interface DraggableComponentProps { onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void; onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; onComponentResizeEnd?: (componentId: string) => void; + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; + previewPageIndex?: number; } function DraggableComponent({ @@ -308,6 +339,8 @@ function DraggableComponent({ onComponentMove, onComponentResize, onComponentResizeEnd, + onRequestResize, + previewPageIndex, }: DraggableComponentProps) { const [{ isDragging }, drag] = useDrag( () => ({ @@ -346,6 +379,9 @@ function DraggableComponent({ effectivePosition={position} isDesignMode={isDesignMode} isSelected={isSelected} + previewPageIndex={previewPageIndex} + onRequestResize={onRequestResize} + screenId={undefined} /> {/* 리사이즈 핸들 (선택된 컴포넌트만) */} @@ -496,66 +532,99 @@ interface ComponentContentProps { effectivePosition: PopGridPosition; isDesignMode: boolean; isSelected: boolean; + previewPageIndex?: number; + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; + /** 화면 ID (이벤트 버스/액션 실행용) */ + screenId?: string; } -function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) { +function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex, onRequestResize, screenId }: ComponentContentProps) { const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; // PopComponentRegistry에서 등록된 컴포넌트 가져오기 const registeredComp = PopComponentRegistry.getComponent(component.type); const PreviewComponent = registeredComp?.preview; - // 디자인 모드: 미리보기 컴포넌트 또는 플레이스홀더 표시 + // 디자인 모드: 실제 컴포넌트 또는 미리보기 표시 (헤더 없음 - 뷰어와 동일하게) if (isDesignMode) { + const ActualComp = registeredComp?.component; + + // 실제 컴포넌트가 등록되어 있으면 실제 데이터로 렌더링 (대시보드 등) + if (ActualComp) { + // 아이콘 컴포넌트는 클릭 이벤트가 필요하므로 pointer-events 허용 + // CardList 컴포넌트도 버튼 클릭이 필요하므로 pointer-events 허용 + const needsPointerEvents = component.type === "pop-icon" || component.type === "pop-card-list"; + + return ( +
+ +
+ ); + } + + // 미등록: preview 컴포넌트 또는 기본 플레이스홀더 return ( -
- {/* 헤더 */} -
- - {component.label || typeLabel} +
+ {PreviewComponent ? ( + + ) : ( + + {typeLabel} -
- - {/* 내용: 등록된 preview 컴포넌트 또는 기본 플레이스홀더 */} -
- {PreviewComponent ? ( - - ) : ( - - {typeLabel} - - )} -
- - {/* 위치 정보 표시 (유효 위치 사용) */} -
- {effectivePosition.col},{effectivePosition.row} - ({effectivePosition.colSpan}×{effectivePosition.rowSpan}) -
+ )}
); } - // 실제 모드: 컴포넌트 렌더링 - return renderActualComponent(component); + // 실제 모드: 컴포넌트 렌더링 (뷰어 모드에서도 리사이즈 지원) + return renderActualComponent(component, effectivePosition, onRequestResize, screenId); } // ======================================== // 실제 컴포넌트 렌더링 (뷰어 모드) // ======================================== -function renderActualComponent(component: PopComponentDefinitionV5): React.ReactNode { - const typeLabel = COMPONENT_TYPE_LABELS[component.type]; - - // 샘플 박스 렌더링 +function renderActualComponent( + component: PopComponentDefinitionV5, + effectivePosition?: PopGridPosition, + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void, + screenId?: string, +): React.ReactNode { + // 레지스트리에서 등록된 실제 컴포넌트 조회 + const registeredComp = PopComponentRegistry.getComponent(component.type); + const ActualComp = registeredComp?.component; + + if (ActualComp) { + return ( +
+ +
+ ); + } + + // 미등록 컴포넌트: 플레이스홀더 (fallback) + const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type; return (
{component.label || typeLabel} diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts index 1a8335ec..15e70c65 100644 --- a/frontend/components/pop/designer/types/pop-layout.ts +++ b/frontend/components/pop/designer/types/pop-layout.ts @@ -9,7 +9,7 @@ /** * POP 컴포넌트 타입 */ -export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트 +export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard" | "pop-card-list" | "pop-button" | "pop-string-list" | "pop-search"; /** * 데이터 흐름 정의 @@ -25,6 +25,16 @@ export interface PopDataConnection { targetComponent: string; targetField: string; transformType?: "direct" | "calculate" | "lookup"; + + // v2: 연결 시스템 전용 + sourceOutput?: string; + targetInput?: string; + filterConfig?: { + targetColumn: string; + targetColumns?: string[]; + filterMode: "equals" | "contains" | "starts_with" | "range"; + }; + label?: string; } /** @@ -208,6 +218,9 @@ export interface PopLayoutDataV5 { mobile_landscape?: PopModeOverrideV5; tablet_portrait?: PopModeOverrideV5; }; + + // 모달 캔버스 목록 (버튼의 "모달 열기" 액션으로 생성) + modals?: PopModalDefinition[]; } /** @@ -342,6 +355,12 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => { export const DEFAULT_COMPONENT_GRID_SIZE: Record = { "pop-sample": { colSpan: 2, rowSpan: 1 }, "pop-text": { colSpan: 3, rowSpan: 1 }, + "pop-icon": { colSpan: 1, rowSpan: 2 }, + "pop-dashboard": { colSpan: 6, rowSpan: 3 }, + "pop-card-list": { colSpan: 4, rowSpan: 3 }, + "pop-button": { colSpan: 2, rowSpan: 1 }, + "pop-string-list": { colSpan: 4, rowSpan: 3 }, + "pop-search": { colSpan: 4, rowSpan: 2 }, }; /** @@ -380,6 +399,95 @@ export const addComponentToV5Layout = ( return newLayout; }; +// ======================================== +// 모달 캔버스 정의 +// ======================================== + +// ======================================== +// 모달 사이즈 시스템 +// ======================================== + +/** 모달 사이즈 프리셋 */ +export type ModalSizePreset = "sm" | "md" | "lg" | "xl" | "full"; + +/** 모달 사이즈 프리셋별 픽셀 값 */ +export const MODAL_SIZE_PRESETS: Record = { + sm: { width: 400, label: "Small (400px)" }, + md: { width: 600, label: "Medium (600px)" }, + lg: { width: 800, label: "Large (800px)" }, + xl: { width: 1000, label: "XLarge (1000px)" }, + full: { width: 9999, label: "Full (화면 꽉 참)" }, +}; + +/** 모달 사이즈 설정 (모드별 독립 설정 가능) */ +export interface ModalSizeConfig { + /** 기본 사이즈 (모든 모드 공통, 기본값: "md") */ + default: ModalSizePreset; + /** 모드별 오버라이드 (미설정 시 default 사용) */ + modeOverrides?: { + mobile_portrait?: ModalSizePreset; + mobile_landscape?: ModalSizePreset; + tablet_portrait?: ModalSizePreset; + tablet_landscape?: ModalSizePreset; + }; +} + +/** + * 주어진 모드에서 모달의 실제 픽셀 너비를 계산 + * - 뷰포트보다 모달이 크면 자동으로 뷰포트에 맞춤 (full 승격) + */ +export function resolveModalWidth( + sizeConfig: ModalSizeConfig | undefined, + mode: GridMode, + viewportWidth: number, +): number { + const preset = sizeConfig?.modeOverrides?.[mode] ?? sizeConfig?.default ?? "md"; + const presetEntry = MODAL_SIZE_PRESETS[preset] ?? MODAL_SIZE_PRESETS.md; + const presetWidth = presetEntry.width; + // full이면 뷰포트 전체, 아니면 프리셋과 뷰포트 중 작은 값 + if (preset === "full") return viewportWidth; + return Math.min(presetWidth, viewportWidth); +} + +/** + * 모달 캔버스 정의 + * + * 버튼의 "모달 열기" 액션이 참조하는 모달 화면. + * 메인 캔버스와 동일한 그리드 시스템을 사용. + * 중첩 모달: parentId로 부모-자식 관계 표현. + */ +export interface PopModalDefinition { + /** 모달 고유 ID (예: "modal-1", "modal-1-1") */ + id: string; + /** 부모 모달 ID (최상위 모달은 undefined) */ + parentId?: string; + /** 모달 제목 (다이얼로그 헤더에 표시) */ + title: string; + /** 이 모달을 연 버튼의 컴포넌트 ID */ + sourceButtonId: string; + /** 모달 내부 그리드 설정 */ + gridConfig: PopGridConfig; + /** 모달 내부 컴포넌트 */ + components: Record; + /** 모드별 오버라이드 */ + overrides?: { + mobile_portrait?: PopModeOverrideV5; + mobile_landscape?: PopModeOverrideV5; + tablet_portrait?: PopModeOverrideV5; + }; + /** 모달 프레임 설정 (닫기 방식) */ + frameConfig?: { + /** 닫기(X) 버튼 표시 여부 (기본 true) */ + showCloseButton?: boolean; + /** 오버레이 클릭으로 닫기 (기본 true) */ + closeOnOverlay?: boolean; + /** ESC 키로 닫기 (기본 true) */ + closeOnEsc?: boolean; + }; + /** 모달 사이즈 설정 (미설정 시 md 기본) */ + sizeConfig?: ModalSizeConfig; +} + // ======================================== // 레거시 타입 별칭 (하위 호환 - 추후 제거) // ======================================== diff --git a/frontend/components/pop/viewer/PopViewerWithModals.tsx b/frontend/components/pop/viewer/PopViewerWithModals.tsx new file mode 100644 index 00000000..8d6e4227 --- /dev/null +++ b/frontend/components/pop/viewer/PopViewerWithModals.tsx @@ -0,0 +1,203 @@ +/** + * PopViewerWithModals - 뷰어 모드에서 모달 렌더링 래퍼 + * + * PopRenderer를 감싸서: + * 1. __pop_modal_open__ 이벤트 구독 → Dialog 열기 + * 2. __pop_modal_close__ 이벤트 구독 → Dialog 닫기 + * 3. 모달 스택 관리 (중첩 모달 지원) + * + * 모달 내부는 또 다른 PopRenderer로 렌더링 (독립 그리드). + */ + +"use client"; + +import { useState, useCallback, useEffect, useMemo } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import PopRenderer from "../designer/renderers/PopRenderer"; +import type { PopLayoutDataV5, PopModalDefinition, GridMode } from "../designer/types/pop-layout"; +import { detectGridMode, resolveModalWidth } from "../designer/types/pop-layout"; +import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { useConnectionResolver } from "@/hooks/pop/useConnectionResolver"; + +// ======================================== +// 타입 +// ======================================== + +interface PopViewerWithModalsProps { + /** 전체 레이아웃 (모달 정의 포함) */ + layout: PopLayoutDataV5; + /** 뷰포트 너비 */ + viewportWidth: number; + /** 화면 ID (이벤트 버스용) */ + screenId: string; + /** 현재 그리드 모드 (PopRenderer 전달용) */ + currentMode?: GridMode; + /** Gap 오버라이드 */ + overrideGap?: number; + /** Padding 오버라이드 */ + overridePadding?: number; +} + +/** 열린 모달 상태 */ +interface OpenModal { + definition: PopModalDefinition; + returnTo?: string; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== + +export default function PopViewerWithModals({ + layout, + viewportWidth, + screenId, + currentMode, + overrideGap, + overridePadding, +}: PopViewerWithModalsProps) { + const [modalStack, setModalStack] = useState([]); + const { subscribe, publish } = usePopEvent(screenId); + + // 연결 해석기: layout에 정의된 connections를 이벤트 라우팅으로 변환 + const stableConnections = useMemo( + () => layout.dataFlow?.connections ?? [], + [layout.dataFlow?.connections] + ); + useConnectionResolver({ + screenId, + connections: stableConnections, + }); + + // 모달 열기/닫기 이벤트 구독 + useEffect(() => { + const unsubOpen = subscribe("__pop_modal_open__", (payload: unknown) => { + const data = payload as { + modalId?: string; + title?: string; + mode?: string; + returnTo?: string; + }; + + if (data?.modalId) { + const modalDef = layout.modals?.find(m => m.id === data.modalId); + if (modalDef) { + setModalStack(prev => [...prev, { + definition: modalDef, + returnTo: data.returnTo, + }]); + } + } + }); + + const unsubClose = subscribe("__pop_modal_close__", (payload: unknown) => { + const data = payload as { selectedRow?: Record } | undefined; + + setModalStack(prev => { + if (prev.length === 0) return prev; + const topModal = prev[prev.length - 1]; + + // 결과 데이터가 있고, 반환 대상이 지정된 경우 결과 이벤트 발행 + if (data?.selectedRow && topModal.returnTo) { + publish("__pop_modal_result__", { + selectedRow: data.selectedRow, + returnTo: topModal.returnTo, + }); + } + + return prev.slice(0, -1); + }); + }); + + return () => { + unsubOpen(); + unsubClose(); + }; + }, [subscribe, publish, layout.modals]); + + // 최상위 모달만 닫기 (X 버튼, overlay 클릭, ESC) + const handleCloseTopModal = useCallback(() => { + setModalStack(prev => prev.slice(0, -1)); + }, []); + + return ( + <> + {/* 메인 화면 렌더링 */} + + + {/* 모달 스택 렌더링 */} + {modalStack.map((modal, index) => { + const { definition } = modal; + const isTopModal = index === modalStack.length - 1; + const closeOnOverlay = definition.frameConfig?.closeOnOverlay !== false; + const closeOnEsc = definition.frameConfig?.closeOnEsc !== false; + + const modalLayout: PopLayoutDataV5 = { + ...layout, + gridConfig: definition.gridConfig, + components: definition.components, + overrides: definition.overrides, + }; + + const detectedMode = currentMode || detectGridMode(viewportWidth); + const modalWidth = resolveModalWidth(definition.sizeConfig, detectedMode, viewportWidth); + const isFull = modalWidth >= viewportWidth; + const rendererWidth = isFull ? viewportWidth : modalWidth - 32; + + return ( + { + if (!open && isTopModal) handleCloseTopModal(); + }} + > + { + // 최상위 모달이 아니면 overlay 클릭 무시 (하위 모달이 먼저 닫히는 것 방지) + if (!isTopModal || !closeOnOverlay) e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + if (!isTopModal || !closeOnEsc) e.preventDefault(); + }} + > + + + {definition.title} + + +
+ +
+
+
+ ); + })} + + ); +} diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 8dad77db..49aed98b 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -17,6 +17,7 @@ import { dynamicFormApi } from "@/lib/api/dynamicForm"; import { useAuth } from "@/hooks/useAuth"; import { ConditionalZone, LayerDefinition } from "@/types/screen-management"; import { convertV2ToLegacy, isValidV2Layout } from "@/lib/utils/layoutV2Converter"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; interface EditModalState { isOpen: boolean; @@ -1154,19 +1155,6 @@ export const EditModal: React.FC = ({ className }) => { if (response.success) { const masterRecordId = response.data?.id || formData.id; - // 🆕 리피터 데이터 저장 이벤트 발생 (V2Repeater 컴포넌트가 리스닝) - window.dispatchEvent( - new CustomEvent("repeaterSave", { - detail: { - parentId: masterRecordId, - masterRecordId, - mainFormData: formData, - tableName: screenData.screenInfo.tableName, - }, - }), - ); - console.log("📋 [EditModal] repeaterSave 이벤트 발생:", { masterRecordId, tableName: screenData.screenInfo.tableName }); - toast.success("데이터가 생성되었습니다."); // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) @@ -1214,6 +1202,40 @@ export const EditModal: React.FC = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } + // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) + try { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); + + console.log("🟢 [EditModal] INSERT 후 repeaterSave 이벤트 발행:", { + parentId: masterRecordId, + tableName: screenData.screenInfo.tableName, + }); + + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: masterRecordId, + tableName: screenData.screenInfo.tableName, + mainFormData: formData, + masterRecordId, + }, + }), + ); + + await repeaterSavePromise; + console.log("✅ [EditModal] INSERT 후 repeaterSave 완료"); + } catch (repeaterError) { + console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + } + handleClose(); } else { throw new Error(response.message || "생성에 실패했습니다."); @@ -1319,6 +1341,40 @@ export const EditModal: React.FC = ({ className }) => { toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); } + // V2Repeater 디테일 데이터 저장 (모달 닫기 전에 실행) + try { + const repeaterSavePromise = new Promise((resolve) => { + const fallbackTimeout = setTimeout(resolve, 5000); + const handler = () => { + clearTimeout(fallbackTimeout); + window.removeEventListener("repeaterSaveComplete", handler); + resolve(); + }; + window.addEventListener("repeaterSaveComplete", handler); + }); + + console.log("🟢 [EditModal] UPDATE 후 repeaterSave 이벤트 발행:", { + parentId: recordId, + tableName: screenData.screenInfo.tableName, + }); + + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: recordId, + tableName: screenData.screenInfo.tableName, + mainFormData: formData, + masterRecordId: recordId, + }, + }), + ); + + await repeaterSavePromise; + console.log("✅ [EditModal] UPDATE 후 repeaterSave 완료"); + } catch (repeaterError) { + console.error("❌ [EditModal] repeaterSave 오류:", repeaterError); + } + handleClose(); } else { throw new Error(response.message || "수정에 실패했습니다."); @@ -1385,12 +1441,16 @@ export const EditModal: React.FC = ({ className }) => {
) : screenData ? ( +
{ const baseHeight = (screenDimensions?.height || 600) + 30; if (activeConditionalComponents.length > 0) { @@ -1546,6 +1606,7 @@ export const EditModal: React.FC = ({ className }) => { ); })}
+
) : (

화면 데이터가 없습니다.

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 { + const compType = c.componentType || c.overrides?.type; + if (compType !== "v2-repeater") return false; + const compConfig = c.componentConfig || c.overrides || {}; + return !compConfig.useCustomTable; + }); + + if (hasRepeaterOnSameTable) { + // 동일 테이블 리피터: 마스터 저장 스킵, 리피터만 저장 + // 리피터가 mainFormData를 각 행에 병합하여 N건 INSERT 처리 + try { + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: null, + masterRecordId: null, + mainFormData: formData, + tableName: screenInfo.tableName, + }, + }), + ); + + toast.success("데이터가 성공적으로 저장되었습니다."); + } catch (error) { + toast.error("저장 중 오류가 발생했습니다."); + } + return; + } + try { - // 🆕 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) + // 리피터 데이터(배열)를 마스터 저장에서 제외 (V2Repeater가 별도로 저장) // 단, 파일 업로드 컴포넌트의 파일 배열(objid 배열)은 포함 const masterFormData: Record = {}; @@ -591,11 +621,8 @@ export const InteractiveScreenViewerDynamic: React.FC { if (!Array.isArray(value)) { - // 배열이 아닌 값은 그대로 저장 masterFormData[key] = value; } else if (mediaColumnNames.has(key)) { - // v2-media 컴포넌트의 배열은 첫 번째 값만 저장 (단일 파일 컬럼 대응) - // 또는 JSON 문자열로 변환하려면 JSON.stringify(value) 사용 masterFormData[key] = value.length > 0 ? value[0] : null; console.log(`📷 미디어 데이터 저장: ${key}, objid: ${masterFormData[key]}`); } else { @@ -608,7 +635,6 @@ export const InteractiveScreenViewerDynamic: React.FC(null); + // 다른 레이어의 컴포넌트 메타 정보 캐시 (데이터 전달 타겟 선택용) + const [otherLayerComponents, setOtherLayerComponents] = useState([]); + // 🆕 activeLayerId 변경 시 해당 레이어의 Zone 찾기 useEffect(() => { if (activeLayerId <= 1 || !selectedScreen?.screenId) { @@ -578,6 +581,41 @@ export default function ScreenDesigner({ findZone(); }, [activeLayerId, selectedScreen?.screenId, zones]); + // 다른 레이어의 컴포넌트 메타 정보 로드 (데이터 전달 타겟 선택용) + useEffect(() => { + if (!selectedScreen?.screenId) return; + const loadOtherLayerComponents = async () => { + try { + const allLayers = await screenApi.getScreenLayers(selectedScreen.screenId); + const currentLayerId = activeLayerIdRef.current || 1; + const otherLayers = allLayers.filter((l: any) => l.layer_id !== currentLayerId && l.layer_id > 0); + + const components: ComponentData[] = []; + for (const layerInfo of otherLayers) { + try { + const layerData = await screenApi.getLayerLayout(selectedScreen.screenId, layerInfo.layer_id); + const rawComps = layerData?.components; + if (rawComps && Array.isArray(rawComps)) { + for (const comp of rawComps) { + components.push({ + ...comp, + _layerName: layerInfo.layer_name || `레이어 ${layerInfo.layer_id}`, + _layerId: String(layerInfo.layer_id), + } as any); + } + } + } catch { + // 개별 레이어 로드 실패 무시 + } + } + setOtherLayerComponents(components); + } catch { + setOtherLayerComponents([]); + } + }; + loadOtherLayerComponents(); + }, [selectedScreen?.screenId, activeLayerId]); + // 캔버스에 렌더링할 컴포넌트 (DB 기반 레이어: 각 레이어별로 별도 로드되므로 전체 표시) const visibleComponents = useMemo(() => { return layout.components; @@ -3968,10 +4006,10 @@ export default function ScreenDesigner({ label: column.columnLabel || column.columnName, tableName: table.tableName, columnName: column.columnName, - required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 - readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 - parentId: formContainerId, // 폼 컨테이너의 자식으로 설정 - componentType: v2Mapping.componentType, // v2-input, v2-select 등 + required: isEntityJoinColumn ? false : column.required, + readonly: false, + parentId: formContainerId, + componentType: v2Mapping.componentType, position: { x: relativeX, y: relativeY, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) @@ -3995,12 +4033,11 @@ export default function ScreenDesigner({ }, componentConfig: { type: v2Mapping.componentType, // v2-input, v2-select 등 - ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 - ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 + ...v2Mapping.componentConfig, }, }; } else { - return; // 폼 컨테이너를 찾을 수 없으면 드롭 취소 + return; } } else { // 일반 캔버스에 드롭한 경우 - 🆕 V2 컴포넌트 시스템 사용 @@ -4036,9 +4073,9 @@ export default function ScreenDesigner({ label: column.columnLabel || column.columnName, // 컬럼 라벨 우선, 없으면 컬럼명 tableName: table.tableName, columnName: column.columnName, - required: isEntityJoinColumn ? false : column.required, // 조인 컬럼은 필수 아님 - readonly: isEntityJoinColumn, // 🆕 엔티티 조인 컬럼은 읽기 전용 - componentType: v2Mapping.componentType, // v2-input, v2-select 등 + required: isEntityJoinColumn ? false : column.required, + readonly: false, + componentType: v2Mapping.componentType, position: { x, y, z: 1 } as Position, size: { width: componentWidth, height: getDefaultHeight(column.widgetType) }, layerId: activeLayerIdRef.current || 1, // 🆕 현재 활성 레이어에 추가 (ref 사용) @@ -4062,8 +4099,7 @@ export default function ScreenDesigner({ }, componentConfig: { type: v2Mapping.componentType, // v2-input, v2-select 등 - ...v2Mapping.componentConfig, // V2 컴포넌트 기본 설정 - ...(isEntityJoinColumn && { disabled: true }), // 🆕 조인 컬럼은 비활성화 + ...v2Mapping.componentConfig, }, }; } @@ -6518,8 +6554,8 @@ export default function ScreenDesigner({ updateComponentProperty(selectedComponent.id, "style", style); } }} - allComponents={layout.components} // 🆕 플로우 위젯 감지용 - menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달 + allComponents={[...layout.components, ...otherLayerComponents]} + menuObjid={menuObjid} /> )} diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index ea2febb1..4919ec33 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useCallback } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -92,13 +92,14 @@ export const ButtonConfigPanel: React.FC = ({ const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState>({}); // 블록별 테이블 Popover 열림 상태 const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState>({}); // 블록별 컬럼 Popover 열림 상태 - // 🆕 데이터 전달 필드 매핑용 상태 - const [mappingSourceColumns, setMappingSourceColumns] = useState>([]); + // 🆕 데이터 전달 필드 매핑용 상태 (멀티 테이블 매핑 지원) + const [mappingSourceColumnsMap, setMappingSourceColumnsMap] = useState>>({}); const [mappingTargetColumns, setMappingTargetColumns] = useState>([]); - const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState>({}); - const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState>({}); - const [mappingSourceSearch, setMappingSourceSearch] = useState>({}); - const [mappingTargetSearch, setMappingTargetSearch] = useState>({}); + const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState>({}); + const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState>({}); + const [mappingSourceSearch, setMappingSourceSearch] = useState>({}); + const [mappingTargetSearch, setMappingTargetSearch] = useState>({}); + const [activeMappingGroupIndex, setActiveMappingGroupIndex] = useState(0); // 🆕 openModalWithData 전용 필드 매핑 상태 const [modalSourceColumns, setModalSourceColumns] = useState>([]); @@ -295,57 +296,57 @@ export const ButtonConfigPanel: React.FC = ({ } }; - // 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드 - useEffect(() => { - const sourceTable = config.action?.dataTransfer?.sourceTable; - const targetTable = config.action?.dataTransfer?.targetTable; + // 멀티 테이블 매핑: 소스/타겟 테이블 컬럼 로드 + const loadMappingColumns = useCallback(async (tableName: string): Promise> => { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + if (response.data.success) { + let columnData = response.data.data; + if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; + if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - const loadColumns = async () => { - if (sourceTable) { - try { - const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`); - if (response.data.success) { - let columnData = response.data.data; - if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; - if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; - - if (Array.isArray(columnData)) { - const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, - })); - setMappingSourceColumns(columns); - } - } - } catch (error) { - console.error("소스 테이블 컬럼 로드 실패:", error); + if (Array.isArray(columnData)) { + return columnData.map((col: any) => ({ + name: col.name || col.columnName, + label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + })); } } + } catch (error) { + console.error(`테이블 ${tableName} 컬럼 로드 실패:`, error); + } + return []; + }, []); - if (targetTable) { - try { - const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`); - if (response.data.success) { - let columnData = response.data.data; - if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; - if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; + useEffect(() => { + const multiTableMappings: Array<{ sourceTable: string }> = config.action?.dataTransfer?.multiTableMappings || []; + const legacySourceTable = config.action?.dataTransfer?.sourceTable; + const targetTable = config.action?.dataTransfer?.targetTable; - if (Array.isArray(columnData)) { - const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, - })); - setMappingTargetColumns(columns); - } - } - } catch (error) { - console.error("타겟 테이블 컬럼 로드 실패:", error); + const loadAll = async () => { + const sourceTableNames = multiTableMappings.map((m) => m.sourceTable).filter(Boolean); + if (legacySourceTable && !sourceTableNames.includes(legacySourceTable)) { + sourceTableNames.push(legacySourceTable); + } + + const newMap: Record> = {}; + for (const tbl of sourceTableNames) { + if (!mappingSourceColumnsMap[tbl]) { + newMap[tbl] = await loadMappingColumns(tbl); } } + if (Object.keys(newMap).length > 0) { + setMappingSourceColumnsMap((prev) => ({ ...prev, ...newMap })); + } + + if (targetTable && mappingTargetColumns.length === 0) { + const cols = await loadMappingColumns(targetTable); + setMappingTargetColumns(cols); + } }; - loadColumns(); - }, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]); + loadAll(); + }, [config.action?.dataTransfer?.multiTableMappings, config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable, loadMappingColumns]); // 🆕 modal 액션: 대상 화면 테이블 조회 및 필드 매핑 로드 useEffect(() => { @@ -2966,11 +2967,17 @@ export const ButtonConfigPanel: React.FC = ({ - {/* 데이터 제공 가능한 컴포넌트 필터링 */} + {/* 자동 탐색 옵션 (레이어별 테이블이 다를 때 유용) */} + +
+ 자동 탐색 (현재 활성 테이블) + (auto) +
+
+ {/* 데이터 제공 가능한 컴포넌트 필터링 (모든 레이어 포함) */} {allComponents .filter((comp: any) => { const type = comp.componentType || comp.type || ""; - // 데이터를 제공할 수 있는 컴포넌트 타입들 return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => type.includes(t), ); @@ -2978,11 +2985,17 @@ export const ButtonConfigPanel: React.FC = ({ .map((comp: any) => { const compType = comp.componentType || comp.type || "unknown"; const compLabel = comp.label || comp.componentConfig?.title || comp.id; + const layerName = comp._layerName; return (
{compLabel} ({compType}) + {layerName && ( + + {layerName} + + )}
); @@ -2999,7 +3012,9 @@ export const ButtonConfigPanel: React.FC = ({ )}
-

테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트

+

+ 레이어별로 다른 테이블이 있을 경우 "자동 탐색"을 선택하면 현재 활성화된 레이어의 테이블을 자동으로 사용합니다 +

@@ -3037,33 +3052,47 @@ export const ButtonConfigPanel: React.FC = ({ { - const currentSources = config.action?.dataTransfer?.additionalSources || []; - const newSources = [...currentSources]; - if (newSources.length === 0) { - newSources.push({ componentId: "", fieldName: e.target.value }); - } else { - newSources[0] = { ...newSources[0], fieldName: e.target.value }; - } - onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); - }} - className="h-8 text-xs" - /> -

타겟 테이블에 저장될 필드명

+ + + + + + + + + 컬럼을 찾을 수 없습니다. + + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: "" }); + } else { + newSources[0] = { ...newSources[0], fieldName: "" }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="text-xs" + > + + 선택 안 함 (전체 데이터 병합) + + {(mappingTargetColumns.length > 0 ? mappingTargetColumns : currentTableColumns).map((col) => ( + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: col.name }); + } else { + newSources[0] = { ...newSources[0], fieldName: col.name }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="text-xs" + > + + {col.label || col.name} + {col.label && col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +

추가 데이터가 저장될 타겟 테이블 컬럼

- {/* 필드 매핑 규칙 */} + {/* 멀티 테이블 필드 매핑 */}
- {/* 소스/타겟 테이블 선택 */} -
-
- - - - - - - - - - 테이블을 찾을 수 없습니다 - - {availableTables.map((table) => ( - { - onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name); - }} - className="text-xs" - > - - {table.label} - ({table.name}) - - ))} - - - - - -
- -
- - - - - - - - - - 테이블을 찾을 수 없습니다 - - {availableTables.map((table) => ( - { - onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name); - }} - className="text-xs" - > - - {table.label} - ({table.name}) - - ))} - - - - - -
+ {/* 타겟 테이블 (공통) */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name); + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + +
- {/* 필드 매핑 규칙 */} + {/* 소스 테이블 매핑 그룹 */}
- +

- 소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다. + 여러 소스 테이블에서 데이터를 전달할 때, 각 테이블별로 매핑 규칙을 설정합니다. 런타임에 소스 테이블을 자동 감지합니다.

- {!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable ? ( + {!config.action?.dataTransfer?.targetTable ? (
-

먼저 소스 테이블과 타겟 테이블을 선택하세요.

+

먼저 타겟 테이블을 선택하세요.

- ) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? ( + ) : !(config.action?.dataTransfer?.multiTableMappings || []).length ? (

- 매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다. + 매핑 그룹이 없습니다. 소스 테이블을 추가하세요.

) : (
- {(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => ( -
- {/* 소스 필드 선택 (Combobox) */} -
- setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} + {/* 소스 테이블 탭 */} +
+ {(config.action?.dataTransfer?.multiTableMappings || []).map((group: any, gIdx: number) => ( +
+ - - - - - setMappingSourceSearch((prev) => ({ ...prev, [index]: value })) - } - /> - - - 컬럼을 찾을 수 없습니다 - - - {mappingSourceColumns.map((col) => ( - { - const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; - rules[index] = { ...rules[index], sourceField: col.name }; - onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); - setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false })); - }} - className="text-xs" - > - - {col.label} - {col.label !== col.name && ( - ({col.name}) - )} - - ))} - - - - - -
- - - - {/* 타겟 필드 선택 (Combobox) */} -
- setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} + {group.sourceTable + ? availableTables.find((t) => t.name === group.sourceTable)?.label || group.sourceTable + : `그룹 ${gIdx + 1}`} + {group.mappingRules?.length > 0 && ( + + {group.mappingRules.length} + + )} + + - - - - - setMappingTargetSearch((prev) => ({ ...prev, [index]: value })) - } - /> - - - 컬럼을 찾을 수 없습니다 - - - {mappingTargetColumns.map((col) => ( - { - const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; - rules[index] = { ...rules[index], targetField: col.name }; - onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); - setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false })); - }} - className="text-xs" - > - - {col.label} - {col.label !== col.name && ( - ({col.name}) - )} - - ))} - - - - - + +
+ ))} +
- -
- ))} + {/* 활성 그룹 편집 영역 */} + {(() => { + const multiMappings = config.action?.dataTransfer?.multiTableMappings || []; + const activeGroup = multiMappings[activeMappingGroupIndex]; + if (!activeGroup) return null; + + const activeSourceTable = activeGroup.sourceTable || ""; + const activeSourceColumns = mappingSourceColumnsMap[activeSourceTable] || []; + const activeRules: any[] = activeGroup.mappingRules || []; + + const updateGroupField = (field: string, value: any) => { + const mappings = [...multiMappings]; + mappings[activeMappingGroupIndex] = { ...mappings[activeMappingGroupIndex], [field]: value }; + onUpdateProperty("componentConfig.action.dataTransfer.multiTableMappings", mappings); + }; + + return ( +
+ {/* 소스 테이블 선택 */} +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + updateGroupField("sourceTable", table.name); + if (!mappingSourceColumnsMap[table.name]) { + const cols = await loadMappingColumns(table.name); + setMappingSourceColumnsMap((prev) => ({ ...prev, [table.name]: cols })); + } + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+ + {/* 매핑 규칙 목록 */} +
+
+ + +
+ + {!activeSourceTable ? ( +

소스 테이블을 먼저 선택하세요.

+ ) : activeRules.length === 0 ? ( +

매핑 없음 (동일 필드명 자동 매핑)

+ ) : ( + activeRules.map((rule: any, rIdx: number) => { + const popoverKeyS = `${activeMappingGroupIndex}-${rIdx}-s`; + const popoverKeyT = `${activeMappingGroupIndex}-${rIdx}-t`; + return ( +
+
+ + setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: open })) + } + > + + + + + + + + 컬럼 없음 + + {activeSourceColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], sourceField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingSourcePopoverOpen((prev) => ({ ...prev, [popoverKeyS]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ({col.name})} + + ))} + + + + + +
+ + + +
+ + setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: open })) + } + > + + + + + + + + 컬럼 없음 + + {mappingTargetColumns.map((col) => ( + { + const newRules = [...activeRules]; + newRules[rIdx] = { ...newRules[rIdx], targetField: col.name }; + updateGroupField("mappingRules", newRules); + setMappingTargetPopoverOpen((prev) => ({ ...prev, [popoverKeyT]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ({col.name})} + + ))} + + + + + +
+ + +
+ ); + }) + )} +
+
+ ); + })()}
)}
@@ -3567,9 +3712,9 @@ export const ButtonConfigPanel: React.FC = ({
1. 소스 컴포넌트에서 데이터를 선택합니다
- 2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드) + 2. 소스 테이블별로 필드 매핑 규칙을 설정합니다
- 3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다 + 3. 이 버튼을 클릭하면 소스 테이블을 자동 감지하여 매핑된 데이터가 타겟으로 전달됩니다

diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index cb0ca751..6242cd89 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -217,6 +217,7 @@ export const V2PropertiesPanel: React.FC = ({ "v2-biz": require("@/components/v2/config-panels/V2BizConfigPanel").V2BizConfigPanel, "v2-hierarchy": require("@/components/v2/config-panels/V2HierarchyConfigPanel").V2HierarchyConfigPanel, "v2-bom-item-editor": require("@/components/v2/config-panels/V2BomItemEditorConfigPanel").V2BomItemEditorConfigPanel, + "v2-bom-tree": require("@/components/v2/config-panels/V2BomTreeConfigPanel").V2BomTreeConfigPanel, }; const V2ConfigPanel = v2ConfigPanels[componentId]; @@ -236,11 +237,13 @@ export const V2PropertiesPanel: React.FC = ({ const extraProps: Record = {}; if (componentId === "v2-select") { extraProps.inputType = inputType; + extraProps.tableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; + extraProps.columnName = selectedComponent.columnName || currentConfig.fieldKey || currentConfig.columnName; } if (componentId === "v2-list") { extraProps.currentTableName = currentTableName; } - if (componentId === "v2-bom-item-editor") { + if (componentId === "v2-bom-item-editor" || componentId === "v2-bom-tree") { extraProps.currentTableName = currentTableName; extraProps.screenTableName = selectedComponent.tableName || currentTable?.tableName || currentTableName; } diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index 3be70840..d6ed8c62 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -72,9 +72,10 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, allColumns = response.data; } - // category 타입 컬럼만 필터링 + // category 타입 중 자체 카테고리만 필터링 (참조 컬럼 제외) const categoryColumns = allColumns.filter( - (col: any) => col.inputType === "category" || col.input_type === "category" + (col: any) => (col.inputType === "category" || col.input_type === "category") + && !col.categoryRef && !col.category_ref ); console.log("✅ 카테고리 컬럼 필터링 완료:", { diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index d2b288ff..1853ebe7 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -23,6 +23,9 @@ import { import { apiClient } from "@/lib/api/client"; import { allocateNumberingCode } from "@/lib/api/numberingRule"; import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { DataReceivable } from "@/types/data-transfer"; +import { toast } from "sonner"; // modal-repeater-table 컴포넌트 재사용 import { RepeaterTable } from "@/lib/registry/components/modal-repeater-table/RepeaterTable"; @@ -38,6 +41,7 @@ declare global { export const V2Repeater: React.FC = ({ config: propConfig, + componentId, parentId, data: initialData, onDataChange, @@ -48,6 +52,12 @@ export const V2Repeater: React.FC = ({ }) => { // ScreenModal에서 전달된 groupedData (모달 간 데이터 전달용) const groupedData = (restProps as any).groupedData || (restProps as any)._groupedData; + + // componentId 결정: 직접 전달 또는 component 객체에서 추출 + const effectiveComponentId = componentId || (restProps as any).component?.id; + + // ScreenContext 연동 (DataReceiver 등록, Provider 없으면 null) + const screenContext = useScreenContextOptional(); // 설정 병합 const config: V2RepeaterConfig = useMemo( () => ({ @@ -65,9 +75,119 @@ export const V2Repeater: React.FC = ({ const [selectedRows, setSelectedRows] = useState>(new Set()); const [modalOpen, setModalOpen] = useState(false); + // 저장 이벤트 핸들러에서 항상 최신 data를 참조하기 위한 ref + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + // 수정 모드에서 로드된 원본 ID 목록 (삭제 추적용) + const loadedIdsRef = useRef>(new Set()); + // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 트리거 const [autoWidthTrigger, setAutoWidthTrigger] = useState(0); + // ScreenContext DataReceiver 등록 (데이터 전달 액션 수신) + const onDataChangeRef = useRef(onDataChange); + onDataChangeRef.current = onDataChange; + + const handleReceiveData = useCallback( + async (incomingData: any[], configOrMode?: any) => { + console.log("📥 [V2Repeater] 데이터 수신:", { count: incomingData?.length, configOrMode }); + + if (!incomingData || incomingData.length === 0) { + toast.warning("전달할 데이터가 없습니다"); + return; + } + + // 데이터 정규화: {0: {...}} 형태 처리 + 소스 테이블 메타 필드 제거 + const metaFieldsToStrip = new Set([ + "id", + "created_date", + "updated_date", + "created_by", + "updated_by", + "company_code", + ]); + const normalizedData = incomingData.map((item: any) => { + let raw = item; + if (item && typeof item === "object" && item[0] && typeof item[0] === "object") { + const { 0: originalData, ...additionalFields } = item; + raw = { ...originalData, ...additionalFields }; + } + const cleaned: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (!metaFieldsToStrip.has(key)) { + cleaned[key] = value; + } + } + return cleaned; + }); + + const mode = configOrMode?.mode || configOrMode || "append"; + + // 카테고리 코드 → 라벨 변환 + // allCategoryColumns 또는 fromMainForm 컬럼의 값을 라벨로 변환 + const codesToResolve = new Set(); + for (const item of normalizedData) { + for (const [key, val] of Object.entries(item)) { + if (key.startsWith("_")) continue; + if (typeof val === "string" && val && !categoryLabelMapRef.current[val]) { + codesToResolve.add(val as string); + } + } + } + + if (codesToResolve.size > 0) { + try { + const resp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(codesToResolve), + }); + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + for (const item of normalizedData) { + for (const key of Object.keys(item)) { + if (key.startsWith("_")) continue; + const val = item[key]; + if (typeof val === "string" && labelData[val]) { + item[key] = labelData[val]; + } + } + } + } + } catch { + // 변환 실패 시 코드 유지 + } + } + + setData((prev) => { + const next = mode === "replace" ? normalizedData : [...prev, ...normalizedData]; + onDataChangeRef.current?.(next); + return next; + }); + + toast.success(`${normalizedData.length}개 항목이 추가되었습니다.`); + }, + [], + ); + + useEffect(() => { + if (screenContext && effectiveComponentId) { + const receiver: DataReceivable = { + componentId: effectiveComponentId, + componentType: "v2-repeater", + receiveData: handleReceiveData, + }; + console.log("📋 [V2Repeater] ScreenContext에 데이터 수신자 등록:", effectiveComponentId); + screenContext.registerDataReceiver(effectiveComponentId, receiver); + + return () => { + screenContext.unregisterDataReceiver(effectiveComponentId); + }; + } + }, [screenContext, effectiveComponentId, handleReceiveData]); + // 소스 테이블 컬럼 라벨 매핑 const [sourceColumnLabels, setSourceColumnLabels] = useState>({}); @@ -76,6 +196,10 @@ export const V2Repeater: React.FC = ({ // 🆕 카테고리 코드 → 라벨 매핑 (RepeaterTable 표시용) const [categoryLabelMap, setCategoryLabelMap] = useState>({}); + const categoryLabelMapRef = useRef>({}); + useEffect(() => { + categoryLabelMapRef.current = categoryLabelMap; + }, [categoryLabelMap]); // 현재 테이블 컬럼 정보 (inputType 매핑용) const [currentTableColumnInfo, setCurrentTableColumnInfo] = useState>({}); @@ -109,35 +233,54 @@ export const V2Repeater: React.FC = ({ }; }, [config.useCustomTable, config.mainTableName, config.dataSource?.tableName]); - // 저장 이벤트 리스너 + // 저장 이벤트 리스너 (dataRef/categoryLabelMapRef를 사용하여 항상 최신 상태 참조) useEffect(() => { const handleSaveEvent = async (event: CustomEvent) => { - // 🆕 mainTableName이 설정된 경우 우선 사용, 없으면 dataSource.tableName 사용 - const tableName = - config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; - const eventParentId = event.detail?.parentId; - const mainFormData = event.detail?.mainFormData; + const currentData = dataRef.current; + const currentCategoryMap = categoryLabelMapRef.current; - // 🆕 마스터 테이블에서 생성된 ID (FK 연결용) + const configTableName = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + const tableName = configTableName || event.detail?.tableName; + const mainFormData = event.detail?.mainFormData; const masterRecordId = event.detail?.masterRecordId || mainFormData?.id; - if (!tableName || data.length === 0) { + console.log("🔵 [V2Repeater] repeaterSave 이벤트 수신:", { + configTableName, + tableName, + masterRecordId, + dataLength: currentData.length, + foreignKeyColumn: config.foreignKeyColumn, + foreignKeySourceColumn: config.foreignKeySourceColumn, + dataSnapshot: currentData.map((r: any) => ({ id: r.id, item_name: r.item_name })), + }); + toast.info(`[디버그] V2Repeater 이벤트 수신: ${currentData.length}건, table=${tableName}`); + + if (!tableName || currentData.length === 0) { + console.warn("🔴 [V2Repeater] 저장 스킵:", { tableName, dataLength: currentData.length }); + toast.warning(`[디버그] V2Repeater 저장 스킵: data=${currentData.length}, table=${tableName}`); + window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); return; } - // V2Repeater 저장 시작 - const saveInfo = { + if (config.foreignKeyColumn) { + const sourceCol = config.foreignKeySourceColumn; + const hasFkSource = sourceCol && mainFormData && mainFormData[sourceCol] !== undefined; + if (!hasFkSource && !masterRecordId) { + console.warn("🔴 [V2Repeater] FK 소스 값/masterRecordId 모두 없어 저장 스킵"); + window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); + return; + } + } + + console.log("V2Repeater 저장 시작", { tableName, - useCustomTable: config.useCustomTable, - mainTableName: config.mainTableName, foreignKeyColumn: config.foreignKeyColumn, masterRecordId, - dataLength: data.length, - }; - console.log("V2Repeater 저장 시작", saveInfo); + dataLength: currentData.length, + }); try { - // 테이블 유효 컬럼 조회 let validColumns: Set = new Set(); try { const columnsResponse = await apiClient.get(`/table-management/tables/${tableName}/columns`); @@ -148,13 +291,10 @@ export const V2Repeater: React.FC = ({ console.warn("테이블 컬럼 정보 조회 실패"); } - for (let i = 0; i < data.length; i++) { - const row = data[i]; - - // 내부 필드 제거 + for (let i = 0; i < currentData.length; i++) { + const row = currentData[i]; const cleanRow = Object.fromEntries(Object.entries(row).filter(([key]) => !key.startsWith("_"))); - // 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함) let mergedData: Record; if (config.useCustomTable && config.mainTableName) { mergedData = { ...cleanRow }; @@ -181,59 +321,83 @@ export const V2Repeater: React.FC = ({ }; } - // 유효하지 않은 컬럼 제거 const filteredData: Record = {}; for (const [key, value] of Object.entries(mergedData)) { if (validColumns.size === 0 || validColumns.has(key)) { - filteredData[key] = value; + if (typeof value === "string" && currentCategoryMap[value]) { + filteredData[key] = currentCategoryMap[value]; + } else { + filteredData[key] = value; + } } } - // 기존 행(id 존재)은 UPDATE, 새 행은 INSERT const rowId = row.id; + console.log(`🔧 [V2Repeater] 행 ${i} 저장:`, { + rowId, + isUpdate: rowId && typeof rowId === "string" && rowId.includes("-"), + filteredDataKeys: Object.keys(filteredData), + }); if (rowId && typeof rowId === "string" && rowId.includes("-")) { - // UUID 형태의 id가 있으면 기존 데이터 → UPDATE const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData; await apiClient.put(`/table-management/tables/${tableName}/edit`, { originalData: { id: rowId }, updatedData: updateFields, }); } else { - // 새 행 → INSERT await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); } } + // 삭제된 행 처리: 원본에는 있었지만 현재 data에 없는 ID를 DELETE + const currentIds = new Set(currentData.map((r) => r.id).filter(Boolean)); + const deletedIds = Array.from(loadedIdsRef.current).filter((id) => !currentIds.has(id)); + if (deletedIds.length > 0) { + console.log("🗑️ [V2Repeater] 삭제할 행:", deletedIds); + try { + await apiClient.delete(`/table-management/tables/${tableName}/delete`, { + data: deletedIds.map((id) => ({ id })), + }); + console.log(`✅ [V2Repeater] ${deletedIds.length}건 삭제 완료`); + } catch (deleteError) { + console.error("❌ [V2Repeater] 삭제 실패:", deleteError); + } + } + + // 저장 완료 후 loadedIdsRef 갱신 + loadedIdsRef.current = new Set(currentData.map((r) => r.id).filter(Boolean)); + + toast.success(`V2Repeater ${currentData.length}건 저장 완료`); } catch (error) { console.error("❌ V2Repeater 저장 실패:", error); - throw error; + toast.error(`V2Repeater 저장 실패: ${error}`); + } finally { + window.dispatchEvent(new CustomEvent("repeaterSaveComplete")); } }; - // V2 EventBus 구독 const unsubscribe = v2EventBus.subscribe( V2_EVENTS.REPEATER_SAVE, async (payload) => { - const tableName = + const configTableName = config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; - if (payload.tableName === tableName) { + if (!configTableName || payload.tableName === configTableName) { await handleSaveEvent({ detail: payload } as CustomEvent); } }, - { componentId: `v2-repeater-${config.dataSource?.tableName}` }, + { componentId: `v2-repeater-${config.dataSource?.tableName || "same-table"}` }, ); - // 레거시 이벤트도 계속 지원 (점진적 마이그레이션) window.addEventListener("repeaterSave" as any, handleSaveEvent); return () => { unsubscribe(); window.removeEventListener("repeaterSave" as any, handleSaveEvent); }; }, [ - data, config.dataSource?.tableName, config.useCustomTable, config.mainTableName, config.foreignKeyColumn, + config.foreignKeySourceColumn, parentId, ]); @@ -301,7 +465,6 @@ export const V2Repeater: React.FC = ({ }); // 각 행에 소스 테이블의 표시 데이터 병합 - // RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함 rows.forEach((row: any) => { const sourceRecord = sourceMap.get(String(row[fkColumn])); if (sourceRecord) { @@ -319,12 +482,50 @@ export const V2Repeater: React.FC = ({ } } + // DB에서 로드된 데이터 중 CATEGORY_ 코드가 있으면 라벨로 변환 + const codesToResolve = new Set(); + for (const row of rows) { + for (const val of Object.values(row)) { + if (typeof val === "string" && val.startsWith("CATEGORY_")) { + codesToResolve.add(val); + } + } + } + + if (codesToResolve.size > 0) { + try { + const labelResp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(codesToResolve), + }); + if (labelResp.data?.success && labelResp.data.data) { + const labelData = labelResp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + for (const row of rows) { + for (const key of Object.keys(row)) { + if (key.startsWith("_")) continue; + const val = row[key]; + if (typeof val === "string" && labelData[val]) { + row[key] = labelData[val]; + } + } + } + } + } catch { + // 라벨 변환 실패 시 코드 유지 + } + } + + // 원본 ID 목록 기록 (삭제 추적용) + const ids = rows.map((r: any) => r.id).filter(Boolean); + loadedIdsRef.current = new Set(ids); + console.log("📋 [V2Repeater] 원본 ID 기록:", ids); + setData(rows); dataLoadedRef.current = true; if (onDataChange) onDataChange(rows); } } catch (error) { - console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error); + console.error("[V2Repeater] 기존 데이터 로드 실패:", error); } }; @@ -346,16 +547,28 @@ export const V2Repeater: React.FC = ({ if (!tableName) return; try { - const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); - const columns = response.data?.data?.columns || response.data?.columns || response.data || []; + const [colResponse, typeResponse] = await Promise.all([ + apiClient.get(`/table-management/tables/${tableName}/columns`), + apiClient.get(`/table-management/tables/${tableName}/web-types`), + ]); + const columns = colResponse.data?.data?.columns || colResponse.data?.columns || colResponse.data || []; + const inputTypes = typeResponse.data?.data || []; + + // inputType/categoryRef 매핑 생성 + const typeMap: Record = {}; + inputTypes.forEach((t: any) => { + typeMap[t.columnName] = t; + }); const columnMap: Record = {}; columns.forEach((col: any) => { const name = col.columnName || col.column_name || col.name; + const typeInfo = typeMap[name]; columnMap[name] = { - inputType: col.inputType || col.input_type || col.webType || "text", + inputType: typeInfo?.inputType || col.inputType || col.input_type || col.webType || "text", displayName: col.displayName || col.display_name || col.label || name, detailSettings: col.detailSettings || col.detail_settings, + categoryRef: typeInfo?.categoryRef || null, }; }); setCurrentTableColumnInfo(columnMap); @@ -487,14 +700,18 @@ export const V2Repeater: React.FC = ({ else if (inputType === "code") type = "select"; else if (inputType === "category") type = "category"; // 🆕 카테고리 타입 - // 🆕 카테고리 참조 ID 가져오기 (tableName.columnName 형식) - // category 타입인 경우 현재 테이블명과 컬럼명을 조합 + // 카테고리 참조 ID 결정 + // DB의 category_ref 설정 우선, 없으면 자기 테이블.컬럼명 사용 let categoryRef: string | undefined; if (inputType === "category") { - // 🆕 소스 표시 컬럼이면 소스 테이블 사용, 아니면 타겟 테이블 사용 - const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName; - if (tableName) { - categoryRef = `${tableName}.${col.key}`; + const dbCategoryRef = colInfo?.detailSettings?.categoryRef || colInfo?.categoryRef; + if (dbCategoryRef) { + categoryRef = dbCategoryRef; + } else { + const tableName = col.isSourceDisplay ? resolvedSourceTable : config.dataSource?.tableName; + if (tableName) { + categoryRef = `${tableName}.${col.key}`; + } } } @@ -512,55 +729,79 @@ export const V2Repeater: React.FC = ({ }); }, [config.columns, sourceColumnLabels, currentTableColumnInfo, resolvedSourceTable, config.dataSource?.tableName]); - // 🆕 데이터 변경 시 카테고리 라벨 로드 (RepeaterTable 표시용) + // 리피터 컬럼 설정에서 카테고리 타입 컬럼 자동 감지 + // repeaterColumns의 resolved type 사용 (config + DB 메타데이터 모두 반영) + const allCategoryColumns = useMemo(() => { + const fromRepeater = repeaterColumns + .filter((col) => col.type === "category") + .map((col) => col.field.replace(/^_display_/, "")); + const merged = new Set([...sourceCategoryColumns, ...fromRepeater]); + return Array.from(merged); + }, [sourceCategoryColumns, repeaterColumns]); + + // CATEGORY_ 코드 배열을 받아 라벨을 일괄 조회하는 함수 + const fetchCategoryLabels = useCallback(async (codes: string[]) => { + if (codes.length === 0) return; + try { + const response = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: codes, + }); + if (response.data?.success && response.data.data) { + setCategoryLabelMap((prev) => ({ ...prev, ...response.data.data })); + } + } catch (error) { + console.error("카테고리 라벨 조회 실패:", error); + } + }, []); + + // parentFormData(마스터 행)에서 카테고리 코드를 미리 로드 + // fromMainForm autoFill에서 참조할 마스터 필드의 라벨을 사전에 확보 useEffect(() => { - const loadCategoryLabels = async () => { - if (sourceCategoryColumns.length === 0 || data.length === 0) { - return; - } + if (!parentFormData) return; + const codes: string[] = []; - // 데이터에서 카테고리 컬럼의 모든 고유 코드 수집 - const allCodes = new Set(); - for (const row of data) { - for (const col of sourceCategoryColumns) { - // _display_ 접두사가 있는 컬럼과 원본 컬럼 모두 확인 - const val = row[`_display_${col}`] || row[col]; - if (val && typeof val === "string") { - const codes = val - .split(",") - .map((c: string) => c.trim()) - .filter(Boolean); - for (const code of codes) { - if (!categoryLabelMap[code] && code.startsWith("CATEGORY_")) { - allCodes.add(code); - } - } - } + // fromMainForm autoFill의 sourceField 값 중 카테고리 컬럼에 해당하는 것만 수집 + for (const col of config.columns) { + if (col.autoFill?.type === "fromMainForm" && col.autoFill.sourceField) { + const val = parentFormData[col.autoFill.sourceField]; + if (typeof val === "string" && val && !categoryLabelMap[val]) { + codes.push(val); } } - - if (allCodes.size === 0) { - return; - } - - try { - const response = await apiClient.post("/table-categories/labels-by-codes", { - valueCodes: Array.from(allCodes), - }); - - if (response.data?.success && response.data.data) { - setCategoryLabelMap((prev) => ({ - ...prev, - ...response.data.data, - })); + // receiveFromParent 패턴 + if ((col as any).receiveFromParent) { + const parentField = (col as any).parentFieldName || col.key; + const val = parentFormData[parentField]; + if (typeof val === "string" && val && !categoryLabelMap[val]) { + codes.push(val); } - } catch (error) { - console.error("카테고리 라벨 조회 실패:", error); } - }; + } - loadCategoryLabels(); - }, [data, sourceCategoryColumns]); + if (codes.length > 0) { + fetchCategoryLabels(codes); + } + }, [parentFormData, config.columns, fetchCategoryLabels]); + + // 데이터 변경 시 카테고리 라벨 로드 + useEffect(() => { + if (data.length === 0) return; + + const allCodes = new Set(); + + for (const row of data) { + for (const col of allCategoryColumns) { + const val = row[`_display_${col}`] || row[col]; + if (val && typeof val === "string") { + val.split(",").map((c: string) => c.trim()).filter(Boolean).forEach((code: string) => { + if (!categoryLabelMap[code]) allCodes.add(code); + }); + } + } + } + + fetchCategoryLabels(Array.from(allCodes)); + }, [data, allCategoryColumns, fetchCategoryLabels]); // 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능) const applyCalculationRules = useCallback( @@ -677,7 +918,12 @@ export const V2Repeater: React.FC = ({ case "fromMainForm": if (col.autoFill.sourceField && mainFormData) { - return mainFormData[col.autoFill.sourceField]; + const rawValue = mainFormData[col.autoFill.sourceField]; + // categoryLabelMap에 매핑이 있으면 라벨로 변환 (접두사 무관) + if (typeof rawValue === "string" && categoryLabelMap[rawValue]) { + return categoryLabelMap[rawValue]; + } + return rawValue; } return ""; @@ -697,7 +943,7 @@ export const V2Repeater: React.FC = ({ return undefined; } }, - [], + [categoryLabelMap], ); // 🆕 채번 API 호출 (비동기) @@ -731,7 +977,12 @@ export const V2Repeater: React.FC = ({ const row: any = { _id: `grouped_${Date.now()}_${index}` }; for (const col of config.columns) { - const sourceValue = item[(col as any).sourceKey || col.key]; + let sourceValue = item[(col as any).sourceKey || col.key]; + + // 카테고리 코드 → 라벨 변환 (접두사 무관, categoryLabelMap 기반) + if (typeof sourceValue === "string" && categoryLabelMap[sourceValue]) { + sourceValue = categoryLabelMap[sourceValue]; + } if (col.isSourceDisplay) { row[col.key] = sourceValue ?? ""; @@ -752,6 +1003,48 @@ export const V2Repeater: React.FC = ({ return row; }); + // 카테고리 컬럼의 코드 → 라벨 변환 (접두사 무관) + const categoryColSet = new Set(allCategoryColumns); + const codesToResolve = new Set(); + for (const row of newRows) { + for (const col of config.columns) { + const val = row[col.key] || row[`_display_${col.key}`]; + if (typeof val === "string" && val && (categoryColSet.has(col.key) || col.autoFill?.type === "fromMainForm")) { + if (!categoryLabelMap[val]) { + codesToResolve.add(val); + } + } + } + } + + if (codesToResolve.size > 0) { + apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(codesToResolve), + }).then((resp) => { + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + const convertedRows = newRows.map((row) => { + const updated = { ...row }; + for (const col of config.columns) { + const val = updated[col.key]; + if (typeof val === "string" && labelData[val]) { + updated[col.key] = labelData[val]; + } + const dispKey = `_display_${col.key}`; + const dispVal = updated[dispKey]; + if (typeof dispVal === "string" && labelData[dispVal]) { + updated[dispKey] = labelData[dispVal]; + } + } + return updated; + }); + setData(convertedRows); + onDataChange?.(convertedRows); + } + }).catch(() => {}); + } + setData(newRows); onDataChange?.(newRows); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -786,7 +1079,7 @@ export const V2Repeater: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [parentFormData, config.columns, generateAutoFillValueSync]); - // 행 추가 (inline 모드 또는 모달 열기) - 비동기로 변경 + // 행 추가 (inline 모드 또는 모달 열기) const handleAddRow = useCallback(async () => { if (isModalMode) { setModalOpen(true); @@ -794,11 +1087,10 @@ export const V2Repeater: React.FC = ({ const newRow: any = { _id: `new_${Date.now()}` }; const currentRowCount = data.length; - // 먼저 동기적 자동 입력 값 적용 + // 동기적 자동 입력 값 적용 for (const col of config.columns) { const autoValue = generateAutoFillValueSync(col, currentRowCount, parentFormData); if (autoValue === null && col.autoFill?.type === "numbering" && col.autoFill.numberingRuleId) { - // 채번 규칙: 즉시 API 호출 newRow[col.key] = await generateNumberingCode(col.autoFill.numberingRuleId); } else if (autoValue !== undefined) { newRow[col.key] = autoValue; @@ -807,10 +1099,51 @@ export const V2Repeater: React.FC = ({ } } + // fromMainForm 등으로 넘어온 카테고리 코드 → 라벨 변환 + // allCategoryColumns에 해당하는 컬럼이거나 categoryLabelMap에 매핑이 있으면 변환 + const categoryColSet = new Set(allCategoryColumns); + const unresolvedCodes: string[] = []; + for (const col of config.columns) { + const val = newRow[col.key]; + if (typeof val !== "string" || !val) continue; + + // 이 컬럼이 카테고리 타입이거나, fromMainForm으로 가져온 값인 경우 + const isCategoryCol = categoryColSet.has(col.key); + const isFromMainForm = col.autoFill?.type === "fromMainForm"; + + if (isCategoryCol || isFromMainForm) { + if (categoryLabelMap[val]) { + newRow[col.key] = categoryLabelMap[val]; + } else { + unresolvedCodes.push(val); + } + } + } + + if (unresolvedCodes.length > 0) { + try { + const resp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: unresolvedCodes, + }); + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + for (const col of config.columns) { + const val = newRow[col.key]; + if (typeof val === "string" && labelData[val]) { + newRow[col.key] = labelData[val]; + } + } + } + } catch { + // 변환 실패 시 코드 유지 + } + } + const newData = [...data, newRow]; handleDataChange(newData); } - }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData]); + }, [isModalMode, config.columns, data, handleDataChange, generateAutoFillValueSync, generateNumberingCode, parentFormData, categoryLabelMap, allCategoryColumns]); // 모달에서 항목 선택 - 비동기로 변경 const handleSelectItems = useCallback( @@ -835,8 +1168,12 @@ export const V2Repeater: React.FC = ({ // 모든 컬럼 처리 (순서대로) for (const col of config.columns) { if (col.isSourceDisplay) { - // 소스 표시 컬럼: 소스 테이블에서 값 복사 (읽기 전용) - row[`_display_${col.key}`] = item[col.key] || ""; + let displayVal = item[col.key] || ""; + // 카테고리 컬럼이면 코드→라벨 변환 (접두사 무관) + if (typeof displayVal === "string" && categoryLabelMap[displayVal]) { + displayVal = categoryLabelMap[displayVal]; + } + row[`_display_${col.key}`] = displayVal; } else { // 자동 입력 값 적용 const autoValue = generateAutoFillValueSync(col, currentRowCount + index, parentFormData); @@ -856,6 +1193,43 @@ export const V2Repeater: React.FC = ({ }), ); + // 카테고리/fromMainForm 컬럼에서 미해결 코드 수집 및 변환 + const categoryColSet = new Set(allCategoryColumns); + const unresolvedCodes = new Set(); + for (const row of newRows) { + for (const col of config.columns) { + const val = row[col.key]; + if (typeof val !== "string" || !val) continue; + const isCategoryCol = categoryColSet.has(col.key); + const isFromMainForm = col.autoFill?.type === "fromMainForm"; + if ((isCategoryCol || isFromMainForm) && !categoryLabelMap[val]) { + unresolvedCodes.add(val); + } + } + } + + if (unresolvedCodes.size > 0) { + try { + const resp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(unresolvedCodes), + }); + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + setCategoryLabelMap((prev) => ({ ...prev, ...labelData })); + for (const row of newRows) { + for (const col of config.columns) { + const val = row[col.key]; + if (typeof val === "string" && labelData[val]) { + row[col.key] = labelData[val]; + } + } + } + } + } catch { + // 변환 실패 시 코드 유지 + } + } + const newData = [...data, ...newRows]; handleDataChange(newData); setModalOpen(false); @@ -869,6 +1243,8 @@ export const V2Repeater: React.FC = ({ generateAutoFillValueSync, generateNumberingCode, parentFormData, + categoryLabelMap, + allCategoryColumns, ], ); @@ -881,9 +1257,6 @@ export const V2Repeater: React.FC = ({ }, [config.columns]); // 🆕 beforeFormSave 이벤트에서 채번 placeholder를 실제 값으로 변환 - const dataRef = useRef(data); - dataRef.current = data; - useEffect(() => { const handleBeforeFormSave = async (event: Event) => { const customEvent = event as CustomEvent; @@ -1112,7 +1485,7 @@ export const V2Repeater: React.FC = ({ selectedRows={selectedRows} onSelectionChange={setSelectedRows} equalizeWidthsTrigger={autoWidthTrigger} - categoryColumns={sourceCategoryColumns} + categoryColumns={allCategoryColumns} categoryLabelMap={categoryLabelMap} />
diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index 4fd27cb0..fe21b790 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -622,6 +622,7 @@ export const V2Select = forwardRef( config: configProp, value, onChange, + onFormDataChange, tableName, columnName, isDesignMode, // 🔧 디자인 모드 (클릭 방지) @@ -630,6 +631,9 @@ export const V2Select = forwardRef( // config가 없으면 기본값 사용 const config = configProp || { mode: "dropdown" as const, source: "static" as const, options: [] }; + // 엔티티 자동 채움: 같은 폼의 다른 컴포넌트 중 참조 테이블 컬럼을 자동 감지 + const allComponents = (props as any).allComponents as any[] | undefined; + const [options, setOptions] = useState(config.options || []); const [loading, setLoading] = useState(false); const [optionsLoaded, setOptionsLoaded] = useState(false); @@ -742,10 +746,7 @@ export const V2Select = forwardRef( const valueCol = entityValueColumn || "id"; const labelCol = entityLabelColumn || "name"; const response = await apiClient.get(`/entity/${entityTable}/options`, { - params: { - value: valueCol, - label: labelCol, - }, + params: { value: valueCol, label: labelCol }, }); const data = response.data; if (data.success && data.data) { @@ -819,6 +820,70 @@ export const V2Select = forwardRef( loadOptions(); }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); + // 같은 폼에서 참조 테이블(entityTable) 컬럼을 사용하는 다른 컴포넌트 자동 감지 + const autoFillTargets = useMemo(() => { + if (source !== "entity" || !entityTable || !allComponents) return []; + + const targets: Array<{ sourceField: string; targetColumnName: string }> = []; + for (const comp of allComponents) { + if (comp.id === id) continue; + + // overrides 구조 지원 (DB에서 로드 시 overrides 안에 데이터가 있음) + const ov = (comp as any).overrides || {}; + const compColumnName = comp.columnName || ov.columnName || comp.componentConfig?.columnName || ""; + + // 방법1: entityJoinTable 속성이 있는 경우 + const joinTable = comp.entityJoinTable || ov.entityJoinTable || comp.componentConfig?.entityJoinTable; + const joinColumn = comp.entityJoinColumn || ov.entityJoinColumn || comp.componentConfig?.entityJoinColumn; + if (joinTable === entityTable && joinColumn) { + targets.push({ sourceField: joinColumn, targetColumnName: compColumnName }); + continue; + } + + // 방법2: columnName이 "테이블명.컬럼명" 형식인 경우 (예: item_info.unit) + if (compColumnName.includes(".")) { + const [prefix, actualColumn] = compColumnName.split("."); + if (prefix === entityTable && actualColumn) { + targets.push({ sourceField: actualColumn, targetColumnName: compColumnName }); + } + } + } + return targets; + }, [source, entityTable, allComponents, id]); + + // 엔티티 autoFill 적용 래퍼 + const handleChangeWithAutoFill = useCallback((newValue: string | string[]) => { + onChange?.(newValue); + + if (autoFillTargets.length === 0 || !onFormDataChange || !entityTable) return; + + const selectedKey = typeof newValue === "string" ? newValue : newValue[0]; + if (!selectedKey) return; + + const valueCol = entityValueColumn || "id"; + + apiClient.get(`/table-management/tables/${entityTable}/data-with-joins`, { + params: { + page: 1, + size: 1, + search: JSON.stringify({ [valueCol]: selectedKey }), + autoFilter: JSON.stringify({ enabled: true, filterColumn: "company_code", userField: "companyCode" }), + }, + }).then((res) => { + const responseData = res.data?.data; + const rows = responseData?.data || responseData?.rows || []; + if (rows.length > 0) { + const fullData = rows[0]; + for (const target of autoFillTargets) { + const sourceValue = fullData[target.sourceField]; + if (sourceValue !== undefined) { + onFormDataChange(target.targetColumnName, sourceValue); + } + } + } + }).catch((err) => console.error("autoFill 조회 실패:", err)); + }, [onChange, autoFillTargets, onFormDataChange, entityTable, entityValueColumn]); + // 모드별 컴포넌트 렌더링 const renderSelect = () => { if (loading) { @@ -876,12 +941,12 @@ export const V2Select = forwardRef( switch (config.mode) { case "dropdown": - case "combobox": // 🔧 콤보박스는 검색 가능한 드롭다운 + case "combobox": return ( ( onChange?.(v)} + onChange={(v) => handleChangeWithAutoFill(v)} disabled={isDisabled} /> ); case "check": - case "checkbox": // 🔧 기존 저장된 값 호환 + case "checkbox": return ( @@ -919,7 +984,7 @@ export const V2Select = forwardRef( @@ -930,7 +995,7 @@ export const V2Select = forwardRef( ( onChange?.(v)} + onChange={(v) => handleChangeWithAutoFill(v)} disabled={isDisabled} /> ); @@ -953,7 +1018,7 @@ export const V2Select = forwardRef( @@ -964,7 +1029,7 @@ export const V2Select = forwardRef( diff --git a/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx b/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx new file mode 100644 index 00000000..7c8c3ed1 --- /dev/null +++ b/frontend/components/v2/config-panels/V2BomTreeConfigPanel.tsx @@ -0,0 +1,1074 @@ +"use client"; + +/** + * BOM 트리 뷰 설정 패널 + * + * V2BomItemEditorConfigPanel 구조 기반: + * - 기본 탭: 디테일 테이블 + 엔티티 선택 + 트리 설정 + * - 컬럼 탭: 소스 표시 컬럼 + 디테일 컬럼 + 선택된 컬럼 상세 + */ + +import React, { useState, useEffect, useMemo, useCallback } from "react"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Database, + Link2, + Trash2, + GripVertical, + ArrowRight, + ChevronDown, + ChevronRight, + Eye, + EyeOff, + Check, + ChevronsUpDown, + GitBranch, +} from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { tableTypeApi } from "@/lib/api/screen"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import { cn } from "@/lib/utils"; + +interface TableRelation { + tableName: string; + tableLabel: string; + foreignKeyColumn: string; + referenceColumn: string; +} + +interface ColumnOption { + columnName: string; + displayName: string; + inputType?: string; + detailSettings?: { + codeGroup?: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; + format?: string; + }; +} + +interface EntityColumnOption { + columnName: string; + displayName: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; +} + +interface TreeColumnConfig { + key: string; + title: string; + width?: string; + visible?: boolean; + hidden?: boolean; + isSourceDisplay?: boolean; +} + +interface BomTreeConfig { + detailTable?: string; + foreignKey?: string; + parentKey?: string; + + historyTable?: string; + versionTable?: string; + + dataSource?: { + sourceTable?: string; + foreignKey?: string; + referenceKey?: string; + displayColumn?: string; + }; + + columns: TreeColumnConfig[]; + + features?: { + showExpandAll?: boolean; + showHeader?: boolean; + showQuantity?: boolean; + showLossRate?: boolean; + showHistory?: boolean; + showVersion?: boolean; + }; +} + +interface V2BomTreeConfigPanelProps { + config: BomTreeConfig; + onChange: (config: BomTreeConfig) => void; + currentTableName?: string; + screenTableName?: string; +} + +export function V2BomTreeConfigPanel({ + config: propConfig, + onChange, + currentTableName: propCurrentTableName, + screenTableName, +}: V2BomTreeConfigPanelProps) { + const currentTableName = screenTableName || propCurrentTableName; + + const config: BomTreeConfig = useMemo( + () => ({ + columns: [], + ...propConfig, + dataSource: { ...propConfig?.dataSource }, + features: { + showExpandAll: true, + showHeader: true, + showQuantity: true, + showLossRate: true, + ...propConfig?.features, + }, + }), + [propConfig], + ); + + const [detailTableColumns, setDetailTableColumns] = useState([]); + const [entityColumns, setEntityColumns] = useState([]); + const [sourceTableColumns, setSourceTableColumns] = useState([]); + const [allTables, setAllTables] = useState<{ tableName: string; displayName: string }[]>([]); + const [relatedTables, setRelatedTables] = useState([]); + const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingSourceColumns, setLoadingSourceColumns] = useState(false); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingRelations, setLoadingRelations] = useState(false); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); + const [expandedColumn, setExpandedColumn] = useState(null); + + const updateConfig = useCallback( + (updates: Partial) => { + onChange({ ...config, ...updates }); + }, + [config, onChange], + ); + + const updateFeatures = useCallback( + (field: string, value: any) => { + updateConfig({ features: { ...config.features, [field]: value } }); + }, + [config.features, updateConfig], + ); + + // 전체 테이블 목록 로드 + useEffect(() => { + const loadTables = async () => { + setLoadingTables(true); + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + setAllTables( + response.data.map((t: any) => ({ + tableName: t.tableName || t.table_name, + displayName: t.displayName || t.table_label || t.tableName || t.table_name, + })), + ); + } + } catch (error) { + console.error("테이블 목록 로드 실패:", error); + } finally { + setLoadingTables(false); + } + }; + loadTables(); + }, []); + + // 연관 테이블 로드 + useEffect(() => { + const loadRelatedTables = async () => { + const baseTable = currentTableName; + if (!baseTable) { + setRelatedTables([]); + return; + } + setLoadingRelations(true); + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get( + `/table-management/columns/${baseTable}/referenced-by`, + ); + if (response.data.success && response.data.data) { + setRelatedTables( + response.data.data.map((rel: any) => ({ + tableName: rel.tableName || rel.table_name, + tableLabel: rel.tableLabel || rel.table_label || rel.tableName || rel.table_name, + foreignKeyColumn: rel.columnName || rel.column_name, + referenceColumn: rel.referenceColumn || rel.reference_column || "id", + })), + ); + } + } catch (error) { + console.error("연관 테이블 로드 실패:", error); + setRelatedTables([]); + } finally { + setLoadingRelations(false); + } + }; + loadRelatedTables(); + }, [currentTableName]); + + // 디테일 테이블 선택 + const handleDetailTableSelect = useCallback( + (tableName: string) => { + const relation = relatedTables.find((r) => r.tableName === tableName); + updateConfig({ + detailTable: tableName, + foreignKey: relation?.foreignKeyColumn || config.foreignKey, + }); + }, + [relatedTables, config.foreignKey, updateConfig], + ); + + // 디테일 테이블 컬럼 로드 + useEffect(() => { + const loadColumns = async () => { + if (!config.detailTable) { + setDetailTableColumns([]); + setEntityColumns([]); + return; + } + setLoadingColumns(true); + try { + const columnData = await tableTypeApi.getColumns(config.detailTable); + const cols: ColumnOption[] = []; + const entityCols: EntityColumnOption[] = []; + + for (const c of columnData) { + let detailSettings: any = null; + if (c.detailSettings) { + try { + detailSettings = + typeof c.detailSettings === "string" ? JSON.parse(c.detailSettings) : c.detailSettings; + } catch { + // ignore + } + } + + const col: ColumnOption = { + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + detailSettings: detailSettings + ? { + codeGroup: detailSettings.codeGroup, + referenceTable: detailSettings.referenceTable, + referenceColumn: detailSettings.referenceColumn, + displayColumn: detailSettings.displayColumn, + format: detailSettings.format, + } + : undefined, + }; + cols.push(col); + + if (col.inputType === "entity") { + const refTable = detailSettings?.referenceTable || c.referenceTable; + if (refTable) { + entityCols.push({ + columnName: col.columnName, + displayName: col.displayName, + referenceTable: refTable, + referenceColumn: detailSettings?.referenceColumn || c.referenceColumn || "id", + displayColumn: detailSettings?.displayColumn || c.displayColumn, + }); + } + } + } + + setDetailTableColumns(cols); + setEntityColumns(entityCols); + } catch (error) { + console.error("컬럼 로드 실패:", error); + setDetailTableColumns([]); + setEntityColumns([]); + } finally { + setLoadingColumns(false); + } + }; + loadColumns(); + }, [config.detailTable]); + + // 소스(엔티티) 테이블 컬럼 로드 + useEffect(() => { + const loadSourceColumns = async () => { + const sourceTable = config.dataSource?.sourceTable; + if (!sourceTable) { + setSourceTableColumns([]); + return; + } + setLoadingSourceColumns(true); + try { + const columnData = await tableTypeApi.getColumns(sourceTable); + setSourceTableColumns( + columnData.map((c: any) => ({ + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + })), + ); + } catch (error) { + console.error("소스 테이블 컬럼 로드 실패:", error); + setSourceTableColumns([]); + } finally { + setLoadingSourceColumns(false); + } + }; + loadSourceColumns(); + }, [config.dataSource?.sourceTable]); + + // 엔티티 컬럼 선택 시 소스 테이블 자동 설정 + const handleEntityColumnSelect = (columnName: string) => { + const selectedEntity = entityColumns.find((c) => c.columnName === columnName); + if (selectedEntity) { + updateConfig({ + dataSource: { + ...config.dataSource, + sourceTable: selectedEntity.referenceTable || "", + foreignKey: selectedEntity.columnName, + referenceKey: selectedEntity.referenceColumn || "id", + displayColumn: selectedEntity.displayColumn, + }, + }); + } + }; + + // 컬럼 토글 + const toggleDetailColumn = (column: ColumnOption) => { + const exists = config.columns.findIndex((c) => c.key === column.columnName && !c.isSourceDisplay); + if (exists >= 0) { + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName || c.isSourceDisplay) }); + } else { + const newCol: TreeColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + }; + updateConfig({ columns: [...config.columns, newCol] }); + } + }; + + const toggleSourceDisplayColumn = (column: ColumnOption) => { + const exists = config.columns.some((c) => c.key === column.columnName && c.isSourceDisplay); + if (exists) { + updateConfig({ columns: config.columns.filter((c) => c.key !== column.columnName) }); + } else { + const newCol: TreeColumnConfig = { + key: column.columnName, + title: column.displayName, + width: "auto", + visible: true, + isSourceDisplay: true, + }; + updateConfig({ columns: [...config.columns, newCol] }); + } + }; + + const isColumnAdded = (columnName: string) => + config.columns.some((c) => c.key === columnName && !c.isSourceDisplay); + + const isSourceColumnSelected = (columnName: string) => + config.columns.some((c) => c.key === columnName && c.isSourceDisplay); + + const updateColumnProp = (key: string, field: keyof TreeColumnConfig, value: any) => { + updateConfig({ + columns: config.columns.map((col) => (col.key === key ? { ...col, [field]: value } : col)), + }); + }; + + // FK/시스템 컬럼 제외한 표시 가능 컬럼 + const displayableColumns = useMemo(() => { + const fkColumn = config.dataSource?.foreignKey; + const systemCols = ["id", "created_at", "updated_at", "created_by", "updated_by", "company_code", "created_date"]; + return detailTableColumns.filter( + (col) => col.columnName !== fkColumn && col.inputType !== "entity" && !systemCols.includes(col.columnName), + ); + }, [detailTableColumns, config.dataSource?.foreignKey]); + + // FK 후보 컬럼 + const fkCandidateColumns = useMemo(() => { + const systemCols = ["created_at", "updated_at", "created_by", "updated_by", "company_code", "created_date"]; + return detailTableColumns.filter((c) => !systemCols.includes(c.columnName)); + }, [detailTableColumns]); + + return ( +
+ + + + 기본 + + + 컬럼 + + + + {/* ─── 기본 설정 탭 ─── */} + + {/* 디테일 테이블 */} +
+ + +
+
+ +
+

+ {config.detailTable + ? allTables.find((t) => t.tableName === config.detailTable)?.displayName || config.detailTable + : "미설정"} +

+ {config.detailTable && config.foreignKey && ( +

+ FK: {config.foreignKey} → {currentTableName || "메인 테이블"}.id +

+ )} +
+
+
+ + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {relatedTables.length > 0 && ( + + {relatedTables.map((rel) => ( + { + handleDetailTableSelect(rel.tableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {rel.tableLabel} + + ({rel.foreignKeyColumn}) + + + ))} + + )} + + + {allTables + .filter((t) => !relatedTables.some((r) => r.tableName === t.tableName)) + .map((table) => ( + { + handleDetailTableSelect(table.tableName); + setTableComboboxOpen(false); + }} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + +
+ + + + {/* 트리 구조 설정 */} +
+
+ + +
+

+ 메인 FK와 부모-자식 계층 FK를 선택하세요 +

+ + {fkCandidateColumns.length > 0 ? ( +
+
+ + +
+
+ + +
+
+ ) : ( +
+

+ {loadingColumns ? "로딩 중..." : "디테일 테이블을 먼저 선택하세요"} +

+
+ )} +
+ + + + {/* 엔티티 선택 (품목 참조) */} +
+ +

+ 트리 노드에 표시할 품목 정보의 소스 엔티티 +

+ + {entityColumns.length > 0 ? ( + + ) : ( +
+

+ {loadingColumns + ? "로딩 중..." + : !config.detailTable + ? "디테일 테이블을 먼저 선택하세요" + : "엔티티 타입 컬럼이 없습니다"} +

+
+ )} + + {config.dataSource?.sourceTable && ( +
+

선택된 엔티티

+
+

참조 테이블: {config.dataSource.sourceTable}

+

FK 컬럼: {config.dataSource.foreignKey}

+
+
+ )} +
+ + + + {/* 이력/버전 테이블 설정 */} +
+ +

+ BOM 변경 이력과 버전 관리에 사용할 테이블을 선택하세요 +

+ +
+
+
+ updateFeatures("showHistory", !!checked)} + /> + +
+ {(config.features?.showHistory ?? true) && ( + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {allTables.map((table) => ( + updateConfig({ historyTable: table.tableName })} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + + )} +
+ +
+
+ updateFeatures("showVersion", !!checked)} + /> + +
+ {(config.features?.showVersion ?? true) && ( + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {allTables.map((table) => ( + updateConfig({ versionTable: table.tableName })} + className="text-xs" + > + + + {table.displayName} + + ))} + + + + + + )} +
+
+
+ + + + {/* 표시 옵션 */} +
+ +
+
+ updateFeatures("showExpandAll", !!checked)} + /> + +
+
+ updateFeatures("showHeader", !!checked)} + /> + +
+
+ updateFeatures("showQuantity", !!checked)} + /> + +
+
+ updateFeatures("showLossRate", !!checked)} + /> + +
+
+
+ + {/* 메인 화면 테이블 참고 */} + {currentTableName && ( + <> + +
+ +
+

{currentTableName}

+

+ 컬럼 {detailTableColumns.length}개 / 엔티티 {entityColumns.length}개 +

+
+
+ + )} +
+ + {/* ─── 컬럼 설정 탭 ─── */} + +
+ +

+ 트리 노드에 표시할 소스/디테일 컬럼을 선택하세요 +

+ + {/* 소스 테이블 컬럼 (표시용) */} + {config.dataSource?.sourceTable && ( + <> +
+ + 소스 테이블 ({config.dataSource.sourceTable}) - 표시용 +
+ {loadingSourceColumns ? ( +

로딩 중...

+ ) : sourceTableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {sourceTableColumns.map((column) => ( +
toggleSourceDisplayColumn(column)} + > + toggleSourceDisplayColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} + 표시 +
+ ))} +
+ )} + + )} + + {/* 디테일 테이블 컬럼 */} +
+ + 디테일 테이블 ({config.detailTable || "미선택"}) - 직접 컬럼 +
+ {loadingColumns ? ( +

로딩 중...

+ ) : displayableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {displayableColumns.map((column) => ( +
toggleDetailColumn(column)} + > + toggleDetailColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + + {column.displayName} + {column.inputType} +
+ ))} +
+ )} +
+ + {/* 선택된 컬럼 상세 */} + {config.columns.length > 0 && ( + <> + +
+ +
+ {config.columns.map((col, index) => ( +
+
e.dataTransfer.setData("columnIndex", String(index))} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const fromIndex = parseInt(e.dataTransfer.getData("columnIndex"), 10); + if (fromIndex !== index) { + const newColumns = [...config.columns]; + const [movedCol] = newColumns.splice(fromIndex, 1); + newColumns.splice(index, 0, movedCol); + updateConfig({ columns: newColumns }); + } + }} + > + + + {!col.isSourceDisplay && ( + + )} + + {col.isSourceDisplay ? ( + + ) : ( + + )} + + updateColumnProp(col.key, "title", e.target.value)} + placeholder="제목" + className="h-6 flex-1 text-xs" + /> + + {!col.isSourceDisplay && ( + + )} + + +
+ + {/* 확장 상세 */} + {!col.isSourceDisplay && expandedColumn === col.key && ( +
+
+ + updateColumnProp(col.key, "width", e.target.value)} + placeholder="auto, 100px, 20%" + className="h-6 text-xs" + /> +
+
+ )} +
+ ))} +
+
+ + )} +
+
+
+ ); +} + +V2BomTreeConfigPanel.displayName = "V2BomTreeConfigPanel"; + +export default V2BomTreeConfigPanel; diff --git a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx index 5b5b5fc2..1f89ae12 100644 --- a/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2RepeaterConfigPanel.tsx @@ -1214,13 +1214,21 @@ export const V2RepeaterConfigPanel: React.FC = ({ )} - {/* 편집 가능 체크박스 */} + {/* 편집 가능 토글 */} {!col.isSourceDisplay && ( - updateColumnProp(col.key, "editable", !!checked)} - title="편집 가능" - /> + )} + + )} ); @@ -227,6 +279,10 @@ interface TreeNodeRowProps { onFieldChange: (tempId: string, field: string, value: string) => void; onDelete: (tempId: string) => void; onAddChild: (parentTempId: string) => void; + onDragStart: (e: React.DragEvent, tempId: string) => void; + onDragOver: (e: React.DragEvent, tempId: string) => void; + onDrop: (e: React.DragEvent, tempId: string) => void; + isDragOver?: boolean; } function TreeNodeRow({ @@ -241,6 +297,10 @@ function TreeNodeRow({ onFieldChange, onDelete, onAddChild, + onDragStart, + onDragOver, + onDrop, + isDragOver, }: TreeNodeRowProps) { const indentPx = depth * 32; const visibleColumns = columns.filter((c) => c.visible !== false); @@ -319,8 +379,13 @@ function TreeNodeRow({ "group flex items-center gap-2 rounded-md border px-2 py-1.5", "transition-colors hover:bg-accent/30", depth > 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)} > @@ -409,7 +474,7 @@ export function BomItemEditorComponent({ // 설정값 추출 const cfg = useMemo(() => component?.componentConfig || {}, [component]); const mainTableName = cfg.mainTableName || "bom_detail"; - const parentKeyColumn = cfg.parentKeyColumn || "parent_detail_id"; + const parentKeyColumn = (cfg.parentKeyColumn && cfg.parentKeyColumn !== "id") ? cfg.parentKeyColumn : "parent_detail_id"; const columns: BomColumnConfig[] = useMemo(() => cfg.columns || [], [cfg.columns]); const visibleColumns = useMemo(() => columns.filter((c) => c.visible !== false), [columns]); const fkColumn = cfg.foreignKeyColumn || "bom_id"; @@ -422,6 +487,28 @@ export function BomItemEditorComponent({ return null; }, [propBomId, formData, selectedRowsData]); + // BOM 전용 API로 현재 current_version_id 조회 + const fetchCurrentVersionId = useCallback(async (id: string): Promise => { + try { + const res = await apiClient.get(`/bom/${id}/versions`); + if (res.data?.success) { + // bom.current_version_id를 직접 반환 (불러오기와 사용확정 구분) + if (res.data.currentVersionId) return res.data.currentVersionId; + // fallback: active 상태 버전 + const activeVersion = res.data.data?.find((v: any) => v.status === "active"); + if (activeVersion) return activeVersion.id; + } + } catch (e) { + console.error("[BomItemEditor] current_version_id 조회 실패:", e); + } + return null; + }, []); + + // formData에서 가져오는 versionId (fallback용) + const propsVersionId = (formData?.current_version_id as string) + || (selectedRowsData?.[0]?.current_version_id as string) + || null; + // ─── 카테고리 옵션 로드 (리피터 방식) ─── useEffect(() => { @@ -431,7 +518,14 @@ export function BomItemEditorComponent({ for (const col of categoryColumns) { const categoryRef = `${mainTableName}.${col.key}`; - if (categoryOptionsMap[categoryRef]) continue; + + const alreadyLoaded = await new Promise((resolve) => { + setCategoryOptionsMap((prev) => { + resolve(!!prev[categoryRef]); + return prev; + }); + }); + if (alreadyLoaded) continue; try { const response = await apiClient.get(`/table-categories/${mainTableName}/${col.key}/values`); @@ -455,21 +549,58 @@ export function BomItemEditorComponent({ // ─── 데이터 로드 ─── + const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; + const sourceTable = cfg.dataSource?.sourceTable || "item_info"; + const loadBomDetails = useCallback( async (id: string) => { if (!id) return; setLoading(true); try { - const result = await entityJoinApi.getTableDataWithJoins(mainTableName, { - page: 1, - size: 500, - search: { [fkColumn]: id }, - sortBy: "seq_no", - sortOrder: "asc", - enableEntityJoin: true, + // isSourceDisplay 컬럼을 추가 조인 컬럼으로 요청 + const displayCols = columns.filter((c) => c.isSourceDisplay); + const additionalJoinColumns = displayCols.map((col) => ({ + sourceTable, + sourceColumn: sourceFk, + joinAlias: `${sourceFk}_${col.key}`, + referenceTable: sourceTable, + })); + + // 서버에서 최신 current_version_id 조회 (항상 최신 보장) + const freshVersionId = await fetchCurrentVersionId(id); + const effectiveVersionId = freshVersionId || propsVersionId; + + const searchFilter: Record = { [fkColumn]: id }; + if (effectiveVersionId) { + searchFilter.version_id = effectiveVersionId; + } + + // autoFilter 비활성화: BOM 전용 API로 company_code 관리 + const res = await apiClient.get(`/table-management/tables/${mainTableName}/data-with-joins`, { + params: { + page: 1, + size: 500, + search: JSON.stringify(searchFilter), + sortBy: "seq_no", + sortOrder: "asc", + enableEntityJoin: true, + additionalJoinColumns: additionalJoinColumns.length > 0 ? JSON.stringify(additionalJoinColumns) : undefined, + autoFilter: JSON.stringify({ enabled: false }), + }, + }); + + const rawData = res.data?.data?.data || res.data?.data || []; + const rows = (Array.isArray(rawData) ? rawData : []).map((row: Record) => { + const mapped = { ...row }; + for (const key of Object.keys(row)) { + if (key.startsWith(`${sourceFk}_`)) { + const shortKey = key.replace(`${sourceFk}_`, ""); + if (!mapped[shortKey]) mapped[shortKey] = row[key]; + } + } + return mapped; }); - const rows = result.data || []; const tree = buildTree(rows); setTreeData(tree); @@ -483,14 +614,20 @@ export function BomItemEditorComponent({ setLoading(false); } }, - [mainTableName, fkColumn], + [mainTableName, fkColumn, sourceFk, sourceTable, columns, fetchCurrentVersionId, propsVersionId], ); + // formData.current_version_id가 변경될 때도 재로드 (버전 전환 시 반영) + const formVersionRef = useRef(null); useEffect(() => { - if (bomId && !isDesignMode) { + if (!bomId || isDesignMode) return; + const currentFormVersion = formData?.current_version_id as string || null; + // bomId가 바뀌거나, formData의 current_version_id가 바뀌면 재로드 + if (formVersionRef.current !== currentFormVersion || !formVersionRef.current) { + formVersionRef.current = currentFormVersion; loadBomDetails(bomId); } - }, [bomId, isDesignMode, loadBomDetails]); + }, [bomId, isDesignMode, loadBomDetails, formData?.current_version_id]); // ─── 트리 빌드 (동적 데이터) ─── @@ -548,10 +685,13 @@ export function BomItemEditorComponent({ id: node.id, tempId: node.tempId, [parentKeyColumn]: parentId, + [fkColumn]: bomId, seq_no: String(idx + 1), level: String(level), _isNew: node._isNew, _targetTable: mainTableName, + _fkColumn: fkColumn, + _deferSave: true, }); if (node.children.length > 0) { traverse(node.children, node.id || node.tempId, level + 1); @@ -560,7 +700,7 @@ export function BomItemEditorComponent({ }; traverse(nodes, null, 0); return result; - }, [parentKeyColumn, mainTableName]); + }, [parentKeyColumn, mainTableName, fkColumn, bomId]); // 트리 변경 시 부모에게 알림 const notifyChange = useCallback( @@ -571,6 +711,164 @@ export function BomItemEditorComponent({ [onChange, flattenTree], ); + // ─── DB 저장 (INSERT/UPDATE/DELETE 일괄) ─── + + const [saving, setSaving] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + + const originalDataRef = React.useRef>(new Set()); + useEffect(() => { + if (treeData.length > 0 && originalDataRef.current.size === 0) { + const collectIds = (nodes: BomItemNode[]) => { + nodes.forEach((n) => { + if (n.id) originalDataRef.current.add(n.id); + collectIds(n.children); + }); + }; + collectIds(treeData); + } + }, [treeData]); + + const markChanged = useCallback(() => setHasChanges(true), []); + const originalNotifyChange = notifyChange; + const notifyChangeWithDirty = useCallback( + (newTree: BomItemNode[]) => { + originalNotifyChange(newTree); + markChanged(); + }, + [originalNotifyChange, markChanged], + ); + + // EditModal 저장 시 beforeFormSave 이벤트로 디테일 데이터도 함께 저장 + useEffect(() => { + if (isDesignMode || !bomId) return; + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + console.log("[BomItemEditor] beforeFormSave 이벤트 수신:", { + bomId, + treeDataLength: treeData.length, + hasRef: !!handleSaveAllRef.current, + }); + if (treeData.length > 0 && handleSaveAllRef.current) { + const savePromise = handleSaveAllRef.current(); + if (detail?.pendingPromises) { + detail.pendingPromises.push(savePromise); + console.log("[BomItemEditor] pendingPromises에 저장 Promise 등록 완료"); + } + } + }; + window.addEventListener("beforeFormSave", handler); + console.log("[BomItemEditor] beforeFormSave 리스너 등록:", { bomId, isDesignMode }); + return () => window.removeEventListener("beforeFormSave", handler); + }, [isDesignMode, bomId, treeData.length]); + + const handleSaveAllRef = React.useRef<(() => Promise) | null>(null); + + const handleSaveAll = useCallback(async () => { + if (!bomId) return; + setSaving(true); + try { + // 저장 시점에도 최신 version_id 조회 + const saveVersionId = await fetchCurrentVersionId(bomId) || propsVersionId; + + const collectAll = (nodes: BomItemNode[], parentRealId: string | null, level: number): any[] => { + const result: any[] = []; + nodes.forEach((node, idx) => { + result.push({ + node, + parentRealId, + level, + seqNo: idx + 1, + }); + if (node.children.length > 0) { + result.push(...collectAll(node.children, node.id || node.tempId, level + 1)); + } + }); + return result; + }; + + const allNodes = collectAll(treeData, null, 0); + const tempToReal: Record = {}; + let savedCount = 0; + + for (const { node, parentRealId, level, seqNo } of allNodes) { + const realParentId = parentRealId + ? tempToReal[parentRealId] || parentRealId + : null; + + if (node._isNew) { + const payload: Record = { + ...node.data, + [fkColumn]: bomId, + [parentKeyColumn]: realParentId, + seq_no: String(seqNo), + level: String(level), + company_code: companyCode || undefined, + version_id: saveVersionId || undefined, + }; + delete payload.id; + delete payload.tempId; + delete payload._isNew; + delete payload._isDeleted; + + const resp = await apiClient.post( + `/table-management/tables/${mainTableName}/add`, + payload, + ); + const newId = resp.data?.data?.id; + if (newId) tempToReal[node.tempId] = newId; + savedCount++; + } else if (node.id) { + const updatedData: Record = { + ...node.data, + id: node.id, + [parentKeyColumn]: realParentId, + seq_no: String(seqNo), + level: String(level), + }; + delete updatedData.tempId; + delete updatedData._isNew; + delete updatedData._isDeleted; + Object.keys(updatedData).forEach((k) => { + if (k.startsWith(`${sourceFk}_`)) delete updatedData[k]; + }); + + await apiClient.put( + `/table-management/tables/${mainTableName}/edit`, + { originalData: { id: node.id }, updatedData }, + ); + savedCount++; + } + } + + const currentIds = new Set(allNodes.filter((a) => a.node.id).map((a) => a.node.id)); + for (const oldId of originalDataRef.current) { + if (!currentIds.has(oldId)) { + await apiClient.delete( + `/table-management/tables/${mainTableName}/delete`, + { data: [{ id: oldId }] }, + ); + savedCount++; + } + } + + originalDataRef.current = new Set(allNodes.filter((a) => a.node.id || tempToReal[a.node.tempId]).map((a) => a.node.id || tempToReal[a.node.tempId])); + setHasChanges(false); + if (bomId) loadBomDetails(bomId); + window.dispatchEvent(new CustomEvent("refreshTable")); + console.log(`[BomItemEditor] ${savedCount}건 저장 완료`); + } catch (error) { + console.error("[BomItemEditor] 저장 실패:", error); + alert("저장 중 오류가 발생했습니다."); + } finally { + setSaving(false); + } + }, [bomId, treeData, fkColumn, parentKeyColumn, mainTableName, companyCode, sourceFk, loadBomDetails, fetchCurrentVersionId, propsVersionId]); + + useEffect(() => { + handleSaveAllRef.current = handleSaveAll; + }, [handleSaveAll]); + // ─── 노드 조작 함수들 ─── // 트리에서 특정 노드 찾기 (재귀) @@ -601,18 +899,18 @@ export function BomItemEditorComponent({ ...node, data: { ...node.data, [field]: value }, })); - notifyChange(newTree); + notifyChangeWithDirty(newTree); }, - [treeData, notifyChange], + [treeData, notifyChangeWithDirty], ); // 노드 삭제 const handleDelete = useCallback( (tempId: string) => { const newTree = findAndUpdate(treeData, tempId, () => null); - notifyChange(newTree); + notifyChangeWithDirty(newTree); }, - [treeData, notifyChange], + [treeData, notifyChangeWithDirty], ); // 하위 품목 추가 시작 (모달 열기) @@ -627,59 +925,62 @@ export function BomItemEditorComponent({ setItemSearchOpen(true); }, []); - // 품목 선택 후 추가 (동적 데이터) + // 품목 선택 후 추가 (다중 선택 지원) const handleItemSelect = useCallback( - (item: ItemInfo) => { - // 소스 테이블 데이터를 _display_ 접두사로 저장 (엔티티 조인 방식) - const sourceData: Record = {}; - const sourceTable = cfg.dataSource?.sourceTable; - if (sourceTable) { - const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; - sourceData[sourceFk] = item.id; - // 소스 표시 컬럼의 데이터 병합 - Object.keys(item).forEach((key) => { - sourceData[`_display_${key}`] = (item as any)[key]; - sourceData[key] = (item as any)[key]; - }); + (selectedItemsList: ItemInfo[]) => { + let newTree = [...treeData]; + + for (const item of selectedItemsList) { + const sourceData: Record = {}; + const sourceTable = cfg.dataSource?.sourceTable; + if (sourceTable) { + const sourceFk = cfg.dataSource?.foreignKey || "child_item_id"; + sourceData[sourceFk] = item.id; + Object.keys(item).forEach((key) => { + sourceData[`_display_${key}`] = (item as any)[key]; + sourceData[key] = (item as any)[key]; + }); + } + + const newNode: BomItemNode = { + tempId: generateTempId(), + parent_detail_id: null, + seq_no: 0, + level: 0, + children: [], + _isNew: true, + data: { + ...sourceData, + quantity: "1", + loss_rate: "0", + remark: "", + }, + }; + + if (addTargetParentId === null) { + newNode.seq_no = newTree.length + 1; + newNode.level = 0; + newTree = [...newTree, newNode]; + } else { + newTree = findAndUpdate(newTree, addTargetParentId, (parent) => { + newNode.parent_detail_id = parent.id || parent.tempId; + newNode.seq_no = parent.children.length + 1; + newNode.level = parent.level + 1; + return { + ...parent, + children: [...parent.children, newNode], + }; + }); + } } - const newNode: BomItemNode = { - tempId: generateTempId(), - parent_detail_id: null, - seq_no: 0, - level: 0, - children: [], - _isNew: true, - data: { - ...sourceData, - quantity: "1", - loss_rate: "0", - remark: "", - }, - }; - - let newTree: BomItemNode[]; - - if (addTargetParentId === null) { - newNode.seq_no = treeData.length + 1; - newNode.level = 0; - newTree = [...treeData, newNode]; - } else { - newTree = findAndUpdate(treeData, addTargetParentId, (parent) => { - newNode.parent_detail_id = parent.id || parent.tempId; - newNode.seq_no = parent.children.length + 1; - newNode.level = parent.level + 1; - return { - ...parent, - children: [...parent.children, newNode], - }; - }); + if (addTargetParentId !== null) { setExpandedNodes((prev) => new Set([...prev, addTargetParentId])); } - notifyChange(newTree); + notifyChangeWithDirty(newTree); }, - [addTargetParentId, treeData, notifyChange, cfg], + [addTargetParentId, treeData, notifyChangeWithDirty, cfg], ); // 펼침/접기 토글 @@ -692,6 +993,101 @@ export function BomItemEditorComponent({ }); }, []); + // ─── 드래그 재정렬 ─── + const [dragId, setDragId] = useState(null); + const [dragOverId, setDragOverId] = useState(null); + + // 트리에서 노드를 제거하고 반환 + const removeNode = (nodes: BomItemNode[], tempId: string): { tree: BomItemNode[]; removed: BomItemNode | null } => { + const result: BomItemNode[] = []; + let removed: BomItemNode | null = null; + for (const node of nodes) { + if (node.tempId === tempId) { + removed = node; + } else { + const childResult = removeNode(node.children, tempId); + if (childResult.removed) removed = childResult.removed; + result.push({ ...node, children: childResult.tree }); + } + } + return { tree: result, removed }; + }; + + // 노드가 대상의 자손인지 확인 (자기 자신의 하위로 드래그 방지) + const isDescendant = (nodes: BomItemNode[], parentId: string, childId: string): boolean => { + const find = (list: BomItemNode[]): BomItemNode | null => { + for (const n of list) { + if (n.tempId === parentId) return n; + const found = find(n.children); + if (found) return found; + } + return null; + }; + const parent = find(nodes); + if (!parent) return false; + const check = (children: BomItemNode[]): boolean => { + for (const c of children) { + if (c.tempId === childId) return true; + if (check(c.children)) return true; + } + return false; + }; + return check(parent.children); + }; + + const handleDragStart = useCallback((e: React.DragEvent, tempId: string) => { + setDragId(tempId); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", tempId); + }, []); + + const handleDragOver = useCallback((e: React.DragEvent, tempId: string) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverId(tempId); + }, []); + + const handleDrop = useCallback((e: React.DragEvent, targetTempId: string) => { + e.preventDefault(); + setDragOverId(null); + if (!dragId || dragId === targetTempId) return; + + // 자기 자신의 하위로 드래그 방지 + if (isDescendant(treeData, dragId, targetTempId)) return; + + const { tree: treeWithout, removed } = removeNode(treeData, dragId); + if (!removed) return; + + // 대상 노드 바로 뒤에 같은 레벨로 삽입 + const insertAfter = (nodes: BomItemNode[], afterId: string, node: BomItemNode): { result: BomItemNode[]; inserted: boolean } => { + const result: BomItemNode[] = []; + let inserted = false; + for (const n of nodes) { + result.push(n); + if (n.tempId === afterId) { + result.push({ ...node, level: n.level, parent_detail_id: n.parent_detail_id }); + inserted = true; + } else if (!inserted) { + const childResult = insertAfter(n.children, afterId, node); + if (childResult.inserted) { + result[result.length - 1] = { ...n, children: childResult.result }; + inserted = true; + } + } + } + return { result, inserted }; + }; + + const { result, inserted } = insertAfter(treeWithout, targetTempId, removed); + if (inserted) { + const reindex = (nodes: BomItemNode[], depth = 0): BomItemNode[] => + nodes.map((n, i) => ({ ...n, seq_no: i + 1, level: depth, children: reindex(n.children, depth + 1) })); + notifyChangeWithDirty(reindex(result)); + } + + setDragId(null); + }, [dragId, treeData, notifyChangeWithDirty]); + // ─── 재귀 렌더링 ─── const renderNodes = (nodes: BomItemNode[], depth: number) => { @@ -711,6 +1107,10 @@ export function BomItemEditorComponent({ onFieldChange={handleFieldChange} onDelete={handleDelete} onAddChild={handleAddChild} + onDragStart={handleDragStart} + onDragOver={handleDragOver} + onDrop={handleDrop} + isDragOver={dragOverId === node.tempId} /> {isExpanded && node.children.length > 0 && @@ -886,19 +1286,33 @@ export function BomItemEditorComponent({
{/* 헤더 */}
-

하위 품목 구성

- +

+ 하위 품목 구성 + {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 | null; + isRootNode?: boolean; + tableName: string; + onSaved?: () => void; +} + +export function BomDetailEditModal({ + open, + onOpenChange, + node, + isRootNode = false, + tableName, + onSaved, +}: BomDetailEditModalProps) { + const [formData, setFormData] = useState>({}); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (node && open) { + if (isRootNode) { + setFormData({ + base_qty: node.base_qty || "", + unit: node.unit || "", + remark: node.remark || "", + }); + } else { + setFormData({ + quantity: node.quantity || "", + unit: node.unit || node.detail_unit || "", + process_type: node.process_type || "", + base_qty: node.base_qty || "", + loss_rate: node.loss_rate || "", + remark: node.remark || "", + }); + } + } + }, [node, open, isRootNode]); + + const handleChange = (field: string, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleSave = async () => { + if (!node) return; + setSaving(true); + try { + const targetTable = isRootNode ? "bom" : tableName; + const realId = isRootNode ? node.id?.replace("__root_", "") : node.id; + await apiClient.put(`/table-management/tables/${targetTable}/${realId}`, formData); + onSaved?.(); + onOpenChange(false); + } catch (error) { + console.error("[BomDetailEdit] 저장 실패:", error); + } finally { + setSaving(false); + } + }; + + if (!node) return null; + + const itemCode = isRootNode + ? node.child_item_code || node.item_code || node.bom_number || "-" + : node.child_item_code || "-"; + const itemName = isRootNode + ? node.child_item_name || node.item_name || "-" + : node.child_item_name || "-"; + + return ( + + + + + {isRootNode ? "BOM 헤더 수정" : "품목 수정"} + + + {isRootNode + ? "BOM 기본 정보를 수정합니다" + : "선택한 품목의 BOM 구성 정보를 수정합니다"} + + + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + handleChange(isRootNode ? "base_qty" : "quantity", e.target.value)} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + handleChange("unit", e.target.value)} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + {!isRootNode && ( + <> +
+
+ + handleChange("process_type", e.target.value)} + placeholder="예: 조립공정" + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ + handleChange("loss_rate", e.target.value)} + className="mt-1 h-8 text-xs sm:h-10 sm:text-sm" + /> +
+
+ +
+
+ + +
+
+ + +
+
+ + )} + +
+ +