diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 7a87d1a0..84a8729c 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -3,6 +3,10 @@ "agent-orchestrator": { "command": "node", "args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"] + }, + "Framelink Figma MCP": { + "command": "npx", + "args": ["-y", "figma-developer-mcp", "--figma-api-key=figd_NrYdIWf-CnC23NyH6eMym7sBdfbZTuXyS91tI3VS", "--stdio"] } } } diff --git a/.gitignore b/.gitignore index a771d2c9..1dbaa5f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Claude Code (로컬 전용 - Git 제외) +.claude/ + # Dependencies node_modules/ npm-debug.log* @@ -286,4 +289,12 @@ uploads/ *.hwp *.hwpx -claude.md \ No newline at end of file +claude.md + +# AI 에이전트 테스트 산출물 +*-test-screenshots/ +*-screenshots/ +*-test.mjs + +# 개인 작업 문서 (popdocs) +popdocs/ \ No newline at end of file diff --git a/PLAN.MD b/PLAN.MD index 0eff7965..45468fa4 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,139 +1,548 @@ -# 프로젝트: V2/V2 컴포넌트 설정 스키마 정비 +# 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성 -## 개요 - -레거시 컴포넌트를 제거하고, V2/V2 컴포넌트 전용 Zod 스키마와 기본값 레지스트리를 한 곳에서 관리한다. - -## 핵심 기능 - -1. [x] 레거시 컴포넌트 스키마 제거 -2. [x] V2 컴포넌트 overrides 스키마 정의 (16개) -3. [x] V2 컴포넌트 overrides 스키마 정의 (9개) -4. [x] componentConfig.ts 한 파일에서 통합 관리 - -## 정의된 V2 컴포넌트 (18개) - -- v2-table-list, v2-button-primary, v2-text-display -- v2-split-panel-layout, v2-section-card, v2-section-paper -- v2-divider-line, v2-repeat-container, v2-rack-structure -- v2-numbering-rule, v2-category-manager, v2-pivot-grid -- v2-location-swap-selector, v2-aggregation-widget -- v2-card-display, v2-table-search-widget, v2-tabs-widget -- v2-v2-repeater - -## 정의된 V2 컴포넌트 (9개) - -- v2-input, v2-select, v2-date -- v2-list, v2-layout, v2-group -- v2-media, v2-biz, v2-hierarchy - -## 테스트 계획 - -### 1단계: 기본 기능 - -- [x] V2 레이아웃 저장 시 컴포넌트별 overrides 스키마 검증 통과 -- [x] V2 컴포넌트 기본값과 스키마가 매칭됨 - -### 2단계: 에러 케이스 - -- [x] 잘못된 overrides 입력 시 Zod 검증 실패 처리 (safeParse + console.warn + graceful fallback) -- [x] 누락된 기본값 컴포넌트 저장 시 안전한 기본값 적용 (레지스트리 조회 → 빈 객체) - -## 에러 처리 계획 - -- 스키마 파싱 실패 시 로그/에러 메시지 표준화 -- 기본값 누락 시 안전한 fallback 적용 - -## 진행 상태 - -- [x] 레거시 컴포넌트 제거 완료 -- [x] V2/V2 스키마 정의 완료 -- [x] 한 파일 통합 관리 완료 - -# 프로젝트: 화면 복제 기능 개선 (DB 구조 개편 후) - -## 개요 - -채번/카테고리에서 `menu_objid` 의존성 제거 완료 후, 화면 복제 기능을 새 DB 구조에 맞게 수정하고 테스트합니다. - -## 핵심 변경사항 - -### DB 구조 변경 (완료) - -- 채번규칙: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반 -- 카테고리: `menu_objid` 의존성 제거 → `table_name + column_name + company_code` 기반 -- 복제 순서 의존성 문제 해결 - -### 복제 옵션 정리 (완료) - -- [x] **삭제**: 코드 카테고리 + 코드 복사 옵션 -- [x] **삭제**: 연쇄관계 설정 복사 옵션 -- [x] **이름 변경**: "카테고리 매핑 + 값 복사" → "카테고리 값 복사" - -### 현재 복제 옵션 (3개) - -1. **채번 규칙 복사** - 채번규칙 복제 -2. **카테고리 값 복사** - 카테고리 값 복제 (table_column_category_values) -3. **테이블 타입관리 입력타입 설정 복사** - table_type_columns 복제 +> **작성일**: 2026-02-10 +> **상태**: 코딩 완료 (방어 로직 패치 포함) +> **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성 --- -## 테스트 계획 +## 1. 문제 요약 -### 1. 화면 간 연결 복제 테스트 +pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가. -- [ ] 수주관리 1번→2번→3번→4번 화면 연결 상태에서 복제 -- [ ] 복제 후 연결 관계가 유지되는지 확인 -- [ ] 각 화면의 고유 키값이 새로운 화면을 참조하도록 변경되는지 확인 - -### 2. 제어관리 복제 테스트 - -- [ ] 다른 회사로 제어관리 복제 -- [ ] 복제된 플로우 스텝/연결이 정상 작동하는지 확인 - -### 3. 추가 옵션 복제 테스트 - -- [ ] 채번규칙 복사 정상 작동 확인 -- [ ] 카테고리 값 복사 정상 작동 확인 -- [ ] 테이블 타입관리 입력타입 설정 복사 정상 작동 확인 - -### 4. 기본 복제 테스트 - -- [ ] 단일 화면 복제 (모달 포함) -- [ ] 그룹 전체 복제 (재귀적) -- [ ] 메뉴 동기화 정상 작동 +| # | 문제 | 심각도 | 영향 | +|---|------|--------|------| +| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 | +| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 | +| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 | +| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 | --- -## 관련 파일 +## 2. 수정 대상 파일 (2개) -- `frontend/components/screen/CopyScreenModal.tsx` - 복제 모달 -- `frontend/components/screen/ScreenGroupTreeView.tsx` - 트리 뷰 + 컨텍스트 메뉴 -- `backend-node/src/services/screenManagementService.ts` - 복제 서비스 -- `backend-node/src/services/numberingRuleService.ts` - 채번규칙 서비스 -- `docs/DB_STRUCTURE_DIAGRAM.md` - DB 구조 문서 +### 파일 A: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx` -## 진행 상태 +**변경 유형**: 설정 UI 추가 3건 + +#### 변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래) + +집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가. + +**추가할 위치**: `{/* 집계 함수 + 대상 컬럼 */}` 블록 다음, `{/* 자동 새로고침 */}` 블록 이전 + +**추가할 코드** (약 50줄): + +```tsx +{/* 그룹핑 (차트용 X축 분류) */} +{dataSource.aggregation && ( +
+ + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {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 e454742a..4b3d212a 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..62fc8bbe 100644 --- a/backend-node/src/controllers/entitySearchController.ts +++ b/backend-node/src/controllers/entitySearchController.ts @@ -3,16 +3,115 @@ import { AuthenticatedRequest } from "../types/auth"; import { getPool } from "../database/db"; import { logger } from "../utils/logger"; +/** + * 필터 조건을 WHERE절에 적용하는 공통 헬퍼 + * filters JSON 배열: [{ column, operator, value }] + */ +function applyFilters( + filtersJson: string | undefined, + existingColumns: Set, + whereConditions: string[], + params: any[], + startParamIndex: number, + tableName: string, +): number { + let paramIndex = startParamIndex; + + if (!filtersJson) return paramIndex; + + let filters: Array<{ column: string; operator: string; value: unknown }>; + try { + filters = JSON.parse(filtersJson as string); + } catch { + logger.warn("filters JSON 파싱 실패", { tableName, filtersJson }); + return paramIndex; + } + + if (!Array.isArray(filters)) return paramIndex; + + for (const filter of filters) { + const { column, operator = "=", value } = filter; + if (!column || !existingColumns.has(column)) { + logger.warn("필터 컬럼 미존재 제외", { tableName, column }); + continue; + } + + switch (operator) { + case "=": + whereConditions.push(`"${column}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "!=": + whereConditions.push(`"${column}" != $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case ">": + case "<": + case ">=": + case "<=": + whereConditions.push(`"${column}" ${operator} $${paramIndex}`); + params.push(value); + paramIndex++; + break; + case "in": { + const inVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (inVals.length > 0) { + const ph = inVals.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${column}" IN (${ph})`); + params.push(...inVals); + paramIndex += inVals.length; + } + break; + } + case "notIn": { + const notInVals = Array.isArray(value) ? value : String(value).split(",").map(v => v.trim()); + if (notInVals.length > 0) { + const ph = notInVals.map((_, i) => `$${paramIndex + i}`).join(", "); + whereConditions.push(`"${column}" NOT IN (${ph})`); + params.push(...notInVals); + paramIndex += notInVals.length; + } + break; + } + case "like": + whereConditions.push(`"${column}"::text ILIKE $${paramIndex}`); + params.push(`%${value}%`); + paramIndex++; + break; + case "isNull": + whereConditions.push(`"${column}" IS NULL`); + break; + case "isNotNull": + whereConditions.push(`"${column}" IS NOT NULL`); + break; + default: + whereConditions.push(`"${column}" = $${paramIndex}`); + params.push(value); + paramIndex++; + break; + } + } + + return paramIndex; +} + /** * 테이블 컬럼의 DISTINCT 값 조회 API (inputType: select 용) * GET /api/entity/:tableName/distinct/:columnName * * 해당 테이블의 해당 컬럼에서 DISTINCT 값을 조회하여 선택박스 옵션으로 반환 + * + * Query Params: + * - labelColumn: 별도의 라벨 컬럼 (선택) + * - filters: JSON 배열 형태의 필터 조건 (선택) + * 예: [{"column":"status","operator":"=","value":"active"}] */ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Response) { try { const { tableName, columnName } = req.params; - const { labelColumn } = req.query; // 선택적: 별도의 라벨 컬럼 + const { labelColumn, filters: filtersParam } = req.query; // 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -68,6 +167,16 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re whereConditions.push(`"${columnName}" IS NOT NULL`); whereConditions.push(`"${columnName}" != ''`); + // 필터 조건 적용 + paramIndex = applyFilters( + filtersParam as string | undefined, + existingColumns, + whereConditions, + params, + paramIndex, + tableName, + ); + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; @@ -88,6 +197,7 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re columnName, labelColumn: effectiveLabelColumn, companyCode, + hasFilters: !!filtersParam, rowCount: result.rowCount, }); @@ -111,11 +221,14 @@ export async function getDistinctColumnValues(req: AuthenticatedRequest, res: Re * Query Params: * - value: 값 컬럼 (기본: id) * - label: 표시 컬럼 (기본: name) + * - fields: 추가 반환 컬럼 (콤마 구분) + * - filters: JSON 배열 형태의 필터 조건 (선택) + * 예: [{"column":"status","operator":"=","value":"active"}] */ 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, filters: filtersParam } = req.query; // tableName 유효성 검증 if (!tableName || tableName === "undefined" || tableName === "null") { @@ -163,13 +276,35 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) paramIndex++; } + // 필터 조건 적용 + paramIndex = applyFilters( + filtersParam as string | undefined, + existingColumns, + whereConditions, + params, + paramIndex, + tableName, + ); + const whereClause = whereConditions.length > 0 ? `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 @@ -183,7 +318,9 @@ export async function getEntityOptions(req: AuthenticatedRequest, res: Response) valueColumn, labelColumn: effectiveLabelColumn, companyCode, + hasFilters: !!filtersParam, 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/database/db.ts b/backend-node/src/database/db.ts index 4c249ac3..6fc10cf1 100644 --- a/backend-node/src/database/db.ts +++ b/backend-node/src/database/db.ts @@ -13,9 +13,13 @@ import { PoolClient, QueryResult as PgQueryResult, QueryResultRow, + types, } from "pg"; import config from "../config/environment"; +// DATE 타입(OID 1082)을 문자열로 반환 (타임존 변환에 의한 -1day 버그 방지) +types.setTypeParser(1082, (val: string) => val); + // PostgreSQL 연결 풀 let pool: Pool | null = null; 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 087f08c0..7630b359 100644 --- a/backend-node/src/routes/processWorkStandardRoutes.ts +++ b/backend-node/src/routes/processWorkStandardRoutes.ts @@ -3,14 +3,21 @@ */ import express from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; import * as ctrl from "../controllers/processWorkStandardController"; const router = express.Router(); +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/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 2c25f7e0..6f412de5 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1721,18 +1721,28 @@ export class ScreenManagementService { throw new Error("이 화면의 레이아웃을 조회할 권한이 없습니다."); } - // 🆕 V2 테이블 우선 조회 (회사별 → 공통(*)) + // V2 테이블 우선 조회: 기본 레이어(layer_id=1)만 가져옴 + // layer_id 필터 없이 queryOne 하면 조건부 레이어가 반환될 수 있음 let v2Layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = $2`, + WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`, [screenId, companyCode], ); - // 회사별 레이아웃 없으면 공통(*) 조회 + // 최고관리자(*): 화면 정의의 company_code로 재조회 + if (!v2Layout && companyCode === "*" && existingScreen.company_code && existingScreen.company_code !== "*") { + v2Layout = await queryOne<{ layout_data: any }>( + `SELECT layout_data FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 AND layer_id = 1`, + [screenId, existingScreen.company_code], + ); + } + + // 일반 사용자: 회사별 레이아웃 없으면 공통(*) 조회 if (!v2Layout && companyCode !== "*") { v2Layout = await queryOne<{ layout_data: any }>( `SELECT layout_data FROM screen_layouts_v2 - WHERE screen_id = $1 AND company_code = '*'`, + WHERE screen_id = $1 AND company_code = '*' AND layer_id = 1`, [screenId], ); } @@ -5302,7 +5312,22 @@ export class ScreenManagementService { [screenId, companyCode, layerId], ); - // 회사별 레이어가 없으면 공통(*) 조회 + // 최고관리자(*): 화면 정의의 company_code로 재조회 + if (!layout && companyCode === "*") { + const screenDef = await queryOne<{ company_code: string }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + if (screenDef && screenDef.company_code && screenDef.company_code !== "*") { + layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>( + `SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2 + WHERE screen_id = $1 AND company_code = $2 AND layer_id = $3`, + [screenId, screenDef.company_code, layerId], + ); + } + } + + // 일반 사용자: 회사별 레이어가 없으면 공통(*) 조회 if (!layout && companyCode !== "*") { layout = await queryOne<{ layout_data: any; layer_name: string; condition_config: any }>( `SELECT layout_data, layer_name, condition_config FROM screen_layouts_v2 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/backend-node/src/types/tableManagement.ts b/backend-node/src/types/tableManagement.ts index 8c786063..977031b8 100644 --- a/backend-node/src/types/tableManagement.ts +++ b/backend-node/src/types/tableManagement.ts @@ -44,6 +44,7 @@ export interface ColumnSettings { displayColumn?: string; // 🎯 Entity 조인에서 표시할 컬럼명 displayOrder?: number; // 표시 순서 isVisible?: boolean; // 표시 여부 + categoryRef?: string | null; // 카테고리 참조 } export interface TableLabels { 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)/screen/[screenCode]/page.tsx b/frontend/app/(main)/screen/[screenCode]/page.tsx new file mode 100644 index 00000000..0817065e --- /dev/null +++ b/frontend/app/(main)/screen/[screenCode]/page.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useEffect } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { Loader2 } from "lucide-react"; +import { apiClient } from "@/lib/api/client"; + +/** + * /screen/COMPANY_7_167 → /screens/4153 리다이렉트 + * 메뉴 URL이 screenCode 기반이므로, screenId로 변환 후 이동 + */ +export default function ScreenCodeRedirectPage() { + const params = useParams(); + const router = useRouter(); + const screenCode = params.screenCode as string; + + useEffect(() => { + if (!screenCode) return; + + const numericId = parseInt(screenCode); + if (!isNaN(numericId)) { + router.replace(`/screens/${numericId}`); + return; + } + + const resolve = async () => { + try { + const res = await apiClient.get("/screen-management/screens", { + params: { screenCode }, + }); + const screens = res.data?.data || []; + if (screens.length > 0) { + const id = screens[0].screenId || screens[0].screen_id; + router.replace(`/screens/${id}`); + } else { + router.replace("/"); + } + } catch { + router.replace("/"); + } + }; + resolve(); + }, [screenCode, router]); + + return ( +
+ +
+ ); +} 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..854b1159 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 }) => {
+
) : (

화면 데이터가 없습니다.

@@ -1282,7 +1288,7 @@ export const ScreenModal: React.FC = ({ 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 096d50e9..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; @@ -376,12 +377,26 @@ export const EditModal: React.FC = ({ className }) => { try { setLoading(true); - // 화면 정보와 레이아웃 데이터 로딩 - const [screenInfo, layoutData] = await Promise.all([ + // 화면 정보와 레이아웃 데이터 로딩 (ScreenModal과 동일하게 V2 API 우선) + const [screenInfo, v2LayoutData] = await Promise.all([ screenApi.getScreen(screenId), - screenApi.getLayout(screenId), + screenApi.getLayoutV2(screenId), ]); + // V2 → Legacy 변환 (ScreenModal과 동일한 패턴) + let layoutData: any = null; + if (v2LayoutData && isValidV2Layout(v2LayoutData)) { + layoutData = convertV2ToLegacy(v2LayoutData); + if (layoutData) { + layoutData.screenResolution = v2LayoutData.screenResolution || layoutData.screenResolution; + } + } + + // V2 없으면 기존 API fallback + if (!layoutData) { + layoutData = await screenApi.getLayout(screenId); + } + if (screenInfo && layoutData) { const components = layoutData.components || []; @@ -1140,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 콜백 실행 (테이블 새로고침) @@ -1200,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 || "생성에 실패했습니다."); @@ -1305,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 || "수정에 실패했습니다."); @@ -1371,12 +1441,16 @@ export const EditModal: React.FC = ({ className }) => {
) : screenData ? ( +
{ const baseHeight = (screenDimensions?.height || 600) + 30; if (activeConditionalComponents.length > 0) { @@ -1532,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/filters/FormDatePicker.tsx b/frontend/components/screen/filters/FormDatePicker.tsx new file mode 100644 index 00000000..5ab5a1ff --- /dev/null +++ b/frontend/components/screen/filters/FormDatePicker.tsx @@ -0,0 +1,344 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; + +interface FormDatePickerProps { + id?: string; + value: string; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; + readOnly?: boolean; + includeTime?: boolean; +} + +export const FormDatePicker: React.FC = ({ + id, + value, + onChange, + placeholder, + disabled = false, + readOnly = false, + includeTime = false, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [timeValue, setTimeValue] = useState("00:00"); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + const parseDate = (val: string): Date | undefined => { + if (!val) return undefined; + try { + const date = new Date(val); + if (isNaN(date.getTime())) return undefined; + return date; + } catch { + return undefined; + } + }; + + const selectedDate = parseDate(value); + + useEffect(() => { + if (isOpen) { + setViewMode("calendar"); + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12); + if (includeTime) { + const hours = String(selectedDate.getHours()).padStart(2, "0"); + const minutes = String(selectedDate.getMinutes()).padStart(2, "0"); + setTimeValue(`${hours}:${minutes}`); + } + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); + setTimeValue("00:00"); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [isOpen]); + + const formatDisplayValue = (): string => { + if (!selectedDate) return ""; + if (includeTime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko }); + return format(selectedDate, "yyyy-MM-dd", { locale: ko }); + }; + + const buildDateStr = (date: Date, time?: string) => { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + if (includeTime) return `${y}-${m}-${d}T${time || timeValue}`; + return `${y}-${m}-${d}`; + }; + + const handleDateClick = (date: Date) => { + onChange(buildDateStr(date)); + if (!includeTime) setIsOpen(false); + }; + + const handleTimeChange = (newTime: string) => { + setTimeValue(newTime); + if (selectedDate) onChange(buildDateStr(selectedDate, newTime)); + }; + + const handleSetToday = () => { + const today = new Date(); + if (includeTime) { + const t = `${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`; + onChange(buildDateStr(today, t)); + } else { + onChange(buildDateStr(today)); + } + setIsOpen(false); + }; + + const handleClear = () => { + onChange(""); + setIsTyping(false); + setIsOpen(false); + }; + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!isOpen) setIsOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + onChange(buildDateStr(date)); + setCurrentMonth(new Date(y, m, 1)); + if (!includeTime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400); + else setIsTyping(false); + } + } + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + const dayOfWeek = monthStart.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; + + return ( + { if (!open) { setIsOpen(false); setIsTyping(false); } }}> + +
{ if (!disabled && !readOnly) setIsOpen(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readOnly && !isOpen) setIsOpen(true); }} + onBlur={() => { if (!isOpen) setIsTyping(false); }} + className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> + {selectedDate && !disabled && !readOnly && !isTyping && ( + { + e.stopPropagation(); + handleClear(); + }} + /> + )} +
+
+ e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} + + {includeTime && viewMode === "calendar" && ( +
+ 시간: + handleTimeChange(e.target.value)} + className="border-input h-8 rounded-md border px-2 text-xs" + /> +
+ )} +
+ + + ); +}; diff --git a/frontend/components/screen/filters/InlineCellDatePicker.tsx b/frontend/components/screen/filters/InlineCellDatePicker.tsx new file mode 100644 index 00000000..f47546b4 --- /dev/null +++ b/frontend/components/screen/filters/InlineCellDatePicker.tsx @@ -0,0 +1,279 @@ +"use client"; + +import React, { useState, useEffect, useRef } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; + +interface InlineCellDatePickerProps { + value: string; + onChange: (value: string) => void; + onSave: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + inputRef?: React.RefObject; +} + +export const InlineCellDatePicker: React.FC = ({ + value, + onChange, + onSave, + onKeyDown, + inputRef, +}) => { + const [isOpen, setIsOpen] = useState(true); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const localInputRef = useRef(null); + const actualInputRef = inputRef || localInputRef; + + const parseDate = (val: string): Date | undefined => { + if (!val) return undefined; + try { + const date = new Date(val); + if (isNaN(date.getTime())) return undefined; + return date; + } catch { + return undefined; + } + }; + + const selectedDate = parseDate(value); + + useEffect(() => { + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + } + }, []); + + const handleDateClick = (date: Date) => { + const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleSetToday = () => { + const today = new Date(); + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleClear = () => { + onChange(""); + setIsOpen(false); + setTimeout(() => onSave(), 50); + }; + + const handleInputChange = (raw: string) => { + onChange(raw); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + const dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + onChange(dateStr); + setIsOpen(false); + setTimeout(() => onSave(), 50); + } + } + }; + + const handlePopoverClose = (open: boolean) => { + if (!open) { + setIsOpen(false); + onSave(); + } + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + const startDate = new Date(monthStart); + const dayOfWeek = startDate.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; + + return ( + + + handleInputChange(e.target.value)} + onKeyDown={onKeyDown} + onClick={() => setIsOpen(true)} + placeholder="YYYYMMDD" + className="border-primary bg-background h-full w-full border-2 px-2 py-1 text-xs focus:outline-none sm:px-4 sm:py-1.5 sm:text-sm" + /> + + e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} +
+ + + ); +}; diff --git a/frontend/components/screen/filters/ModernDatePicker.tsx b/frontend/components/screen/filters/ModernDatePicker.tsx index 54fdcfed..79f16a41 100644 --- a/frontend/components/screen/filters/ModernDatePicker.tsx +++ b/frontend/components/screen/filters/ModernDatePicker.tsx @@ -34,6 +34,8 @@ export const ModernDatePicker: React.FC = ({ label, value const [isOpen, setIsOpen] = useState(false); const [currentMonth, setCurrentMonth] = useState(new Date()); const [selectingType, setSelectingType] = useState<"from" | "to">("from"); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); // 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장) const [tempValue, setTempValue] = useState(value || {}); @@ -43,6 +45,7 @@ export const ModernDatePicker: React.FC = ({ label, value if (isOpen) { setTempValue(value || {}); setSelectingType("from"); + setViewMode("calendar"); } }, [isOpen, value]); @@ -234,60 +237,150 @@ export const ModernDatePicker: React.FC = ({ label, value
- {/* 월 네비게이션 */} -
- -
{format(currentMonth, "yyyy년 MM월", { locale: ko })}
- -
- - {/* 요일 헤더 */} -
- {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( -
- {day} -
- ))} -
- - {/* 날짜 그리드 */} -
- {allDays.map((date, index) => { - if (!date) { - return
; - } - - const isCurrentMonth = isSameMonth(date, currentMonth); - const isSelected = isRangeStart(date) || isRangeEnd(date); - const isInRangeDate = isInRange(date); - const isTodayDate = isToday(date); - - return ( - - ); - })} -
+
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> + {/* 월 선택 뷰 */} +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> + {/* 월 네비게이션 */} +
+ + + +
+ + {/* 요일 헤더 */} +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ + {/* 날짜 그리드 */} +
+ {allDays.map((date, index) => { + if (!date) { + return
; + } + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = isRangeStart(date) || isRangeEnd(date); + const isInRangeDate = isInRange(date); + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} {/* 선택된 범위 표시 */} {(tempValue.from || tempValue.to) && ( 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/screen/table-options/GroupingPanel.tsx b/frontend/components/screen/table-options/GroupingPanel.tsx index 0495991d..867448d0 100644 --- a/frontend/components/screen/table-options/GroupingPanel.tsx +++ b/frontend/components/screen/table-options/GroupingPanel.tsx @@ -99,7 +99,7 @@ export const GroupingPanel: React.FC = ({ 전체 해제
-
+
{selectedColumns.map((colName, index) => { const col = table?.columns.find( (c) => c.columnName === colName diff --git a/frontend/components/screen/table-options/TableSettingsModal.tsx b/frontend/components/screen/table-options/TableSettingsModal.tsx index ef07e017..4f9325cb 100644 --- a/frontend/components/screen/table-options/TableSettingsModal.tsx +++ b/frontend/components/screen/table-options/TableSettingsModal.tsx @@ -557,7 +557,7 @@ export const TableSettingsModal: React.FC = ({ isOpen, onClose, onFilters 전체 해제
-
+
{selectedGroupColumns.map((colName, index) => { const col = table?.columns.find((c) => c.columnName === colName); if (!col) return null; diff --git a/frontend/components/screen/widgets/types/DateWidget.tsx b/frontend/components/screen/widgets/types/DateWidget.tsx index edb78df9..3b0f47e2 100644 --- a/frontend/components/screen/widgets/types/DateWidget.tsx +++ b/frontend/components/screen/widgets/types/DateWidget.tsx @@ -1,7 +1,22 @@ "use client"; -import React from "react"; -import { Input } from "@/components/ui/input"; +import React, { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { CalendarIcon, ChevronLeft, ChevronRight, X } from "lucide-react"; +import { + format, + addMonths, + subMonths, + startOfMonth, + endOfMonth, + eachDayOfInterval, + isSameMonth, + isSameDay, + isToday, +} from "date-fns"; +import { ko } from "date-fns/locale"; +import { cn } from "@/lib/utils"; import { WebTypeComponentProps } from "@/lib/registry/types"; import { WidgetComponent, DateTypeConfig } from "@/types/screen"; @@ -10,99 +25,341 @@ export const DateWidget: React.FC = ({ component, value, const { placeholder, required, style } = widget; const config = widget.webTypeConfig as DateTypeConfig | undefined; - // 사용자가 테두리를 설정했는지 확인 const hasCustomBorder = style && (style.borderWidth || style.borderStyle || style.borderColor || style.border); const borderClass = hasCustomBorder ? "!border-0" : ""; - // 날짜 포맷팅 함수 - const formatDateValue = (val: string) => { - if (!val) return ""; + const isDatetime = widget.widgetType === "datetime"; + const [isOpen, setIsOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [timeValue, setTimeValue] = useState("00:00"); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + const parseDate = (val: string | undefined): Date | undefined => { + if (!val) return undefined; try { const date = new Date(val); - if (isNaN(date.getTime())) return val; - - if (widget.widgetType === "datetime") { - return date.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm - } else { - return date.toISOString().slice(0, 10); // YYYY-MM-DD - } + if (isNaN(date.getTime())) return undefined; + return date; } catch { - return val; + return undefined; } }; - // 날짜 유효성 검증 - const validateDate = (dateStr: string): boolean => { - if (!dateStr) return true; - - const date = new Date(dateStr); - if (isNaN(date.getTime())) return false; - - // 최소/최대 날짜 검증 - if (config?.minDate) { - const minDate = new Date(config.minDate); - if (date < minDate) return false; - } - - if (config?.maxDate) { - const maxDate = new Date(config.maxDate); - if (date > maxDate) return false; - } - - return true; - }; - - // 입력값 처리 - const handleChange = (e: React.ChangeEvent) => { - const inputValue = e.target.value; - - if (validateDate(inputValue)) { - onChange?.(inputValue); - } - }; - - // 웹타입에 따른 input type 결정 - const getInputType = () => { - switch (widget.widgetType) { - case "datetime": - return "datetime-local"; - case "date": - default: - return "date"; - } - }; - - // 기본값 설정 (현재 날짜/시간) - const getDefaultValue = () => { + const getDefaultValue = (): string => { if (config?.defaultValue === "current") { const now = new Date(); - if (widget.widgetType === "datetime") { - return now.toISOString().slice(0, 16); - } else { - return now.toISOString().slice(0, 10); - } + if (isDatetime) return now.toISOString().slice(0, 16); + return now.toISOString().slice(0, 10); } return ""; }; const finalValue = value || getDefaultValue(); + const selectedDate = parseDate(finalValue); + + useEffect(() => { + if (isOpen) { + setViewMode("calendar"); + if (selectedDate) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + if (isDatetime) { + const hours = String(selectedDate.getHours()).padStart(2, "0"); + const minutes = String(selectedDate.getMinutes()).padStart(2, "0"); + setTimeValue(`${hours}:${minutes}`); + } + } else { + setCurrentMonth(new Date()); + setTimeValue("00:00"); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [isOpen]); + + const formatDisplayValue = (): string => { + if (!selectedDate) return ""; + if (isDatetime) return format(selectedDate, "yyyy-MM-dd HH:mm", { locale: ko }); + return format(selectedDate, "yyyy-MM-dd", { locale: ko }); + }; + + const handleDateClick = (date: Date) => { + let dateStr: string; + if (isDatetime) { + const [hours, minutes] = timeValue.split(":").map(Number); + const dt = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours || 0, minutes || 0); + dateStr = `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}T${timeValue}`; + } else { + dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + } + onChange?.(dateStr); + if (!isDatetime) { + setIsOpen(false); + } + }; + + const handleTimeChange = (newTime: string) => { + setTimeValue(newTime); + if (selectedDate) { + const dateStr = `${selectedDate.getFullYear()}-${String(selectedDate.getMonth() + 1).padStart(2, "0")}-${String(selectedDate.getDate()).padStart(2, "0")}T${newTime}`; + onChange?.(dateStr); + } + }; + + const handleClear = () => { + onChange?.(""); + setIsTyping(false); + setIsOpen(false); + }; + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!isOpen) setIsOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const y = parseInt(digitsOnly.slice(0, 4), 10); + const m = parseInt(digitsOnly.slice(4, 6), 10) - 1; + const d = parseInt(digitsOnly.slice(6, 8), 10); + const date = new Date(y, m, d); + if (date.getFullYear() === y && date.getMonth() === m && date.getDate() === d && y >= 1900 && y <= 2100) { + let dateStr: string; + if (isDatetime) { + dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}T${timeValue}`; + } else { + dateStr = `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`; + } + onChange?.(dateStr); + setCurrentMonth(new Date(y, m, 1)); + if (!isDatetime) setTimeout(() => { setIsTyping(false); setIsOpen(false); }, 400); + else setIsTyping(false); + } + } + }; + + const handleSetToday = () => { + const today = new Date(); + if (isDatetime) { + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}T${String(today.getHours()).padStart(2, "0")}:${String(today.getMinutes()).padStart(2, "0")}`; + onChange?.(dateStr); + } else { + const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`; + onChange?.(dateStr); + } + setIsOpen(false); + }; + + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: monthStart, end: monthEnd }); + + const startDate = new Date(monthStart); + const dayOfWeek = startDate.getDay(); + const paddingDays = dayOfWeek === 0 ? 6 : dayOfWeek - 1; + const allDays = [...Array(paddingDays).fill(null), ...days]; return ( - + { if (!v) { setIsOpen(false); setIsTyping(false); } }}> + +
{ if (!readonly) setIsOpen(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!readonly && !isOpen) setIsOpen(true); }} + onBlur={() => { if (!isOpen) setIsTyping(false); }} + className="h-full w-full truncate bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> + {selectedDate && !readonly && !isTyping && ( + { + e.stopPropagation(); + handleClear(); + }} + /> + )} +
+
+ e.preventDefault()}> +
+
+ + +
+ + {viewMode === "year" ? ( + <> +
+ +
+ {yearRangeStart} - {yearRangeStart + 11} +
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+ +
+ {["월", "화", "수", "목", "금", "토", "일"].map((day) => ( +
+ {day} +
+ ))} +
+ +
+ {allDays.map((date, index) => { + if (!date) return
; + + const isCurrentMonth = isSameMonth(date, currentMonth); + const isSelected = selectedDate ? isSameDay(date, selectedDate) : false; + const isTodayDate = isToday(date); + + return ( + + ); + })} +
+ + )} + + {/* datetime 타입: 시간 입력 */} + {isDatetime && viewMode === "calendar" && ( +
+ 시간: + handleTimeChange(e.target.value)} + className="border-input h-8 rounded-md border px-2 text-xs" + /> +
+ )} + +
+ + ); }; DateWidget.displayName = "DateWidget"; - - diff --git a/frontend/components/table-category/CategoryColumnList.tsx b/frontend/components/table-category/CategoryColumnList.tsx index 3be70840..872e7d57 100644 --- a/frontend/components/table-category/CategoryColumnList.tsx +++ b/frontend/components/table-category/CategoryColumnList.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect, useMemo } from "react"; import { apiClient } from "@/lib/api/client"; import { getCategoryValues } from "@/lib/api/tableCategoryValue"; -import { FolderTree, Loader2, Search, X } from "lucide-react"; +import { ChevronRight, FolderTree, Loader2, Search, X } from "lucide-react"; import { Input } from "@/components/ui/input"; interface CategoryColumn { @@ -30,6 +30,7 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, const [columns, setColumns] = useState([]); const [isLoading, setIsLoading] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); // 검색어로 필터링된 컬럼 목록 const filteredColumns = useMemo(() => { @@ -49,6 +50,44 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, }); }, [columns, searchQuery]); + // 테이블별로 그룹화된 컬럼 목록 + const groupedColumns = useMemo(() => { + const groups: { tableName: string; tableLabel: string; columns: CategoryColumn[] }[] = []; + const groupMap = new Map(); + + for (const col of filteredColumns) { + const key = col.tableName; + if (!groupMap.has(key)) { + groupMap.set(key, []); + } + groupMap.get(key)!.push(col); + } + + for (const [tblName, cols] of groupMap) { + groups.push({ + tableName: tblName, + tableLabel: cols[0]?.tableLabel || tblName, + columns: cols, + }); + } + + return groups; + }, [filteredColumns]); + + // 선택된 컬럼이 있는 그룹을 자동 펼침 + useEffect(() => { + if (!selectedColumn) return; + const tableName = selectedColumn.split(".")[0]; + if (tableName) { + setExpandedGroups((prev) => { + if (prev.has(tableName)) return prev; + const next = new Set(prev); + next.add(tableName); + return next; + }); + } + }, [selectedColumn]); + useEffect(() => { // 메뉴 종속 없이 항상 회사 기준으로 카테고리 컬럼 조회 loadCategoryColumnsByMenu(); @@ -72,9 +111,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("✅ 카테고리 컬럼 필터링 완료:", { @@ -278,35 +318,114 @@ export function CategoryColumnList({ tableName, selectedColumn, onColumnSelect, )}
-
+
{filteredColumns.length === 0 && searchQuery ? (
'{searchQuery}'에 대한 검색 결과가 없습니다
) : null} - {filteredColumns.map((column) => { - const uniqueKey = `${column.tableName}.${column.columnName}`; - const isSelected = selectedColumn === uniqueKey; // 테이블명.컬럼명으로 비교 - return ( -
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} - className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ - isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" - }`} - > -
- -
-

{column.columnLabel || column.columnName}

-

{column.tableLabel || column.tableName}

-
- - {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} - + {groupedColumns.map((group) => { + const isExpanded = expandedGroups.has(group.tableName); + const totalValues = group.columns.reduce((sum, c) => sum + (c.valueCount ?? 0), 0); + const hasSelectedInGroup = group.columns.some( + (c) => selectedColumn === `${c.tableName}.${c.columnName}`, + ); + + // 그룹이 1개뿐이면 드롭다운 없이 바로 표시 + if (groupedColumns.length <= 1) { + return ( +
+ {group.columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + const isSelected = selectedColumn === uniqueKey; + return ( +
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} + className={`cursor-pointer rounded-lg border px-4 py-2 transition-all ${ + isSelected ? "border-primary bg-primary/10 shadow-sm" : "hover:bg-muted/50" + }`} + > +
+ +
+

{column.columnLabel || column.columnName}

+

{column.tableLabel || column.tableName}

+
+ + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ); + })}
+ ); + } + + return ( +
+ {/* 드롭다운 헤더 */} + + + {/* 펼쳐진 컬럼 목록 */} + {isExpanded && ( +
+ {group.columns.map((column) => { + const uniqueKey = `${column.tableName}.${column.columnName}`; + const isSelected = selectedColumn === uniqueKey; + return ( +
onColumnSelect(uniqueKey, column.columnLabel || column.columnName, column.tableName)} + className={`cursor-pointer rounded-md px-3 py-1.5 transition-all ${ + isSelected ? "bg-primary/10 font-semibold text-primary" : "hover:bg-muted/50" + }`} + > +
+ + {column.columnLabel || column.columnName} + + {column.valueCount !== undefined ? `${column.valueCount}개` : "..."} + +
+
+ ); + })} +
+ )}
); })} diff --git a/frontend/components/ui/alert-dialog.tsx b/frontend/components/ui/alert-dialog.tsx index 3c7a9239..2da0647f 100644 --- a/frontend/components/ui/alert-dialog.tsx +++ b/frontend/components/ui/alert-dialog.tsx @@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( 2100) return null; + return date; +} + /** * 단일 날짜 선택 컴포넌트 */ const SingleDatePicker = forwardRef< - HTMLButtonElement, + HTMLDivElement, { value?: string; onChange?: (value: string) => void; @@ -83,80 +95,227 @@ const SingleDatePicker = forwardRef< ref, ) => { const [open, setOpen] = useState(false); + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + const inputRef = React.useRef(null); const date = useMemo(() => parseDate(value, dateFormat), [value, dateFormat]); - const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); - const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); - // 표시할 날짜 텍스트 계산 (ISO 형식이면 포맷팅, 아니면 그대로) const displayText = useMemo(() => { if (!value) return ""; - // Date 객체로 변환 후 포맷팅 - if (date && isValid(date)) { - return formatDate(date, dateFormat); - } + if (date && isValid(date)) return formatDate(date, dateFormat); return value; }, [value, date, dateFormat]); - const handleSelect = useCallback( - (selectedDate: Date | undefined) => { - if (selectedDate) { - onChange?.(formatDate(selectedDate, dateFormat)); - setOpen(false); + useEffect(() => { + if (open) { + setViewMode("calendar"); + if (date && isValid(date)) { + setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1)); + setYearRangeStart(Math.floor(date.getFullYear() / 12) * 12); + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); } - }, - [dateFormat, onChange], - ); + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [open]); + + const handleDateClick = useCallback((clickedDate: Date) => { + onChange?.(formatDate(clickedDate, dateFormat)); + setIsTyping(false); + setOpen(false); + }, [dateFormat, onChange]); const handleToday = useCallback(() => { onChange?.(formatDate(new Date(), dateFormat)); + setIsTyping(false); setOpen(false); }, [dateFormat, onChange]); const handleClear = useCallback(() => { onChange?.(""); + setIsTyping(false); setOpen(false); }, [onChange]); + const handleTriggerInput = useCallback((raw: string) => { + setIsTyping(true); + setTypingValue(raw); + if (!open) setOpen(true); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const parsed = parseManualDateInput(digitsOnly); + if (parsed) { + onChange?.(formatDate(parsed, dateFormat)); + setCurrentMonth(new Date(parsed.getFullYear(), parsed.getMonth(), 1)); + setTimeout(() => { setIsTyping(false); setOpen(false); }, 400); + } + } + }, [dateFormat, onChange, open]); + + const mStart = startOfMonth(currentMonth); + const mEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: mStart, end: mEnd }); + const dow = mStart.getDay(); + const padding = dow === 0 ? 6 : dow - 1; + const allDays = [...Array(padding).fill(null), ...days]; + return ( - + { if (!v) { setOpen(false); setIsTyping(false); } }}> - + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readonly && !open) setOpen(true); }} + onBlur={() => { if (!open) setIsTyping(false); }} + className={cn( + "h-full w-full bg-transparent text-sm outline-none", + "placeholder:text-muted-foreground disabled:cursor-not-allowed", + !displayText && !isTyping && "text-muted-foreground", + )} + /> +
- - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - return false; - }} - /> -
- {showToday && ( - + )} + +
+ + {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+
+ {["월", "화", "수", "목", "금", "토", "일"].map((d) => ( +
{d}
+ ))} +
+
+ {allDays.map((d, idx) => { + if (!d) return
; + const isCur = isSameMonth(d, currentMonth); + const isSel = date ? isSameDay(d, date) : false; + const isT = isTodayFn(d); + return ( + + ); + })} +
+ )} -
@@ -168,6 +327,149 @@ SingleDatePicker.displayName = "SingleDatePicker"; /** * 날짜 범위 선택 컴포넌트 */ +/** + * 범위 날짜 팝오버 내부 캘린더 (drill-down 지원) + */ +const RangeCalendarPopover: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; + selectedDate?: Date; + onSelect: (date: Date) => void; + label: string; + disabled?: boolean; + readonly?: boolean; + displayValue?: string; +}> = ({ open, onOpenChange, selectedDate, onSelect, label, disabled, readonly, displayValue }) => { + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [viewMode, setViewMode] = useState<"calendar" | "year" | "month">("calendar"); + const [yearRangeStart, setYearRangeStart] = useState(() => Math.floor(new Date().getFullYear() / 12) * 12); + const [isTyping, setIsTyping] = useState(false); + const [typingValue, setTypingValue] = useState(""); + + useEffect(() => { + if (open) { + setViewMode("calendar"); + if (selectedDate && isValid(selectedDate)) { + setCurrentMonth(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1)); + setYearRangeStart(Math.floor(selectedDate.getFullYear() / 12) * 12); + } else { + setCurrentMonth(new Date()); + setYearRangeStart(Math.floor(new Date().getFullYear() / 12) * 12); + } + } else { + setIsTyping(false); + setTypingValue(""); + } + }, [open]); + + const handleTriggerInput = (raw: string) => { + setIsTyping(true); + setTypingValue(raw); + const digitsOnly = raw.replace(/\D/g, ""); + if (digitsOnly.length === 8) { + const parsed = parseManualDateInput(digitsOnly); + if (parsed) { + setIsTyping(false); + onSelect(parsed); + } + } + }; + + const mStart = startOfMonth(currentMonth); + const mEnd = endOfMonth(currentMonth); + const days = eachDayOfInterval({ start: mStart, end: mEnd }); + const dow = mStart.getDay(); + const padding = dow === 0 ? 6 : dow - 1; + const allDays = [...Array(padding).fill(null), ...days]; + + return ( + { if (!v) { setIsTyping(false); } onOpenChange(v); }}> + +
{ if (!disabled && !readonly) onOpenChange(true); }} + > + + handleTriggerInput(e.target.value)} + onClick={(e) => e.stopPropagation()} + onFocus={() => { if (!disabled && !readonly && !open) onOpenChange(true); }} + onBlur={() => { if (!open) setIsTyping(false); }} + className="h-full w-full bg-transparent text-sm font-normal outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + /> +
+
+ e.preventDefault()}> +
+ {viewMode === "year" ? ( + <> +
+ +
{yearRangeStart} - {yearRangeStart + 11}
+ +
+
+ {Array.from({ length: 12 }, (_, i) => yearRangeStart + i).map((year) => ( + + ))} +
+ + ) : viewMode === "month" ? ( + <> +
+ + + +
+
+ {Array.from({ length: 12 }, (_, i) => i).map((month) => ( + + ))} +
+ + ) : ( + <> +
+ + + +
+
+ {["월", "화", "수", "목", "금", "토", "일"].map((d) => ( +
{d}
+ ))} +
+
+ {allDays.map((d, idx) => { + if (!d) return
; + const isCur = isSameMonth(d, currentMonth); + const isSel = selectedDate ? isSameDay(d, selectedDate) : false; + const isT = isTodayFn(d); + return ( + + ); + })} +
+ + )} +
+ + + ); +}; + const RangeDatePicker = forwardRef< HTMLDivElement, { @@ -186,102 +488,38 @@ const RangeDatePicker = forwardRef< const startDate = useMemo(() => parseDate(value[0], dateFormat), [value, dateFormat]); const endDate = useMemo(() => parseDate(value[1], dateFormat), [value, dateFormat]); - const minDateObj = useMemo(() => parseDate(minDate, dateFormat), [minDate, dateFormat]); - const maxDateObj = useMemo(() => parseDate(maxDate, dateFormat), [maxDate, dateFormat]); const handleStartSelect = useCallback( - (date: Date | undefined) => { - if (date) { - const newStart = formatDate(date, dateFormat); - // 시작일이 종료일보다 크면 종료일도 같이 변경 - if (endDate && date > endDate) { - onChange?.([newStart, newStart]); - } else { - onChange?.([newStart, value[1]]); - } - setOpenStart(false); + (date: Date) => { + const newStart = formatDate(date, dateFormat); + if (endDate && date > endDate) { + onChange?.([newStart, newStart]); + } else { + onChange?.([newStart, value[1]]); } + setOpenStart(false); }, [value, dateFormat, endDate, onChange], ); const handleEndSelect = useCallback( - (date: Date | undefined) => { - if (date) { - const newEnd = formatDate(date, dateFormat); - // 종료일이 시작일보다 작으면 시작일도 같이 변경 - if (startDate && date < startDate) { - onChange?.([newEnd, newEnd]); - } else { - onChange?.([value[0], newEnd]); - } - setOpenEnd(false); + (date: Date) => { + const newEnd = formatDate(date, dateFormat); + if (startDate && date < startDate) { + onChange?.([newEnd, newEnd]); + } else { + onChange?.([value[0], newEnd]); } + setOpenEnd(false); }, [value, dateFormat, startDate, onChange], ); return (
- {/* 시작 날짜 */} - - - - - - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - return false; - }} - /> - - - + ~ - - {/* 종료 날짜 */} - - - - - - { - if (minDateObj && date < minDateObj) return true; - if (maxDateObj && date > maxDateObj) return true; - // 시작일보다 이전 날짜는 선택 불가 - if (startDate && date < startDate) return true; - return false; - }} - /> - - +
); }); 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..f0021eeb 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -23,7 +23,7 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -import { V2SelectProps, SelectOption } from "@/types/v2-components"; +import { V2SelectProps, SelectOption, V2SelectFilter } from "@/types/v2-components"; import { Check, ChevronsUpDown, X, ArrowLeftRight } from "lucide-react"; import { apiClient } from "@/lib/api/client"; import V2FormContext from "./V2FormContext"; @@ -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); @@ -651,6 +655,7 @@ export const V2Select = forwardRef( const labelColumn = config.labelColumn; const apiEndpoint = config.apiEndpoint; const staticOptions = config.options; + const configFilters = config.filters; // 계층 코드 연쇄 선택 관련 const hierarchical = config.hierarchical; @@ -658,6 +663,54 @@ export const V2Select = forwardRef( // FormContext에서 부모 필드 값 가져오기 (Context가 없으면 null) const formContext = useContext(V2FormContext); + + /** + * 필터 조건을 API 전달용 JSON으로 변환 + * field/user 타입은 런타임 값으로 치환 + */ + const resolvedFiltersJson = useMemo(() => { + if (!configFilters || configFilters.length === 0) return undefined; + + const resolved: Array<{ column: string; operator: string; value: unknown }> = []; + + for (const f of configFilters) { + const vt = f.valueType || "static"; + + // isNull/isNotNull은 값 불필요 + if (f.operator === "isNull" || f.operator === "isNotNull") { + resolved.push({ column: f.column, operator: f.operator, value: null }); + continue; + } + + let resolvedValue: unknown = f.value; + + if (vt === "field" && f.fieldRef) { + // 다른 폼 필드 참조 + if (formContext) { + resolvedValue = formContext.getValue(f.fieldRef); + } else { + const fd = (props as any).formData; + resolvedValue = fd?.[f.fieldRef]; + } + // 참조 필드 값이 비어있으면 이 필터 건너뜀 + if (resolvedValue === undefined || resolvedValue === null || resolvedValue === "") continue; + } else if (vt === "user" && f.userField) { + // 로그인 사용자 정보 참조 (props에서 가져옴) + const userMap: Record = { + companyCode: (props as any).companyCode, + userId: (props as any).userId, + deptCode: (props as any).deptCode, + userName: (props as any).userName, + }; + resolvedValue = userMap[f.userField]; + if (!resolvedValue) continue; + } + + resolved.push({ column: f.column, operator: f.operator, value: resolvedValue }); + } + + return resolved.length > 0 ? JSON.stringify(resolved) : undefined; + }, [configFilters, formContext, props]); // 부모 필드의 값 계산 const parentValue = useMemo(() => { @@ -680,6 +733,13 @@ export const V2Select = forwardRef( } }, [parentValue, hierarchical, source]); + // 필터 조건이 변경되면 옵션 다시 로드 + useEffect(() => { + if (resolvedFiltersJson !== undefined) { + setOptionsLoaded(false); + } + }, [resolvedFiltersJson]); + useEffect(() => { // 이미 로드된 경우 스킵 (static 제외, 계층 구조 제외) if (optionsLoaded && source !== "static") { @@ -727,11 +787,13 @@ export const V2Select = forwardRef( } } else if (source === "db" && table) { // DB 테이블에서 로드 + const dbParams: Record = { + value: valueColumn || "id", + label: labelColumn || "name", + }; + if (resolvedFiltersJson) dbParams.filters = resolvedFiltersJson; const response = await apiClient.get(`/entity/${table}/options`, { - params: { - value: valueColumn || "id", - label: labelColumn || "name", - }, + params: dbParams, }); const data = response.data; if (data.success && data.data) { @@ -741,11 +803,10 @@ export const V2Select = forwardRef( // 엔티티(참조 테이블)에서 로드 const valueCol = entityValueColumn || "id"; const labelCol = entityLabelColumn || "name"; + const entityParams: Record = { value: valueCol, label: labelCol }; + if (resolvedFiltersJson) entityParams.filters = resolvedFiltersJson; const response = await apiClient.get(`/entity/${entityTable}/options`, { - params: { - value: valueCol, - label: labelCol, - }, + params: entityParams, }); const data = response.data; if (data.success && data.data) { @@ -789,11 +850,13 @@ export const V2Select = forwardRef( } } else if (source === "select" || source === "distinct") { // 해당 테이블의 해당 컬럼에서 DISTINCT 값 조회 - // tableName, columnName은 props에서 가져옴 - // 🆕 columnName이 컴포넌트 ID 형식(comp_xxx)이면 유효하지 않으므로 건너뜀 const isValidColumnName = columnName && !columnName.startsWith("comp_"); if (tableName && isValidColumnName) { - const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`); + const distinctParams: Record = {}; + if (resolvedFiltersJson) distinctParams.filters = resolvedFiltersJson; + const response = await apiClient.get(`/entity/${tableName}/distinct/${columnName}`, { + params: distinctParams, + }); const data = response.data; if (data.success && data.data) { fetchedOptions = data.data.map((item: { value: string; label: string }) => ({ @@ -817,7 +880,71 @@ export const V2Select = forwardRef( }; loadOptions(); - }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue]); + }, [source, entityTable, entityValueColumn, entityLabelColumn, codeGroup, table, valueColumn, labelColumn, apiEndpoint, staticOptions, optionsLoaded, hierarchical, parentValue, resolvedFiltersJson]); + + // 같은 폼에서 참조 테이블(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 = () => { @@ -876,12 +1003,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 +1046,7 @@ export const V2Select = forwardRef( @@ -930,7 +1057,7 @@ export const V2Select = forwardRef( ( onChange?.(v)} + onChange={(v) => handleChangeWithAutoFill(v)} disabled={isDisabled} /> ); @@ -953,7 +1080,7 @@ export const V2Select = forwardRef( @@ -964,7 +1091,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="편집 가능" - /> + )} +
+ +

+ {targetTable} 테이블에서 옵션을 불러올 때 적용할 조건 +

+ + {loadingColumns && ( +
+ + 컬럼 목록 로딩 중... +
+ )} + + {filters.length === 0 && ( +

+ 필터 조건이 없습니다 +

+ )} + +
+ {filters.map((filter, index) => ( +
+ {/* 행 1: 컬럼 + 연산자 + 삭제 */} +
+ {/* 컬럼 선택 */} + + + {/* 연산자 선택 */} + + + {/* 삭제 버튼 */} + +
+ + {/* 행 2: 값 유형 + 값 입력 (isNull/isNotNull 제외) */} + {needsValue(filter.operator) && ( +
+ {/* 값 유형 */} + + + {/* 값 입력 영역 */} + {(filter.valueType || "static") === "static" && ( + updateFilter(index, { value: e.target.value })} + placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"} + className="h-7 flex-1 text-[11px]" + /> + )} + + {filter.valueType === "field" && ( + updateFilter(index, { fieldRef: e.target.value })} + placeholder="참조할 필드명 (columnName)" + className="h-7 flex-1 text-[11px]" + /> + )} + + {filter.valueType === "user" && ( + + )} +
+ )} +
+ ))} +
+
+ ); +}; + interface V2SelectConfigPanelProps { config: Record; onChange: (config: Record) => void; - /** 컬럼의 inputType (entity 타입인 경우에만 엔티티 소스 표시) */ + /** 컬럼의 inputType (entity/category 타입 확인용) */ inputType?: string; + /** 현재 테이블명 (카테고리 값 조회용) */ + tableName?: string; + /** 현재 컬럼명 (카테고리 값 조회용) */ + columnName?: string; } -export const V2SelectConfigPanel: React.FC = ({ config, onChange, inputType }) => { - // 엔티티 타입인지 확인 +export const V2SelectConfigPanel: React.FC = ({ + config, + onChange, + inputType, + tableName, + columnName, +}) => { const isEntityType = inputType === "entity"; - // 엔티티 테이블의 컬럼 목록 + const isCategoryType = inputType === "category"; + const [entityColumns, setEntityColumns] = useState([]); const [loadingColumns, setLoadingColumns] = useState(false); - // 설정 업데이트 핸들러 + // 카테고리 값 목록 + const [categoryValues, setCategoryValues] = useState([]); + const [loadingCategoryValues, setLoadingCategoryValues] = useState(false); + + // 필터용 컬럼 목록 (옵션 데이터 소스 테이블의 컬럼) + const [filterColumns, setFilterColumns] = useState([]); + const [loadingFilterColumns, setLoadingFilterColumns] = useState(false); + const updateConfig = (field: string, value: any) => { onChange({ ...config, [field]: value }); }; + // 필터 대상 테이블 결정 + const filterTargetTable = useMemo(() => { + const src = config.source || "static"; + if (src === "entity") return config.entityTable; + if (src === "db") return config.table; + if (src === "distinct" || src === "select") return tableName; + return null; + }, [config.source, config.entityTable, config.table, tableName]); + + // 필터 대상 테이블의 컬럼 로드 + useEffect(() => { + if (!filterTargetTable) { + setFilterColumns([]); + return; + } + + const loadFilterColumns = async () => { + setLoadingFilterColumns(true); + try { + const response = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`); + const data = response.data.data || response.data; + const columns = data.columns || data || []; + setFilterColumns( + columns.map((col: any) => ({ + columnName: col.columnName || col.column_name || col.name, + columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name, + })) + ); + } catch { + setFilterColumns([]); + } finally { + setLoadingFilterColumns(false); + } + }; + + loadFilterColumns(); + }, [filterTargetTable]); + + // 카테고리 타입이면 source를 자동으로 category로 설정 + useEffect(() => { + if (isCategoryType && config.source !== "category") { + onChange({ ...config, source: "category" }); + } + }, [isCategoryType]); + + // 카테고리 값 로드 + const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => { + if (!catTable || !catColumn) { + setCategoryValues([]); + return; + } + + setLoadingCategoryValues(true); + try { + const response = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`); + const data = response.data; + if (data.success && data.data) { + const flattenTree = (items: any[], depth: number = 0): CategoryValueOption[] => { + const result: CategoryValueOption[] = []; + for (const item of items) { + result.push({ + valueCode: item.valueCode, + valueLabel: depth > 0 ? `${" ".repeat(depth)}${item.valueLabel}` : item.valueLabel, + }); + if (item.children && item.children.length > 0) { + result.push(...flattenTree(item.children, depth + 1)); + } + } + return result; + }; + setCategoryValues(flattenTree(data.data)); + } + } catch (error) { + console.error("카테고리 값 조회 실패:", error); + setCategoryValues([]); + } finally { + setLoadingCategoryValues(false); + } + }, []); + + // 카테고리 소스일 때 값 로드 + useEffect(() => { + if (config.source === "category") { + const catTable = config.categoryTable || tableName; + const catColumn = config.categoryColumn || columnName; + if (catTable && catColumn) { + loadCategoryValues(catTable, catColumn); + } + } + }, [config.source, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]); + // 엔티티 테이블 변경 시 컬럼 목록 조회 - const loadEntityColumns = useCallback(async (tableName: string) => { - if (!tableName) { + const loadEntityColumns = useCallback(async (tblName: string) => { + if (!tblName) { setEntityColumns([]); return; } setLoadingColumns(true); try { - const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=500`); + const response = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`); const data = response.data.data || response.data; const columns = data.columns || data || []; const columnOptions: ColumnOption[] = columns.map((col: any) => { const name = col.columnName || col.column_name || col.name; - // displayName 우선 사용 const label = col.displayName || col.display_name || col.columnLabel || col.column_label || name; return { @@ -72,7 +417,6 @@ export const V2SelectConfigPanel: React.FC = ({ config } }, []); - // 엔티티 테이블이 변경되면 컬럼 목록 로드 useEffect(() => { if (config.source === "entity" && config.entityTable) { loadEntityColumns(config.entityTable); @@ -98,6 +442,9 @@ export const V2SelectConfigPanel: React.FC = ({ config updateConfig("options", newOptions); }; + // 현재 source 결정 (카테고리 타입이면 강제 category) + const effectiveSource = isCategoryType ? "category" : config.source || "static"; + return (
{/* 선택 모드 */} @@ -125,21 +472,102 @@ export const V2SelectConfigPanel: React.FC = ({ config {/* 데이터 소스 */}
- + {isCategoryType ? ( +
+ 카테고리 (자동 설정) +
+ ) : ( + + )}
+ {/* 카테고리 설정 */} + {effectiveSource === "category" && ( +
+
+ +
+
+
+

테이블

+

{config.categoryTable || tableName || "-"}

+
+
+

컬럼

+

{config.categoryColumn || columnName || "-"}

+
+
+
+
+ + {/* 카테고리 값 로딩 중 */} + {loadingCategoryValues && ( +
+ + 카테고리 값 로딩 중... +
+ )} + + {/* 카테고리 값 목록 표시 */} + {categoryValues.length > 0 && ( +
+ +
+ {categoryValues.map((cv) => ( +
+ {cv.valueCode} + {cv.valueLabel} +
+ ))} +
+
+ )} + + {/* 기본값 설정 */} + {categoryValues.length > 0 && ( +
+ + +

화면 로드 시 자동 선택될 카테고리 값

+
+ )} + + {/* 카테고리 값 없음 안내 */} + {!loadingCategoryValues && categoryValues.length === 0 && ( +

+ 카테고리 값이 없습니다. 테이블 카테고리 관리에서 값을 추가해주세요. +

+ )} +
+ )} + {/* 정적 옵션 관리 */} - {(config.source || "static") === "static" && ( + {effectiveSource === "static" && (
@@ -199,8 +627,8 @@ export const V2SelectConfigPanel: React.FC = ({ config
)} - {/* 공통 코드 설정 - 테이블 타입 관리에서 설정되므로 정보만 표시 */} - {config.source === "code" && ( + {/* 공통 코드 설정 */} + {effectiveSource === "code" && (
{config.codeGroup ? ( @@ -212,7 +640,7 @@ export const V2SelectConfigPanel: React.FC = ({ config )} {/* 엔티티(참조 테이블) 설정 */} - {config.source === "entity" && ( + {effectiveSource === "entity" && (
@@ -228,7 +656,6 @@ export const V2SelectConfigPanel: React.FC = ({ config

- {/* 컬럼 로딩 중 표시 */} {loadingColumns && (
@@ -236,7 +663,6 @@ export const V2SelectConfigPanel: React.FC = ({ config
)} - {/* 컬럼 선택 - 테이블이 설정되어 있고 컬럼 목록이 있는 경우 Select로 표시 */}
@@ -296,12 +722,20 @@ export const V2SelectConfigPanel: React.FC = ({ config
- {/* 컬럼이 없는 경우 안내 */} {config.entityTable && !loadingColumns && entityColumns.length === 0 && (

테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요.

)} + + {config.entityTable && entityColumns.length > 0 && ( +
+

+ 같은 폼에 참조 테이블({config.entityTable})의 컬럼이 배치되어 있으면, 엔티티 선택 시 해당 필드가 자동으로 + 채워집니다. +

+
+ )}
)} @@ -359,6 +793,20 @@ export const V2SelectConfigPanel: React.FC = ({ config />
)} + + {/* 데이터 필터 조건 - static 소스 외 모든 소스에서 사용 */} + {effectiveSource !== "static" && filterTargetTable && ( + <> + + updateConfig("filters", filters)} + /> + + )}
); }; diff --git a/frontend/contexts/ScreenContext.tsx b/frontend/contexts/ScreenContext.tsx index 5e9bb2f1..8a57f9cb 100644 --- a/frontend/contexts/ScreenContext.tsx +++ b/frontend/contexts/ScreenContext.tsx @@ -6,17 +6,28 @@ "use client"; import React, { createContext, useContext, useCallback, useRef, useState } from "react"; -import type { DataProvidable, DataReceivable } from "@/types/data-transfer"; +import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer"; import { logger } from "@/lib/utils/logger"; import type { SplitPanelPosition } from "@/contexts/SplitPanelContext"; +/** + * 대기 중인 데이터 전달 항목 + * 타겟 컴포넌트가 아직 마운트되지 않은 경우 (조건부 레이어 등) 버퍼에 저장 + */ +export interface PendingTransfer { + targetComponentId: string; + data: any[]; + config: DataReceiverConfig; + timestamp: number; + targetLayerId?: string; +} + interface ScreenContextValue { screenId?: number; tableName?: string; - menuObjid?: number; // 메뉴 OBJID (카테고리 값 조회 시 필요) - splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right) + menuObjid?: number; + splitPanelPosition?: SplitPanelPosition; - // 🆕 폼 데이터 (RepeaterFieldGroup 등 컴포넌트 데이터 저장) formData: Record; updateFormData: (fieldName: string, value: any) => void; @@ -33,6 +44,11 @@ interface ScreenContextValue { // 모든 컴포넌트 조회 getAllDataProviders: () => Map; getAllDataReceivers: () => Map; + + // 대기 중인 데이터 전달 (레이어 내부 컴포넌트 미마운트 대응) + addPendingTransfer: (transfer: PendingTransfer) => void; + getPendingTransfer: (componentId: string) => PendingTransfer | undefined; + clearPendingTransfer: (componentId: string) => void; } const ScreenContext = createContext(null); @@ -57,11 +73,10 @@ export function ScreenContextProvider({ }: ScreenContextProviderProps) { const dataProvidersRef = useRef>(new Map()); const dataReceiversRef = useRef>(new Map()); + const pendingTransfersRef = useRef>(new Map()); - // 🆕 폼 데이터 상태 (RepeaterFieldGroup 등 컴포넌트 데이터 저장) const [formData, setFormData] = useState>({}); - // 🆕 폼 데이터 업데이트 함수 const updateFormData = useCallback((fieldName: string, value: any) => { setFormData((prev) => { const updated = { ...prev, [fieldName]: value }; @@ -87,6 +102,25 @@ export function ScreenContextProvider({ const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => { dataReceiversRef.current.set(componentId, receiver); logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType }); + + // 대기 중인 데이터 전달이 있으면 즉시 수신 처리 + const pending = pendingTransfersRef.current.get(componentId); + if (pending) { + logger.info("대기 중인 데이터 전달 자동 수신", { + componentId, + dataCount: pending.data.length, + waitedMs: Date.now() - pending.timestamp, + }); + receiver + .receiveData(pending.data, pending.config) + .then(() => { + pendingTransfersRef.current.delete(componentId); + logger.info("대기 데이터 전달 완료", { componentId }); + }) + .catch((err) => { + logger.error("대기 데이터 전달 실패", { componentId, error: err }); + }); + } }, []); const unregisterDataReceiver = useCallback((componentId: string) => { @@ -110,7 +144,24 @@ export function ScreenContextProvider({ return new Map(dataReceiversRef.current); }, []); - // 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지) + const addPendingTransfer = useCallback((transfer: PendingTransfer) => { + pendingTransfersRef.current.set(transfer.targetComponentId, transfer); + logger.info("데이터 전달 대기열 추가", { + targetComponentId: transfer.targetComponentId, + dataCount: transfer.data.length, + targetLayerId: transfer.targetLayerId, + }); + }, []); + + const getPendingTransfer = useCallback((componentId: string) => { + return pendingTransfersRef.current.get(componentId); + }, []); + + const clearPendingTransfer = useCallback((componentId: string) => { + pendingTransfersRef.current.delete(componentId); + logger.debug("대기 데이터 전달 클리어", { componentId }); + }, []); + const value = React.useMemo( () => ({ screenId, @@ -127,6 +178,9 @@ export function ScreenContextProvider({ getDataReceiver, getAllDataProviders, getAllDataReceivers, + addPendingTransfer, + getPendingTransfer, + clearPendingTransfer, }), [ screenId, @@ -143,6 +197,9 @@ export function ScreenContextProvider({ getDataReceiver, getAllDataProviders, getAllDataReceivers, + addPendingTransfer, + getPendingTransfer, + clearPendingTransfer, ], ); diff --git a/frontend/hooks/pop/executePopAction.ts b/frontend/hooks/pop/executePopAction.ts new file mode 100644 index 00000000..5800125f --- /dev/null +++ b/frontend/hooks/pop/executePopAction.ts @@ -0,0 +1,199 @@ +/** + * executePopAction - POP 액션 실행 순수 함수 + * + * pop-button, pop-string-list 등 여러 컴포넌트에서 재사용 가능한 + * 액션 실행 코어 로직. React 훅에 의존하지 않음. + * + * 사용처: + * - usePopAction 훅 (pop-button용 래퍼) + * - pop-string-list 카드 버튼 (직접 호출) + * - 향후 pop-table 행 액션 등 + */ + +import type { ButtonMainAction } from "@/lib/registry/pop-components/pop-button"; +import { apiClient } from "@/lib/api/client"; +import { dataApi } from "@/lib/api/data"; + +// ======================================== +// 타입 정의 +// ======================================== + +/** 액션 실행 결과 */ +export interface ActionResult { + success: boolean; + data?: unknown; + error?: string; +} + +/** 이벤트 발행 함수 시그니처 (usePopEvent의 publish와 동일) */ +type PublishFn = (eventName: string, payload?: unknown) => void; + +/** executePopAction 옵션 */ +interface ExecuteOptions { + /** 필드 매핑 (소스 컬럼명 → 타겟 컬럼명) */ + fieldMapping?: Record; + /** 화면 ID (이벤트 발행 시 사용) */ + screenId?: string; + /** 이벤트 발행 함수 (순수 함수이므로 외부에서 주입) */ + publish?: PublishFn; +} + +// ======================================== +// 내부 헬퍼 +// ======================================== + +/** + * 필드 매핑 적용 + * 소스 데이터의 컬럼명을 타겟 테이블 컬럼명으로 변환 + */ +function applyFieldMapping( + rowData: Record, + mapping?: Record +): Record { + if (!mapping || Object.keys(mapping).length === 0) { + return { ...rowData }; + } + + const result: Record = {}; + for (const [sourceKey, value] of Object.entries(rowData)) { + // 매핑이 있으면 타겟 키로 변환, 없으면 원본 키 유지 + const targetKey = mapping[sourceKey] || sourceKey; + result[targetKey] = value; + } + return result; +} + +/** + * rowData에서 PK 추출 + * id > pk 순서로 시도, 없으면 rowData 전체를 복합키로 사용 + */ +function extractPrimaryKey( + rowData: Record +): string | number | Record { + if (rowData.id != null) return rowData.id as string | number; + if (rowData.pk != null) return rowData.pk as string | number; + // 복합키: rowData 전체를 Record로 전달 (dataApi.deleteRecord가 object 지원) + return rowData as Record; +} + +// ======================================== +// 메인 함수 +// ======================================== + +/** + * POP 액션 실행 (순수 함수) + * + * @param action - 버튼 메인 액션 설정 + * @param rowData - 대상 행 데이터 (리스트 컴포넌트에서 전달) + * @param options - 필드 매핑, screenId, publish 함수 + * @returns 실행 결과 + */ +export async function executePopAction( + action: ButtonMainAction, + rowData?: Record, + options?: ExecuteOptions +): Promise { + const { fieldMapping, publish } = options || {}; + + try { + switch (action.type) { + // ── 저장 ── + case "save": { + if (!action.targetTable) { + return { success: false, error: "저장 대상 테이블이 설정되지 않았습니다." }; + } + const data = rowData + ? applyFieldMapping(rowData, fieldMapping) + : {}; + const result = await dataApi.createRecord(action.targetTable, data); + return { success: !!result?.success, data: result?.data, error: result?.message }; + } + + // ── 삭제 ── + case "delete": { + if (!action.targetTable) { + return { success: false, error: "삭제 대상 테이블이 설정되지 않았습니다." }; + } + if (!rowData) { + return { success: false, error: "삭제할 데이터가 없습니다." }; + } + const mappedData = applyFieldMapping(rowData, fieldMapping); + const pk = extractPrimaryKey(mappedData); + const result = await dataApi.deleteRecord(action.targetTable, pk); + return { success: !!result?.success, error: result?.message }; + } + + // ── API 호출 ── + case "api": { + if (!action.apiEndpoint) { + return { success: false, error: "API 엔드포인트가 설정되지 않았습니다." }; + } + const body = rowData + ? applyFieldMapping(rowData, fieldMapping) + : undefined; + const method = (action.apiMethod || "POST").toUpperCase(); + + let response; + switch (method) { + case "GET": + response = await apiClient.get(action.apiEndpoint, { params: body }); + break; + case "POST": + response = await apiClient.post(action.apiEndpoint, body); + break; + case "PUT": + response = await apiClient.put(action.apiEndpoint, body); + break; + case "DELETE": + response = await apiClient.delete(action.apiEndpoint, { data: body }); + break; + default: + response = await apiClient.post(action.apiEndpoint, body); + } + + const resData = response?.data; + return { + success: resData?.success !== false, + data: resData?.data ?? resData, + }; + } + + // ── 모달 열기 ── + case "modal": { + if (!publish) { + return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." }; + } + publish("__pop_modal_open__", { + modalId: action.modalScreenId, + title: action.modalTitle, + mode: action.modalMode, + items: action.modalItems, + rowData, + }); + return { success: true }; + } + + // ── 이벤트 발행 ── + case "event": { + if (!publish) { + return { success: false, error: "이벤트 발행 함수가 제공되지 않았습니다." }; + } + if (!action.eventName) { + return { success: false, error: "이벤트 이름이 설정되지 않았습니다." }; + } + publish(action.eventName, { + ...(action.eventPayload || {}), + row: rowData, + }); + return { success: true }; + } + + default: + return { success: false, error: `알 수 없는 액션 타입: ${action.type}` }; + } + } catch (err: unknown) { + const message = + err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다."; + return { success: false, error: message }; + } +} diff --git a/frontend/hooks/pop/index.ts b/frontend/hooks/pop/index.ts new file mode 100644 index 00000000..3a6c792b --- /dev/null +++ b/frontend/hooks/pop/index.ts @@ -0,0 +1,26 @@ +/** + * POP 공통 훅 배럴 파일 + * + * 사용법: import { usePopEvent, useDataSource } from "@/hooks/pop"; + */ + +// 이벤트 통신 훅 +export { usePopEvent, cleanupScreen } from "./usePopEvent"; + +// 데이터 CRUD 훅 +export { useDataSource } from "./useDataSource"; +export type { MutationResult, DataSourceResult } from "./useDataSource"; + +// 액션 실행 순수 함수 +export { executePopAction } from "./executePopAction"; +export type { ActionResult } from "./executePopAction"; + +// 액션 실행 React 훅 +export { usePopAction } from "./usePopAction"; +export type { PendingConfirmState } from "./usePopAction"; + +// 연결 해석기 +export { useConnectionResolver } from "./useConnectionResolver"; + +// SQL 빌더 유틸 (고급 사용 시) +export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder"; diff --git a/frontend/hooks/pop/popSqlBuilder.ts b/frontend/hooks/pop/popSqlBuilder.ts new file mode 100644 index 00000000..bd9fd599 --- /dev/null +++ b/frontend/hooks/pop/popSqlBuilder.ts @@ -0,0 +1,195 @@ +/** + * POP 공통 SQL 빌더 + * + * DataSourceConfig를 SQL 문자열로 변환하는 순수 유틸리티. + * 원본: pop-dashboard/utils/dataFetcher.ts에서 추출 (로직 동일). + * + * 대시보드(dataFetcher.ts)는 기존 코드를 그대로 유지하고, + * 새 컴포넌트(useDataSource 등)는 이 파일을 사용한다. + * 향후 대시보드 교체 시 dataFetcher.ts가 이 파일을 import하도록 변경 예정. + * + * 보안: + * - escapeSQL: SQL 인젝션 방지 (문자열 이스케이프) + * - sanitizeIdentifier: 테이블명/컬럼명에서 위험 문자 제거 + */ + +import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types"; + +// ===== SQL 값 이스케이프 ===== + +/** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */ +function escapeSQL(value: unknown): string { + if (value === null || value === undefined) return "NULL"; + if (typeof value === "number") return String(value); + if (typeof value === "boolean") return value ? "TRUE" : "FALSE"; + // 문자열: 작은따옴표 이스케이프 + const str = String(value).replace(/'/g, "''"); + return `'${str}'`; +} + +// ===== 식별자 검증 (테이블명, 컬럼명) ===== + +/** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */ +function sanitizeIdentifier(name: string): string { + // 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용 + return name.replace(/[^a-zA-Z0-9_.]/g, ""); +} + +// ===== 설정 완료 여부 검증 ===== + +/** + * DataSourceConfig의 필수값이 모두 채워졌는지 검증 + * 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는 + * SQL을 생성하지 않도록 사전 차단 + * + * @returns null이면 유효, 문자열이면 미완료 사유 + */ +export function validateDataSourceConfig(config: DataSourceConfig): string | null { + // 테이블명 필수 + if (!config.tableName || !config.tableName.trim()) { + return "테이블이 선택되지 않았습니다"; + } + + // 집계 함수가 설정되었으면 대상 컬럼도 필수 + // (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능) + if (config.aggregation) { + const aggType = config.aggregation.type?.toLowerCase(); + const aggCol = config.aggregation.column?.trim(); + if (aggType !== "count" && !aggCol) { + return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`; + } + } + + // 조인이 있으면 조인 조건 필수 + if (config.joins?.length) { + for (const join of config.joins) { + if (!join.targetTable?.trim()) { + return "조인 대상 테이블이 선택되지 않았습니다"; + } + if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) { + return "조인 조건 컬럼이 설정되지 않았습니다"; + } + } + } + + return null; +} + +// ===== 필터 조건 SQL 생성 ===== + +/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */ +function buildWhereClause(filters: DataSourceFilter[]): string { + // 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어) + const validFilters = filters.filter((f) => f.column?.trim()); + if (!validFilters.length) return ""; + + const conditions = validFilters.map((f) => { + const col = sanitizeIdentifier(f.column); + + switch (f.operator) { + case "between": { + const arr = Array.isArray(f.value) ? f.value : [f.value, f.value]; + return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`; + } + case "in": { + const arr = Array.isArray(f.value) ? f.value : [f.value]; + const vals = arr.map(escapeSQL).join(", "); + return `${col} IN (${vals})`; + } + case "like": + return `${col} LIKE ${escapeSQL(f.value)}`; + default: + return `${col} ${f.operator} ${escapeSQL(f.value)}`; + } + }); + + return `WHERE ${conditions.join(" AND ")}`; +} + +// ===== 집계 SQL 빌더 ===== + +/** + * DataSourceConfig를 SELECT SQL로 변환 + * + * @param config - 데이터 소스 설정 + * @returns SQL 문자열 + */ +export function buildAggregationSQL(config: DataSourceConfig): string { + const tableName = sanitizeIdentifier(config.tableName); + + // SELECT 절 + let selectClause: string; + if (config.aggregation) { + const aggType = config.aggregation.type.toUpperCase(); + const aggCol = config.aggregation.column?.trim() + ? sanitizeIdentifier(config.aggregation.column) + : ""; + + // COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수 + if (!aggCol) { + selectClause = aggType === "COUNT" + ? "COUNT(*) as value" + : `${aggType}(${tableName}.*) as value`; + } else { + selectClause = `${aggType}(${aggCol}) as value`; + } + + // GROUP BY가 있으면 해당 컬럼도 SELECT에 포함 + if (config.aggregation.groupBy?.length) { + const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", "); + selectClause = `${groupCols}, ${selectClause}`; + } + } else { + selectClause = "*"; + } + + // FROM 절 (조인 포함 - 조건이 완전한 조인만 적용) + let fromClause = tableName; + if (config.joins?.length) { + for (const join of config.joins) { + // 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어) + if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) { + continue; + } + const joinTable = sanitizeIdentifier(join.targetTable); + const joinType = join.joinType.toUpperCase(); + const srcCol = sanitizeIdentifier(join.on.sourceColumn); + const tgtCol = sanitizeIdentifier(join.on.targetColumn); + fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`; + } + } + + // WHERE 절 + const whereClause = config.filters?.length + ? buildWhereClause(config.filters) + : ""; + + // GROUP BY 절 + let groupByClause = ""; + if (config.aggregation?.groupBy?.length) { + groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`; + } + + // ORDER BY 절 + let orderByClause = ""; + if (config.sort?.length) { + const sortCols = config.sort + .map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`) + .join(", "); + orderByClause = `ORDER BY ${sortCols}`; + } + + // LIMIT 절 + const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : ""; + + return [ + `SELECT ${selectClause}`, + `FROM ${fromClause}`, + whereClause, + groupByClause, + orderByClause, + limitClause, + ] + .filter(Boolean) + .join(" "); +} diff --git a/frontend/hooks/pop/useConnectionResolver.ts b/frontend/hooks/pop/useConnectionResolver.ts new file mode 100644 index 00000000..3c20acc2 --- /dev/null +++ b/frontend/hooks/pop/useConnectionResolver.ts @@ -0,0 +1,70 @@ +/** + * useConnectionResolver - 런타임 컴포넌트 연결 해석기 + * + * PopViewerWithModals에서 사용. + * layout.dataFlow.connections를 읽고, 소스 컴포넌트의 __comp_output__ 이벤트를 + * 타겟 컴포넌트의 __comp_input__ 이벤트로 자동 변환/중계한다. + * + * 이벤트 규칙: + * 소스: __comp_output__${sourceComponentId}__${outputKey} + * 타겟: __comp_input__${targetComponentId}__${inputKey} + */ + +import { useEffect, useRef } from "react"; +import { usePopEvent } from "./usePopEvent"; +import type { PopDataConnection } from "@/components/pop/designer/types/pop-layout"; + +interface UseConnectionResolverOptions { + screenId: string; + connections: PopDataConnection[]; +} + +export function useConnectionResolver({ + screenId, + connections, +}: UseConnectionResolverOptions): void { + const { publish, subscribe } = usePopEvent(screenId); + + // 연결 목록을 ref로 저장하여 콜백 안정성 확보 + const connectionsRef = useRef(connections); + connectionsRef.current = connections; + + useEffect(() => { + if (!connections || connections.length === 0) return; + + const unsubscribers: (() => void)[] = []; + + // 소스별로 그룹핑하여 구독 생성 + const sourceGroups = new Map(); + for (const conn of connections) { + const sourceEvent = `__comp_output__${conn.sourceComponent}__${conn.sourceOutput || conn.sourceField}`; + const existing = sourceGroups.get(sourceEvent) || []; + existing.push(conn); + sourceGroups.set(sourceEvent, existing); + } + + for (const [sourceEvent, conns] of sourceGroups) { + const unsub = subscribe(sourceEvent, (payload: unknown) => { + for (const conn of conns) { + const targetEvent = `__comp_input__${conn.targetComponent}__${conn.targetInput || conn.targetField}`; + + // 항상 통일된 구조로 감싸서 전달: { value, filterConfig?, _connectionId } + const enrichedPayload = { + value: payload, + filterConfig: conn.filterConfig, + _connectionId: conn.id, + }; + + publish(targetEvent, enrichedPayload); + } + }); + unsubscribers.push(unsub); + } + + return () => { + for (const unsub of unsubscribers) { + unsub(); + } + }; + }, [screenId, connections, subscribe, publish]); +} diff --git a/frontend/hooks/pop/useDataSource.ts b/frontend/hooks/pop/useDataSource.ts new file mode 100644 index 00000000..23bd9f93 --- /dev/null +++ b/frontend/hooks/pop/useDataSource.ts @@ -0,0 +1,383 @@ +/** + * useDataSource - POP 컴포넌트용 데이터 CRUD 통합 훅 + * + * DataSourceConfig를 받아서 자동으로 적절한 API를 선택하여 데이터를 조회/생성/수정/삭제한다. + * + * 조회 분기: + * - aggregation 또는 joins가 있으면 → SQL 빌더 + executeQuery (대시보드와 동일) + * - 그 외 → dataApi.getTableData (단순 테이블 조회) + * + * CRUD: + * - save: dataApi.createRecord + * - update: dataApi.updateRecord + * - remove: dataApi.deleteRecord + * + * 사용 패턴: + * ```typescript + * // 집계 조회 (대시보드용) + * const { data, loading } = useDataSource({ + * tableName: "sales_order", + * aggregation: { type: "sum", column: "amount", groupBy: ["category"] }, + * refreshInterval: 30, + * }); + * + * // 단순 목록 조회 (테이블용) + * const { data, refetch } = useDataSource({ + * tableName: "purchase_order", + * sort: [{ column: "created_at", direction: "desc" }], + * limit: 20, + * }); + * + * // 저장/삭제 (버튼용) + * const { save, remove } = useDataSource({ tableName: "inbound_record" }); + * await save({ supplier_id: "SUP-001", quantity: 50 }); + * ``` + */ + +import { useState, useCallback, useEffect, useRef } from "react"; +import { apiClient } from "@/lib/api/client"; +import { dashboardApi } from "@/lib/api/dashboard"; +import { dataApi } from "@/lib/api/data"; +import type { DataSourceConfig, DataSourceFilter } from "@/lib/registry/pop-components/types"; +import { validateDataSourceConfig, buildAggregationSQL } from "./popSqlBuilder"; + +// ===== 타입 정의 ===== + +/** 조회 결과 */ +export interface DataSourceResult { + /** 데이터 행 배열 */ + rows: Record[]; + /** 단일 집계 값 (aggregation 시) 또는 전체 행 수 */ + value: number; + /** 전체 행 수 (페이징용) */ + total: number; +} + +/** CRUD 작업 결과 */ +export interface MutationResult { + success: boolean; + data?: unknown; + error?: string; +} + +/** refetch 시 전달할 오버라이드 필터 */ +interface OverrideOptions { + filters?: Record; +} + +// ===== 내부: 집계/조인 조회 ===== + +/** + * 집계 또는 조인이 포함된 DataSourceConfig를 SQL로 변환하여 실행 + * dataFetcher.ts의 fetchAggregatedData와 동일한 로직 + */ +async function fetchWithSqlBuilder( + config: DataSourceConfig +): Promise { + const sql = buildAggregationSQL(config); + + // API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백 + let queryResult: { columns: string[]; rows: Record[] }; + try { + // 1차: apiClient (axios 기반, 인증/세션 안정적) + const response = await apiClient.post("/dashboards/execute-query", { query: sql }); + if (response.data?.success && response.data?.data) { + queryResult = response.data.data; + } else { + throw new Error(response.data?.message || "쿼리 실행 실패"); + } + } catch { + // 2차: dashboardApi (fetch 기반, 폴백) + queryResult = await dashboardApi.executeQuery(sql); + } + + if (queryResult.rows.length === 0) { + return { rows: [], value: 0, total: 0 }; + } + + // PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨 → 숫자 변환 + const processedRows = queryResult.rows.map((row) => { + const converted: Record = { ...row }; + for (const key of Object.keys(converted)) { + const val = converted[key]; + if (typeof val === "string" && val !== "" && !isNaN(Number(val))) { + converted[key] = Number(val); + } + } + return converted; + }); + + // 첫 번째 행의 value 컬럼 추출 + const firstRow = processedRows[0]; + const numericValue = parseFloat( + String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0) + ); + + return { + rows: processedRows, + value: Number.isFinite(numericValue) ? numericValue : 0, + total: processedRows.length, + }; +} + +// ===== 내부: 단순 테이블 조회 ===== + +/** + * aggregation/joins 없는 단순 테이블 조회 + * dataApi.getTableData 래핑 + */ +async function fetchSimpleTable( + config: DataSourceConfig, + overrideFilters?: Record +): Promise { + // config.filters를 Record 형태로 변환 + const baseFilters: Record = {}; + if (config.filters?.length) { + for (const f of config.filters) { + if (f.column?.trim()) { + baseFilters[f.column] = f.value; + } + } + } + + // overrideFilters가 있으면 병합 (같은 키는 override가 덮어씀) + const mergedFilters = overrideFilters + ? { ...baseFilters, ...overrideFilters } + : baseFilters; + + const tableResult = await dataApi.getTableData(config.tableName, { + page: 1, + size: config.limit ?? 100, + sortBy: config.sort?.[0]?.column, + sortOrder: config.sort?.[0]?.direction, + filters: Object.keys(mergedFilters).length > 0 ? mergedFilters : undefined, + }); + + return { + rows: tableResult.data, + value: tableResult.total ?? tableResult.data.length, + total: tableResult.total ?? tableResult.data.length, + }; +} + +// ===== 내부: overrideFilters를 DataSourceFilter 배열에 병합 ===== + +/** + * 기존 config에 overrideFilters를 병합한 새 config 생성 + * 같은 column이 있으면 override 값으로 대체 + */ +function mergeFilters( + config: DataSourceConfig, + overrideFilters?: Record +): DataSourceConfig { + if (!overrideFilters || Object.keys(overrideFilters).length === 0) { + return config; + } + + // 기존 filters에서 override 대상이 아닌 것만 유지 + const overrideColumns = new Set(Object.keys(overrideFilters)); + const existingFilters: DataSourceFilter[] = (config.filters ?? []).filter( + (f) => !overrideColumns.has(f.column) + ); + + // override를 DataSourceFilter로 변환하여 추가 + const newFilters: DataSourceFilter[] = Object.entries(overrideFilters).map( + ([column, value]) => ({ + column, + operator: "=" as const, + value, + }) + ); + + return { + ...config, + filters: [...existingFilters, ...newFilters], + }; +} + +// ===== 메인 훅 ===== + +/** + * POP 컴포넌트용 데이터 CRUD 통합 훅 + * + * @param config - DataSourceConfig (tableName 필수) + * @returns data, loading, error, refetch, save, update, remove + */ +export function useDataSource(config: DataSourceConfig) { + const [data, setData] = useState({ + rows: [], + value: 0, + total: 0, + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // config를 ref로 저장 (콜백 안정성) + const configRef = useRef(config); + configRef.current = config; + + // 자동 새로고침 타이머 + const refreshTimerRef = useRef | null>(null); + + // ===== 조회 (READ) ===== + + const refetch = useCallback( + async (options?: OverrideOptions): Promise => { + const currentConfig = configRef.current; + + // 테이블명 없으면 조회하지 않음 + if (!currentConfig.tableName?.trim()) { + return; + } + + setLoading(true); + setError(null); + + try { + const hasAggregation = !!currentConfig.aggregation; + const hasJoins = !!(currentConfig.joins && currentConfig.joins.length > 0); + + let result: DataSourceResult; + + if (hasAggregation || hasJoins) { + // 집계/조인 → SQL 빌더 경로 + // 설정 완료 여부 검증 + const merged = mergeFilters(currentConfig, options?.filters); + const validationError = validateDataSourceConfig(merged); + if (validationError) { + setError(validationError); + setLoading(false); + return; + } + result = await fetchWithSqlBuilder(merged); + } else { + // 단순 조회 → dataApi 경로 + result = await fetchSimpleTable(currentConfig, options?.filters); + } + + setData(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "데이터 조회 실패"; + setError(message); + } finally { + setLoading(false); + } + }, + [] // configRef 사용으로 의존성 불필요 + ); + + // ===== 생성 (CREATE) ===== + + const save = useCallback( + async (record: Record): Promise => { + const tableName = configRef.current.tableName; + if (!tableName?.trim()) { + return { success: false, error: "테이블이 설정되지 않았습니다" }; + } + + try { + const result = await dataApi.createRecord(tableName, record); + return { + success: result.success ?? true, + data: result.data, + error: result.message, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "레코드 생성 실패"; + return { success: false, error: message }; + } + }, + [] + ); + + // ===== 수정 (UPDATE) ===== + + const update = useCallback( + async ( + id: string | number, + record: Record + ): Promise => { + const tableName = configRef.current.tableName; + if (!tableName?.trim()) { + return { success: false, error: "테이블이 설정되지 않았습니다" }; + } + + try { + const result = await dataApi.updateRecord(tableName, id, record); + return { + success: result.success ?? true, + data: result.data, + error: result.message, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "레코드 수정 실패"; + return { success: false, error: message }; + } + }, + [] + ); + + // ===== 삭제 (DELETE) ===== + + const remove = useCallback( + async ( + id: string | number | Record + ): Promise => { + const tableName = configRef.current.tableName; + if (!tableName?.trim()) { + return { success: false, error: "테이블이 설정되지 않았습니다" }; + } + + try { + const result = await dataApi.deleteRecord(tableName, id); + return { + success: result.success ?? true, + data: result.data, + error: result.message, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "레코드 삭제 실패"; + return { success: false, error: message }; + } + }, + [] + ); + + // ===== 자동 조회 + 새로고침 ===== + + // config.tableName 또는 refreshInterval이 변경되면 재조회 + const tableName = config.tableName; + const refreshInterval = config.refreshInterval; + + useEffect(() => { + // 테이블명 있으면 초기 조회 + if (tableName?.trim()) { + refetch(); + } + + // refreshInterval 설정 시 자동 새로고침 + if (refreshInterval && refreshInterval > 0) { + const sec = Math.max(5, refreshInterval); // 최소 5초 + refreshTimerRef.current = setInterval(() => { + refetch(); + }, sec * 1000); + } + + return () => { + if (refreshTimerRef.current) { + clearInterval(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }; + }, [tableName, refreshInterval, refetch]); + + return { + data, + loading, + error, + refetch, + save, + update, + remove, + } as const; +} diff --git a/frontend/hooks/pop/usePopAction.ts b/frontend/hooks/pop/usePopAction.ts new file mode 100644 index 00000000..267beb4e --- /dev/null +++ b/frontend/hooks/pop/usePopAction.ts @@ -0,0 +1,218 @@ +/** + * usePopAction - POP 액션 실행 React 훅 + * + * executePopAction (순수 함수)를 래핑하여 React UI 관심사를 처리: + * - 로딩 상태 (isLoading) + * - 확인 다이얼로그 (pendingConfirm) + * - 토스트 알림 + * - 후속 액션 체이닝 (followUpActions) + * + * 사용처: + * - PopButtonComponent (메인 버튼) + * + * pop-string-list 등 리스트 컴포넌트는 executePopAction을 직접 호출하여 + * 훅 인스턴스 폭발 문제를 회피함. + */ + +import { useState, useCallback, useRef } from "react"; +import type { + ButtonMainAction, + FollowUpAction, + ConfirmConfig, +} from "@/lib/registry/pop-components/pop-button"; +import { usePopEvent } from "./usePopEvent"; +import { executePopAction } from "./executePopAction"; +import type { ActionResult } from "./executePopAction"; +import { toast } from "sonner"; + +// ======================================== +// 타입 정의 +// ======================================== + +/** 확인 대기 중인 액션 상태 */ +export interface PendingConfirmState { + action: ButtonMainAction; + rowData?: Record; + fieldMapping?: Record; + confirm: ConfirmConfig; + followUpActions?: FollowUpAction[]; +} + +/** execute 호출 시 옵션 */ +interface ExecuteActionOptions { + /** 대상 행 데이터 */ + rowData?: Record; + /** 필드 매핑 */ + fieldMapping?: Record; + /** 확인 다이얼로그 설정 */ + confirm?: ConfirmConfig; + /** 후속 액션 */ + followUpActions?: FollowUpAction[]; +} + +// ======================================== +// 상수 +// ======================================== + +/** 액션 성공 시 토스트 메시지 */ +const ACTION_SUCCESS_MESSAGES: Record = { + save: "저장되었습니다.", + delete: "삭제되었습니다.", + api: "요청이 완료되었습니다.", + modal: "", + event: "", +}; + +// ======================================== +// 메인 훅 +// ======================================== + +/** + * POP 액션 실행 훅 + * + * @param screenId - 화면 ID (이벤트 버스 연결용) + * @returns execute, isLoading, pendingConfirm, confirmExecute, cancelConfirm + */ +export function usePopAction(screenId: string) { + const [isLoading, setIsLoading] = useState(false); + const [pendingConfirm, setPendingConfirm] = useState(null); + + const { publish } = usePopEvent(screenId); + + // publish 안정성 보장 (콜백 내에서 최신 참조 사용) + const publishRef = useRef(publish); + publishRef.current = publish; + + /** + * 실제 실행 (확인 다이얼로그 이후 or 확인 불필요 시) + */ + const runAction = useCallback( + async ( + action: ButtonMainAction, + rowData?: Record, + fieldMapping?: Record, + followUpActions?: FollowUpAction[] + ): Promise => { + setIsLoading(true); + + try { + const result = await executePopAction(action, rowData, { + fieldMapping, + screenId, + publish: publishRef.current, + }); + + // 결과에 따른 토스트 + if (result.success) { + const msg = ACTION_SUCCESS_MESSAGES[action.type]; + if (msg) toast.success(msg); + } else { + toast.error(result.error || "작업에 실패했습니다."); + } + + // 성공 시 후속 액션 실행 + if (result.success && followUpActions?.length) { + await executeFollowUpActions(followUpActions); + } + + return result; + } finally { + setIsLoading(false); + } + }, + [screenId] + ); + + /** + * 후속 액션 실행 + */ + const executeFollowUpActions = useCallback( + async (actions: FollowUpAction[]) => { + for (const followUp of actions) { + switch (followUp.type) { + case "event": + if (followUp.eventName) { + publishRef.current(followUp.eventName, followUp.eventPayload); + } + break; + + case "refresh": + // 새로고침 이벤트 발행 (구독하는 컴포넌트가 refetch) + publishRef.current("__pop_refresh__"); + break; + + case "navigate": + if (followUp.targetScreenId) { + publishRef.current("__pop_navigate__", { + screenId: followUp.targetScreenId, + params: followUp.params, + }); + } + break; + + case "close-modal": + publishRef.current("__pop_modal_close__"); + break; + } + } + }, + [] + ); + + /** + * 외부에서 호출하는 실행 함수 + * confirm이 활성화되어 있으면 pendingConfirm에 저장하고 대기. + * 비활성화이면 즉시 실행. + */ + const execute = useCallback( + async ( + action: ButtonMainAction, + options?: ExecuteActionOptions + ): Promise => { + const { rowData, fieldMapping, confirm, followUpActions } = options || {}; + + // 확인 다이얼로그 필요 시 대기 + if (confirm?.enabled) { + setPendingConfirm({ + action, + rowData, + fieldMapping, + confirm, + followUpActions, + }); + return { success: true }; // 대기 상태이므로 일단 success + } + + // 즉시 실행 + return runAction(action, rowData, fieldMapping, followUpActions); + }, + [runAction] + ); + + /** + * 확인 다이얼로그에서 "확인" 클릭 시 + */ + const confirmExecute = useCallback(async () => { + if (!pendingConfirm) return; + + const { action, rowData, fieldMapping, followUpActions } = pendingConfirm; + setPendingConfirm(null); + + await runAction(action, rowData, fieldMapping, followUpActions); + }, [pendingConfirm, runAction]); + + /** + * 확인 다이얼로그에서 "취소" 클릭 시 + */ + const cancelConfirm = useCallback(() => { + setPendingConfirm(null); + }, []); + + return { + execute, + isLoading, + pendingConfirm, + confirmExecute, + cancelConfirm, + } as const; +} diff --git a/frontend/hooks/pop/usePopEvent.ts b/frontend/hooks/pop/usePopEvent.ts new file mode 100644 index 00000000..c600d838 --- /dev/null +++ b/frontend/hooks/pop/usePopEvent.ts @@ -0,0 +1,190 @@ +/** + * usePopEvent - POP 컴포넌트 간 이벤트 통신 훅 + * + * 같은 화면(screenId) 안에서만 동작하는 이벤트 버스. + * 다른 screenId 간에는 완전히 격리됨. + * + * 주요 기능: + * - publish/subscribe: 일회성 이벤트 (거래처 선택됨, 저장 완료 등) + * - getSharedData/setSharedData: 지속성 상태 (버튼 클릭 시 다른 컴포넌트 값 수집용) + * + * 사용 패턴: + * ```typescript + * const { publish, subscribe, getSharedData, setSharedData } = usePopEvent("S001"); + * + * // 이벤트 구독 (반드시 useEffect 안에서, cleanup 필수) + * useEffect(() => { + * const unsub = subscribe("supplier-selected", (payload) => { + * console.log(payload.supplierId); + * }); + * return unsub; + * }, []); + * + * // 이벤트 발행 + * publish("supplier-selected", { supplierId: "SUP-001" }); + * + * // 공유 데이터 저장/조회 + * setSharedData("selectedSupplier", { id: "SUP-001" }); + * const supplier = getSharedData("selectedSupplier"); + * ``` + */ + +import { useCallback, useRef } from "react"; + +// ===== 타입 정의 ===== + +/** 이벤트 콜백 함수 타입 */ +type EventCallback = (payload: unknown) => void; + +/** 화면별 이벤트 리스너 맵: eventName -> Set */ +type ListenerMap = Map>; + +/** 화면별 공유 데이터 맵: key -> value */ +type SharedDataMap = Map; + +// ===== 전역 저장소 (React 외부, 모듈 스코프) ===== +// SSR 환경에서 서버/클라이언트 간 공유 방지 + +/** screenId별 이벤트 리스너 저장소 */ +const screenBuses: Map = + typeof window !== "undefined" ? new Map() : new Map(); + +/** screenId별 공유 데이터 저장소 */ +const sharedDataStore: Map = + typeof window !== "undefined" ? new Map() : new Map(); + +// ===== 내부 헬퍼 ===== + +/** 해당 screenId의 리스너 맵 가져오기 (없으면 생성) */ +function getListenerMap(screenId: string): ListenerMap { + let map = screenBuses.get(screenId); + if (!map) { + map = new Map(); + screenBuses.set(screenId, map); + } + return map; +} + +/** 해당 screenId의 공유 데이터 맵 가져오기 (없으면 생성) */ +function getSharedMap(screenId: string): SharedDataMap { + let map = sharedDataStore.get(screenId); + if (!map) { + map = new Map(); + sharedDataStore.set(screenId, map); + } + return map; +} + +// ===== 외부 API: 화면 정리 ===== + +/** + * 화면 언마운트 시 해당 screenId의 모든 리스너 + 공유 데이터 정리 + * 메모리 누수 방지용. 뷰어 또는 PopRenderer에서 화면 전환 시 호출. + */ +export function cleanupScreen(screenId: string): void { + screenBuses.delete(screenId); + sharedDataStore.delete(screenId); +} + +// ===== 메인 훅 ===== + +/** + * POP 컴포넌트 간 이벤트 통신 훅 + * + * @param screenId - 화면 ID (같은 screenId 안에서만 통신) + * @returns publish, subscribe, getSharedData, setSharedData + */ +export function usePopEvent(screenId: string) { + // screenId를 ref로 저장 (콜백 안정성) + const screenIdRef = useRef(screenId); + screenIdRef.current = screenId; + + /** + * 이벤트 발행 + * 해당 screenId + eventName에 등록된 모든 콜백에 payload 전달 + */ + const publish = useCallback( + (eventName: string, payload?: unknown): void => { + const listeners = getListenerMap(screenIdRef.current); + const callbacks = listeners.get(eventName); + if (!callbacks || callbacks.size === 0) return; + + // Set을 배열로 복사 후 순회 (순회 중 unsubscribe 안전) + const callbackArray = Array.from(callbacks); + for (const cb of callbackArray) { + try { + cb(payload); + } catch (err) { + // 개별 콜백 에러가 다른 콜백 실행을 막지 않음 + console.error( + `[usePopEvent] 콜백 에러 (screen: ${screenIdRef.current}, event: ${eventName}):`, + err + ); + } + } + }, + [] + ); + + /** + * 이벤트 구독 + * + * 주의: 반드시 useEffect 안에서 호출하고, 반환값(unsubscribe)을 cleanup에서 호출할 것. + * + * @returns unsubscribe 함수 + */ + const subscribe = useCallback( + (eventName: string, callback: EventCallback): (() => void) => { + const listeners = getListenerMap(screenIdRef.current); + + let callbacks = listeners.get(eventName); + if (!callbacks) { + callbacks = new Set(); + listeners.set(eventName, callbacks); + } + callbacks.add(callback); + + // unsubscribe 함수 반환 + const capturedScreenId = screenIdRef.current; + return () => { + const map = screenBuses.get(capturedScreenId); + if (!map) return; + const cbs = map.get(eventName); + if (!cbs) return; + cbs.delete(callback); + // 빈 Set 정리 + if (cbs.size === 0) { + map.delete(eventName); + } + }; + }, + [] + ); + + /** + * 공유 데이터 조회 + * 다른 컴포넌트가 setSharedData로 저장한 값을 가져옴 + */ + const getSharedData = useCallback( + (key: string): T | undefined => { + const shared = sharedDataStore.get(screenIdRef.current); + if (!shared) return undefined; + return shared.get(key) as T | undefined; + }, + [] + ); + + /** + * 공유 데이터 저장 + * 같은 screenId의 다른 컴포넌트가 getSharedData로 읽을 수 있음 + */ + const setSharedData = useCallback( + (key: string, value: unknown): void => { + const shared = getSharedMap(screenIdRef.current); + shared.set(key, value); + }, + [] + ); + + return { publish, subscribe, getSharedData, setSharedData } as const; +} diff --git a/frontend/hooks/useAuth.ts b/frontend/hooks/useAuth.ts index 737710d3..d03aab29 100644 --- a/frontend/hooks/useAuth.ts +++ b/frontend/hooks/useAuth.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useRouter } from "next/navigation"; import { apiCall } from "@/lib/api/client"; +import { AuthLogger } from "@/lib/authLogger"; interface UserInfo { userId: string; @@ -161,13 +162,15 @@ export const useAuth = () => { const token = TokenManager.getToken(); if (!token || TokenManager.isTokenExpired(token)) { + AuthLogger.log("AUTH_CHECK_FAIL", `refreshUserData: 토큰 ${!token ? "없음" : "만료됨"}`); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); setLoading(false); return; } - // 토큰이 유효하면 우선 인증된 상태로 설정 + AuthLogger.log("AUTH_CHECK_START", "refreshUserData: API로 인증 상태 확인 시작"); + setAuthStatus({ isLoggedIn: true, isAdmin: false, @@ -186,15 +189,16 @@ export const useAuth = () => { }; setAuthStatus(finalAuthStatus); + AuthLogger.log("AUTH_CHECK_SUCCESS", `사용자: ${userInfo.userId}, 인증: ${finalAuthStatus.isLoggedIn}`); - // API 결과가 비인증이면 상태만 업데이트 (리다이렉트는 client.ts가 처리) if (!finalAuthStatus.isLoggedIn) { + AuthLogger.log("AUTH_CHECK_FAIL", "API 응답에서 비인증 상태 반환 → 토큰 제거"); TokenManager.removeToken(); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); } } else { - // userInfo 조회 실패 → 토큰에서 최소 정보 추출하여 유지 + AuthLogger.log("AUTH_CHECK_FAIL", "userInfo 조회 실패 → 토큰 기반 임시 인증 유지 시도"); try { const payload = JSON.parse(atob(token.split(".")[1])); const tempUser: UserInfo = { @@ -210,14 +214,14 @@ export const useAuth = () => { isAdmin: tempUser.isAdmin, }); } catch { - // 토큰 파싱도 실패하면 비인증 상태로 전환 + AuthLogger.log("AUTH_CHECK_FAIL", "토큰 파싱 실패 → 비인증 전환"); TokenManager.removeToken(); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); } } } catch { - // API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도 + AuthLogger.log("AUTH_CHECK_FAIL", "API 호출 전체 실패 → 토큰 기반 임시 인증 유지 시도"); try { const payload = JSON.parse(atob(token.split(".")[1])); const tempUser: UserInfo = { @@ -233,6 +237,7 @@ export const useAuth = () => { isAdmin: tempUser.isAdmin, }); } catch { + AuthLogger.log("AUTH_CHECK_FAIL", "최종 fallback 실패 → 비인증 전환"); TokenManager.removeToken(); setUser(null); setAuthStatus({ isLoggedIn: false, isAdmin: false }); @@ -408,19 +413,19 @@ export const useAuth = () => { const token = TokenManager.getToken(); if (token && !TokenManager.isTokenExpired(token)) { - // 유효한 토큰 → 우선 인증 상태로 설정 후 API 확인 + AuthLogger.log("AUTH_CHECK_START", `초기 인증 확인: 유효한 토큰 존재 (경로: ${window.location.pathname})`); setAuthStatus({ isLoggedIn: true, isAdmin: false, }); refreshUserData(); } else if (token && TokenManager.isTokenExpired(token)) { - // 만료된 토큰 → 정리 (리다이렉트는 AuthGuard에서 처리) + AuthLogger.log("TOKEN_EXPIRED_DETECTED", `초기 확인 시 만료된 토큰 발견 → 정리 (경로: ${window.location.pathname})`); TokenManager.removeToken(); setAuthStatus({ isLoggedIn: false, isAdmin: false }); setLoading(false); } else { - // 토큰 없음 → 비인증 상태 (리다이렉트는 AuthGuard에서 처리) + AuthLogger.log("AUTH_CHECK_FAIL", `초기 확인: 토큰 없음 (경로: ${window.location.pathname})`); setAuthStatus({ isLoggedIn: false, isAdmin: false }); setLoading(false); } diff --git a/frontend/hooks/useMenu.ts b/frontend/hooks/useMenu.ts index 32fb3d4e..59ddab02 100644 --- a/frontend/hooks/useMenu.ts +++ b/frontend/hooks/useMenu.ts @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; import { MenuItem, MenuState } from "@/types/menu"; import { apiClient } from "@/lib/api/client"; +import { AuthLogger } from "@/lib/authLogger"; /** * 메뉴 관련 비즈니스 로직을 관리하는 커스텀 훅 @@ -84,8 +85,8 @@ export const useMenu = (user: any, authLoading: boolean) => { } else { setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); } - } catch { - // API 실패 시 빈 메뉴로 유지 (401은 client.ts 인터셉터가 리다이렉트 처리) + } catch (err: any) { + AuthLogger.log("MENU_LOAD_FAIL", `메뉴 로드 실패: ${err?.response?.status || err?.message || "unknown"}`); setMenuState((prev: MenuState) => ({ ...prev, isLoading: false })); } }, [convertToUpperCaseKeys, buildMenuTree]); diff --git a/frontend/lib/api/client.ts b/frontend/lib/api/client.ts index 7abe856c..2338ad63 100644 --- a/frontend/lib/api/client.ts +++ b/frontend/lib/api/client.ts @@ -1,4 +1,14 @@ import axios, { AxiosResponse, AxiosError, InternalAxiosRequestConfig } from "axios"; +import { AuthLogger } from "@/lib/authLogger"; + +const authLog = (event: string, detail: string) => { + if (typeof window === "undefined") return; + try { + AuthLogger.log(event as any, detail); + } catch { + // 로거 실패해도 앱 동작에 영향 없음 + } +}; // API URL 동적 설정 - 환경변수 우선 사용 const getApiBaseUrl = (): string => { @@ -149,9 +159,12 @@ const refreshToken = async (): Promise => { try { const currentToken = TokenManager.getToken(); if (!currentToken) { + authLog("TOKEN_REFRESH_FAIL", "갱신 시도했으나 토큰 자체가 없음"); return null; } + authLog("TOKEN_REFRESH_START", `남은시간: ${Math.round(TokenManager.getTimeUntilExpiry(currentToken) / 60000)}분`); + const response = await axios.post( `${API_BASE_URL}/auth/refresh`, {}, @@ -165,10 +178,13 @@ const refreshToken = async (): Promise => { if (response.data?.success && response.data?.data?.token) { const newToken = response.data.data.token; TokenManager.setToken(newToken); + authLog("TOKEN_REFRESH_SUCCESS", "토큰 갱신 완료"); return newToken; } + authLog("TOKEN_REFRESH_FAIL", `API 응답 실패: success=${response.data?.success}`); return null; - } catch { + } catch (err: any) { + authLog("TOKEN_REFRESH_FAIL", `API 호출 에러: ${err?.response?.status || err?.message || "unknown"}`); return null; } }; @@ -210,16 +226,21 @@ const setupVisibilityRefresh = (): void => { document.addEventListener("visibilitychange", () => { if (!document.hidden) { const token = TokenManager.getToken(); - if (!token) return; + if (!token) { + authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 없음"); + return; + } if (TokenManager.isTokenExpired(token)) { - // 만료됐으면 갱신 시도 + authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 감지 → 갱신 시도"); refreshToken().then((newToken) => { if (!newToken) { + authLog("REDIRECT_TO_LOGIN", "탭 복귀 후 토큰 갱신 실패로 리다이렉트"); redirectToLogin(); } }); } else if (TokenManager.isTokenExpiringSoon(token)) { + authLog("VISIBILITY_CHANGE", "탭 복귀 시 토큰 만료 임박 → 갱신 시도"); refreshToken(); } } @@ -268,6 +289,7 @@ const redirectToLogin = (): void => { if (isRedirecting) return; if (window.location.pathname === "/login") return; + authLog("REDIRECT_TO_LOGIN", `리다이렉트 실행 (from: ${window.location.pathname})`); isRedirecting = true; TokenManager.removeToken(); window.location.href = "/login"; @@ -301,15 +323,13 @@ apiClient.interceptors.request.use( if (token) { if (!TokenManager.isTokenExpired(token)) { - // 유효한 토큰 → 그대로 사용 config.headers.Authorization = `Bearer ${token}`; } else { - // 만료된 토큰 → 갱신 시도 후 사용 + authLog("TOKEN_EXPIRED_DETECTED", `요청 전 토큰 만료 감지 (${config.url}) → 갱신 시도`); const newToken = await refreshToken(); if (newToken) { config.headers.Authorization = `Bearer ${newToken}`; } - // 갱신 실패해도 요청은 보냄 (401 응답 인터셉터에서 처리) } } @@ -378,12 +398,16 @@ apiClient.interceptors.response.use( // 401 에러 처리 (핵심 개선) if (status === 401 && typeof window !== "undefined") { - const errorData = error.response?.data as { error?: { code?: string } }; + const errorData = error.response?.data as { error?: { code?: string; details?: string } }; const errorCode = errorData?.error?.code; + const errorDetails = errorData?.error?.details; const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }; + authLog("API_401_RECEIVED", `URL: ${url} | 코드: ${errorCode || "없음"} | 상세: ${errorDetails || "없음"}`); + // 이미 재시도한 요청이면 로그인으로 if (originalRequest?._retry) { + authLog("REDIRECT_TO_LOGIN", `재시도 후에도 401 (${url}) → 로그인 리다이렉트`); redirectToLogin(); return Promise.reject(error); } @@ -395,6 +419,7 @@ apiClient.interceptors.response.use( originalRequest._retry = true; try { + authLog("API_401_RETRY", `토큰 만료로 갱신 후 재시도 (${url})`); const newToken = await refreshToken(); if (newToken) { isRefreshing = false; @@ -404,17 +429,18 @@ apiClient.interceptors.response.use( } else { isRefreshing = false; onRefreshFailed(new Error("토큰 갱신 실패")); + authLog("REDIRECT_TO_LOGIN", `토큰 갱신 실패 (${url}) → 로그인 리다이렉트`); redirectToLogin(); return Promise.reject(error); } } catch (refreshError) { isRefreshing = false; onRefreshFailed(refreshError as Error); + authLog("REDIRECT_TO_LOGIN", `토큰 갱신 예외 (${url}) → 로그인 리다이렉트`); redirectToLogin(); return Promise.reject(error); } } else { - // 다른 요청이 이미 갱신 중 → 갱신 완료 대기 후 재시도 try { const newToken = await waitForTokenRefresh(); originalRequest._retry = true; @@ -427,6 +453,7 @@ apiClient.interceptors.response.use( } // TOKEN_MISSING, INVALID_TOKEN 등 → 로그인으로 + authLog("REDIRECT_TO_LOGIN", `복구 불가능한 인증 에러 (${errorCode || "UNKNOWN"}, ${url}) → 로그인 리다이렉트`); redirectToLogin(); } diff --git a/frontend/lib/authLogger.ts b/frontend/lib/authLogger.ts new file mode 100644 index 00000000..f30284ab --- /dev/null +++ b/frontend/lib/authLogger.ts @@ -0,0 +1,225 @@ +/** + * 인증 이벤트 로거 + * - 토큰 갱신/삭제/리다이렉트 발생 시 원인을 기록 + * - localStorage에 저장하여 브라우저에서 확인 가능 + * - 콘솔에서 window.__AUTH_LOG.show() 로 조회 + */ + +const STORAGE_KEY = "auth_debug_log"; +const MAX_ENTRIES = 200; + +export type AuthEventType = + | "TOKEN_SET" + | "TOKEN_REMOVED" + | "TOKEN_EXPIRED_DETECTED" + | "TOKEN_REFRESH_START" + | "TOKEN_REFRESH_SUCCESS" + | "TOKEN_REFRESH_FAIL" + | "REDIRECT_TO_LOGIN" + | "API_401_RECEIVED" + | "API_401_RETRY" + | "AUTH_CHECK_START" + | "AUTH_CHECK_SUCCESS" + | "AUTH_CHECK_FAIL" + | "AUTH_GUARD_BLOCK" + | "AUTH_GUARD_PASS" + | "MENU_LOAD_FAIL" + | "VISIBILITY_CHANGE" + | "MIDDLEWARE_REDIRECT"; + +interface AuthLogEntry { + timestamp: string; + event: AuthEventType; + detail: string; + tokenStatus: string; + url: string; + stack?: string; +} + +function getTokenSummary(): string { + if (typeof window === "undefined") return "SSR"; + + const token = localStorage.getItem("authToken"); + if (!token) return "없음"; + + try { + const payload = JSON.parse(atob(token.split(".")[1])); + const exp = payload.exp * 1000; + const now = Date.now(); + const remainMs = exp - now; + + if (remainMs <= 0) { + return `만료됨(${Math.abs(Math.round(remainMs / 60000))}분 전)`; + } + + const remainMin = Math.round(remainMs / 60000); + const remainHour = Math.floor(remainMin / 60); + const min = remainMin % 60; + + return `유효(${remainHour}h${min}m 남음, user:${payload.userId})`; + } catch { + return "파싱실패"; + } +} + +function getCallStack(): string { + try { + const stack = new Error().stack || ""; + const lines = stack.split("\n").slice(3, 7); + return lines.map((l) => l.trim()).join(" <- "); + } catch { + return ""; + } +} + +function writeLog(event: AuthEventType, detail: string) { + if (typeof window === "undefined") return; + + const entry: AuthLogEntry = { + timestamp: new Date().toISOString(), + event, + detail, + tokenStatus: getTokenSummary(), + url: window.location.pathname + window.location.search, + stack: getCallStack(), + }; + + // 콘솔 출력 (그룹) + const isError = ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK"].includes(event); + const logFn = isError ? console.warn : console.debug; + logFn(`[AuthLog] ${event}: ${detail} | 토큰: ${entry.tokenStatus} | ${entry.url}`); + + // localStorage에 저장 + try { + const stored = localStorage.getItem(STORAGE_KEY); + const logs: AuthLogEntry[] = stored ? JSON.parse(stored) : []; + logs.push(entry); + + // 최대 개수 초과 시 오래된 것 제거 + while (logs.length > MAX_ENTRIES) { + logs.shift(); + } + + localStorage.setItem(STORAGE_KEY, JSON.stringify(logs)); + } catch { + // localStorage 공간 부족 등의 경우 무시 + } +} + +/** + * 저장된 로그 조회 + */ +function getLogs(): AuthLogEntry[] { + if (typeof window === "undefined") return []; + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : []; + } catch { + return []; + } +} + +/** + * 로그 초기화 + */ +function clearLogs() { + if (typeof window === "undefined") return; + localStorage.removeItem(STORAGE_KEY); +} + +/** + * 로그를 테이블 형태로 콘솔에 출력 + */ +function showLogs(filter?: AuthEventType | "ERROR") { + const logs = getLogs(); + + if (logs.length === 0) { + console.log("[AuthLog] 저장된 로그가 없습니다."); + return; + } + + let filtered = logs; + if (filter === "ERROR") { + filtered = logs.filter((l) => + ["TOKEN_REMOVED", "REDIRECT_TO_LOGIN", "API_401_RECEIVED", "TOKEN_REFRESH_FAIL", "AUTH_GUARD_BLOCK", "AUTH_CHECK_FAIL", "TOKEN_EXPIRED_DETECTED"].includes(l.event) + ); + } else if (filter) { + filtered = logs.filter((l) => l.event === filter); + } + + console.log(`\n[AuthLog] 총 ${filtered.length}건 (전체 ${logs.length}건)`); + console.log("─".repeat(120)); + + filtered.forEach((entry, i) => { + const time = entry.timestamp.replace("T", " ").split(".")[0]; + console.log( + `${i + 1}. [${time}] ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}\n` + ); + }); +} + +/** + * 마지막 리다이렉트 원인 조회 + */ +function getLastRedirectReason(): AuthLogEntry | null { + const logs = getLogs(); + for (let i = logs.length - 1; i >= 0; i--) { + if (logs[i].event === "REDIRECT_TO_LOGIN") { + return logs[i]; + } + } + return null; +} + +/** + * 로그를 텍스트 파일로 다운로드 + */ +function downloadLogs() { + if (typeof window === "undefined") return; + + const logs = getLogs(); + if (logs.length === 0) { + console.log("[AuthLog] 저장된 로그가 없습니다."); + return; + } + + const text = logs + .map((entry, i) => { + const time = entry.timestamp.replace("T", " ").split(".")[0]; + return `[${i + 1}] ${time} | ${entry.event}\n 상세: ${entry.detail}\n 토큰: ${entry.tokenStatus}\n 경로: ${entry.url}${entry.stack ? `\n 호출: ${entry.stack}` : ""}`; + }) + .join("\n\n"); + + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `auth-debug-log_${new Date().toISOString().slice(0, 19).replace(/[T:]/g, "-")}.txt`; + a.click(); + URL.revokeObjectURL(url); + + console.log("[AuthLog] 로그 파일 다운로드 완료"); +} + +// 전역 접근 가능하게 등록 +if (typeof window !== "undefined") { + (window as any).__AUTH_LOG = { + show: showLogs, + errors: () => showLogs("ERROR"), + clear: clearLogs, + download: downloadLogs, + lastRedirect: getLastRedirectReason, + raw: getLogs, + }; +} + +export const AuthLogger = { + log: writeLog, + getLogs, + clearLogs, + showLogs, + downloadLogs, + getLastRedirectReason, +}; + +export default AuthLogger; diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index ff0285a2..72af2a34 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -8,6 +8,76 @@ import { filterDOMProps } from "@/lib/utils/domPropsFilter"; // 통합 폼 시스템 import import { useV2FormOptional } from "@/components/v2/V2FormContext"; +import { apiClient } from "@/lib/api/client"; + +// 컬럼 메타데이터 캐시 (테이블명 → 컬럼 설정 맵) +const columnMetaCache: Record> = {}; +const columnMetaLoading: Record> = {}; + +async function loadColumnMeta(tableName: string): Promise { + if (columnMetaCache[tableName] || columnMetaLoading[tableName]) return; + + columnMetaLoading[tableName] = (async () => { + try { + const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`); + const data = response.data.data || response.data; + const columns = data.columns || data || []; + const map: Record = {}; + for (const col of columns) { + const name = col.column_name || col.columnName; + if (name) map[name] = col; + } + columnMetaCache[tableName] = map; + } catch { + columnMetaCache[tableName] = {}; + } finally { + delete columnMetaLoading[tableName]; + } + })(); + + await columnMetaLoading[tableName]; +} + +// table_type_columns 기반 componentConfig 병합 (기존 설정이 없을 때만 DB 메타데이터로 보완) +function mergeColumnMeta(tableName: string | undefined, columnName: string | undefined, componentConfig: any): any { + if (!tableName || !columnName) return componentConfig; + + const meta = columnMetaCache[tableName]?.[columnName]; + if (!meta) return componentConfig; + + const inputType = meta.input_type || meta.inputType; + if (!inputType) return componentConfig; + + // 이미 source가 올바르게 설정된 경우 건드리지 않음 + const existingSource = componentConfig?.source; + if (existingSource && existingSource !== "static" && existingSource !== "distinct" && existingSource !== "select") { + return componentConfig; + } + + const merged = { ...componentConfig }; + + // source가 미설정/기본값일 때만 DB 메타데이터로 보완 + if (inputType === "entity") { + const refTable = meta.reference_table || meta.referenceTable; + const refColumn = meta.reference_column || meta.referenceColumn; + const displayCol = meta.display_column || meta.displayColumn; + if (refTable && !merged.entityTable) { + merged.source = "entity"; + merged.entityTable = refTable; + merged.entityValueColumn = refColumn || "id"; + merged.entityLabelColumn = displayCol || "name"; + } + } else if (inputType === "category" && !existingSource) { + merged.source = "category"; + } else if (inputType === "select" && !existingSource) { + const detail = typeof meta.detail_settings === "string" ? JSON.parse(meta.detail_settings || "{}") : (meta.detail_settings || {}); + if (detail.options && !merged.options?.length) { + merged.options = detail.options; + } + } + + return merged; +} // 컴포넌트 렌더러 인터페이스 export interface ComponentRenderer { @@ -175,6 +245,15 @@ export const DynamicComponentRenderer: React.FC = children, ...props }) => { + // 컬럼 메타데이터 로드 트리거 (테이블명이 있으면 비동기 로드) + const screenTableName = props.tableName || (component as any).tableName; + const [, forceUpdate] = React.useState(0); + React.useEffect(() => { + if (screenTableName && !columnMetaCache[screenTableName]) { + loadColumnMeta(screenTableName).then(() => forceUpdate((v) => v + 1)); + } + }, [screenTableName]); + // 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용 // 🆕 V2 레이아웃의 경우 url에서 컴포넌트 타입 추출 (예: "@/lib/registry/components/v2-input" → "v2-input") const extractTypeFromUrl = (url: string | undefined): string | undefined => { @@ -551,24 +630,34 @@ export const DynamicComponentRenderer: React.FC = height: finalStyle.height, }; + // 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선) + const isEntityJoinColumn = fieldName?.includes("."); + const baseColumnName = isEntityJoinColumn ? undefined : fieldName; + const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {}); + + // 엔티티 조인 컬럼은 런타임에서 readonly/disabled 강제 해제 + const effectiveComponent = isEntityJoinColumn + ? { ...component, componentConfig: mergedComponentConfig, readonly: false } + : { ...component, componentConfig: mergedComponentConfig }; + const rendererProps = { - component, + component: effectiveComponent, isSelected, onClick, onDragStart, onDragEnd, size: component.size || newComponent.defaultSize, position: component.position, - config: component.componentConfig, - componentConfig: component.componentConfig, + config: mergedComponentConfig, + componentConfig: mergedComponentConfig, // componentConfig의 모든 속성을 props로 spread (tableName, displayField 등) - ...(component.componentConfig || {}), + ...(mergedComponentConfig || {}), // 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선) style: mergedStyle, // 🆕 라벨 표시 (labelDisplay가 true일 때만) label: effectiveLabel, - // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 - inputType: (component as any).inputType || component.componentConfig?.inputType, + // 🆕 V2 레이아웃에서 overrides에서 복원된 상위 레벨 속성들도 전달 (DB 메타데이터 우선) + inputType: (baseColumnName && columnMetaCache[screenTableName || ""]?.[baseColumnName]?.input_type) || (component as any).inputType || mergedComponentConfig?.inputType, columnName: (component as any).columnName || component.componentConfig?.columnName, value: currentValue, // formData에서 추출한 현재 값 전달 // 새로운 기능들 전달 @@ -608,9 +697,8 @@ export const DynamicComponentRenderer: React.FC = // componentConfig.mode가 있으면 유지 (entity-search-input의 UI 모드) mode: component.componentConfig?.mode || mode, isInModal, - readonly: component.readonly, - // 🆕 disabledFields 체크 또는 기존 readonly - disabled: disabledFields?.includes(fieldName) || component.readonly, + readonly: isEntityJoinColumn ? false : component.readonly, + disabled: isEntityJoinColumn ? false : (disabledFields?.includes(fieldName) || component.readonly), originalData, allComponents, onUpdateLayout, diff --git a/frontend/lib/registry/PopComponentRegistry.ts b/frontend/lib/registry/PopComponentRegistry.ts index 31f9a4e1..0d7df5ec 100644 --- a/frontend/lib/registry/PopComponentRegistry.ts +++ b/frontend/lib/registry/PopComponentRegistry.ts @@ -2,6 +2,24 @@ import React from "react"; +/** + * 연결 메타 항목: 컴포넌트가 보내거나 받을 수 있는 데이터 슬롯 + */ +export interface ConnectionMetaItem { + key: string; + label: string; + type: "filter_value" | "selected_row" | "action_trigger" | "data_refresh" | string; + description?: string; +} + +/** + * 컴포넌트 연결 메타데이터: 디자이너가 연결 가능한 입출력 정의 + */ +export interface ComponentConnectionMeta { + sendable: ConnectionMetaItem[]; + receivable: ConnectionMetaItem[]; +} + /** * POP 컴포넌트 정의 인터페이스 */ @@ -15,6 +33,7 @@ export interface PopComponentDefinition { configPanel?: React.ComponentType; preview?: React.ComponentType<{ config?: any }>; // 디자이너 미리보기용 defaultProps?: Record; + connectionMeta?: ComponentConnectionMeta; // POP 전용 속성 touchOptimized?: boolean; minTouchArea?: number; diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 173a67ad..f753a240 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -1328,9 +1328,9 @@ export const ButtonPrimaryComponent: React.FC = ({ )}
- {/* 확인 다이얼로그 - EditModal보다 위에 표시하도록 z-index 최상위로 설정 */} + {/* 확인 다이얼로그 */} - + {getConfirmTitle()} {getConfirmMessage()} diff --git a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx index 13a7ac4f..2f35c799 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx @@ -24,29 +24,33 @@ export const ImageDisplayComponent: React.FC = ({ style, ...props }) => { - // 컴포넌트 설정 const componentConfig = { ...config, ...component.config, } as ImageDisplayConfig; - // 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) + const objectFit = componentConfig.objectFit || "contain"; + const altText = componentConfig.altText || "이미지"; + const borderRadius = componentConfig.borderRadius ?? 8; + const showBorder = componentConfig.showBorder ?? true; + const backgroundColor = componentConfig.backgroundColor || "#f9fafb"; + const placeholder = componentConfig.placeholder || "이미지 없음"; + + const imageSrc = component.value || componentConfig.imageUrl || ""; + const componentStyle: React.CSSProperties = { width: "100%", height: "100%", ...component.style, ...style, - // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) width: "100%", }; - // 디자인 모드 스타일 if (isDesignMode) { componentStyle.border = "1px dashed #cbd5e1"; componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; } - // 이벤트 핸들러 const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); onClick?.(); @@ -88,7 +92,9 @@ export const ImageDisplayComponent: React.FC = ({ }} > {component.label} - {component.required && *} + {(component.required || componentConfig.required) && ( + * + )} )} @@ -96,43 +102,53 @@ export const ImageDisplayComponent: React.FC = ({ style={{ width: "100%", height: "100%", - border: "1px solid #d1d5db", - borderRadius: "8px", + border: showBorder ? "1px solid #d1d5db" : "none", + borderRadius: `${borderRadius}px`, overflow: "hidden", display: "flex", alignItems: "center", justifyContent: "center", - backgroundColor: "#f9fafb", + backgroundColor, transition: "all 0.2s ease-in-out", - boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)", + boxShadow: showBorder ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : "none", + opacity: componentConfig.disabled ? 0.5 : 1, + cursor: componentConfig.disabled ? "not-allowed" : "default", }} onMouseEnter={(e) => { - e.currentTarget.style.borderColor = "#f97316"; - e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)"; + if (!componentConfig.disabled) { + if (showBorder) { + e.currentTarget.style.borderColor = "#f97316"; + } + e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)"; + } }} onMouseLeave={(e) => { - e.currentTarget.style.borderColor = "#d1d5db"; - e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)"; + if (showBorder) { + e.currentTarget.style.borderColor = "#d1d5db"; + } + e.currentTarget.style.boxShadow = showBorder + ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" + : "none"; }} onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd} > - {component.value || componentConfig.imageUrl ? ( + {imageSrc ? ( {componentConfig.altText { (e.target as HTMLImageElement).style.display = "none"; if (e.target?.parentElement) { e.target.parentElement.innerHTML = `
-
🖼️
+
이미지 로드 실패
`; @@ -150,8 +166,22 @@ export const ImageDisplayComponent: React.FC = ({ fontSize: "14px", }} > -
🖼️
-
이미지 없음
+ + + + + +
{placeholder}
)}
@@ -161,7 +191,6 @@ export const ImageDisplayComponent: React.FC = ({ /** * ImageDisplay 래퍼 컴포넌트 - * 추가적인 로직이나 상태 관리가 필요한 경우 사용 */ export const ImageDisplayWrapper: React.FC = (props) => { return ; diff --git a/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx b/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx index 6c73e1d9..7f36f51b 100644 --- a/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx +++ b/frontend/lib/registry/components/image-display/ImageDisplayConfigPanel.tsx @@ -9,63 +9,166 @@ import { ImageDisplayConfig } from "./types"; export interface ImageDisplayConfigPanelProps { config: ImageDisplayConfig; - onChange: (config: Partial) => void; + onChange?: (config: Partial) => void; + onConfigChange?: (config: Partial) => void; } /** * ImageDisplay 설정 패널 - * 컴포넌트의 설정값들을 편집할 수 있는 UI 제공 */ export const ImageDisplayConfigPanel: React.FC = ({ config, onChange, + onConfigChange, }) => { const handleChange = (key: keyof ImageDisplayConfig, value: any) => { - onChange({ [key]: value }); + const update = { ...config, [key]: value }; + onChange?.(update); + onConfigChange?.(update); }; return (
-
- image-display 설정 +
이미지 표시 설정
+ + {/* 이미지 URL */} +
+ + handleChange("imageUrl", e.target.value)} + placeholder="https://..." + className="h-8 text-xs" + /> +

+ 데이터 바인딩 값이 없을 때 표시할 기본 이미지 +

- {/* file 관련 설정 */} + {/* 대체 텍스트 */}
- + + handleChange("altText", e.target.value)} + placeholder="이미지 설명" + className="h-8 text-xs" + /> +
+ + {/* 이미지 맞춤 */} +
+ + +
+ + {/* 테두리 둥글기 */} +
+ + handleChange("borderRadius", parseInt(e.target.value) || 0)} + className="h-8 text-xs" + /> +
+ + {/* 배경 색상 */} +
+ +
+ handleChange("backgroundColor", e.target.value)} + className="h-8 w-8 cursor-pointer rounded border" + /> + handleChange("backgroundColor", e.target.value)} + className="h-8 flex-1 text-xs" + /> +
+
+ + {/* 플레이스홀더 */} +
+ handleChange("placeholder", e.target.value)} + placeholder="이미지 없음" + className="h-8 text-xs" />
- {/* 공통 설정 */} -
- + {/* 테두리 표시 */} +
handleChange("disabled", checked)} + id="showBorder" + checked={config.showBorder ?? true} + onCheckedChange={(checked) => handleChange("showBorder", checked)} /> +
-
- - handleChange("required", checked)} - /> -
- -
- + {/* 읽기 전용 */} +
handleChange("readonly", checked)} /> + +
+ + {/* 필수 입력 */} +
+ handleChange("required", checked)} + /> +
); diff --git a/frontend/lib/registry/components/image-display/config.ts b/frontend/lib/registry/components/image-display/config.ts index 268382f0..bae67e14 100644 --- a/frontend/lib/registry/components/image-display/config.ts +++ b/frontend/lib/registry/components/image-display/config.ts @@ -6,9 +6,14 @@ import { ImageDisplayConfig } from "./types"; * ImageDisplay 컴포넌트 기본 설정 */ export const ImageDisplayDefaultConfig: ImageDisplayConfig = { - placeholder: "입력하세요", - - // 공통 기본값 + imageUrl: "", + altText: "이미지", + objectFit: "contain", + borderRadius: 8, + showBorder: true, + backgroundColor: "#f9fafb", + placeholder: "이미지 없음", + disabled: false, required: false, readonly: false, @@ -18,23 +23,31 @@ export const ImageDisplayDefaultConfig: ImageDisplayConfig = { /** * ImageDisplay 컴포넌트 설정 스키마 - * 유효성 검사 및 타입 체크에 사용 */ export const ImageDisplayConfigSchema = { - placeholder: { type: "string", default: "" }, - - // 공통 스키마 + imageUrl: { type: "string", default: "" }, + altText: { type: "string", default: "이미지" }, + objectFit: { + type: "enum", + values: ["contain", "cover", "fill", "none", "scale-down"], + default: "contain", + }, + borderRadius: { type: "number", default: 8 }, + showBorder: { type: "boolean", default: true }, + backgroundColor: { type: "string", default: "#f9fafb" }, + placeholder: { type: "string", default: "이미지 없음" }, + disabled: { type: "boolean", default: false }, required: { type: "boolean", default: false }, readonly: { type: "boolean", default: false }, - variant: { - type: "enum", - values: ["default", "outlined", "filled"], - default: "default" + variant: { + type: "enum", + values: ["default", "outlined", "filled"], + default: "default", }, - size: { - type: "enum", - values: ["sm", "md", "lg"], - default: "md" + size: { + type: "enum", + values: ["sm", "md", "lg"], + default: "md", }, }; diff --git a/frontend/lib/registry/components/image-display/index.ts b/frontend/lib/registry/components/image-display/index.ts index ddb38f95..ffa5712a 100644 --- a/frontend/lib/registry/components/image-display/index.ts +++ b/frontend/lib/registry/components/image-display/index.ts @@ -21,7 +21,13 @@ export const ImageDisplayDefinition = createComponentDefinition({ webType: "file", component: ImageDisplayWrapper, defaultConfig: { - placeholder: "입력하세요", + imageUrl: "", + altText: "이미지", + objectFit: "contain", + borderRadius: 8, + showBorder: true, + backgroundColor: "#f9fafb", + placeholder: "이미지 없음", }, defaultSize: { width: 200, height: 200 }, configPanel: ImageDisplayConfigPanel, diff --git a/frontend/lib/registry/components/image-display/types.ts b/frontend/lib/registry/components/image-display/types.ts index f2b6971d..e882ebe4 100644 --- a/frontend/lib/registry/components/image-display/types.ts +++ b/frontend/lib/registry/components/image-display/types.ts @@ -6,20 +6,24 @@ import { ComponentConfig } from "@/types/component"; * ImageDisplay 컴포넌트 설정 타입 */ export interface ImageDisplayConfig extends ComponentConfig { - // file 관련 설정 + // 이미지 관련 설정 + imageUrl?: string; + altText?: string; + objectFit?: "contain" | "cover" | "fill" | "none" | "scale-down"; + borderRadius?: number; + showBorder?: boolean; + backgroundColor?: string; placeholder?: string; - + // 공통 설정 disabled?: boolean; required?: boolean; readonly?: boolean; - placeholder?: string; - helperText?: string; - + // 스타일 관련 variant?: "default" | "outlined" | "filled"; size?: "sm" | "md" | "lg"; - + // 이벤트 관련 onChange?: (value: any) => void; onFocus?: () => void; @@ -37,7 +41,7 @@ export interface ImageDisplayProps { config?: ImageDisplayConfig; className?: string; style?: React.CSSProperties; - + // 이벤트 핸들러 onChange?: (value: any) => void; onFocus?: () => void; diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 9649893b..e1754a55 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -116,6 +116,7 @@ import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선 import "./v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰 import "./v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기 import "./v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준 +import "./v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅 /** * 컴포넌트 초기화 함수 diff --git a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx index 78969fd0..d57ae60b 100644 --- a/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx +++ b/frontend/lib/registry/components/modal-repeater-table/RepeaterTable.tsx @@ -162,6 +162,79 @@ export function RepeaterTable({ // 초기 균등 분배 실행 여부 (마운트 시 한 번만 실행) const initializedRef = useRef(false); + // 편집 가능한 컬럼 인덱스 목록 (방향키 네비게이션용) + const editableColIndices = useMemo( + () => visibleColumns.reduce((acc, col, idx) => { + if (col.editable && !col.calculated) acc.push(idx); + return acc; + }, []), + [visibleColumns], + ); + + // 방향키로 리피터 셀 간 이동 + const handleArrowNavigation = useCallback( + (e: React.KeyboardEvent) => { + const key = e.key; + if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(key)) return; + + const target = e.target as HTMLElement; + const cell = target.closest("[data-repeater-row]") as HTMLElement | null; + if (!cell) return; + + const row = Number(cell.dataset.repeaterRow); + const col = Number(cell.dataset.repeaterCol); + if (isNaN(row) || isNaN(col)) return; + + // 텍스트 입력 중 좌/우 방향키는 커서 이동에 사용하므로 무시 + if ((key === "ArrowLeft" || key === "ArrowRight") && target.tagName === "INPUT") { + const input = target as HTMLInputElement; + const len = input.value?.length ?? 0; + const pos = input.selectionStart ?? 0; + // 커서가 끝에 있을 때만 오른쪽 이동, 처음에 있을 때만 왼쪽 이동 + if (key === "ArrowRight" && pos < len) return; + if (key === "ArrowLeft" && pos > 0) return; + } + + let nextRow = row; + let nextColPos = editableColIndices.indexOf(col); + + switch (key) { + case "ArrowUp": + nextRow = Math.max(0, row - 1); + break; + case "ArrowDown": + nextRow = Math.min(data.length - 1, row + 1); + break; + case "ArrowLeft": + nextColPos = Math.max(0, nextColPos - 1); + break; + case "ArrowRight": + nextColPos = Math.min(editableColIndices.length - 1, nextColPos + 1); + break; + } + + const nextCol = editableColIndices[nextColPos]; + if (nextRow === row && nextCol === col) return; + + e.preventDefault(); + + const selector = `[data-repeater-row="${nextRow}"][data-repeater-col="${nextCol}"]`; + const nextCell = containerRef.current?.querySelector(selector) as HTMLElement | null; + if (!nextCell) return; + + const focusable = nextCell.querySelector( + 'input:not([disabled]), select:not([disabled]), [role="combobox"]:not([disabled]), button:not([disabled])', + ); + if (focusable) { + focusable.focus(); + if (focusable.tagName === "INPUT") { + (focusable as HTMLInputElement).select(); + } + } + }, + [editableColIndices, data.length], + ); + // DnD 센서 설정 const sensors = useSensors( useSensor(PointerSensor, { @@ -480,15 +553,20 @@ export function RepeaterTable({ const isEditing = editingCell?.rowIndex === rowIndex && editingCell?.field === column.field; const value = row[column.field]; - // 🆕 카테고리 라벨 변환 함수 + // 카테고리 라벨 변환 함수 const getCategoryDisplayValue = (val: any): string => { if (!val || typeof val !== "string") return val || "-"; - // 카테고리 컬럼이 아니면 그대로 반환 - const fieldName = column.field.replace(/^_display_/, ""); // _display_ 접두사 제거 - if (!categoryColumns.includes(fieldName)) return val; + const fieldName = column.field.replace(/^_display_/, ""); + const isCategoryColumn = categoryColumns.includes(fieldName); - // 쉼표로 구분된 다중 값 처리 + // categoryLabelMap에 직접 매핑이 있으면 바로 변환 (접두사 무관) + if (categoryLabelMap[val]) return categoryLabelMap[val]; + + // 카테고리 컬럼이 아니면 원래 값 반환 + if (!isCategoryColumn) return val; + + // 콤마 구분된 다중 값 처리 const codes = val .split(",") .map((c: string) => c.trim()) @@ -643,7 +721,7 @@ export function RepeaterTable({ return ( -
+
{renderCell(row, col, rowIndex)} diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index c2bb436d..c2be4bb4 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx @@ -13,8 +13,34 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { X } from "lucide-react"; -import * as LucideIcons from "lucide-react"; +import { + X, + Check, + Plus, + Minus, + Edit, + Trash2, + Search, + Save, + RefreshCw, + AlertCircle, + Info, + Settings, + ChevronDown, + ChevronUp, + ChevronRight, + Copy, + Download, + Upload, + ExternalLink, + type LucideIcon, +} from "lucide-react"; + +const LUCIDE_ICON_MAP: Record = { + X, Check, Plus, Minus, Edit, Trash2, Search, Save, RefreshCw, + AlertCircle, Info, Settings, ChevronDown, ChevronUp, ChevronRight, + Copy, Download, Upload, ExternalLink, +}; import { commonCodeApi } from "@/lib/api/commonCode"; import { cn } from "@/lib/utils"; @@ -67,9 +93,12 @@ export const SelectedItemsDetailInputComponent: React.FC { + // sourceKeyField는 config에서 직접 지정 (ConfigPanel 자동 감지에서 설정됨) + return componentConfig.sourceKeyField || "item_id"; + }, [componentConfig.sourceKeyField]); // 🆕 dataSourceId 우선순위: URL 파라미터 > 컴포넌트 설정 > component.id const dataSourceId = useMemo( @@ -446,10 +475,16 @@ export const SelectedItemsDetailInputComponent: React.FC = {}; + + // sourceKeyField 자동 매핑 (item_id = originalData.id) + if (sourceKeyField && item.originalData?.id) { + baseRecord[sourceKeyField] = item.originalData.id; + } + + // 나머지 autoFillFrom 필드 (sourceKeyField 제외) additionalFields.forEach((f) => { - if (f.autoFillFrom && item.originalData) { + if (f.name !== sourceKeyField && f.autoFillFrom && item.originalData) { const value = item.originalData[f.autoFillFrom]; if (value !== undefined && value !== null) { baseRecord[f.name] = value; @@ -504,7 +539,7 @@ export const SelectedItemsDetailInputComponent: React.FC { - const groupFields = additionalFields.filter((f) => f.groupId === group.id); - groupFields.forEach((field) => { - if (field.name === sourceKeyField && field.autoFillFrom && item.originalData) { - sourceKeyValue = item.originalData[field.autoFillFrom] || null; - } - }); - }); - } - - // 3순위: fallback (최후의 수단) + // 2순위: 원본 데이터의 id를 sourceKeyField 값으로 사용 (신규 등록 모드) if (!sourceKeyValue && item.originalData) { sourceKeyValue = item.originalData.id || null; } @@ -1559,7 +1581,7 @@ export const SelectedItemsDetailInputComponent: React.FC; } diff --git a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx index 61f755a4..1f70e7e0 100644 --- a/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx +++ b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputConfigPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useMemo, useEffect } from "react"; +import React, { useState, useMemo, useEffect, useRef } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; @@ -10,7 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Card, CardContent } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Plus, X, ChevronDown, ChevronRight } from "lucide-react"; -import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat } from "./types"; +import { SelectedItemsDetailInputConfig, AdditionalFieldDefinition, FieldGroup, DisplayItem, DisplayItemType, EmptyBehavior, DisplayFieldFormat, AutoDetectedFk } from "./types"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Check, ChevronsUpDown } from "lucide-react"; @@ -97,7 +97,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC>([]); - const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); + const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState>([]); + + // FK 자동 감지 결과 + const [autoDetectedFks, setAutoDetectedFks] = useState([]); // 🆕 원본 테이블 컬럼 로드 useEffect(() => { @@ -130,10 +133,11 @@ export const SelectedItemsDetailInputConfigPanel: React.FC { if (!config.targetTable) { setLoadedTargetTableColumns([]); + setAutoDetectedFks([]); return; } @@ -149,7 +153,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC(() => { + if (!config.targetTable || loadedTargetTableColumns.length === 0) return []; + + const entityFkColumns = loadedTargetTableColumns.filter( + (col) => col.inputType === "entity" && col.referenceTable + ); + if (entityFkColumns.length === 0) return []; + + return entityFkColumns.map((col) => { + let mappingType: "source" | "parent" | "unknown" = "unknown"; + if (config.sourceTable && col.referenceTable === config.sourceTable) { + mappingType = "source"; + } else if (config.sourceTable && col.referenceTable !== config.sourceTable) { + mappingType = "parent"; + } + return { + columnName: col.columnName, + columnLabel: col.columnLabel, + referenceTable: col.referenceTable!, + referenceColumn: col.referenceColumn || "id", + mappingType, + }; + }); + }, [config.targetTable, config.sourceTable, loadedTargetTableColumns]); + + // 감지 결과를 state에 반영 + useEffect(() => { + setAutoDetectedFks(detectedFks); + }, [detectedFks]); + + // 자동 매핑 적용 (최초 1회만, targetTable 변경 시 리셋) + useEffect(() => { + fkAutoAppliedRef.current = false; + }, [config.targetTable]); + + useEffect(() => { + if (fkAutoAppliedRef.current || detectedFks.length === 0) return; + + const sourceFk = detectedFks.find((fk) => fk.mappingType === "source"); + const parentFks = detectedFks.filter((fk) => fk.mappingType === "parent"); + let changed = false; + + // sourceKeyField 자동 설정 + if (sourceFk && !config.sourceKeyField) { + console.log("🔗 sourceKeyField 자동 설정:", sourceFk.columnName); + handleChange("sourceKeyField", sourceFk.columnName); + changed = true; + } + + // parentDataMapping 자동 생성 (기존에 없을 때만) + if (parentFks.length > 0 && (!config.parentDataMapping || config.parentDataMapping.length === 0)) { + const autoMappings = parentFks.map((fk) => ({ + sourceTable: fk.referenceTable, + sourceField: "id", + targetField: fk.columnName, + })); + console.log("🔗 parentDataMapping 자동 생성:", autoMappings); + handleChange("parentDataMapping", autoMappings); + changed = true; + } + + if (changed) { + fkAutoAppliedRef.current = true; + } + }, [detectedFks]); + // 🆕 필드 그룹 변경 시 로컬 입력 상태 동기화 useEffect(() => { setLocalFieldGroups(config.fieldGroups || []); @@ -898,6 +975,37 @@ export const SelectedItemsDetailInputConfigPanel: React.FC최종 데이터를 저장할 테이블

+ {/* FK 자동 감지 결과 표시 */} + {autoDetectedFks.length > 0 && ( +
+

+ FK 자동 감지됨 ({autoDetectedFks.length}건) +

+
+ {autoDetectedFks.map((fk) => ( +
+ + {fk.mappingType === "source" ? "원본" : fk.mappingType === "parent" ? "부모" : "미분류"} + + {fk.columnName} + -> + {fk.referenceTable} +
+ ))} +
+

+ 엔티티 설정 기반 자동 매핑. sourceKeyField와 parentDataMapping이 자동으로 설정됩니다. +

+
+ )} + {/* 표시할 원본 데이터 컬럼 */}
@@ -961,7 +1069,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - {localFields.map((field, index) => ( + {localFields.map((field, index) => { + return (
@@ -1255,7 +1364,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC - ))} + ); + })}
{group.items.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; - const itemId = item[sourceColumn] || item.id || item.ID || idx; + const itemId = item[sourceColumn] || item.id || item.ID; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); return ( handleLeftItemSelect(item)} className={`hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" @@ -2596,14 +2700,14 @@ export const SplitPanelLayoutComponent: React.FC {filteredData.map((item, idx) => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; - const itemId = item[sourceColumn] || item.id || item.ID || idx; + const itemId = item[sourceColumn] || item.id || item.ID; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); return ( handleLeftItemSelect(item)} className={`hover:bg-accent cursor-pointer transition-colors ${ isSelected ? "bg-primary/10" : "" @@ -2698,7 +2802,8 @@ export const SplitPanelLayoutComponent: React.FC // 재귀 렌더링 함수 const renderTreeItem = (item: any, index: number): React.ReactNode => { const sourceColumn = componentConfig.leftPanel?.itemAddConfig?.sourceColumn || "id"; - const itemId = item[sourceColumn] || item.id || item.ID || index; + const rawItemId = item[sourceColumn] || item.id || item.ID; + const itemId = rawItemId != null ? rawItemId : index; const isSelected = selectedLeftItem && (selectedLeftItem[sourceColumn] === itemId || selectedLeftItem === item); const hasChildren = item.children && item.children.length > 0; @@ -2749,7 +2854,7 @@ export const SplitPanelLayoutComponent: React.FC const displaySubtitle = displayFields[1]?.value || null; return ( - + {/* 현재 항목 */}
return (
{currentTabData.map((item: any, idx: number) => { - const itemId = item.id || idx; + const itemId = item.id ?? idx; const isExpanded = expandedRightItems.has(itemId); // 표시할 컬럼 결정 @@ -3097,7 +3202,7 @@ export const SplitPanelLayoutComponent: React.FC const detailColumns = columnsToShow.slice(summaryCount); return ( -
+
toggleRightItemExpansion(itemId)} @@ -3287,10 +3392,10 @@ export const SplitPanelLayoutComponent: React.FC
{filteredData.map((item, idx) => { - const itemId = item.id || item.ID || idx; + const itemId = item.id || item.ID; return ( - + {columnsToShow.map((col, colIdx) => (
return (
{/* 요약 정보 */} diff --git a/frontend/lib/registry/components/table-list/TableListComponent.tsx b/frontend/lib/registry/components/table-list/TableListComponent.tsx index d0f9d5aa..aee70dd2 100644 --- a/frontend/lib/registry/components/table-list/TableListComponent.tsx +++ b/frontend/lib/registry/components/table-list/TableListComponent.tsx @@ -781,6 +781,7 @@ export const TableListComponent: React.FC = ({ const dataProvider: DataProvidable = { componentId: component.id, componentType: "table-list", + tableName: tableConfig.selectedTable, getSelectedData: () => { // 🆕 필터링된 데이터에서 선택된 행만 반환 (우측에 추가된 항목 제외) @@ -940,23 +941,35 @@ export const TableListComponent: React.FC = ({ } } - // 일반 타입 또는 카테고리 조회 실패 시: 현재 데이터 기반 + // 백엔드 DISTINCT API로 전체 고유값 조회 (페이징과 무관하게 모든 값 반환) + try { + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/entity/${tableConfig.selectedTable}/distinct/${columnName}`); + + if (response.data.success && response.data.data && response.data.data.length > 0) { + return response.data.data.map((item: any) => ({ + value: String(item.value), + label: String(item.label), + })); + } + } catch { + // DISTINCT API 실패 시 현재 데이터 기반으로 fallback + } + + // fallback: 현재 로드된 데이터에서 고유 값 추출 const isLabelType = ["category", "entity", "code"].includes(inputType); const labelField = isLabelType ? `${columnName}_name` : columnName; - // 현재 로드된 데이터에서 고유 값 추출 - const uniqueValuesMap = new Map(); // value -> label + const uniqueValuesMap = new Map(); data.forEach((row) => { const value = row[columnName]; if (value !== null && value !== undefined && value !== "") { - // 백엔드 조인된 _name 필드 사용 (없으면 원본 값) const label = isLabelType && row[labelField] ? row[labelField] : String(value); uniqueValuesMap.set(String(value), label); } }); - // Map을 배열로 변환하고 라벨 기준으로 정렬 const result = Array.from(uniqueValuesMap.entries()) .map(([value, label]) => ({ value: value, @@ -4192,9 +4205,10 @@ export const TableListComponent: React.FC = ({ // inputType 기반 포맷팅 (columnMeta에서 가져온 inputType 우선) const inputType = meta?.inputType || column.inputType; - // 🖼️ 이미지 타입: 작은 썸네일 표시 + // 🖼️ 이미지 타입: 작은 썸네일 표시 (다중 이미지인 경우 대표 이미지 1개만) if (inputType === "image" && value && typeof value === "string") { - const imageUrl = getFullImageUrl(value); + const firstImage = value.includes(",") ? value.split(",")[0].trim() : value; + const imageUrl = getFullImageUrl(firstImage); return ( = ({ // 다중 값인 경우: 여러 배지 렌더링 return ( -
+
{values.map((val, idx) => { const categoryData = mapping?.[val]; const displayLabel = categoryData?.label || val; @@ -4316,7 +4330,7 @@ export const TableListComponent: React.FC = ({ // 배지 없음 옵션: color가 없거나, "none"이거나, 매핑 데이터가 없으면 텍스트만 표시 if (!displayColor || displayColor === "none" || !categoryData) { return ( - + {displayLabel} {idx < values.length - 1 && ", "} @@ -4330,7 +4344,7 @@ export const TableListComponent: React.FC = ({ backgroundColor: displayColor, borderColor: displayColor, }} - className="text-white" + className="shrink-0 whitespace-nowrap text-white" > {displayLabel} diff --git a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx index 120022a5..06226c9e 100644 --- a/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx +++ b/frontend/lib/registry/components/universal-form-modal/TableSectionRenderer.tsx @@ -554,6 +554,69 @@ export function TableSectionRenderer({ loadCategoryOptions(); }, [tableConfig.source.tableName, tableConfig.columns]); + // receiveFromParent / internal 매핑으로 넘어오는 formData 값의 라벨 사전 로드 + useEffect(() => { + if (!formData || Object.keys(formData).length === 0) return; + if (!tableConfig.columns) return; + + const codesToResolve: string[] = []; + for (const col of tableConfig.columns) { + // receiveFromParent 컬럼 + if ((col as any).receiveFromParent) { + const parentField = (col as any).parentFieldName || col.field; + const val = formData[parentField]; + if (typeof val === "string" && val) { + codesToResolve.push(val); + } + } + // internal 매핑 컬럼 + const mapping = (col as any).valueMapping; + if (mapping?.type === "internal" && mapping.internalField) { + const val = formData[mapping.internalField]; + if (typeof val === "string" && val) { + codesToResolve.push(val); + } + } + } + + if (codesToResolve.length === 0) return; + + const loadParentLabels = async () => { + try { + const resp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: codesToResolve, + }); + if (resp.data?.success && resp.data.data) { + const labelData = resp.data.data as Record; + // categoryOptionsMap에 추가 (receiveFromParent 컬럼별로) + const newOptionsMap: Record = {}; + for (const col of tableConfig.columns) { + let val: string | undefined; + if ((col as any).receiveFromParent) { + const parentField = (col as any).parentFieldName || col.field; + val = formData[parentField] as string; + } + const mapping = (col as any).valueMapping; + if (mapping?.type === "internal" && mapping.internalField) { + val = formData[mapping.internalField] as string; + } + if (val && typeof val === "string" && labelData[val]) { + newOptionsMap[col.field] = [{ value: val, label: labelData[val] }]; + } + } + if (Object.keys(newOptionsMap).length > 0) { + setCategoryOptionsMap((prev) => ({ ...prev, ...newOptionsMap })); + } + } + } catch { + // 라벨 조회 실패 시 무시 + } + }; + + loadParentLabels(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formData, tableConfig.columns]); + // 조건부 테이블: 동적 옵션 로드 (optionSource 설정이 있는 경우) useEffect(() => { if (!isConditionalMode) return; @@ -1005,6 +1068,23 @@ export function TableSectionRenderer({ }); }, [tableConfig.columns, dynamicSelectOptionsMap, categoryOptionsMap]); + // categoryOptionsMap에서 RepeaterTable용 카테고리 정보 파생 + const tableCategoryColumns = useMemo(() => { + return Object.keys(categoryOptionsMap); + }, [categoryOptionsMap]); + + const tableCategoryLabelMap = useMemo(() => { + const map: Record = {}; + for (const options of Object.values(categoryOptionsMap)) { + for (const opt of options) { + if (opt.value && opt.label) { + map[opt.value] = opt.label; + } + } + } + return map; + }, [categoryOptionsMap]); + // 원본 계산 규칙 (조건부 계산 포함) const originalCalculationRules: TableCalculationRule[] = useMemo( () => tableConfig.calculations || [], @@ -1312,6 +1392,67 @@ export function TableSectionRenderer({ }), ); + // 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용) + const categoryFields = (tableConfig.columns || []) + .filter((col) => col.type === "category" || col.type === "select") + .reduce>>((acc, col) => { + const options = categoryOptionsMap[col.field]; + if (options && options.length > 0) { + acc[col.field] = {}; + for (const opt of options) { + acc[col.field][opt.value] = opt.label; + } + } + return acc; + }, {}); + + // receiveFromParent / internal 매핑으로 넘어온 값도 포함하여 변환 + if (Object.keys(categoryFields).length > 0) { + for (const item of mappedItems) { + for (const [field, codeToLabel] of Object.entries(categoryFields)) { + const val = item[field]; + if (typeof val === "string" && codeToLabel[val]) { + item[field] = codeToLabel[val]; + } + } + } + } + + // categoryOptionsMap에 없는 경우 API fallback + const unresolvedCodes = new Set(); + const categoryColFields = new Set( + (tableConfig.columns || []).filter((col) => col.type === "category").map((col) => col.field), + ); + for (const item of mappedItems) { + for (const field of categoryColFields) { + const val = item[field]; + if (typeof val === "string" && val && !categoryFields[field]?.[val] && val !== item[field]) { + unresolvedCodes.add(val); + } + } + } + + if (unresolvedCodes.size > 0) { + try { + const labelResp = await apiClient.post("/table-categories/labels-by-codes", { + valueCodes: Array.from(unresolvedCodes), + }); + if (labelResp.data?.success && labelResp.data.data) { + const labelData = labelResp.data.data as Record; + for (const item of mappedItems) { + for (const field of categoryColFields) { + const val = item[field]; + if (typeof val === "string" && labelData[val]) { + item[field] = labelData[val]; + } + } + } + } + } catch { + // 변환 실패 시 코드 유지 + } + } + // 계산 필드 업데이트 const calculatedItems = calculateAll(mappedItems); @@ -1319,7 +1460,7 @@ export function TableSectionRenderer({ const newData = [...tableData, ...calculatedItems]; handleDataChange(newData); }, - [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources], + [tableConfig.columns, formData, tableData, calculateAll, handleDataChange, activeDataSources, categoryOptionsMap], ); // 컬럼 모드/조회 옵션 변경 핸들러 @@ -1667,6 +1808,31 @@ export function TableSectionRenderer({ }), ); + // 카테고리 타입 컬럼의 코드 → 라벨 변환 (categoryOptionsMap 활용) + const categoryFields = (tableConfig.columns || []) + .filter((col) => col.type === "category" || col.type === "select") + .reduce>>((acc, col) => { + const options = categoryOptionsMap[col.field]; + if (options && options.length > 0) { + acc[col.field] = {}; + for (const opt of options) { + acc[col.field][opt.value] = opt.label; + } + } + return acc; + }, {}); + + if (Object.keys(categoryFields).length > 0) { + for (const item of mappedItems) { + for (const [field, codeToLabel] of Object.entries(categoryFields)) { + const val = item[field]; + if (typeof val === "string" && codeToLabel[val]) { + item[field] = codeToLabel[val]; + } + } + } + } + // 현재 조건의 데이터에 추가 const currentData = conditionalTableData[modalCondition] || []; const newData = [...currentData, ...mappedItems]; @@ -1964,6 +2130,8 @@ export function TableSectionRenderer({ [conditionValue]: newSelected, })); }} + categoryColumns={tableCategoryColumns} + categoryLabelMap={tableCategoryLabelMap} equalizeWidthsTrigger={widthTrigger} /> @@ -2055,6 +2223,8 @@ export function TableSectionRenderer({ })); }} equalizeWidthsTrigger={widthTrigger} + categoryColumns={tableCategoryColumns} + categoryLabelMap={tableCategoryLabelMap} /> ); @@ -2185,6 +2355,8 @@ export function TableSectionRenderer({ selectedRows={selectedRows} onSelectionChange={setSelectedRows} equalizeWidthsTrigger={widthTrigger} + categoryColumns={tableCategoryColumns} + categoryLabelMap={tableCategoryLabelMap} /> {/* 항목 선택 모달 */} diff --git a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx index 26acaf34..6d55b650 100644 --- a/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx +++ b/frontend/lib/registry/components/universal-form-modal/UniversalFormModalComponent.tsx @@ -28,6 +28,7 @@ import { apiClient } from "@/lib/api/client"; import { allocateNumberingCode, previewNumberingCode } from "@/lib/api/numberingRule"; import { useCascadingDropdown } from "@/hooks/useCascadingDropdown"; import { CascadingDropdownConfig } from "@/types/screen-management"; +import { FormDatePicker } from "@/components/screen/filters/FormDatePicker"; import { UniversalFormModalComponentProps, @@ -247,6 +248,10 @@ export function UniversalFormModalComponent({ // 폼 데이터 상태 const [formData, setFormData] = useState({}); + // formDataRef: 항상 최신 formData를 유지하는 ref + // React 상태 업데이트는 비동기적이므로, handleBeforeFormSave 등에서 + // 클로저의 formData가 오래된 값을 참조하는 문제를 방지 + const formDataRef = useRef({}); const [, setOriginalData] = useState>({}); // 반복 섹션 데이터 @@ -398,18 +403,19 @@ export function UniversalFormModalComponent({ console.log("[UniversalFormModal] beforeFormSave 이벤트 수신"); console.log("[UniversalFormModal] 설정된 필드 목록:", Array.from(configuredFields)); + // formDataRef.current 사용: blur+click 동시 처리 시 클로저의 formData가 오래된 문제 방지 + const latestFormData = formDataRef.current; + // 🆕 시스템 필드 병합: id는 설정 여부와 관계없이 항상 전달 (UPDATE/INSERT 판단용) - // - 신규 등록: formData.id가 없으므로 영향 없음 - // - 편집 모드: formData.id가 있으면 메인 테이블 UPDATE에 사용 - if (formData.id !== undefined && formData.id !== null && formData.id !== "") { - event.detail.formData.id = formData.id; - console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, formData.id); + if (latestFormData.id !== undefined && latestFormData.id !== null && latestFormData.id !== "") { + event.detail.formData.id = latestFormData.id; + console.log(`[UniversalFormModal] 시스템 필드 병합: id =`, latestFormData.id); } // UniversalFormModal에 설정된 필드만 병합 (채번 규칙 포함) // 외부 formData에 이미 값이 있어도 UniversalFormModal 값으로 덮어씀 // (UniversalFormModal이 해당 필드의 주인이므로) - for (const [key, value] of Object.entries(formData)) { + for (const [key, value] of Object.entries(latestFormData)) { // 설정에 정의된 필드 또는 채번 규칙 ID 필드만 병합 const isConfiguredField = configuredFields.has(key); const isNumberingRuleId = key.endsWith("_numberingRuleId"); @@ -432,17 +438,13 @@ export function UniversalFormModalComponent({ } // 🆕 테이블 섹션 데이터 병합 (품목 리스트 등) - // 참고: initializeForm에서 DB 로드 시 __tableSection_ (더블), - // handleTableDataChange에서 수정 시 _tableSection_ (싱글) 사용 - for (const [key, value] of Object.entries(formData)) { - // 싱글/더블 언더스코어 모두 처리 + // formDataRef.current(= latestFormData) 사용: React 상태 커밋 전에도 최신 데이터 보장 + for (const [key, value] of Object.entries(latestFormData)) { + // _tableSection_ 과 __tableSection_ 모두 원본 키 그대로 전달 + // buttonActions.ts에서 DB데이터(__tableSection_)와 수정데이터(_tableSection_)를 구분하여 병합 if ((key.startsWith("_tableSection_") || key.startsWith("__tableSection_")) && Array.isArray(value)) { - // 저장 시에는 _tableSection_ 키로 통일 (buttonActions.ts에서 이 키를 기대) - const normalizedKey = key.startsWith("__tableSection_") - ? key.replace("__tableSection_", "_tableSection_") - : key; - event.detail.formData[normalizedKey] = value; - console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key} → ${normalizedKey}, ${value.length}개 항목`); + event.detail.formData[key] = value; + console.log(`[UniversalFormModal] 테이블 섹션 병합: ${key}, ${value.length}개 항목`); } // 🆕 원본 테이블 섹션 데이터도 병합 (삭제 추적용) @@ -457,6 +459,22 @@ export function UniversalFormModalComponent({ event.detail.formData._originalGroupedData = originalGroupedData; console.log(`[UniversalFormModal] 원본 그룹 데이터 병합: ${originalGroupedData.length}개`); } + + // 🆕 부모 formData의 중첩 객체(modalKey)도 최신 데이터로 업데이트 + // onChange(setTimeout)가 아직 부모에 전파되지 않았을 수 있으므로 직접 업데이트 + for (const parentKey of Object.keys(event.detail.formData)) { + const parentValue = event.detail.formData[parentKey]; + if (parentValue && typeof parentValue === "object" && !Array.isArray(parentValue)) { + const hasTableSection = Object.keys(parentValue).some( + (k) => k.startsWith("_tableSection_") || k.startsWith("__tableSection_"), + ); + if (hasTableSection) { + event.detail.formData[parentKey] = { ...latestFormData }; + console.log(`[UniversalFormModal] 부모 중첩 객체 업데이트: ${parentKey}`); + break; + } + } + } }; window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener); @@ -482,10 +500,11 @@ export function UniversalFormModalComponent({ // 테이블 섹션 데이터 설정 const tableSectionKey = `_tableSection_${tableSection.id}`; - setFormData((prev) => ({ - ...prev, - [tableSectionKey]: _groupedData, - })); + setFormData((prev) => { + const newData = { ...prev, [tableSectionKey]: _groupedData }; + formDataRef.current = newData; + return newData; + }); groupedDataInitializedRef.current = true; }, [_groupedData, config.sections]); @@ -965,6 +984,7 @@ export function UniversalFormModalComponent({ } setFormData(newFormData); + formDataRef.current = newFormData; setRepeatSections(newRepeatSections); setCollapsedSections(newCollapsed); setActivatedOptionalFieldGroups(newActivatedGroups); @@ -1132,6 +1152,9 @@ export function UniversalFormModalComponent({ console.log(`[연쇄 드롭다운] 부모 ${columnName} 변경 → 자식 ${childField} 초기화`); } + // ref 즉시 업데이트 (React 상태 커밋 전에도 최신 데이터 접근 가능) + formDataRef.current = newData; + // onChange는 렌더링 외부에서 호출해야 함 (setTimeout 사용) if (onChange) { setTimeout(() => onChange(newData), 0); @@ -1813,11 +1836,11 @@ export function UniversalFormModalComponent({ case "date": return ( - onChangeHandler(e.target.value)} + onChange={onChangeHandler} + placeholder={field.placeholder || "날짜를 선택하세요"} disabled={isDisabled} readOnly={field.readOnly} /> @@ -1825,13 +1848,14 @@ export function UniversalFormModalComponent({ case "datetime": return ( - onChangeHandler(e.target.value)} + onChange={onChangeHandler} + placeholder={field.placeholder || "날짜/시간을 선택하세요"} disabled={isDisabled} readOnly={field.readOnly} + includeTime /> ); diff --git a/frontend/lib/registry/components/universal-form-modal/types.ts b/frontend/lib/registry/components/universal-form-modal/types.ts index a937f5b2..c6673d8d 100644 --- a/frontend/lib/registry/components/universal-form-modal/types.ts +++ b/frontend/lib/registry/components/universal-form-modal/types.ts @@ -393,7 +393,7 @@ export interface TableModalFilter { export interface TableColumnConfig { field: string; // 필드명 (저장할 컬럼명) label: string; // 컬럼 헤더 라벨 - type: "text" | "number" | "date" | "select"; // 입력 타입 + type: "text" | "number" | "date" | "select" | "category"; // 입력 타입 // 소스 필드 매핑 (검색 모달에서 가져올 컬럼명) sourceField?: string; // 소스 테이블의 컬럼명 (미설정 시 field와 동일) diff --git a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx index 8191e68b..e4521ac0 100644 --- a/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx +++ b/frontend/lib/registry/components/v2-bom-item-editor/BomItemEditorComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { GripVertical, Plus, @@ -13,6 +13,7 @@ import { import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, @@ -26,6 +27,7 @@ import { DialogHeader, DialogTitle, DialogDescription, + DialogFooter, } from "@/components/ui/dialog"; import { entityJoinApi } from "@/lib/api/entityJoin"; import { apiClient } from "@/lib/api/client"; @@ -35,21 +37,23 @@ import { apiClient } from "@/lib/api/client"; interface BomItemNode { tempId: string; id?: string; - bom_id?: string; parent_detail_id: string | null; seq_no: number; level: number; - child_item_id: string; - child_item_code: string; - child_item_name: string; - child_item_type: string; - quantity: string; - unit: string; - loss_rate: string; - remark: string; children: BomItemNode[]; _isNew?: boolean; _isDeleted?: boolean; + data: Record; +} + +interface BomColumnConfig { + key: string; + title: string; + width?: string; + visible?: boolean; + editable?: boolean; + isSourceDisplay?: boolean; + inputType?: string; } interface ItemInfo { @@ -80,7 +84,7 @@ const generateTempId = () => `temp_${Date.now()}_${++tempIdCounter}`; interface ItemSearchModalProps { open: boolean; onClose: () => void; - onSelect: (item: ItemInfo) => void; + onSelect: (items: ItemInfo[]) => void; companyCode?: string; } @@ -92,6 +96,7 @@ function ItemSearchModal({ }: ItemSearchModalProps) { const [searchText, setSearchText] = useState(""); const [items, setItems] = useState([]); + const [selectedItems, setSelectedItems] = useState>(new Set()); const [loading, setLoading] = useState(false); const searchItems = useCallback( @@ -107,7 +112,7 @@ function ItemSearchModal({ enableEntityJoin: true, companyCodeOverride: companyCode, }); - setItems(result.data || []); + setItems((result.data || []) as ItemInfo[]); } catch (error) { console.error("[BomItemEditor] 품목 검색 실패:", error); } finally { @@ -120,6 +125,7 @@ function ItemSearchModal({ useEffect(() => { if (open) { setSearchText(""); + setSelectedItems(new Set()); searchItems(""); } }, [open, searchItems]); @@ -178,6 +184,15 @@ function ItemSearchModal({ + @@ -189,11 +204,31 @@ function ItemSearchModal({ { - onSelect(item); - onClose(); + setSelectedItems((prev) => { + const next = new Set(prev); + if (next.has(item.id)) next.delete(item.id); + else next.add(item.id); + return next; + }); }} - className="hover:bg-accent cursor-pointer border-t transition-colors" + className={cn( + "cursor-pointer border-t transition-colors", + selectedItems.has(item.id) ? "bg-primary/10" : "hover:bg-accent", + )} > + @@ -206,22 +241,48 @@ function ItemSearchModal({
+ 0 && selectedItems.size === items.length} + onCheckedChange={(checked) => { + if (checked) setSelectedItems(new Set(items.map((i) => i.id))); + else setSelectedItems(new Set()); + }} + /> + 품목코드 품목명 구분
e.stopPropagation()}> + { + setSelectedItems((prev) => { + const next = new Set(prev); + if (checked) next.add(item.id); + else next.delete(item.id); + return next; + }); + }} + /> + {item.item_number}
)}
+ + {selectedItems.size > 0 && ( + + + {selectedItems.size}개 선택됨 + + + + )} ); } -// ─── 트리 노드 행 렌더링 ─── +// ─── 트리 노드 행 렌더링 (config.columns 기반 동적 셀) ─── interface TreeNodeRowProps { node: BomItemNode; depth: number; expanded: boolean; hasChildren: boolean; + columns: BomColumnConfig[]; + categoryOptionsMap: Record; + mainTableName?: string; onToggle: () => void; 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({ @@ -229,12 +290,88 @@ function TreeNodeRow({ depth, expanded, hasChildren, + columns, + categoryOptionsMap, + mainTableName, onToggle, onFieldChange, onDelete, onAddChild, + onDragStart, + onDragOver, + onDrop, + isDragOver, }: TreeNodeRowProps) { const indentPx = depth * 32; + const visibleColumns = columns.filter((c) => c.visible !== false); + + const renderCell = (col: BomColumnConfig) => { + const value = node.data[col.key] ?? ""; + + // 소스 표시 컬럼 (읽기 전용) + if (col.isSourceDisplay) { + return ( + + {value || "-"} + + ); + } + + // 카테고리 타입: API에서 로드한 옵션으로 Select 렌더링 + if (col.inputType === "category") { + const categoryRef = mainTableName ? `${mainTableName}.${col.key}` : ""; + const options = categoryOptionsMap[categoryRef] || []; + return ( + + ); + } + + // 편집 불가능 컬럼 + if (col.editable === false) { + return ( + + {value || "-"} + + ); + } + + // 숫자 입력 + if (col.inputType === "number" || col.inputType === "decimal") { + return ( + onFieldChange(node.tempId, col.key, e.target.value)} + className="h-7 w-full min-w-[50px] text-center text-xs" + placeholder={col.title} + /> + ); + } + + // 기본 텍스트 입력 + return ( + onFieldChange(node.tempId, col.key, e.target.value)} + className="h-7 w-full min-w-[50px] text-xs" + placeholder={col.title} + /> + ); + }; return (
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)} > - {/* 드래그 핸들 */} - {/* 펼침/접기 */} - {/* 삭제 버튼 */}
- {/* 더미 트리 미리보기 */} -
- {dummyRows.map((row, i) => ( -
0 && "border-l-2 border-l-primary/20", - i === 0 && "bg-accent/30", - )} - style={{ marginLeft: `${row.depth * 20}px` }} - > - - {row.depth === 0 ? ( - - ) : ( - - )} - - {i + 1} - - - {row.code} - - - {row.name} - - - {/* 소스 표시 컬럼 미리보기 */} - {sourceColumns.slice(0, 2).map((col: any) => ( - - {col.title} - - ))} - - {/* 입력 컬럼 미리보기 */} - {inputColumns.slice(0, 2).map((col: any) => ( -
- {col.key === "quantity" || col.title === "수량" - ? row.qty - : ""} -
- ))} - -
-
- -
-
- -
-
+ {/* 테이블 형태 미리보기 - config.columns 순서 그대로 */} +
+ {visibleColumns.length === 0 ? ( +
+ +

+ 컬럼 탭에서 표시할 컬럼을 선택하세요 +

- ))} + ) : ( + + + + + {visibleColumns.map((col: any) => ( + + ))} + + + + + {DUMMY_DEPTHS.map((depth, rowIdx) => ( + + + + {visibleColumns.map((col: any) => ( + + ))} + + + ))} + +
+ # + {col.title} + 액션
+
+ {depth === 0 ? ( + + ) : ( + + )} +
+
+ {rowIdx + 1} + + {col.isSourceDisplay ? ( + + {getDummyValue(col, rowIdx) || col.title} + + ) : col.editable !== false ? ( +
+ {getDummyValue(col, rowIdx)} +
+ ) : ( + + {getDummyValue(col, rowIdx)} + + )} +
+
+
+ +
+
+ +
+
+
+ )}
); @@ -784,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" + /> +
+
+ +
+
+ + +
+
+ + +
+
+ + )} + +
+ +