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..0194c053 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Claude Code (로컬 전용 - Git 제외) +.claude/ + # Dependencies node_modules/ npm-debug.log* @@ -286,4 +289,7 @@ uploads/ *.hwp *.hwpx -claude.md \ No newline at end of file +claude.md + +# 개인 작업 문서 (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/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/(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/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/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/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/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx b/frontend/lib/registry/components/selected-items-detail-input/SelectedItemsDetailInputComponent.tsx index c2bb436d..949cd74b 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"; @@ -1559,7 +1585,7 @@ export const SelectedItemsDetailInputComponent: React.FC; } diff --git a/frontend/lib/registry/pop-components/index.ts b/frontend/lib/registry/pop-components/index.ts index b604f9e8..36d1109c 100644 --- a/frontend/lib/registry/pop-components/index.ts +++ b/frontend/lib/registry/pop-components/index.ts @@ -13,8 +13,14 @@ export * from "./types"; // POP 컴포넌트 등록 import "./pop-text"; +import "./pop-icon"; +import "./pop-dashboard"; +import "./pop-card-list"; + +import "./pop-button"; +import "./pop-string-list"; +import "./pop-search"; // 향후 추가될 컴포넌트들: // import "./pop-field"; -// import "./pop-button"; // import "./pop-list"; diff --git a/frontend/lib/registry/pop-components/pop-button.tsx b/frontend/lib/registry/pop-components/pop-button.tsx new file mode 100644 index 00000000..b5532c30 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-button.tsx @@ -0,0 +1,998 @@ +"use client"; + +import { useCallback } from "react"; +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { usePopAction } from "@/hooks/pop/usePopAction"; +import { usePopDesignerContext } from "@/components/pop/designer/PopDesignerContext"; +import { + Save, + Trash2, + LogOut, + Menu, + ExternalLink, + Plus, + Check, + X, + Edit, + Search, + RefreshCw, + Download, + Upload, + Send, + Copy, + Settings, + ChevronDown, + type LucideIcon, +} from "lucide-react"; +import { toast } from "sonner"; + +// ======================================== +// STEP 1: 타입 정의 +// ======================================== + +/** 메인 액션 타입 (5종) */ +export type ButtonActionType = "save" | "delete" | "api" | "modal" | "event"; + +/** 후속 액션 타입 (4종) */ +export type FollowUpActionType = "event" | "refresh" | "navigate" | "close-modal"; + +/** 버튼 variant (shadcn 기반 4종) */ +export type ButtonVariant = "default" | "secondary" | "outline" | "destructive"; + +/** 모달 열기 방식 */ +export type ModalMode = "dropdown" | "fullscreen" | "screen-ref"; + +/** 확인 다이얼로그 설정 */ +export interface ConfirmConfig { + enabled: boolean; + message?: string; // 빈값이면 기본 메시지 +} + +/** 후속 액션 1건 */ +export interface FollowUpAction { + type: FollowUpActionType; + // event + eventName?: string; + eventPayload?: Record; + // navigate + targetScreenId?: string; + params?: Record; +} + +/** 드롭다운 모달 메뉴 항목 */ +export interface ModalMenuItem { + label: string; + screenId?: string; + action?: string; // 커스텀 이벤트명 +} + +/** 메인 액션 설정 */ +export interface ButtonMainAction { + type: ButtonActionType; + // save/delete 공통 + targetTable?: string; + // api + apiEndpoint?: string; + apiMethod?: "GET" | "POST" | "PUT" | "DELETE"; + // modal + modalMode?: ModalMode; + modalScreenId?: string; + modalTitle?: string; + modalItems?: ModalMenuItem[]; + // event + eventName?: string; + eventPayload?: Record; +} + +/** 프리셋 이름 */ +export type ButtonPreset = + | "save" + | "delete" + | "logout" + | "menu" + | "modal-open" + | "custom"; + +/** pop-button 전체 설정 */ +export interface PopButtonConfig { + label: string; + variant: ButtonVariant; + icon?: string; // Lucide 아이콘 이름 + iconOnly?: boolean; + preset: ButtonPreset; + confirm?: ConfirmConfig; + action: ButtonMainAction; + followUpActions?: FollowUpAction[]; +} + +// ======================================== +// 상수 +// ======================================== + +/** 메인 액션 타입 라벨 */ +const ACTION_TYPE_LABELS: Record = { + save: "저장", + delete: "삭제", + api: "API 호출", + modal: "모달 열기", + event: "이벤트 발행", +}; + +/** 후속 액션 타입 라벨 */ +const FOLLOWUP_TYPE_LABELS: Record = { + event: "이벤트 발행", + refresh: "새로고침", + navigate: "화면 이동", + "close-modal": "모달 닫기", +}; + +/** variant 라벨 */ +const VARIANT_LABELS: Record = { + default: "기본 (Primary)", + secondary: "보조 (Secondary)", + outline: "외곽선 (Outline)", + destructive: "위험 (Destructive)", +}; + +/** 프리셋 라벨 */ +const PRESET_LABELS: Record = { + save: "저장", + delete: "삭제", + logout: "로그아웃", + menu: "메뉴 (드롭다운)", + "modal-open": "모달 열기", + custom: "직접 설정", +}; + +/** 모달 모드 라벨 */ +const MODAL_MODE_LABELS: Record = { + dropdown: "드롭다운", + fullscreen: "전체 모달", + "screen-ref": "화면 선택", +}; + +/** API 메서드 라벨 */ +const API_METHOD_LABELS: Record = { + GET: "GET", + POST: "POST", + PUT: "PUT", + DELETE: "DELETE", +}; + +/** 주요 Lucide 아이콘 목록 (설정 패널용) */ +const ICON_OPTIONS: { value: string; label: string }[] = [ + { value: "none", label: "없음" }, + { value: "Save", label: "저장 (Save)" }, + { value: "Trash2", label: "삭제 (Trash)" }, + { value: "LogOut", label: "로그아웃 (LogOut)" }, + { value: "Menu", label: "메뉴 (Menu)" }, + { value: "ExternalLink", label: "외부링크 (ExternalLink)" }, + { value: "Plus", label: "추가 (Plus)" }, + { value: "Check", label: "확인 (Check)" }, + { value: "X", label: "취소 (X)" }, + { value: "Edit", label: "수정 (Edit)" }, + { value: "Search", label: "검색 (Search)" }, + { value: "RefreshCw", label: "새로고침 (RefreshCw)" }, + { value: "Download", label: "다운로드 (Download)" }, + { value: "Upload", label: "업로드 (Upload)" }, + { value: "Send", label: "전송 (Send)" }, + { value: "Copy", label: "복사 (Copy)" }, + { value: "Settings", label: "설정 (Settings)" }, + { value: "ChevronDown", label: "아래 화살표 (ChevronDown)" }, +]; + +/** 프리셋별 기본 설정 */ +const PRESET_DEFAULTS: Record> = { + save: { + label: "저장", + variant: "default", + icon: "Save", + confirm: { enabled: false }, + action: { type: "save" }, + }, + delete: { + label: "삭제", + variant: "destructive", + icon: "Trash2", + confirm: { enabled: true, message: "" }, + action: { type: "delete" }, + }, + logout: { + label: "로그아웃", + variant: "outline", + icon: "LogOut", + confirm: { enabled: true, message: "로그아웃 하시겠습니까?" }, + action: { + type: "api", + apiEndpoint: "/api/auth/logout", + apiMethod: "POST", + }, + }, + menu: { + label: "메뉴", + variant: "secondary", + icon: "Menu", + confirm: { enabled: false }, + action: { type: "modal", modalMode: "dropdown" }, + }, + "modal-open": { + label: "열기", + variant: "outline", + icon: "ExternalLink", + confirm: { enabled: false }, + action: { type: "modal", modalMode: "fullscreen" }, + }, + custom: { + label: "버튼", + variant: "default", + icon: "none", + confirm: { enabled: false }, + action: { type: "save" }, + }, +}; + +/** 확인 다이얼로그 기본 메시지 (액션별) */ +const DEFAULT_CONFIRM_MESSAGES: Record = { + save: "저장하시겠습니까?", + delete: "삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + api: "실행하시겠습니까?", + modal: "열기하시겠습니까?", + event: "실행하시겠습니까?", +}; + +// ======================================== +// 헬퍼 함수 +// ======================================== + +/** 섹션 구분선 */ +function SectionDivider({ label }: { label: string }) { + return ( +
+
+
+ {label} +
+
+
+ ); +} + +/** 허용된 아이콘 맵 (개별 import로 트리 쉐이킹 적용) */ +const LUCIDE_ICON_MAP: Record = { + Save, Trash2, LogOut, Menu, ExternalLink, Plus, Check, X, + Edit, Search, RefreshCw, Download, Upload, Send, Copy, Settings, ChevronDown, +}; + +/** Lucide 아이콘 동적 렌더링 */ +function DynamicLucideIcon({ + name, + size = 16, + className, +}: { + name: string; + size?: number; + className?: string; +}) { + const IconComponent = LUCIDE_ICON_MAP[name]; + if (!IconComponent) return null; + return ; +} + +// ======================================== +// STEP 2: 메인 컴포넌트 +// ======================================== + +interface PopButtonComponentProps { + config?: PopButtonConfig; + label?: string; + isDesignMode?: boolean; + screenId?: string; +} + +export function PopButtonComponent({ + config, + label, + isDesignMode, + screenId, +}: PopButtonComponentProps) { + // usePopAction 훅으로 액션 실행 통합 + const { + execute, + isLoading, + pendingConfirm, + confirmExecute, + cancelConfirm, + } = usePopAction(screenId || ""); + + // 확인 메시지 결정 + const getConfirmMessage = useCallback((): string => { + if (pendingConfirm?.confirm?.message) return pendingConfirm.confirm.message; + if (config?.confirm?.message) return config.confirm.message; + return DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]; + }, [pendingConfirm?.confirm?.message, config?.confirm?.message, config?.action?.type]); + + // 클릭 핸들러 + const handleClick = useCallback(async () => { + // 디자인 모드: 실제 실행 안 함 + if (isDesignMode) { + toast.info( + `[디자인 모드] ${ACTION_TYPE_LABELS[config?.action?.type || "save"]} 액션` + ); + return; + } + + const action = config?.action; + if (!action) return; + + await execute(action, { + confirm: config?.confirm, + followUpActions: config?.followUpActions, + }); + }, [isDesignMode, config?.action, config?.confirm, config?.followUpActions, execute]); + + // 외형 + const buttonLabel = config?.label || label || "버튼"; + const variant = config?.variant || "default"; + const iconName = config?.icon || ""; + const isIconOnly = config?.iconOnly || false; + + return ( + <> +
+ +
+ + {/* 확인 다이얼로그 (usePopAction의 pendingConfirm 상태로 제어) */} + { if (!open) cancelConfirm(); }}> + + + + 실행 확인 + + + {getConfirmMessage()} + + + + + 취소 + + + 확인 + + + + + + ); +} + +// ======================================== +// STEP 3: 설정 패널 +// ======================================== + +interface PopButtonConfigPanelProps { + config: PopButtonConfig; + onUpdate: (config: PopButtonConfig) => void; +} + +export function PopButtonConfigPanel({ + config, + onUpdate, +}: PopButtonConfigPanelProps) { + const isCustom = config?.preset === "custom"; + + // 프리셋 변경 핸들러 + const handlePresetChange = (preset: ButtonPreset) => { + const defaults = PRESET_DEFAULTS[preset]; + onUpdate({ + ...config, + preset, + label: defaults.label || config.label, + variant: defaults.variant || config.variant, + icon: defaults.icon ?? config.icon, + confirm: defaults.confirm || config.confirm, + action: (defaults.action as ButtonMainAction) || config.action, + // 후속 액션은 프리셋 변경 시 유지 + }); + }; + + // 메인 액션 업데이트 헬퍼 + const updateAction = (updates: Partial) => { + onUpdate({ + ...config, + action: { ...config.action, ...updates }, + }); + }; + + return ( +
+ {/* 프리셋 선택 */} + + + {!isCustom && ( +

+ 프리셋 변경 시 외형과 액션이 자동 설정됩니다 +

+ )} + + {/* 외형 설정 */} + +
+ {/* 라벨 */} +
+ + onUpdate({ ...config, label: e.target.value })} + placeholder="버튼 텍스트" + className="h-8 text-xs" + /> +
+ + {/* variant */} +
+ + +
+ + {/* 아이콘 */} +
+ + +
+ + {/* 아이콘 전용 모드 */} +
+ + onUpdate({ ...config, iconOnly: checked === true }) + } + /> + +
+
+ + {/* 메인 액션 */} + +
+ {/* 액션 타입 */} +
+ + + {!isCustom && ( +

+ 프리셋에 의해 고정됨. 변경하려면 "직접 설정" 선택 +

+ )} +
+ + {/* 액션별 추가 설정 */} + +
+ + {/* 확인 다이얼로그 */} + +
+
+ + onUpdate({ + ...config, + confirm: { + ...config?.confirm, + enabled: checked === true, + }, + }) + } + /> + +
+ {config?.confirm?.enabled && ( +
+ + onUpdate({ + ...config, + confirm: { + ...config?.confirm, + enabled: true, + message: e.target.value, + }, + }) + } + placeholder="비워두면 기본 메시지 사용" + className="h-8 text-xs" + /> +

+ 기본:{" "} + {DEFAULT_CONFIRM_MESSAGES[config?.action?.type || "save"]} +

+
+ )} +
+ + {/* 후속 액션 */} + + + onUpdate({ ...config, followUpActions: actions }) + } + /> +
+ ); +} + +// ======================================== +// 액션 세부 필드 (타입별) +// ======================================== + +function ActionDetailFields({ + action, + onUpdate, + disabled, +}: { + action?: ButtonMainAction; + onUpdate: (updates: Partial) => void; + disabled?: boolean; +}) { + // 디자이너 컨텍스트 (뷰어에서는 null) + const designerCtx = usePopDesignerContext(); + const actionType = action?.type || "save"; + + switch (actionType) { + case "save": + case "delete": + return ( +
+ + onUpdate({ targetTable: e.target.value })} + placeholder="테이블명 입력" + className="h-8 text-xs" + disabled={disabled} + /> +
+ ); + + case "api": + return ( +
+
+ + onUpdate({ apiEndpoint: e.target.value })} + placeholder="/api/..." + className="h-8 text-xs" + disabled={disabled} + /> +
+
+ + +
+
+ ); + + case "modal": + return ( +
+
+ + +
+ {action?.modalMode === "screen-ref" && ( +
+ + + onUpdate({ modalScreenId: e.target.value }) + } + placeholder="화면 ID" + className="h-8 text-xs" + disabled={disabled} + /> +
+ )} +
+ + onUpdate({ modalTitle: e.target.value })} + placeholder="모달 제목 (선택)" + className="h-8 text-xs" + disabled={disabled} + /> +
+ {/* 모달 캔버스 생성/열기 (fullscreen 모드 + 디자이너 내부) */} + {action?.modalMode === "fullscreen" && designerCtx && ( +
+ {action?.modalScreenId ? ( + + ) : ( + + )} +
+ )} +
+ ); + + case "event": + return ( +
+
+ + onUpdate({ eventName: e.target.value })} + placeholder="예: data-saved, item-selected" + className="h-8 text-xs" + disabled={disabled} + /> +
+
+ ); + + default: + return null; + } +} + +// ======================================== +// 후속 액션 편집기 +// ======================================== + +function FollowUpActionsEditor({ + actions, + onUpdate, +}: { + actions: FollowUpAction[]; + onUpdate: (actions: FollowUpAction[]) => void; +}) { + // 추가 + const handleAdd = () => { + onUpdate([...actions, { type: "event" }]); + }; + + // 삭제 + const handleRemove = (index: number) => { + onUpdate(actions.filter((_, i) => i !== index)); + }; + + // 수정 + const handleUpdate = (index: number, updates: Partial) => { + const newActions = [...actions]; + newActions[index] = { ...newActions[index], ...updates }; + onUpdate(newActions); + }; + + return ( +
+ {actions.length === 0 && ( +

+ 메인 액션 성공 후 순차 실행할 후속 동작 +

+ )} + + {actions.map((fa, idx) => ( +
+
+ + 후속 {idx + 1} + + +
+ + {/* 타입 선택 */} + + + {/* 타입별 추가 입력 */} + {fa.type === "event" && ( + + handleUpdate(idx, { eventName: e.target.value }) + } + placeholder="이벤트명" + className="h-7 text-xs" + /> + )} + {fa.type === "navigate" && ( + + handleUpdate(idx, { targetScreenId: e.target.value }) + } + placeholder="화면 ID" + className="h-7 text-xs" + /> + )} +
+ ))} + + +
+ ); +} + +// ======================================== +// STEP 4: 미리보기 + 레지스트리 등록 +// ======================================== + +function PopButtonPreviewComponent({ + config, +}: { + config?: PopButtonConfig; +}) { + const buttonLabel = config?.label || "버튼"; + const variant = config?.variant || "default"; + const iconName = config?.icon || ""; + const isIconOnly = config?.iconOnly || false; + + return ( +
+ +
+ ); +} + +// 레지스트리 등록 +PopComponentRegistry.registerComponent({ + id: "pop-button", + name: "버튼", + description: "액션 버튼 (저장/삭제/API/모달/이벤트)", + category: "action", + icon: "MousePointerClick", + component: PopButtonComponent, + configPanel: PopButtonConfigPanel, + preview: PopButtonPreviewComponent, + defaultProps: { + label: "버튼", + variant: "default", + preset: "custom", + confirm: { enabled: false }, + action: { type: "save" }, + } as PopButtonConfig, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx new file mode 100644 index 00000000..806dedb1 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list/NumberInputModal.tsx @@ -0,0 +1,209 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Delete } from "lucide-react"; +import { + Dialog, + DialogPortal, + DialogOverlay, +} from "@/components/ui/dialog"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { + PackageUnitModal, + PACKAGE_UNITS, + type PackageUnit, +} from "./PackageUnitModal"; + +interface NumberInputModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + unit?: string; + initialValue?: number; + initialPackageUnit?: string; + min?: number; + maxValue?: number; + onConfirm: (value: number, packageUnit?: string) => void; +} + +export function NumberInputModal({ + open, + onOpenChange, + unit = "EA", + initialValue = 0, + initialPackageUnit, + min = 0, + maxValue = 999999, + onConfirm, +}: NumberInputModalProps) { + const [displayValue, setDisplayValue] = useState(""); + const [packageUnit, setPackageUnit] = useState(undefined); + const [isPackageModalOpen, setIsPackageModalOpen] = useState(false); + + useEffect(() => { + if (open) { + setDisplayValue(initialValue > 0 ? String(initialValue) : ""); + setPackageUnit(initialPackageUnit); + } + }, [open, initialValue, initialPackageUnit]); + + const handleNumberClick = (num: string) => { + const newStr = displayValue + num; + const numericValue = parseInt(newStr, 10); + setDisplayValue(numericValue > maxValue ? String(maxValue) : newStr); + }; + + const handleBackspace = () => + setDisplayValue((prev) => prev.slice(0, -1)); + const handleClear = () => setDisplayValue(""); + const handleMax = () => setDisplayValue(String(maxValue)); + + const handleConfirm = () => { + const numericValue = parseInt(displayValue, 10) || 0; + const finalValue = Math.max(min, Math.min(maxValue, numericValue)); + onConfirm(finalValue, packageUnit); + onOpenChange(false); + }; + + const handlePackageUnitSelect = (selected: PackageUnit) => { + setPackageUnit(selected); + }; + + const matchedUnit = packageUnit + ? PACKAGE_UNITS.find((u) => u.value === packageUnit) + : null; + const packageUnitLabel = matchedUnit?.label ?? null; + const packageUnitEmoji = matchedUnit?.emoji ?? "📦"; + + const displayText = displayValue + ? parseInt(displayValue, 10).toLocaleString() + : ""; + + return ( + <> + + + + + {/* 파란 헤더 */} +
+ + 최대 {maxValue.toLocaleString()} {unit} + + +
+ +
+ {/* 숫자 표시 영역 */} +
+ {displayText ? ( + + {displayText} + + ) : ( + 0 + )} +
+ + {/* 안내 텍스트 */} +

+ 수량을 입력하세요 +

+ + {/* 키패드 4x4 */} +
+ {/* 1행: 7 8 9 ← (주황) */} + {["7", "8", "9"].map((n) => ( + + ))} + + + {/* 2행: 4 5 6 C (주황) */} + {["4", "5", "6"].map((n) => ( + + ))} + + + {/* 3행: 1 2 3 MAX (파란) */} + {["1", "2", "3"].map((n) => ( + + ))} + + + {/* 4행: 0 / 확인 (초록, 3칸) */} + + +
+
+ +
+
+
+ + {/* 포장 단위 선택 모달 */} + + + ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx new file mode 100644 index 00000000..0911bc47 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list/PackageUnitModal.tsx @@ -0,0 +1,77 @@ +"use client"; + +import React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, +} from "@/components/ui/dialog"; + +export const PACKAGE_UNITS = [ + { value: "box", label: "박스", emoji: "📦" }, + { value: "bag", label: "포대", emoji: "🛍️" }, + { value: "pack", label: "팩", emoji: "📋" }, + { value: "bundle", label: "묶음", emoji: "🔗" }, + { value: "roll", label: "롤", emoji: "🧻" }, + { value: "barrel", label: "통", emoji: "🪣" }, +] as const; + +export type PackageUnit = (typeof PACKAGE_UNITS)[number]["value"]; + +interface PackageUnitModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (unit: PackageUnit) => void; +} + +export function PackageUnitModal({ + open, + onOpenChange, + onSelect, +}: PackageUnitModalProps) { + const handleSelect = (unit: PackageUnit) => { + onSelect(unit); + onOpenChange(false); + }; + + return ( + + + + + + {/* 헤더 */} +
+

📦 포장 단위 선택

+
+ + {/* 3x2 그리드 */} +
+ {PACKAGE_UNITS.map((unit) => ( + + ))} +
+ + {/* X 닫기 버튼 */} + + + Close + +
+
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx new file mode 100644 index 00000000..bf7d71ed --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListComponent.tsx @@ -0,0 +1,1001 @@ +"use client"; + +/** + * pop-card-list 런타임 컴포넌트 + * + * 테이블의 각 행이 하나의 카드로 표시됩니다. + * 카드 구조: + * - 헤더: 코드 + 제목 + * - 본문: 이미지(왼쪽) + 라벨-값 목록(오른쪽) + */ + +import React, { useEffect, useState, useRef, useMemo, useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, ShoppingCart, X } from "lucide-react"; +import * as LucideIcons from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import type { + PopCardListConfig, + CardTemplateConfig, + CardFieldBinding, + CardInputFieldConfig, + CardCalculatedFieldConfig, + CardCartActionConfig, + CardPresetSpec, + CartItem, +} from "../types"; +import { + DEFAULT_CARD_IMAGE, + CARD_PRESET_SPECS, +} from "../types"; +import { dataApi } from "@/lib/api/data"; +import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { NumberInputModal } from "./NumberInputModal"; + +// Lucide 아이콘 동적 렌더링 +function DynamicLucideIcon({ name, size = 20 }: { name?: string; size?: number }) { + if (!name) return ; + const icons = LucideIcons as unknown as Record>; + const IconComp = icons[name]; + if (!IconComp) return ; + return ; +} + +// 마퀴 애니메이션 keyframes (한 번만 삽입) +const MARQUEE_STYLE_ID = "pop-card-marquee-style"; +if (typeof document !== "undefined" && !document.getElementById(MARQUEE_STYLE_ID)) { + const style = document.createElement("style"); + style.id = MARQUEE_STYLE_ID; + style.textContent = ` + @keyframes pop-marquee { + 0%, 15% { transform: translateX(0); } + 85%, 100% { transform: translateX(var(--marquee-offset)); } + } + `; + document.head.appendChild(style); +} + +// 텍스트가 컨테이너보다 넓을 때 자동 슬라이딩하는 컴포넌트 +function MarqueeText({ + children, + className, + style, +}: { + children: React.ReactNode; + className?: string; + style?: React.CSSProperties; +}) { + const containerRef = useRef(null); + const textRef = useRef(null); + const [overflowPx, setOverflowPx] = useState(0); + + const measure = useCallback(() => { + const container = containerRef.current; + const text = textRef.current; + if (!container || !text) return; + const diff = text.scrollWidth - container.clientWidth; + setOverflowPx(diff > 1 ? diff : 0); + }, []); + + useEffect(() => { + measure(); + }, [children, measure]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const ro = new ResizeObserver(() => measure()); + ro.observe(container); + return () => ro.disconnect(); + }, [measure]); + + return ( +
+ 0 + ? { + ["--marquee-offset" as string]: `-${overflowPx}px`, + animation: "pop-marquee 5s ease-in-out infinite alternate", + } + : undefined + } + > + {children} + +
+ ); +} + +interface PopCardListComponentProps { + config?: PopCardListConfig; + className?: string; + screenId?: string; + // 동적 크기 변경을 위한 props (PopRenderer에서 전달) + componentId?: string; + currentRowSpan?: number; + currentColSpan?: number; + onRequestResize?: (componentId: string, newRowSpan: number, newColSpan?: number) => void; +} + +// 테이블 행 데이터 타입 +type RowData = Record; + +// 카드 내부 스타일 규격 (프리셋에서 매핑) +interface ScaledConfig { + cardHeight: number; + cardWidth: number; + imageSize: number; + padding: number; + gap: number; + headerPaddingX: number; + headerPaddingY: number; + codeTextSize: number; + titleTextSize: number; + bodyTextSize: number; +} + +export function PopCardListComponent({ + config, + className, + screenId, + componentId, + currentRowSpan, + currentColSpan, + onRequestResize, +}: PopCardListComponentProps) { + const isHorizontalMode = (config?.scrollDirection || "vertical") === "horizontal"; + const maxGridColumns = config?.gridColumns || 2; + const configGridRows = config?.gridRows || 3; + const dataSource = config?.dataSource; + const template = config?.cardTemplate; + + // 이벤트 기반 company_code 필터링 + const [eventCompanyCode, setEventCompanyCode] = useState(); + const { subscribe, publish, getSharedData, setSharedData } = usePopEvent(screenId || "default"); + const router = useRouter(); + + useEffect(() => { + if (!screenId) return; + const unsub = subscribe("company_selected", (payload: unknown) => { + const p = payload as { companyCode?: string } | undefined; + setEventCompanyCode(p?.companyCode); + }); + return unsub; + }, [screenId, subscribe]); + + // 데이터 상태 + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 확장/페이지네이션 상태 + const [isExpanded, setIsExpanded] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [originalRowSpan, setOriginalRowSpan] = useState(null); + + // 컨테이너 ref + 크기 측정 + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [containerHeight, setContainerHeight] = useState(0); + const baseContainerHeight = useRef(0); + + useEffect(() => { + if (!containerRef.current) return; + const observer = new ResizeObserver((entries) => { + const { width, height } = entries[0].contentRect; + if (width > 0) setContainerWidth(width); + if (height > 0) setContainerHeight(height); + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + // 이미지 URL 없는 항목 카운트 (toast 중복 방지용) + const missingImageCountRef = useRef(0); + const toastShownRef = useRef(false); + + const spec: CardPresetSpec = CARD_PRESET_SPECS.large; + + // 브레이크포인트별 열 제한: colSpan >= 8이면 config 최대값 허용, 그 외 1열 + const maxAllowedColumns = useMemo(() => { + if (!currentColSpan) return maxGridColumns; + if (currentColSpan >= 8) return maxGridColumns; + return 1; + }, [currentColSpan, maxGridColumns]); + + // 카드 최소 너비 기준으로 컨테이너에 들어갈 수 있는 열 개수 자동 계산 + const minCardWidth = Math.round(spec.height * 1.6); + const autoColumns = containerWidth > 0 + ? Math.max(1, Math.floor((containerWidth + spec.gap) / (minCardWidth + spec.gap))) + : maxGridColumns; + const gridColumns = Math.min(autoColumns, maxGridColumns, maxAllowedColumns); + + // 컨테이너 높이 기반 행 수 자동 계산 (잘림 방지) + const effectiveGridRows = useMemo(() => { + if (containerHeight <= 0) return configGridRows; + + const controlBarHeight = 44; + const effectiveHeight = baseContainerHeight.current > 0 + ? baseContainerHeight.current + : containerHeight; + const availableHeight = effectiveHeight - controlBarHeight; + + const cardHeightWithGap = spec.height + spec.gap; + const fittableRows = Math.max(1, Math.floor( + (availableHeight + spec.gap) / cardHeightWithGap + )); + + return Math.min(configGridRows, fittableRows); + }, [containerHeight, configGridRows, spec]); + + const gridRows = effectiveGridRows; + + // 카드 크기: 컨테이너 실측 크기에서 gridColumns x gridRows 기준으로 동적 계산 + const scaled = useMemo((): ScaledConfig => { + const gap = spec.gap; + const controlBarHeight = 44; + + const buildScaledConfig = (cardWidth: number, cardHeight: number): ScaledConfig => { + const scale = cardHeight / spec.height; + return { + cardHeight, + cardWidth, + imageSize: Math.round(spec.imageSize * scale), + padding: Math.round(spec.padding * scale), + gap, + headerPaddingX: Math.round(spec.headerPadX * scale), + headerPaddingY: Math.round(spec.headerPadY * scale), + codeTextSize: Math.round(spec.codeText * scale), + titleTextSize: Math.round(spec.titleText * scale), + bodyTextSize: Math.round(spec.bodyText * scale), + }; + }; + + if (containerWidth <= 0 || containerHeight <= 0) { + return buildScaledConfig(Math.round(spec.height * 1.6), spec.height); + } + + const effectiveHeight = baseContainerHeight.current > 0 + ? baseContainerHeight.current + : containerHeight; + + const availableHeight = effectiveHeight - controlBarHeight; + const availableWidth = containerWidth; + + const cardHeight = Math.max(spec.height, + Math.floor((availableHeight - gap * (gridRows - 1)) / gridRows)); + const cardWidth = Math.max(Math.round(spec.height * 1.6), + Math.floor((availableWidth - gap * (gridColumns - 1)) / gridColumns)); + + return buildScaledConfig(cardWidth, cardHeight); + }, [spec, containerWidth, containerHeight, gridColumns, gridRows]); + + // 기본 상태에서 표시할 카드 수 + const visibleCardCount = useMemo(() => { + return gridColumns * gridRows; + }, [gridColumns, gridRows]); + + // 더보기 버튼 표시 여부 + const hasMoreCards = rows.length > visibleCardCount; + + // 확장 상태에서 표시할 카드 수 계산 + const expandedCardsPerPage = useMemo(() => { + // 가로/세로 모두: 기본 표시 수의 2배 + 스크롤 유도를 위해 1줄 추가 + // 가로: 컴포넌트 크기 변경 없이 카드 2배 → 가로 스크롤로 탐색 + // 세로: rowSpan 2배 → 2배 영역에 카드 채움 + 세로 스크롤 + return Math.max(1, visibleCardCount * 2 + gridColumns); + }, [visibleCardCount, gridColumns]); + + // 스크롤 영역 ref + const scrollAreaRef = useRef(null); + + // 현재 표시할 카드 결정 + const displayCards = useMemo(() => { + if (!isExpanded) { + // 기본 상태: visibleCardCount만큼만 표시 + return rows.slice(0, visibleCardCount); + } else { + // 확장 상태: 현재 페이지의 카드들 (스크롤 가능하도록 더 많이) + const start = (currentPage - 1) * expandedCardsPerPage; + const end = start + expandedCardsPerPage; + return rows.slice(start, end); + } + }, [rows, isExpanded, visibleCardCount, currentPage, expandedCardsPerPage]); + + // 총 페이지 수 + const totalPages = isExpanded + ? Math.ceil(rows.length / expandedCardsPerPage) + : 1; + // 페이지네이션 필요: 확장 상태에서 전체 카드가 한 페이지를 초과할 때 + const needsPagination = isExpanded && totalPages > 1; + + // 페이지 변경 핸들러 + const handlePrevPage = () => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + }; + + const handleNextPage = () => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + }; + + // 확장/접기 토글: 세로 모드에서만 rowSpan 2배 확장, 가로 모드에서는 크기 변경 없이 카드만 추가 표시 + const toggleExpand = () => { + if (isExpanded) { + if (!isHorizontalMode && originalRowSpan !== null && componentId && onRequestResize) { + onRequestResize(componentId, originalRowSpan); + } + setCurrentPage(1); + setOriginalRowSpan(null); + baseContainerHeight.current = 0; + setIsExpanded(false); + } else { + baseContainerHeight.current = containerHeight; + if (!isHorizontalMode && componentId && onRequestResize && currentRowSpan !== undefined) { + setOriginalRowSpan(currentRowSpan); + onRequestResize(componentId, currentRowSpan * 2); + } + setIsExpanded(true); + } + }; + + // 페이지 변경 시 스크롤 위치 초기화 (가로/세로 모두) + useEffect(() => { + if (scrollAreaRef.current && isExpanded) { + scrollAreaRef.current.scrollTop = 0; + scrollAreaRef.current.scrollLeft = 0; + } + }, [currentPage, isExpanded]); + + // 데이터 조회 + useEffect(() => { + if (!dataSource?.tableName) { + setLoading(false); + setRows([]); + return; + } + + const fetchData = async () => { + setLoading(true); + setError(null); + missingImageCountRef.current = 0; + toastShownRef.current = false; + + try { + // 필터 조건 구성 + const filters: Record = {}; + if (dataSource.filters && dataSource.filters.length > 0) { + dataSource.filters.forEach((f) => { + if (f.column && f.value) { + filters[f.column] = f.value; + } + }); + } + + // 이벤트로 수신한 company_code 필터 병합 + if (eventCompanyCode) { + filters["company_code"] = eventCompanyCode; + } + + // 정렬 조건 + const sortBy = dataSource.sort?.column; + const sortOrder = dataSource.sort?.direction; + + // 개수 제한 + const size = + dataSource.limit?.mode === "limited" && dataSource.limit?.count + ? dataSource.limit.count + : 100; + + // TODO: 조인 지원은 추후 구현 + // 현재는 단일 테이블 조회만 지원 + + const result = await dataApi.getTableData(dataSource.tableName, { + page: 1, + size, + sortBy: sortOrder ? sortBy : undefined, + sortOrder, + filters: Object.keys(filters).length > 0 ? filters : undefined, + }); + + setRows(result.data || []); + } catch (err) { + const message = err instanceof Error ? err.message : "데이터 조회 실패"; + setError(message); + setRows([]); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [dataSource, eventCompanyCode]); + + // 이미지 URL 없는 항목 체크 및 toast 표시 + useEffect(() => { + if ( + !loading && + rows.length > 0 && + template?.image?.enabled && + template?.image?.imageColumn && + !toastShownRef.current + ) { + const imageColumn = template.image.imageColumn; + const missingCount = rows.filter((row) => !row[imageColumn]).length; + + if (missingCount > 0) { + missingImageCountRef.current = missingCount; + toastShownRef.current = true; + toast.warning( + `${missingCount}개 항목의 이미지 URL이 없어 기본 이미지로 표시됩니다` + ); + } + } + }, [loading, rows, template?.image]); + + + // 카드 영역 스타일 + const cardAreaStyle: React.CSSProperties = { + gap: `${scaled.gap}px`, + ...(isHorizontalMode + ? { + gridTemplateRows: `repeat(${gridRows}, ${scaled.cardHeight}px)`, + gridAutoFlow: "column", + gridAutoColumns: `${scaled.cardWidth}px`, + } + : { + // 세로 모드: 1fr 비율 기반으로 컨테이너 너비 초과 방지 + gridTemplateColumns: `repeat(${gridColumns}, 1fr)`, + gridAutoRows: `${scaled.cardHeight}px`, + }), + }; + + // 세로 모드 스크롤 클래스: 비확장 시 overflow hidden, 확장 시에만 세로 스크롤 허용 + const scrollClassName = isHorizontalMode + ? "overflow-x-auto overflow-y-hidden" + : isExpanded + ? "overflow-y-auto overflow-x-hidden" + : "overflow-hidden"; + + return ( +
+ {!dataSource?.tableName ? ( +
+

+ 데이터 소스를 설정해주세요. +

+
+ ) : loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

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

데이터가 없습니다.

+
+ ) : ( + <> + {/* 카드 영역 (스크롤 가능) */} +
+ {displayCards.map((row, index) => ( + + ))} +
+ + {/* 하단 컨트롤 영역 */} + {hasMoreCards && ( +
+
+
+ + + {rows.length}건 + +
+ + {isExpanded && needsPagination && ( +
+ + + {currentPage} / {totalPages} + + +
+ )} +
+
+ )} + + )} +
+ ); +} + +// ===== 카드 컴포넌트 ===== + +function Card({ + row, + template, + scaled, + inputField, + calculatedField, + cartAction, + publish, + getSharedData, + setSharedData, + router, +}: { + row: RowData; + template?: CardTemplateConfig; + scaled: ScaledConfig; + inputField?: CardInputFieldConfig; + calculatedField?: CardCalculatedFieldConfig; + cartAction?: CardCartActionConfig; + publish: (eventName: string, payload?: unknown) => void; + getSharedData: (key: string) => T | undefined; + setSharedData: (key: string, value: unknown) => void; + router: ReturnType; +}) { + const header = template?.header; + const image = template?.image; + const body = template?.body; + + // 입력 필드 상태 + const [inputValue, setInputValue] = useState( + inputField?.defaultValue || 0 + ); + const [packageUnit, setPackageUnit] = useState(undefined); + const [isModalOpen, setIsModalOpen] = useState(false); + + // 담기/취소 토글 상태 + const [isCarted, setIsCarted] = useState(false); + + // 헤더 값 추출 + const codeValue = header?.codeField ? row[header.codeField] : null; + const titleValue = header?.titleField ? row[header.titleField] : null; + + // 이미지 URL 결정 + const imageUrl = + image?.enabled && image?.imageColumn && row[image.imageColumn] + ? String(row[image.imageColumn]) + : image?.defaultImage || DEFAULT_CARD_IMAGE; + + // 계산 필드 값 계산 + const calculatedValue = useMemo(() => { + if (!calculatedField?.enabled || !calculatedField?.formula) return null; + return evaluateFormula(calculatedField.formula, row, inputValue); + }, [calculatedField, row, inputValue]); + + // effectiveMax: DB 컬럼 우선, 없으면 inputField.max 폴백 + const effectiveMax = useMemo(() => { + if (inputField?.maxColumn) { + const colVal = Number(row[inputField.maxColumn]); + if (!isNaN(colVal) && colVal > 0) return colVal; + } + return inputField?.max ?? 999999; + }, [inputField, row]); + + // 기본값이 설정되지 않은 경우 최대값으로 자동 초기화 + useEffect(() => { + if (inputField?.enabled && !inputField?.defaultValue && effectiveMax > 0 && effectiveMax < 999999) { + setInputValue(effectiveMax); + } + }, [effectiveMax, inputField?.enabled, inputField?.defaultValue]); + + const cardStyle: React.CSSProperties = { + height: `${scaled.cardHeight}px`, + overflow: "hidden", + }; + + const headerStyle: React.CSSProperties = { + padding: `${scaled.headerPaddingY}px ${scaled.headerPaddingX}px`, + }; + + const bodyStyle: React.CSSProperties = { + padding: `${scaled.padding}px`, + gap: `${scaled.gap}px`, + }; + + const imageContainerStyle: React.CSSProperties = { + width: `${scaled.imageSize}px`, + height: `${scaled.imageSize}px`, + }; + + const handleInputClick = (e: React.MouseEvent) => { + e.stopPropagation(); + setIsModalOpen(true); + }; + + const handleInputConfirm = (value: number, unit?: string) => { + setInputValue(value); + setPackageUnit(unit); + }; + + // 담기: sharedData에 CartItem 누적 + 이벤트 발행 + 토글 + const handleCartAdd = () => { + const cartItem: CartItem = { + row, + quantity: inputValue, + packageUnit: packageUnit || undefined, + }; + + const existing = getSharedData("cart_items") || []; + setSharedData("cart_items", [...existing, cartItem]); + publish("cart_item_added", cartItem); + + setIsCarted(true); + toast.success("장바구니에 담겼습니다."); + + if (cartAction?.navigateMode === "screen" && cartAction.targetScreenId) { + router.push(`/pop/screens/${cartAction.targetScreenId}`); + } + }; + + // 취소: sharedData에서 해당 아이템 제거 + 이벤트 발행 + 토글 복원 + const handleCartCancel = () => { + const existing = getSharedData("cart_items") || []; + const rowKey = JSON.stringify(row); + const filtered = existing.filter( + (item) => JSON.stringify(item.row) !== rowKey + ); + setSharedData("cart_items", filtered); + publish("cart_item_removed", { row }); + + setIsCarted(false); + toast.info("장바구니에서 제거되었습니다."); + }; + + // pop-icon 스타일 담기/취소 버튼 아이콘 크기 (스케일 반영) + const iconSize = Math.max(14, Math.round(18 * (scaled.bodyTextSize / 11))); + const cartLabel = cartAction?.label || "담기"; + const cancelLabel = cartAction?.cancelLabel || "취소"; + + return ( +
+ {/* 헤더 영역 */} + {(codeValue !== null || titleValue !== null) && ( +
+
+ {codeValue !== null && ( + + {formatValue(codeValue)} + + )} + {titleValue !== null && ( + + {formatValue(titleValue)} + + )} +
+
+ )} + + {/* 본문 영역 */} +
+ {/* 이미지 (왼쪽) */} + {image?.enabled && ( +
+
+ { + const target = e.target as HTMLImageElement; + if (target.src !== DEFAULT_CARD_IMAGE) target.src = DEFAULT_CARD_IMAGE; + }} + /> +
+
+ )} + + {/* 필드 목록 (중간, flex-1) */} +
+
+ {body?.fields && body.fields.length > 0 ? ( + body.fields.map((field) => ( + + )) + ) : ( +
+ 본문 필드를 추가하세요 +
+ )} + + {/* 계산 필드 */} + {calculatedField?.enabled && calculatedValue !== null && ( +
+ + {calculatedField.label || "계산값"} + + + {calculatedValue.toLocaleString()}{calculatedField.unit ? ` ${calculatedField.unit}` : ""} + +
+ )} +
+
+ + {/* 오른쪽: 수량 버튼 + 담기/취소 버튼 (inputField 활성화 시만) */} + {inputField?.enabled && ( +
+ {/* 수량 버튼 */} + + + {/* pop-icon 스타일 담기/취소 토글 버튼 */} + {isCarted ? ( + + ) : ( + + )} +
+ )} +
+ + {/* 숫자 입력 모달 */} + {inputField?.enabled && ( + + )} +
+ ); +} + +// ===== 필드 행 컴포넌트 ===== + +function FieldRow({ + field, + row, + scaled, +}: { + field: CardFieldBinding; + row: RowData; + scaled: ScaledConfig; +}) { + const value = row[field.columnName]; + + // 비율 기반 라벨 최소 너비 + const labelMinWidth = Math.round(50 * (scaled.bodyTextSize / 12)); + + return ( +
+ {/* 라벨 */} + + {field.label} + + {/* 값 - 기본 검정색, textColor 설정 시 해당 색상 */} + + {formatValue(value)} + +
+ ); +} + +// ===== 값 포맷팅 ===== + +function formatValue(value: unknown): string { + if (value === null || value === undefined) { + return "-"; + } + if (typeof value === "number") { + return value.toLocaleString(); + } + if (typeof value === "boolean") { + return value ? "예" : "아니오"; + } + if (value instanceof Date) { + return value.toLocaleDateString(); + } + // ISO 날짜 문자열 감지 및 포맷 + if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) { + const date = new Date(value); + if (!isNaN(date.getTime())) { + // MM-DD 형식으로 표시 + return `${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; + } + } + return String(value); +} + +// ===== 계산식 평가 ===== + +/** + * 간단한 계산식을 평가합니다. + * 지원 연산: +, -, *, / + * 특수 변수: $input (입력 필드 값) + * + * @param formula 계산식 (예: "order_qty - inbound_qty", "$input - received_qty") + * @param row 데이터 행 + * @param inputValue 입력 필드 값 + * @returns 계산 결과 또는 null (계산 실패 시) + */ +function evaluateFormula( + formula: string, + row: RowData, + inputValue: number +): number | null { + try { + // 수식에서 컬럼명과 $input을 실제 값으로 치환 + let expression = formula; + + // $input을 입력값으로 치환 + expression = expression.replace(/\$input/g, String(inputValue)); + + // 컬럼명을 값으로 치환 (알파벳, 숫자, 언더스코어로 구성된 식별자) + const columnPattern = /[a-zA-Z_][a-zA-Z0-9_]*/g; + expression = expression.replace(columnPattern, (match) => { + // 이미 숫자로 치환된 경우 스킵 + if (/^\d+$/.test(match)) return match; + + const value = row[match]; + if (value === null || value === undefined) return "0"; + if (typeof value === "number") return String(value); + const parsed = parseFloat(String(value)); + return isNaN(parsed) ? "0" : String(parsed); + }); + + // 안전한 계산 (기본 산술 연산만 허용) + // 허용: 숫자, +, -, *, /, (, ), 공백, 소수점 + if (!/^[\d\s+\-*/().]+$/.test(expression)) { + console.warn("Invalid formula expression:", expression); + return null; + } + + // eval 대신 Function 사용 (더 안전) + const result = new Function(`return (${expression})`)(); + + if (typeof result !== "number" || isNaN(result) || !isFinite(result)) { + return null; + } + + return Math.round(result * 100) / 100; // 소수점 2자리까지 + } catch (error) { + console.warn("Formula evaluation error:", error); + return null; + } +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx new file mode 100644 index 00000000..d8b31c34 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListConfig.tsx @@ -0,0 +1,1874 @@ +"use client"; + +/** + * pop-card-list 설정 패널 + * + * 3개 탭: + * [테이블] - 데이터 테이블 선택 + * [카드 템플릿] - 헤더/이미지/본문 필드 + 레이아웃 설정 + * [데이터 소스] - 조인/필터/정렬/개수 설정 + */ + +import React, { useState, useEffect, useMemo } from "react"; +import { ChevronDown, ChevronRight, Plus, Trash2, Database } from "lucide-react"; +import type { GridMode } from "@/components/pop/designer/types/pop-layout"; +import { GRID_BREAKPOINTS } from "@/components/pop/designer/types/pop-layout"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { + PopCardListConfig, + CardListDataSource, + CardTemplateConfig, + CardHeaderConfig, + CardImageConfig, + CardBodyConfig, + CardFieldBinding, + CardColumnJoin, + CardColumnFilter, + CardScrollDirection, + FilterOperator, + CardInputFieldConfig, + CardCalculatedFieldConfig, + CardCartActionConfig, +} from "../types"; +import { + CARD_SCROLL_DIRECTION_LABELS, + DEFAULT_CARD_IMAGE, +} from "../types"; +import { + fetchTableList, + fetchTableColumns, + type TableInfo, + type ColumnInfo, +} from "../pop-dashboard/utils/dataFetcher"; + +// ===== Props ===== + +interface ConfigPanelProps { + config: PopCardListConfig | undefined; + onUpdate: (config: PopCardListConfig) => void; + currentMode?: GridMode; + currentColSpan?: number; +} + +// ===== 기본값 ===== + +const DEFAULT_DATA_SOURCE: CardListDataSource = { + tableName: "", +}; + +const DEFAULT_HEADER: CardHeaderConfig = { + codeField: undefined, + titleField: undefined, +}; + +const DEFAULT_IMAGE: CardImageConfig = { + enabled: true, + imageColumn: undefined, + defaultImage: DEFAULT_CARD_IMAGE, +}; + +const DEFAULT_BODY: CardBodyConfig = { + fields: [], +}; + +const DEFAULT_TEMPLATE: CardTemplateConfig = { + header: DEFAULT_HEADER, + image: DEFAULT_IMAGE, + body: DEFAULT_BODY, +}; + +const DEFAULT_CONFIG: PopCardListConfig = { + dataSource: DEFAULT_DATA_SOURCE, + cardTemplate: DEFAULT_TEMPLATE, + scrollDirection: "vertical", + gridColumns: 2, + gridRows: 3, + cardSize: "large", +}; + +// ===== 색상 옵션 ===== + +const COLOR_OPTIONS = [ + { value: "__default__", label: "기본" }, + { value: "#ef4444", label: "빨간색" }, + { value: "#f97316", label: "주황색" }, + { value: "#eab308", label: "노란색" }, + { value: "#22c55e", label: "초록색" }, + { value: "#3b82f6", label: "파란색" }, + { value: "#8b5cf6", label: "보라색" }, + { value: "#6b7280", label: "회색" }, +]; + +// ===== 메인 컴포넌트 ===== + +export function PopCardListConfigPanel({ config, onUpdate, currentMode, currentColSpan }: ConfigPanelProps) { + // 3탭 구조: 기본 설정 (테이블+레이아웃) → 데이터 소스 → 카드 템플릿 + const [activeTab, setActiveTab] = useState<"basic" | "template" | "dataSource">( + "basic" + ); + + // config가 없으면 기본값 사용 + const cfg: PopCardListConfig = config || DEFAULT_CONFIG; + + // config 업데이트 헬퍼 + const updateConfig = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + // 테이블이 선택되었는지 확인 + const hasTable = !!cfg.dataSource?.tableName; + + return ( +
+ {/* 탭 헤더 - 3탭 구조 */} +
+ + + +
+ + {/* 탭 내용 */} +
+ {activeTab === "basic" && ( + + )} + {activeTab === "dataSource" && ( + + )} + {activeTab === "template" && ( + + )} +
+
+ ); +} + +// ===== 기본 설정 탭 (테이블 + 레이아웃 통합) ===== + +function BasicSettingsTab({ + config, + onUpdate, + currentMode, + currentColSpan, +}: { + config: PopCardListConfig; + onUpdate: (partial: Partial) => void; + currentMode?: GridMode; + currentColSpan?: number; +}) { + const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; + const [tables, setTables] = useState([]); + + // 테이블 목록 로드 + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + // 모드별 추천값 계산 + const recommendation = useMemo(() => { + if (!currentMode) return null; + const columns = GRID_BREAKPOINTS[currentMode].columns; + if (columns >= 8) return { rows: 3, cols: 2 }; + if (columns >= 6) return { rows: 3, cols: 1 }; + return { rows: 2, cols: 1 }; + }, [currentMode]); + + // 열 최대값: colSpan 기반 제한 + const maxColumns = useMemo(() => { + if (!currentColSpan) return 2; + return currentColSpan >= 8 ? 2 : 1; + }, [currentColSpan]); + + // 현재 모드 라벨 + const modeLabel = currentMode ? GRID_BREAKPOINTS[currentMode].label : null; + + // 모드 변경 시 열/행 자동 적용 + useEffect(() => { + if (!recommendation) return; + const currentRows = config.gridRows || 3; + const currentCols = config.gridColumns || 2; + if (currentRows !== recommendation.rows || currentCols !== recommendation.cols) { + onUpdate({ + gridRows: recommendation.rows, + gridColumns: recommendation.cols, + }); + } + }, [currentMode]); // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+ {/* 테이블 선택 섹션 */} + +
+
+ + +
+ + {dataSource.tableName && ( +
+ + {dataSource.tableName} +
+ )} +
+
+ + {/* 레이아웃 설정 섹션 */} + +
+ {/* 현재 모드 뱃지 */} + {modeLabel && ( +
+ 현재: + + {modeLabel} + +
+ )} + + {/* 스크롤 방향 */} +
+ +
+ {(["horizontal", "vertical"] as CardScrollDirection[]).map((dir) => ( + + ))} +
+
+ + {/* 그리드 배치 설정 (행 x 열) */} +
+ +
+ + onUpdate({ gridRows: parseInt(e.target.value, 10) || 3 }) + } + className="h-7 w-16 text-center text-xs" + /> + x + + onUpdate({ gridColumns: Math.min(parseInt(e.target.value, 10) || 1, maxColumns) }) + } + className="h-7 w-16 text-center text-xs" + disabled={maxColumns === 1} + /> +
+ +

+ {config.scrollDirection === "horizontal" + ? "격자로 배치, 가로 스크롤" + : "격자로 배치, 세로 스크롤"} +

+

+ {maxColumns === 1 + ? "현재 모드에서 열 최대 1 (모드 변경 시 자동 적용)" + : "모드 변경 시 열/행 자동 적용 / 열 최대 2"} +

+
+
+
+
+ ); +} + +// ===== 데이터 소스 탭 ===== + +function DataSourceTab({ + config, + onUpdate, +}: { + config: PopCardListConfig; + onUpdate: (partial: Partial) => void; +}) { + const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + + // 테이블 목록 로드 + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + // 테이블 선택 시 컬럼 목록 로드 + useEffect(() => { + if (dataSource.tableName) { + fetchTableColumns(dataSource.tableName).then(setColumns); + } else { + setColumns([]); + } + }, [dataSource.tableName]); + + const updateDataSource = (partial: Partial) => { + onUpdate({ + dataSource: { ...dataSource, ...partial }, + }); + }; + + // 테이블이 선택되지 않은 경우 + if (!dataSource.tableName) { + return ( +
+ +

+ 먼저 테이블 탭에서 테이블을 선택하세요 +

+
+ ); + } + + return ( +
+ {/* 현재 선택된 테이블 표시 */} +
+ + {dataSource.tableName} +
+ + {/* 조인 설정 */} + 0 + ? `${dataSource.joins.length}개` + : "없음" + } + > + + + + {/* 필터 설정 */} + 0 + ? `${dataSource.filters.length}개` + : "없음" + } + > + + + + {/* 정렬 설정 */} + + + + + {/* 표시 개수 */} + + + +
+ ); +} + +// ===== 카드 템플릿 탭 ===== + +function CardTemplateTab({ + config, + onUpdate, +}: { + config: PopCardListConfig; + onUpdate: (partial: Partial) => void; +}) { + const dataSource = config.dataSource || DEFAULT_DATA_SOURCE; + const template = config.cardTemplate || DEFAULT_TEMPLATE; + const [columns, setColumns] = useState([]); + + // 테이블 컬럼 로드 + useEffect(() => { + if (dataSource.tableName) { + fetchTableColumns(dataSource.tableName).then(setColumns); + } else { + setColumns([]); + } + }, [dataSource.tableName]); + + const updateTemplate = (partial: Partial) => { + onUpdate({ + cardTemplate: { ...template, ...partial }, + }); + }; + + // 테이블 미선택 시 안내 + if (!dataSource.tableName) { + return ( +
+
+

테이블을 먼저 선택해주세요

+

데이터 소스 탭에서 테이블을 선택하세요

+
+
+ ); + } + + return ( +
+ {/* 헤더 설정 */} + + updateTemplate({ header })} + /> + + + {/* 이미지 설정 */} + + updateTemplate({ image })} + /> + + + {/* 본문 필드 */} + + updateTemplate({ body })} + /> + + + {/* 입력 필드 설정 */} + + onUpdate({ inputField })} + /> + + + {/* 계산 필드 설정 */} + + onUpdate({ calculatedField })} + /> + + + {/* 담기 버튼 설정 */} + + onUpdate({ cartAction })} + /> + +
+ ); +} + +// ===== 접기/펴기 섹션 컴포넌트 ===== + +function CollapsibleSection({ + title, + badge, + defaultOpen = false, + children, +}: { + title: string; + badge?: string; + defaultOpen?: boolean; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + {open &&
{children}
} +
+ ); +} + +// ===== 헤더 설정 섹션 ===== + +function HeaderSettingsSection({ + header, + columns, + onUpdate, +}: { + header: CardHeaderConfig; + columns: ColumnInfo[]; + onUpdate: (header: CardHeaderConfig) => void; +}) { + return ( +
+ {/* 코드 필드 */} +
+ + +

+ 카드 헤더 왼쪽에 표시될 코드 (예: ITEM032) +

+
+ + {/* 제목 필드 */} +
+ + +

+ 카드 헤더 오른쪽에 표시될 제목 (예: 너트 M10) +

+
+
+ ); +} + +// ===== 이미지 설정 섹션 ===== + +function ImageSettingsSection({ + image, + columns, + onUpdate, +}: { + image: CardImageConfig; + columns: ColumnInfo[]; + onUpdate: (image: CardImageConfig) => void; +}) { + return ( +
+ {/* 이미지 사용 여부 */} +
+ + onUpdate({ ...image, enabled: checked })} + /> +
+ + {image.enabled && ( + <> + {/* 기본 이미지 미리보기 */} +
+
+ 기본 이미지 미리보기 +
+
+
+ 기본 이미지 +
+
+
+ + {/* 기본 이미지 URL */} +
+ + + onUpdate({ + ...image, + defaultImage: e.target.value || DEFAULT_CARD_IMAGE, + }) + } + placeholder="이미지 URL 입력" + className="mt-1 h-7 text-xs" + /> +

+ 이미지가 없는 항목에 표시될 기본 이미지 +

+
+ + {/* 이미지 컬럼 선택 */} +
+ + +

+ DB에서 이미지 URL을 가져올 컬럼. URL이 없으면 기본 이미지 사용 +

+
+ + )} +
+ ); +} + +// ===== 본문 필드 섹션 ===== + +function BodyFieldsSection({ + body, + columns, + onUpdate, +}: { + body: CardBodyConfig; + columns: ColumnInfo[]; + onUpdate: (body: CardBodyConfig) => void; +}) { + const fields = body.fields || []; + + // 필드 추가 + const addField = () => { + const newField: CardFieldBinding = { + id: `field-${Date.now()}`, + columnName: "", + label: "", + textColor: undefined, + }; + onUpdate({ fields: [...fields, newField] }); + }; + + // 필드 업데이트 + const updateField = (index: number, updated: CardFieldBinding) => { + const newFields = [...fields]; + newFields[index] = updated; + onUpdate({ fields: newFields }); + }; + + // 필드 삭제 + const deleteField = (index: number) => { + const newFields = fields.filter((_, i) => i !== index); + onUpdate({ fields: newFields }); + }; + + // 필드 순서 이동 + const moveField = (index: number, direction: "up" | "down") => { + const newIndex = direction === "up" ? index - 1 : index + 1; + if (newIndex < 0 || newIndex >= fields.length) return; + + const newFields = [...fields]; + [newFields[index], newFields[newIndex]] = [ + newFields[newIndex], + newFields[index], + ]; + onUpdate({ fields: newFields }); + }; + + return ( +
+ {/* 필드 목록 */} + {fields.length === 0 ? ( +
+

+ 본문에 표시할 필드를 추가하세요 +

+
+ ) : ( +
+ {fields.map((field, index) => ( + updateField(index, updated)} + onDelete={() => deleteField(index)} + onMove={(dir) => moveField(index, dir)} + /> + ))} +
+ )} + + {/* 필드 추가 버튼 */} + +
+ ); +} + +// ===== 필드 편집기 ===== + +function FieldEditor({ + field, + index, + columns, + totalCount, + onUpdate, + onDelete, + onMove, +}: { + field: CardFieldBinding; + index: number; + columns: ColumnInfo[]; + totalCount: number; + onUpdate: (field: CardFieldBinding) => void; + onDelete: () => void; + onMove: (direction: "up" | "down") => void; +}) { + return ( +
+
+ {/* 순서 이동 버튼 */} +
+ + +
+ + {/* 필드 설정 */} +
+
+ {/* 라벨 */} +
+ + onUpdate({ ...field, label: e.target.value })} + placeholder="예: 발주일" + className="mt-1 h-7 text-xs" + /> +
+ + {/* 컬럼 */} +
+ + +
+
+ + {/* 텍스트 색상 */} +
+ + +
+
+ + {/* 삭제 버튼 */} + +
+
+ ); +} + + +// ===== 입력 필드 설정 섹션 ===== + +function InputFieldSettingsSection({ + inputField, + columns, + onUpdate, +}: { + inputField?: CardInputFieldConfig; + columns: ColumnInfo[]; + onUpdate: (inputField: CardInputFieldConfig) => void; +}) { + const field = inputField || { + enabled: false, + label: "발주 수량", + unit: "EA", + defaultValue: 0, + min: 0, + max: 999999, + step: 1, + }; + + const updateField = (partial: Partial) => { + onUpdate({ ...field, ...partial }); + }; + + return ( +
+ {/* 활성화 스위치 */} +
+ + updateField({ enabled })} + /> +
+ + {field.enabled && ( + <> + {/* 라벨 */} +
+ + updateField({ label: e.target.value })} + className="mt-1 h-7 text-xs" + placeholder="발주 수량" + /> +
+ + {/* 단위 */} +
+ + updateField({ unit: e.target.value })} + className="mt-1 h-7 text-xs" + placeholder="EA" + /> +
+ + {/* 기본값 */} +
+ + updateField({ defaultValue: parseInt(e.target.value, 10) || 0 })} + className="mt-1 h-7 text-xs" + placeholder="0" + /> +
+ + {/* 최소/최대값 */} +
+
+ + updateField({ min: parseInt(e.target.value, 10) || 0 })} + className="mt-1 h-7 text-xs" + placeholder="0" + /> +
+
+ + updateField({ max: parseInt(e.target.value, 10) || 999999 })} + className="mt-1 h-7 text-xs" + placeholder="999999" + /> +
+
+ + {/* 최대값 컬럼 */} +
+ + +

+ 설정 시 각 카드 행의 해당 컬럼 값이 숫자패드 최대값으로 사용됨 (예: unreceived_qty) +

+
+ + {/* 저장 컬럼 (선택사항) */} +
+ + +

+ 입력값을 저장할 DB 컬럼 (현재는 로컬 상태만 유지) +

+
+ + )} +
+ ); +} + +// ===== 계산 필드 설정 섹션 ===== + +function CalculatedFieldSettingsSection({ + calculatedField, + columns, + onUpdate, +}: { + calculatedField?: CardCalculatedFieldConfig; + columns: ColumnInfo[]; + onUpdate: (calculatedField: CardCalculatedFieldConfig) => void; +}) { + const field = calculatedField || { + enabled: false, + label: "미입고", + formula: "", + sourceColumns: [], + unit: "EA", + }; + + const updateField = (partial: Partial) => { + onUpdate({ ...field, ...partial }); + }; + + return ( +
+ {/* 활성화 스위치 */} +
+ + updateField({ enabled })} + /> +
+ + {field.enabled && ( + <> + {/* 라벨 */} +
+ + updateField({ label: e.target.value })} + className="mt-1 h-7 text-xs" + placeholder="미입고" + /> +
+ + {/* 계산식 */} +
+ + updateField({ formula: e.target.value })} + className="mt-1 h-7 text-xs font-mono" + placeholder="$input - received_qty" + /> +

+ 사용 가능: 컬럼명, $input (입력값), +, -, *, / +

+
+ + {/* 단위 */} +
+ + updateField({ unit: e.target.value })} + className="mt-1 h-7 text-xs" + placeholder="EA" + /> +
+ + {/* 사용 가능한 컬럼 목록 */} +
+ +
+
+ {columns.map((col) => ( + { + // 클릭 시 계산식에 컬럼명 추가 + const currentFormula = field.formula || ""; + updateField({ formula: currentFormula + col.name }); + }} + > + {col.name} + + ))} +
+
+

+ 클릭하면 계산식에 추가됩니다 +

+
+ + )} +
+ ); +} + +// ===== 조인 설정 섹션 ===== + +function JoinSettingsSection({ + dataSource, + tables, + onUpdate, +}: { + dataSource: CardListDataSource; + tables: TableInfo[]; + onUpdate: (partial: Partial) => void; +}) { + const joins = dataSource.joins || []; + const [sourceColumns, setSourceColumns] = useState([]); + const [targetColumnsMap, setTargetColumnsMap] = useState< + Record + >({}); + + // 소스 테이블 컬럼 로드 + useEffect(() => { + if (dataSource.tableName) { + fetchTableColumns(dataSource.tableName).then(setSourceColumns); + } + }, [dataSource.tableName]); + + // 조인 추가 + const addJoin = () => { + const newJoin: CardColumnJoin = { + targetTable: "", + joinType: "LEFT", + sourceColumn: "", + targetColumn: "", + }; + onUpdate({ joins: [...joins, newJoin] }); + }; + + // 조인 업데이트 + const updateJoin = (index: number, updated: CardColumnJoin) => { + const newJoins = [...joins]; + newJoins[index] = updated; + onUpdate({ joins: newJoins }); + + // 대상 테이블 컬럼 로드 + if (updated.targetTable && !targetColumnsMap[updated.targetTable]) { + fetchTableColumns(updated.targetTable).then((cols) => { + setTargetColumnsMap((prev) => ({ + ...prev, + [updated.targetTable]: cols, + })); + }); + } + }; + + // 조인 삭제 + const deleteJoin = (index: number) => { + const newJoins = joins.filter((_, i) => i !== index); + onUpdate({ joins: newJoins.length > 0 ? newJoins : undefined }); + }; + + return ( +
+ {joins.length === 0 ? ( +
+

+ 다른 테이블과 조인하여 추가 컬럼을 사용할 수 있습니다 +

+
+ ) : ( +
+ {joins.map((join, index) => ( +
+
+ + 조인 {index + 1} + + +
+ + {/* 조인 타입 */} + + + {/* 대상 테이블 */} + + + {/* ON 조건 */} + {join.targetTable && ( +
+ + = + +
+ )} +
+ ))} +
+ )} + + +
+ ); +} + +// ===== 필터 설정 섹션 ===== + +function FilterSettingsSection({ + dataSource, + columns, + onUpdate, +}: { + dataSource: CardListDataSource; + columns: ColumnInfo[]; + onUpdate: (partial: Partial) => void; +}) { + const filters = dataSource.filters || []; + + const operators: { value: FilterOperator; label: string }[] = [ + { value: "=", label: "=" }, + { value: "!=", label: "!=" }, + { value: ">", label: ">" }, + { value: ">=", label: ">=" }, + { value: "<", label: "<" }, + { value: "<=", label: "<=" }, + { value: "like", label: "LIKE" }, + ]; + + // 필터 추가 + const addFilter = () => { + const newFilter: CardColumnFilter = { + column: "", + operator: "=", + value: "", + }; + onUpdate({ filters: [...filters, newFilter] }); + }; + + // 필터 업데이트 + const updateFilter = (index: number, updated: CardColumnFilter) => { + const newFilters = [...filters]; + newFilters[index] = updated; + onUpdate({ filters: newFilters }); + }; + + // 필터 삭제 + const deleteFilter = (index: number) => { + const newFilters = filters.filter((_, i) => i !== index); + onUpdate({ filters: newFilters.length > 0 ? newFilters : undefined }); + }; + + return ( +
+ {filters.length === 0 ? ( +
+

+ 필터 조건을 추가하여 데이터를 필터링할 수 있습니다 +

+
+ ) : ( +
+ {filters.map((filter, index) => ( +
+ + + + + + updateFilter(index, { ...filter, value: e.target.value }) + } + placeholder="값" + className="h-7 flex-1 text-xs" + /> + + +
+ ))} +
+ )} + + +
+ ); +} + +// ===== 정렬 설정 섹션 ===== + +function SortSettingsSection({ + dataSource, + columns, + onUpdate, +}: { + dataSource: CardListDataSource; + columns: ColumnInfo[]; + onUpdate: (partial: Partial) => void; +}) { + const sort = dataSource.sort; + + return ( +
+ {/* 정렬 사용 여부 */} +
+ + +
+ + {sort && ( +
+ {/* 정렬 컬럼 */} +
+ + +
+ + {/* 정렬 방향 */} +
+ +
+ + +
+
+
+ )} +
+ ); +} + +// ===== 표시 개수 설정 섹션 ===== + +function LimitSettingsSection({ + dataSource, + onUpdate, +}: { + dataSource: CardListDataSource; + onUpdate: (partial: Partial) => void; +}) { + const limit = dataSource.limit || { mode: "all" as const }; + const isLimited = limit.mode === "limited"; + + return ( +
+ {/* 모드 선택 */} +
+ + +
+ + {isLimited && ( +
+ + + onUpdate({ + limit: { + mode: "limited", + count: parseInt(e.target.value, 10) || 10, + }, + }) + } + className="mt-1 h-7 text-xs" + /> +
+ )} +
+ ); +} + +// ===== 담기 버튼 설정 섹션 ===== + +function CartActionSettingsSection({ + cartAction, + onUpdate, +}: { + cartAction?: CardCartActionConfig; + onUpdate: (cartAction: CardCartActionConfig) => void; +}) { + const action: CardCartActionConfig = cartAction || { + navigateMode: "none", + iconType: "lucide", + iconValue: "ShoppingCart", + label: "담기", + cancelLabel: "취소", + }; + + const update = (partial: Partial) => { + onUpdate({ ...action, ...partial }); + }; + + return ( +
+ {/* 네비게이션 모드 */} +
+ + +
+ + {/* 대상 화면 ID (screen 모드일 때만) */} + {action.navigateMode === "screen" && ( +
+ + update({ targetScreenId: e.target.value })} + placeholder="예: 15" + className="mt-1 h-7 text-xs" + /> +

+ 담기 클릭 시 이동할 POP 화면의 screenId +

+
+ )} + + {/* 아이콘 타입 */} +
+ + +
+ + {/* 아이콘 값 */} +
+ + update({ iconValue: e.target.value })} + placeholder={ + action.iconType === "emoji" ? "예: 🛒" : "예: ShoppingCart" + } + className="mt-1 h-7 text-xs" + /> + {action.iconType === "lucide" && ( +

+ PascalCase로 입력 (ShoppingCart, Package, Truck 등) +

+ )} +
+ + {/* 담기 라벨 */} +
+ + update({ label: e.target.value })} + placeholder="담기" + className="mt-1 h-7 text-xs" + /> +
+ + {/* 취소 라벨 */} +
+ + update({ cancelLabel: e.target.value })} + placeholder="취소" + className="mt-1 h-7 text-xs" + /> +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx b/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx new file mode 100644 index 00000000..312567b9 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list/PopCardListPreview.tsx @@ -0,0 +1,191 @@ +"use client"; + +/** + * pop-card-list 디자인 모드 미리보기 컴포넌트 (V2) + * + * 디자이너 캔버스에서 표시되는 미리보기 + * 이미지 참조 기반 카드 구조 반영 + */ + +import React from "react"; +import { LayoutGrid, Package } from "lucide-react"; +import type { PopCardListConfig } from "../types"; +import { + CARD_SCROLL_DIRECTION_LABELS, + CARD_SIZE_LABELS, + DEFAULT_CARD_IMAGE, +} from "../types"; + +interface PopCardListPreviewProps { + config?: PopCardListConfig; +} + +export function PopCardListPreviewComponent({ + config, +}: PopCardListPreviewProps) { + const scrollDirection = config?.scrollDirection || "vertical"; + const cardSize = config?.cardSize || "medium"; + const dataSource = config?.dataSource; + const template = config?.cardTemplate; + + const hasTable = !!dataSource?.tableName; + const hasHeader = + !!template?.header?.codeField || !!template?.header?.titleField; + const hasImage = template?.image?.enabled ?? true; + const fieldCount = template?.body?.fields?.length || 0; + + const sampleCardCount = 2; + + return ( +
+ {/* 헤더 */} +
+
+ + 카드 목록 +
+ + {/* 설정 배지 */} +
+ + {CARD_SCROLL_DIRECTION_LABELS[scrollDirection]} + + + {CARD_SIZE_LABELS[cardSize]} + +
+
+ + {/* 테이블 미선택 시 안내 */} + {!hasTable ? ( +
+
+ +

+ 데이터 소스를 설정하세요 +

+
+
+ ) : ( + <> + {/* 테이블 정보 */} +
+ + {dataSource.tableName} + +
+ + {/* 카드 미리보기 */} +
+ {Array.from({ length: sampleCardCount }).map((_, idx) => ( + + ))} +
+ + {/* 필드 정보 */} + {fieldCount > 0 && ( +
+ + {fieldCount}개 필드 설정됨 + +
+ )} + + )} +
+ ); +} + +// ===== 미리보기 카드 컴포넌트 ===== + +function PreviewCard({ + index, + hasHeader, + hasImage, + fieldCount, + cardSize, + scrollDirection, +}: { + index: number; + hasHeader: boolean; + hasImage: boolean; + fieldCount: number; + cardSize: string; + scrollDirection: string; +}) { + const sizeClass = + cardSize === "small" + ? "min-h-[60px]" + : cardSize === "large" + ? "min-h-[100px]" + : "min-h-[80px]"; + + const widthClass = + scrollDirection === "vertical" + ? "w-full" + : "min-w-[140px] flex-shrink-0"; + + return ( +
+ {/* 헤더 */} + {hasHeader && ( +
+
+ + +
+
+ )} + + {/* 본문 */} +
+ {/* 이미지 */} + {hasImage && ( +
+
+ +
+
+ )} + + {/* 필드 목록 */} +
+ {fieldCount > 0 ? ( + Array.from({ length: Math.min(fieldCount, 3) }).map((_, i) => ( +
+ + +
+ )) + ) : ( +
+ + 필드 추가 + +
+ )} +
+
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-card-list/index.tsx b/frontend/lib/registry/pop-components/pop-card-list/index.tsx new file mode 100644 index 00000000..6e417c1e --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-card-list/index.tsx @@ -0,0 +1,65 @@ +"use client"; + +/** + * pop-card-list 컴포넌트 레지스트리 등록 진입점 (V2) + * + * 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨 + */ + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopCardListComponent } from "./PopCardListComponent"; +import { PopCardListConfigPanel } from "./PopCardListConfig"; +import { PopCardListPreviewComponent } from "./PopCardListPreview"; +import type { PopCardListConfig } from "../types"; +import { DEFAULT_CARD_IMAGE } from "../types"; + +const defaultConfig: PopCardListConfig = { + // 데이터 소스 (테이블 단위) + dataSource: { + tableName: "", + }, + // 카드 템플릿 + cardTemplate: { + header: { + codeField: undefined, + titleField: undefined, + }, + image: { + enabled: true, + imageColumn: undefined, + defaultImage: DEFAULT_CARD_IMAGE, + }, + body: { + fields: [], + }, + }, + // 스크롤 방향 + scrollDirection: "vertical", + cardSize: "medium", + // 그리드 배치 (가로 x 세로) + gridColumns: 3, + gridRows: 2, + // 담기 버튼 기본 설정 + cartAction: { + navigateMode: "none", + iconType: "lucide", + iconValue: "ShoppingCart", + label: "담기", + cancelLabel: "취소", + }, +}; + +// 레지스트리 등록 +PopComponentRegistry.registerComponent({ + id: "pop-card-list", + name: "카드 목록", + description: "테이블 데이터를 카드 형태로 표시 (헤더 + 이미지 + 필드 목록)", + category: "display", + icon: "LayoutGrid", + component: PopCardListComponent, + configPanel: PopCardListConfigPanel, + preview: PopCardListPreviewComponent, + defaultProps: defaultConfig, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx new file mode 100644 index 00000000..8c9ce861 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx @@ -0,0 +1,459 @@ +"use client"; + +/** + * pop-dashboard 메인 컴포넌트 (뷰어용) + * + * 멀티 아이템 컨테이너: 여러 집계 아이템을 묶어서 다양한 표시 모드로 렌더링 + * + * @INFRA-EXTRACT 대상: + * - fetchAggregatedData 호출부 -> useDataSource로 교체 예정 + * - filter_changed 이벤트 수신 -> usePopEvent로 교체 예정 + */ + +import React, { useState, useEffect, useCallback, useRef } from "react"; +import type { + PopDashboardConfig, + DashboardItem, + DashboardPage, +} from "../types"; +import { fetchAggregatedData } from "./utils/dataFetcher"; +import { + evaluateFormula, + formatFormulaResult, +} from "./utils/formula"; + +// 서브타입 아이템 컴포넌트 +import { KpiCardComponent } from "./items/KpiCard"; +import { ChartItemComponent } from "./items/ChartItem"; +import { GaugeItemComponent } from "./items/GaugeItem"; +import { StatCardComponent } from "./items/StatCard"; + +// 표시 모드 컴포넌트 +import { ArrowsModeComponent } from "./modes/ArrowsMode"; +import { AutoSlideModeComponent } from "./modes/AutoSlideMode"; +import { GridModeComponent } from "./modes/GridMode"; +import { ScrollModeComponent } from "./modes/ScrollMode"; + +// ===== 마이그레이션: 기존 config -> 페이지 기반 구조 변환 ===== + +/** + * 기존 config를 페이지 기반 구조로 마이그레이션. + * 런타임에서만 사용 (저장된 config 원본은 변경하지 않음). + * + * 시나리오1: displayMode="grid" (가장 오래된 형태) + * 시나리오2: useGridLayout=true (직전 마이그레이션 결과) + * 시나리오3: pages 이미 있음 (새 형태) -> 변환 불필요 + * 시나리오4: pages 없음 + grid 아님 -> 빈 pages (아이템 하나씩 슬라이드) + */ +export function migrateConfig( + raw: Record +): PopDashboardConfig { + const config = { ...raw } as PopDashboardConfig & Record; + + // pages가 이미 있으면 마이그레이션 불필요 + if ( + Array.isArray(config.pages) && + config.pages.length > 0 + ) { + return config; + } + + // 시나리오1: displayMode="grid" / 시나리오2: useGridLayout=true + const wasGrid = + config.displayMode === ("grid" as string) || + (config as Record).useGridLayout === true; + + if (wasGrid) { + const cols = + ((config as Record).gridColumns as number) ?? 2; + const rows = + ((config as Record).gridRows as number) ?? 2; + const cells = + ((config as Record).gridCells as DashboardPage["gridCells"]) ?? []; + + const page: DashboardPage = { + id: "migrated-page-1", + label: "페이지 1", + gridColumns: cols, + gridRows: rows, + gridCells: cells, + }; + + config.pages = [page]; + + // displayMode="grid" 보정 + if (config.displayMode === ("grid" as string)) { + (config as Record).displayMode = "arrows"; + } + } + + return config as PopDashboardConfig; +} + +// ===== 내부 타입 ===== + +interface ItemData { + /** 단일 집계 값 */ + value: number; + /** 데이터 행 (차트용) */ + rows: Record[]; + /** 수식 결과 표시 문자열 */ + formulaDisplay: string | null; + /** 에러 메시지 */ + error: string | null; +} + +// ===== 데이터 로딩 함수 ===== + +/** 단일 아이템의 데이터를 조회 */ +async function loadItemData(item: DashboardItem): Promise { + try { + // 수식 모드 + if (item.formula?.enabled && item.formula.values.length > 0) { + // 각 값(A, B, ...)을 병렬 조회 + const results = await Promise.allSettled( + item.formula.values.map((fv) => fetchAggregatedData(fv.dataSource)) + ); + + const valueMap: Record = {}; + for (let i = 0; i < item.formula.values.length; i++) { + const result = results[i]; + const fv = item.formula.values[i]; + valueMap[fv.id] = + result.status === "fulfilled" ? result.value.value : 0; + } + + const calculatedValue = evaluateFormula( + item.formula.expression, + valueMap + ); + const formulaDisplay = formatFormulaResult(item.formula, valueMap); + + return { + value: calculatedValue, + rows: [], + formulaDisplay, + error: null, + }; + } + + // 단일 집계 모드 + const result = await fetchAggregatedData(item.dataSource); + if (result.error) { + return { value: 0, rows: [], formulaDisplay: null, error: result.error }; + } + + return { + value: result.value, + rows: result.rows ?? [], + formulaDisplay: null, + error: null, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "데이터 로딩 실패"; + return { value: 0, rows: [], formulaDisplay: null, error: message }; + } +} + +// ===== 메인 컴포넌트 ===== + +export function PopDashboardComponent({ + config, + previewPageIndex, +}: { + config?: PopDashboardConfig; + /** 디자이너 페이지 미리보기: 이 인덱스의 페이지만 단독 렌더링 (-1이면 기본 모드) */ + previewPageIndex?: number; +}) { + const [dataMap, setDataMap] = useState>({}); + const [loading, setLoading] = useState(true); + const refreshTimerRef = useRef | null>(null); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(300); + + // 보이는 아이템만 필터링 (hooks 이전에 early return 불가하므로 빈 배열 허용) + const visibleItems = Array.isArray(config?.items) + ? config.items.filter((item) => item.visible) + : []; + + // 컨테이너 크기 감지 + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + // 아이템 ID 목록 문자열 키 (참조 안정성 보장 - 매 렌더마다 새 배열 참조 방지) + const visibleItemIds = JSON.stringify(visibleItems.map((i) => i.id)); + + // 데이터 로딩 함수 + // eslint-disable-next-line react-hooks/exhaustive-deps + const fetchAllData = useCallback(async () => { + if (!visibleItems.length) { + setLoading(false); + return; + } + + setLoading(true); + + // 모든 아이템 병렬 로딩 (하나 실패해도 나머지 표시) + // @INFRA-EXTRACT: useDataSource로 교체 예정 + const results = await Promise.allSettled( + visibleItems.map((item) => loadItemData(item)) + ); + + const newDataMap: Record = {}; + for (let i = 0; i < visibleItems.length; i++) { + const result = results[i]; + newDataMap[visibleItems[i].id] = + result.status === "fulfilled" + ? result.value + : { value: 0, rows: [], formulaDisplay: null, error: "로딩 실패" }; + } + + setDataMap(newDataMap); + setLoading(false); + }, [visibleItemIds]); + + // 초기 로딩 + 주기적 새로고침 + useEffect(() => { + fetchAllData(); + + // refreshInterval 적용 (첫 번째 아이템 기준, 최소 5초 강제) + const rawRefreshSec = visibleItems[0]?.dataSource.refreshInterval; + const refreshSec = rawRefreshSec && rawRefreshSec > 0 + ? Math.max(5, rawRefreshSec) + : 0; + if (refreshSec > 0) { + refreshTimerRef.current = setInterval(fetchAllData, refreshSec * 1000); + } + + return () => { + if (refreshTimerRef.current) { + clearInterval(refreshTimerRef.current); + refreshTimerRef.current = null; + } + }; + // visibleItemIds로 아이템 변경 감지 (visibleItems 객체 참조 대신 문자열 키 사용) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchAllData, visibleItemIds]); + + // 빈 설정 (모든 hooks 이후에 early return) + if (!config || !config.items?.length) { + return ( +
+ + 대시보드 아이템을 추가하세요 + +
+ ); + } + + // 단일 아이템 렌더링 + const renderSingleItem = (item: DashboardItem) => { + const itemData = dataMap[item.id]; + if (!itemData) { + return ( +
+ 로딩 중... +
+ ); + } + + if (itemData.error) { + return ( +
+ {itemData.error} +
+ ); + } + + switch (item.subType) { + case "kpi-card": + return ( + + ); + 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 ( + + ); + } + case "gauge": + return ; + case "stat-card": { + // StatCard: 카테고리별 건수 맵 구성 (필터 적용) + const categoryData: Record = {}; + if (item.statConfig?.categories) { + for (const cat of item.statConfig.categories) { + if (cat.filter.column && cat.filter.value !== undefined && 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 ( + + ); + } + default: + return ( +
+ + 미지원 타입: {item.subType} + +
+ ); + } + }; + + // 로딩 상태 + if (loading && !Object.keys(dataMap).length) { + return ( +
+
+
+ ); + } + + // 마이그레이션: 기존 config를 페이지 기반으로 변환 + const migrated = migrateConfig(config as unknown as Record); + const pages = migrated.pages ?? []; + const displayMode = migrated.displayMode; + + // 페이지 하나를 GridModeComponent로 렌더링 + const renderPageContent = (page: DashboardPage) => { + return ( + { + const item = visibleItems.find((i) => i.id === itemId); + if (!item) return null; + return renderSingleItem(item); + }} + /> + ); + }; + + // 슬라이드 수: pages가 있으면 페이지 수, 없으면 아이템 수 (기존 동작) + const slideCount = pages.length > 0 ? pages.length : visibleItems.length; + + // 슬라이드 렌더 콜백: pages가 있으면 페이지 렌더, 없으면 단일 아이템 + const renderSlide = (index: number) => { + if (pages.length > 0 && pages[index]) { + return renderPageContent(pages[index]); + } + // fallback: 아이템 하나씩 (기존 동작 - pages 미설정 시) + if (visibleItems[index]) { + return renderSingleItem(visibleItems[index]); + } + return null; + }; + + // 페이지 미리보기 모드: 특정 페이지만 단독 렌더링 (디자이너에서 사용) + if ( + typeof previewPageIndex === "number" && + previewPageIndex >= 0 && + pages[previewPageIndex] + ) { + return ( +
+ {renderPageContent(pages[previewPageIndex])} +
+ ); + } + + // 표시 모드별 렌더링 + return ( +
+ {displayMode === "arrows" && ( + + )} + + {displayMode === "auto-slide" && ( + + )} + + {displayMode === "scroll" && ( + + )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx new file mode 100644 index 00000000..f64e09ae --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -0,0 +1,2338 @@ +"use client"; + +/** + * pop-dashboard 설정 패널 (디자이너용) + * + * 3개 탭: + * [기본 설정] - 표시 모드, 간격, 인디케이터 + * [아이템 관리] - 아이템 추가/삭제/순서변경, 데이터 소스 설정 + * [페이지] - 페이지(슬라이드) 추가/삭제, 각 페이지 독립 그리드 레이아웃 + */ + +import React, { useState, useEffect, useCallback } from "react"; +import { + Plus, + Trash2, + ChevronDown, + ChevronUp, + GripVertical, + Check, + ChevronsUpDown, + Eye, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import type { + PopDashboardConfig, + DashboardItem, + DashboardSubType, + DashboardDisplayMode, + DataSourceConfig, + DataSourceFilter, + FilterOperator, + FormulaConfig, + ItemVisibility, + DashboardCell, + DashboardPage, + JoinConfig, + JoinType, + ItemStyleConfig, + AggregationType, +} from "../types"; +import { + TEXT_ALIGN_LABELS, +} from "../types"; +import { migrateConfig } from "./PopDashboardComponent"; +import { + fetchTableColumns, + fetchTableList, + type ColumnInfo, + type TableInfo, +} from "./utils/dataFetcher"; +import { validateExpression } from "./utils/formula"; + +// ===== Props ===== + +interface ConfigPanelProps { + config: PopDashboardConfig | undefined; + onUpdate: (config: PopDashboardConfig) => void; + /** 페이지 미리보기 요청 (-1이면 해제) */ + onPreviewPage?: (pageIndex: number) => void; + /** 현재 미리보기 중인 페이지 인덱스 */ + previewPageIndex?: number; +} + +// ===== 기본값 ===== + +const DEFAULT_CONFIG: PopDashboardConfig = { + items: [], + pages: [], + displayMode: "arrows", + autoSlideInterval: 5, + autoSlideResumeDelay: 3, + showIndicator: true, + gap: 8, +}; + +const DEFAULT_VISIBILITY: ItemVisibility = { + showLabel: true, + showValue: true, + showUnit: true, + showTrend: true, + showSubLabel: false, + showTarget: true, +}; + +const DEFAULT_DATASOURCE: DataSourceConfig = { + tableName: "", + filters: [], + sort: [], +}; + +// ===== 라벨 상수 ===== + +const DISPLAY_MODE_LABELS: Record = { + arrows: "좌우 버튼", + "auto-slide": "자동 슬라이드", + scroll: "스크롤", +}; + +const SUBTYPE_LABELS: Record = { + "kpi-card": "KPI 카드", + chart: "차트", + gauge: "게이지", + "stat-card": "통계 카드", +}; + +const JOIN_TYPE_LABELS: Record = { + inner: "INNER JOIN", + left: "LEFT JOIN", + right: "RIGHT JOIN", +}; + +// ===== 집계 함수 유효성 검증 유틸 ===== + +// 아이템 타입별 사용 가능한 집계 함수 +const SUBTYPE_AGGREGATION_MAP: Record = { + "kpi-card": ["count", "sum", "avg", "min", "max"], + chart: ["count", "sum", "avg", "min", "max"], + gauge: ["count", "sum", "avg", "min", "max"], + "stat-card": ["count"], +}; + +// 집계 함수 라벨 +const AGGREGATION_LABELS: Record = { + count: "건수 (COUNT)", + sum: "합계 (SUM)", + avg: "평균 (AVG)", + min: "최소 (MIN)", + max: "최대 (MAX)", +}; + +// 숫자 전용 집계 함수 (숫자 컬럼에만 사용 가능) +const NUMERIC_ONLY_AGGREGATIONS: AggregationType[] = ["sum", "avg"]; + +// PostgreSQL 숫자 타입 판별용 패턴 +const NUMERIC_TYPE_PATTERNS = [ + "int", "integer", "bigint", "smallint", + "numeric", "decimal", "real", "double", + "float", "serial", "bigserial", "smallserial", + "money", "number", +]; + +/** 컬럼이 숫자 타입인지 판별 */ +function isNumericColumn(col: ColumnInfo): boolean { + const t = (col.type || "").toLowerCase(); + const u = (col.udtName || "").toLowerCase(); + return NUMERIC_TYPE_PATTERNS.some( + (pattern) => t.includes(pattern) || u.includes(pattern) + ); +} + +/** 현재 집계 함수가 숫자 전용(sum/avg)인지 판별 */ +function isNumericOnlyAggregation(aggType: string | undefined): boolean { + return !!aggType && NUMERIC_ONLY_AGGREGATIONS.includes(aggType as AggregationType); +} + +const FILTER_OPERATOR_LABELS: Record = { + "=": "같음 (=)", + "!=": "다름 (!=)", + ">": "초과 (>)", + ">=": "이상 (>=)", + "<": "미만 (<)", + "<=": "이하 (<=)", + like: "포함 (LIKE)", + in: "목록 (IN)", + between: "범위 (BETWEEN)", +}; + +// ===== 데이터 소스 편집기 ===== + +function DataSourceEditor({ + dataSource, + onChange, + subType, +}: { + dataSource: DataSourceConfig; + onChange: (ds: DataSourceConfig) => void; + subType?: DashboardSubType; +}) { + // 테이블 목록 (Combobox용) + const [tables, setTables] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [tableOpen, setTableOpen] = useState(false); + + // 컬럼 목록 (집계 대상 컬럼용) + const [columns, setColumns] = useState([]); + const [loadingCols, setLoadingCols] = useState(false); + const [columnOpen, setColumnOpen] = useState(false); + + // 그룹핑 컬럼 (차트 X축용) + const [groupByOpen, setGroupByOpen] = useState(false); + + // 마운트 시 테이블 목록 로드 + useEffect(() => { + setLoadingTables(true); + fetchTableList() + .then(setTables) + .finally(() => setLoadingTables(false)); + }, []); + + // 테이블 변경 시 컬럼 목록 조회 + useEffect(() => { + if (!dataSource.tableName) { + setColumns([]); + return; + } + setLoadingCols(true); + fetchTableColumns(dataSource.tableName) + .then(setColumns) + .finally(() => setLoadingCols(false)); + }, [dataSource.tableName]); + + return ( +
+ {/* 테이블 선택 (검색 가능한 Combobox) */} +
+ + + + + + + + + + + 테이블을 찾을 수 없습니다 + + + {tables.map((table) => ( + { + const newVal = + table.tableName === dataSource.tableName + ? "" + : table.tableName; + onChange({ ...dataSource, tableName: newVal }); + setTableOpen(false); + }} + className="text-xs" + > + +
+ + {table.displayName || table.tableName} + + {table.displayName && + table.displayName !== table.tableName && ( + + {table.tableName} + + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 집계 함수 + 대상 컬럼 */} +
+
+ + +
+ + {dataSource.aggregation && ( +
+ + + + + + + + + + + {isNumericOnlyAggregation(dataSource.aggregation?.type) + ? "숫자 타입 컬럼이 없습니다." + : "컬럼을 찾을 수 없습니다."} + + + {(isNumericOnlyAggregation(dataSource.aggregation?.type) + ? columns.filter(isNumericColumn) + : columns + ).map((col) => ( + { + onChange({ + ...dataSource, + aggregation: { + ...dataSource.aggregation!, + column: col.name, + }, + }); + setColumnOpen(false); + }} + className="text-xs" + > + + {col.name} + + ({col.type}) + + + ))} + + + + + +
+ )} +
+ + {/* 그룹핑 (차트 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축 카테고리로 사용됩니다 +

+ {subType === "chart" && !dataSource.aggregation?.groupBy?.length && ( +

+ 차트 모드에서는 그룹핑(X축)을 설정해야 의미 있는 차트가 표시됩니다 +

+ )} +
+ )} + + {/* 자동 새로고침 (Switch + 주기 입력) */} +
+
+ + 0} + onCheckedChange={(checked) => + onChange({ + ...dataSource, + refreshInterval: checked ? 30 : 0, + }) + } + /> +
+ {(dataSource.refreshInterval ?? 0) > 0 && ( +
+ + + onChange({ + ...dataSource, + refreshInterval: Math.max( + 5, + parseInt(e.target.value) || 30 + ), + }) + } + className="h-7 text-xs" + min={5} + /> +
+ )} +
+ + {/* 조인 설정 */} + onChange({ ...dataSource, joins })} + /> + + {/* 필터 조건 */} + onChange({ ...dataSource, filters })} + /> +
+ ); +} + +// ===== 조인 편집기 ===== + +function JoinEditor({ + joins, + mainTable, + onChange, +}: { + joins: JoinConfig[]; + mainTable: string; + onChange: (joins: JoinConfig[]) => void; +}) { + const [tables, setTables] = useState([]); + + // 테이블 목록 로드 + useEffect(() => { + fetchTableList().then(setTables); + }, []); + + const addJoin = () => { + onChange([ + ...joins, + { + targetTable: "", + joinType: "left", + on: { sourceColumn: "", targetColumn: "" }, + }, + ]); + }; + + const updateJoin = (index: number, partial: Partial) => { + const newJoins = [...joins]; + newJoins[index] = { ...newJoins[index], ...partial }; + onChange(newJoins); + }; + + const removeJoin = (index: number) => { + onChange(joins.filter((_, i) => i !== index)); + }; + + return ( +
+
+ + +
+ + {!mainTable && joins.length === 0 && ( +

+ 먼저 메인 테이블을 선택하세요 +

+ )} + + {joins.map((join, index) => ( + updateJoin(index, partial)} + onRemove={() => removeJoin(index)} + /> + ))} +
+ ); +} + +function JoinRow({ + join, + mainTable, + tables, + onUpdate, + onRemove, +}: { + join: JoinConfig; + mainTable: string; + tables: TableInfo[]; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +}) { + const [targetColumns, setTargetColumns] = useState([]); + const [sourceColumns, setSourceColumns] = useState([]); + const [targetTableOpen, setTargetTableOpen] = useState(false); + + // 메인 테이블 컬럼 로드 + useEffect(() => { + if (!mainTable) return; + fetchTableColumns(mainTable).then(setSourceColumns); + }, [mainTable]); + + // 조인 대상 테이블 컬럼 로드 + useEffect(() => { + if (!join.targetTable) return; + fetchTableColumns(join.targetTable).then(setTargetColumns); + }, [join.targetTable]); + + return ( +
+
+ {/* 조인 타입 */} + + + {/* 조인 대상 테이블 (Combobox) */} + + + + + + + + + + 없음 + + + {tables + .filter((t) => t.tableName !== mainTable) + .map((t) => ( + { + onUpdate({ targetTable: t.tableName }); + setTargetTableOpen(false); + }} + className="text-xs" + > + {t.displayName || t.tableName} + + ))} + + + + + + + {/* 삭제 */} + +
+ + {/* 조인 조건 (ON 절) */} + {join.targetTable && ( +
+ ON + + = + +
+ )} +
+ ); +} + +// ===== 필터 편집기 ===== + +function FilterEditor({ + filters, + tableName, + onChange, +}: { + filters: DataSourceFilter[]; + tableName: string; + onChange: (filters: DataSourceFilter[]) => void; +}) { + const [columns, setColumns] = useState([]); + + useEffect(() => { + if (!tableName) return; + fetchTableColumns(tableName).then(setColumns); + }, [tableName]); + + const addFilter = () => { + onChange([...filters, { column: "", operator: "=", value: "" }]); + }; + + const updateFilter = ( + index: number, + partial: Partial + ) => { + const newFilters = [...filters]; + newFilters[index] = { ...newFilters[index], ...partial }; + + // operator 변경 시 value 초기화 + if (partial.operator) { + if (partial.operator === "between") { + newFilters[index].value = ["", ""]; + } else if (partial.operator === "in") { + newFilters[index].value = []; + } else if ( + typeof newFilters[index].value !== "string" && + typeof newFilters[index].value !== "number" + ) { + newFilters[index].value = ""; + } + } + + onChange(newFilters); + }; + + const removeFilter = (index: number) => { + onChange(filters.filter((_, i) => i !== index)); + }; + + return ( +
+
+ + +
+ + {filters.map((filter, index) => ( +
+ {/* 컬럼 선택 */} + + + {/* 연산자 */} + + + {/* 값 입력 (연산자에 따라 다른 UI) */} +
+ {filter.operator === "between" ? ( +
+ { + const arr = Array.isArray(filter.value) + ? [...filter.value] + : ["", ""]; + arr[0] = e.target.value; + updateFilter(index, { value: arr }); + }} + placeholder="시작" + className="h-7 text-[10px]" + /> + { + const arr = Array.isArray(filter.value) + ? [...filter.value] + : ["", ""]; + arr[1] = e.target.value; + updateFilter(index, { value: arr }); + }} + placeholder="끝" + className="h-7 text-[10px]" + /> +
+ ) : filter.operator === "in" ? ( + { + const vals = e.target.value + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + updateFilter(index, { value: vals }); + }} + placeholder="값1, 값2, 값3" + className="h-7 text-[10px]" + /> + ) : ( + + updateFilter(index, { value: e.target.value }) + } + placeholder="값" + className="h-7 text-[10px]" + /> + )} +
+ + {/* 삭제 */} + +
+ ))} +
+ ); +} + +// ===== 수식 편집기 ===== + +function FormulaEditor({ + formula, + onChange, +}: { + formula: FormulaConfig; + onChange: (f: FormulaConfig) => void; +}) { + const availableIds = formula.values.map((v) => v.id); + const isValid = formula.expression + ? validateExpression(formula.expression, availableIds) + : true; + + return ( +
+

계산식 설정

+ + {/* 값 목록 */} + {formula.values.map((fv, index) => ( +
+
+ + {fv.id} + + { + const newValues = [...formula.values]; + newValues[index] = { ...fv, label: e.target.value }; + onChange({ ...formula, values: newValues }); + }} + placeholder="라벨 (예: 생산량)" + className="h-7 flex-1 text-xs" + /> + {formula.values.length > 2 && ( + + )} +
+ { + const newValues = [...formula.values]; + newValues[index] = { ...fv, dataSource: ds }; + onChange({ ...formula, values: newValues }); + }} + /> +
+ ))} + + {/* 값 추가 */} + + + {/* 수식 입력 */} +
+ + + onChange({ ...formula, expression: e.target.value }) + } + placeholder="예: A / B * 100" + className={`h-8 text-xs ${!isValid ? "border-destructive" : ""}`} + /> + {!isValid && ( +

+ 수식에 정의되지 않은 변수가 있습니다 +

+ )} +
+ + {/* 표시 형태 */} +
+ + +
+
+ ); +} + +// ===== 아이템 편집기 ===== + +function ItemEditor({ + item, + index, + onUpdate, + onDelete, + onMoveUp, + onMoveDown, + isFirst, + isLast, +}: { + item: DashboardItem; + index: number; + onUpdate: (item: DashboardItem) => void; + onDelete: () => void; + onMoveUp: () => void; + onMoveDown: () => void; + isFirst: boolean; + isLast: boolean; +}) { + const [expanded, setExpanded] = useState(false); + const [dataMode, setDataMode] = useState<"single" | "formula">( + item.formula?.enabled ? "formula" : "single" + ); + + return ( +
+ {/* 헤더 */} +
+ + onUpdate({ ...item, label: e.target.value })} + placeholder={`아이템 ${index + 1}`} + className="h-6 min-w-0 flex-1 border-0 bg-transparent px-1 text-xs font-medium shadow-none focus-visible:ring-1" + /> + + {SUBTYPE_LABELS[item.subType]} + + + + + + + onUpdate({ ...item, visible: checked }) + } + className="scale-75" + /> + + + + +
+ + {/* 상세 설정 */} + {expanded && ( +
+
+ + +
+ +
+ + +
+ + {dataMode === "formula" && item.formula ? ( + onUpdate({ ...item, formula: f })} + /> + ) : ( + onUpdate({ ...item, dataSource: ds })} + subType={item.subType} + /> + )} + + {/* 요소별 보이기/숨기기 */} +
+ +
+ {( + [ + ["showLabel", "라벨"], + ["showValue", "값"], + ["showUnit", "단위"], + ["showTrend", "증감율"], + ["showSubLabel", "보조라벨"], + ["showTarget", "목표값"], + ] as const + ).map(([key, label]) => ( + + ))} +
+
+ + {/* 서브타입별 추가 설정 */} + {item.subType === "kpi-card" && ( +
+ + + onUpdate({ + ...item, + kpiConfig: { ...item.kpiConfig, unit: e.target.value }, + }) + } + placeholder="EA, 톤, 원" + className="h-8 text-xs" + /> +
+ )} + + {item.subType === "chart" && ( +
+
+ + +
+ + {/* X축/Y축 자동 안내 */} +

+ X축: 그룹핑(X축)에서 선택한 컬럼 자동 적용 / Y축: 집계 결과(value) 자동 적용 +

+
+ )} + + {item.subType === "gauge" && ( +
+
+ + + onUpdate({ + ...item, + gaugeConfig: { + ...item.gaugeConfig, + min: parseInt(e.target.value) || 0, + max: item.gaugeConfig?.max ?? 100, + }, + }) + } + className="h-8 text-xs" + /> +
+
+ + + onUpdate({ + ...item, + gaugeConfig: { + ...item.gaugeConfig, + min: item.gaugeConfig?.min ?? 0, + max: parseInt(e.target.value) || 100, + }, + }) + } + className="h-8 text-xs" + /> +
+
+ + + onUpdate({ + ...item, + gaugeConfig: { + ...item.gaugeConfig, + min: item.gaugeConfig?.min ?? 0, + max: item.gaugeConfig?.max ?? 100, + target: parseInt(e.target.value) || undefined, + }, + }) + } + className="h-8 text-xs" + /> +
+
+ )} + + {/* 통계 카드 카테고리 설정 */} + {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 && ( +

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

+ )} +
+ )} +
+ )} +
+ ); +} + +// ===== 그리드 레이아웃 편집기 ===== + +/** 기본 셀 그리드 생성 헬퍼 */ +function generateDefaultCells( + cols: number, + rows: number +): DashboardCell[] { + const cells: DashboardCell[] = []; + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + cells.push({ + id: `cell-${r}-${c}`, + gridColumn: `${c + 1} / ${c + 2}`, + gridRow: `${r + 1} / ${r + 2}`, + itemId: null, + }); + } + } + return cells; +} + +// ===================================================== +// 아이템 스타일 에디터 (접기/펼치기 지원) +// ===================================================== +function ItemStyleEditor({ + item, + onUpdate, +}: { + item: DashboardItem; + onUpdate: (updatedItem: DashboardItem) => void; +}) { + const [expanded, setExpanded] = useState(false); + + const updateStyle = (partial: Partial) => { + const updatedItem = { + ...item, + itemStyle: { ...item.itemStyle, ...partial }, + }; + onUpdate(updatedItem); + }; + + return ( +
+ {/* 헤더 - 클릭으로 접기/펼치기 */} + + + {/* 내용 - 접기/펼치기 */} + {expanded && ( +
+ {/* 라벨 정렬 */} +
+ + 라벨 정렬 + +
+ {(["left", "center", "right"] as const).map((align) => ( + + ))} +
+
+
+ )} +
+ ); +} + +function GridLayoutEditor({ + cells, + gridColumns, + gridRows, + items, + onChange, + onUpdateItem, +}: { + cells: DashboardCell[]; + gridColumns: number; + gridRows: number; + items: DashboardItem[]; + onChange: (cells: DashboardCell[], cols: number, rows: number) => void; + /** 아이템 스타일 업데이트 콜백 */ + onUpdateItem?: (updatedItem: DashboardItem) => void; +}) { + const ensuredCells = + cells.length > 0 ? cells : generateDefaultCells(gridColumns, gridRows); + + return ( +
+ {/* 행/열 조절 버튼 */} +
+
+ + + + {gridColumns} + + +
+ +
+ + + + {gridRows} + + +
+ + +
+ + {/* 시각적 그리드 프리뷰 + 아이템 배정 */} +
+ {ensuredCells.map((cell) => ( +
+ +
+ ))} +
+ +

+ 각 셀을 클릭하여 아이템을 배정하세요. +/- 버튼으로 행/열을 + 추가/삭제할 수 있습니다. +

+ + {/* 배정된 아이템별 스타일 설정 */} + {onUpdateItem && (() => { + const assignedItemIds = ensuredCells + .map((c) => c.itemId) + .filter((id): id is string => !!id); + const uniqueIds = [...new Set(assignedItemIds)]; + const assignedItems = uniqueIds + .map((id) => items.find((i) => i.id === id)) + .filter((i): i is DashboardItem => !!i); + + if (assignedItems.length === 0) return null; + + return ( +
+ + 아이템 스타일 + + {assignedItems.map((item) => ( + + ))} +
+ ); + })()} +
+ ); +} + +// ===== 페이지 편집기 ===== + +function PageEditor({ + page, + pageIndex, + items, + onChange, + onDelete, + onPreview, + isPreviewing, + onUpdateItem, +}: { + page: DashboardPage; + pageIndex: number; + items: DashboardItem[]; + onChange: (updatedPage: DashboardPage) => void; + onDelete: () => void; + onPreview?: () => void; + isPreviewing?: boolean; + onUpdateItem?: (updatedItem: DashboardItem) => void; +}) { + const [expanded, setExpanded] = useState(true); + + return ( +
+ {/* 헤더 */} +
+ + {page.label || `페이지 ${pageIndex + 1}`} + + + {page.gridColumns}x{page.gridRows} + + + + +
+ + {/* 상세 */} + {expanded && ( +
+ {/* 라벨 */} +
+ + + onChange({ ...page, label: e.target.value }) + } + placeholder={`페이지 ${pageIndex + 1}`} + className="h-7 text-xs" + /> +
+ + {/* GridLayoutEditor 재사용 */} + + onChange({ + ...page, + gridCells: cells, + gridColumns: cols, + gridRows: rows, + }) + } + onUpdateItem={onUpdateItem} + /> +
+ )} +
+ ); +} + +// ===== 메인 설정 패널 ===== + +export function PopDashboardConfigPanel(props: ConfigPanelProps) { + const { config, onUpdate: onChange } = props; + // config가 빈 객체 {}로 전달될 수 있으므로 spread로 기본값 보장 + const merged = { ...DEFAULT_CONFIG, ...(config || {}) }; + + // 마이그레이션: 기존 useGridLayout/grid* -> pages 기반으로 변환 + const cfg = migrateConfig( + merged as unknown as Record + ) as PopDashboardConfig; + + const [activeTab, setActiveTab] = useState<"basic" | "items" | "pages">( + "basic" + ); + + // 설정 변경 헬퍼 + const updateConfig = useCallback( + (partial: Partial) => { + onChange({ ...cfg, ...partial }); + }, + [cfg, onChange] + ); + + // 아이템 추가 + const addItem = useCallback( + (subType: DashboardSubType) => { + const newItem: DashboardItem = { + id: `item-${Date.now()}`, + label: `${SUBTYPE_LABELS[subType]} ${cfg.items.length + 1}`, + visible: true, + subType, + dataSource: { ...DEFAULT_DATASOURCE }, + visibility: { ...DEFAULT_VISIBILITY }, + }; + updateConfig({ items: [...cfg.items, newItem] }); + }, + [cfg.items, updateConfig] + ); + + // 아이템 업데이트 + const updateItem = useCallback( + (index: number, item: DashboardItem) => { + const newItems = [...cfg.items]; + newItems[index] = item; + updateConfig({ items: newItems }); + }, + [cfg.items, updateConfig] + ); + + // 아이템 삭제 (모든 페이지의 셀 배정도 해제) + const deleteItem = useCallback( + (index: number) => { + const deletedId = cfg.items[index].id; + const newItems = cfg.items.filter((_, i) => i !== index); + + const newPages = cfg.pages?.map((page) => ({ + ...page, + gridCells: page.gridCells.map((cell) => + cell.itemId === deletedId ? { ...cell, itemId: null } : cell + ), + })); + + updateConfig({ items: newItems, pages: newPages }); + }, + [cfg.items, cfg.pages, updateConfig] + ); + + // 아이템 순서 변경 + const moveItem = useCallback( + (from: number, to: number) => { + if (to < 0 || to >= cfg.items.length) return; + const newItems = [...cfg.items]; + const [moved] = newItems.splice(from, 1); + newItems.splice(to, 0, moved); + updateConfig({ items: newItems }); + }, + [cfg.items, updateConfig] + ); + + return ( +
+ {/* 탭 헤더 */} +
+ {( + [ + ["basic", "기본 설정"], + ["items", "아이템"], + ["pages", "페이지"], + ] as const + ).map(([key, label]) => ( + + ))} +
+ + {/* ===== 기본 설정 탭 ===== */} + {activeTab === "basic" && ( +
+ {/* 표시 모드 */} +
+ + +
+ + {/* 자동 슬라이드 설정 */} + {cfg.displayMode === "auto-slide" && ( +
+
+ + + updateConfig({ + autoSlideInterval: parseInt(e.target.value) || 5, + }) + } + className="h-8 text-xs" + min={1} + /> +
+
+ + + updateConfig({ + autoSlideResumeDelay: parseInt(e.target.value) || 3, + }) + } + className="h-8 text-xs" + min={1} + /> +
+
+ )} + + {/* 인디케이터 */} +
+ + + updateConfig({ showIndicator: checked }) + } + /> +
+ + {/* 간격 */} +
+ + + updateConfig({ gap: parseInt(e.target.value) || 8 }) + } + className="h-8 text-xs" + min={0} + /> +
+ + {/* 배경색 */} +
+ + + updateConfig({ + backgroundColor: e.target.value || undefined, + }) + } + placeholder="예: #f0f0f0" + className="h-8 text-xs" + /> +
+
+ )} + + {/* ===== 아이템 관리 탭 ===== */} + {activeTab === "items" && ( +
+ {cfg.items.map((item, index) => ( + updateItem(index, updated)} + onDelete={() => deleteItem(index)} + onMoveUp={() => moveItem(index, index - 1)} + onMoveDown={() => moveItem(index, index + 1)} + isFirst={index === 0} + isLast={index === cfg.items.length - 1} + /> + ))} + +
+ {(Object.keys(SUBTYPE_LABELS) as DashboardSubType[]).map( + (subType) => ( + + ) + )} +
+
+ )} + + {/* ===== 페이지 탭 ===== */} + {activeTab === "pages" && ( +
+ {/* 페이지 목록 */} + {(cfg.pages ?? []).map((page, pageIdx) => ( + { + const newPages = [...(cfg.pages ?? [])]; + newPages[pageIdx] = updatedPage; + updateConfig({ pages: newPages }); + }} + onDelete={() => { + const newPages = (cfg.pages ?? []).filter( + (_, i) => i !== pageIdx + ); + updateConfig({ pages: newPages }); + }} + onPreview={() => { + if (props.onPreviewPage) { + // 같은 페이지를 다시 누르면 미리보기 해제 + props.onPreviewPage(props.previewPageIndex === pageIdx ? -1 : pageIdx); + } + }} + isPreviewing={props.previewPageIndex === pageIdx} + onUpdateItem={(updatedItem) => { + const newItems = cfg.items.map((i) => + i.id === updatedItem.id ? updatedItem : i + ); + updateConfig({ items: newItems }); + // 스타일 변경 시 자동으로 해당 페이지 미리보기 활성화 + if (props.onPreviewPage && props.previewPageIndex !== pageIdx) { + props.onPreviewPage(pageIdx); + } + }} + /> + ))} + + {/* 페이지 추가 버튼 */} + + + {(cfg.pages?.length ?? 0) === 0 && ( +

+ 페이지를 추가하면 각 페이지에 독립적인 그리드 레이아웃을 + 설정할 수 있습니다. +
+ 페이지가 없으면 아이템이 하나씩 슬라이드됩니다. +

+ )} +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx new file mode 100644 index 00000000..2c8b7643 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardPreview.tsx @@ -0,0 +1,164 @@ +"use client"; + +/** + * pop-dashboard 디자이너 미리보기 컴포넌트 + * + * 실제 데이터 없이 더미 레이아웃으로 미리보기 표시 + * 디자이너가 설정 변경 시 즉시 미리보기 확인 가능 + */ + +import React from "react"; +import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react"; +import type { PopDashboardConfig, DashboardSubType } from "../types"; +import { migrateConfig } from "./PopDashboardComponent"; + +// ===== 서브타입별 아이콘 매핑 ===== + +const SUBTYPE_ICONS: Record = { + "kpi-card": , + chart: , + gauge: , + "stat-card": , +}; + +const SUBTYPE_LABELS: Record = { + "kpi-card": "KPI", + chart: "차트", + gauge: "게이지", + "stat-card": "통계", +}; + +// ===== 모드 라벨 ===== + +const MODE_LABELS: Record = { + arrows: "좌우 버튼", + "auto-slide": "자동 슬라이드", + scroll: "스크롤", +}; + +// ===== 더미 아이템 프리뷰 ===== + +function DummyItemPreview({ + subType, + label, +}: { + subType: DashboardSubType; + label: string; +}) { + return ( +
+ + {SUBTYPE_ICONS[subType]} + + + {label || SUBTYPE_LABELS[subType]} + +
+ ); +} + +// ===== 메인 미리보기 ===== + +export function PopDashboardPreviewComponent({ + config, +}: { + config?: PopDashboardConfig; +}) { + // config가 빈 객체 {} 또는 items가 없는 경우 방어 + if (!config || !Array.isArray(config.items) || !config.items.length) { + return ( +
+ + 대시보드 +
+ ); + } + + const visibleItems = config.items.filter((i) => i.visible); + + // 마이그레이션 적용 + const migrated = migrateConfig(config as unknown as Record); + const pages = migrated.pages ?? []; + const hasPages = pages.length > 0; + + return ( +
+ {/* 모드 + 페이지 뱃지 */} +
+ + {MODE_LABELS[migrated.displayMode] ?? migrated.displayMode} + + {hasPages && ( + + {pages.length}페이지 + + )} + + {visibleItems.length}개 + +
+ + {/* 미리보기 */} +
+ {hasPages ? ( + // 첫 번째 페이지 그리드 미리보기 +
+ {pages[0].gridCells.length > 0 + ? pages[0].gridCells.map((cell) => { + const item = visibleItems.find( + (i) => i.id === cell.itemId + ); + return ( +
+ {item ? ( + + ) : ( +
+ )} +
+ ); + }) + : visibleItems.slice(0, 4).map((item) => ( + + ))} +
+ ) : ( + // 페이지 미설정: 첫 번째 아이템만 크게 표시 +
+ {visibleItems[0] && ( + + )} + {visibleItems.length > 1 && ( +
+ +{visibleItems.length - 1} +
+ )} +
+ )} +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/index.tsx b/frontend/lib/registry/pop-components/pop-dashboard/index.tsx new file mode 100644 index 00000000..58cdf6e2 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/index.tsx @@ -0,0 +1,35 @@ +"use client"; + +/** + * pop-dashboard 컴포넌트 레지스트리 등록 진입점 + * + * 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨 + */ + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopDashboardComponent } from "./PopDashboardComponent"; +import { PopDashboardConfigPanel } from "./PopDashboardConfig"; +import { PopDashboardPreviewComponent } from "./PopDashboardPreview"; + +// 레지스트리 등록 +PopComponentRegistry.registerComponent({ + id: "pop-dashboard", + name: "대시보드", + description: "여러 집계 아이템을 묶어서 다양한 방식으로 보여줌", + category: "display", + icon: "BarChart3", + component: PopDashboardComponent, + configPanel: PopDashboardConfigPanel, + preview: PopDashboardPreviewComponent, + defaultProps: { + items: [], + pages: [], + displayMode: "arrows", + autoSlideInterval: 5, + autoSlideResumeDelay: 3, + showIndicator: true, + gap: 8, + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx new file mode 100644 index 00000000..fc828925 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx @@ -0,0 +1,195 @@ +"use client"; + +/** + * 차트 서브타입 컴포넌트 + * + * Recharts 기반 막대/원형/라인 차트 + * 컨테이너 크기가 너무 작으면 "차트 표시 불가" 메시지 + */ + +import React from "react"; +import { + BarChart, + Bar, + PieChart, + Pie, + Cell, + LineChart, + Line, + XAxis, + YAxis, + Tooltip, + Legend, + ResponsiveContainer, + CartesianGrid, +} from "recharts"; +import type { DashboardItem } from "../../types"; +import { TEXT_ALIGN_CLASSES } from "../../types"; +import { abbreviateNumber } from "../utils/formula"; + +// ===== Props ===== + +export interface ChartItemProps { + item: DashboardItem; + /** 차트에 표시할 데이터 행 */ + rows: Record[]; + /** 컨테이너 너비 (px) - 최소 크기 판단용 */ + containerWidth: number; +} + +// ===== 기본 색상 팔레트 ===== + +const DEFAULT_COLORS = [ + "#6366f1", // indigo + "#8b5cf6", // violet + "#06b6d4", // cyan + "#10b981", // emerald + "#f59e0b", // amber + "#ef4444", // rose + "#ec4899", // pink + "#14b8a6", // teal +]; + +// ===== 최소 표시 크기 ===== + +const MIN_CHART_WIDTH = 120; + +// ===== 메인 컴포넌트 ===== + +export function ChartItemComponent({ + item, + rows, + containerWidth, +}: ChartItemProps) { + const { chartConfig, visibility, itemStyle } = item; + const chartType = chartConfig?.chartType ?? "bar"; + const colors = chartConfig?.colors?.length + ? chartConfig.colors + : DEFAULT_COLORS; + const xKey = chartConfig?.xAxisColumn ?? "name"; + const yKey = chartConfig?.yAxisColumn ?? "value"; + + // 라벨 정렬만 사용자 설정 + const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"]; + + // 컨테이너가 너무 작으면 메시지 표시 + if (containerWidth < MIN_CHART_WIDTH) { + return ( +
+ 차트 +
+ ); + } + + // 데이터 없음 + if (!rows.length) { + return ( +
+ 데이터 없음 +
+ ); + } + + // X축 라벨이 긴지 판정 (7자 이상이면 대각선) + const hasLongLabels = rows.some( + (r) => String(r[xKey] ?? "").length > 7 + ); + const xAxisTickProps = hasLongLabels + ? { fontSize: 10, angle: -45, textAnchor: "end" as const } + : { fontSize: 10 }; + // 긴 라벨이 있으면 하단 여백 확보 + const chartMargin = hasLongLabels + ? { top: 5, right: 10, bottom: 40, left: 10 } + : { top: 5, right: 10, bottom: 5, left: 10 }; + + return ( +
+ {/* 라벨 - 사용자 정렬 적용 */} + {visibility.showLabel && ( +

+ {item.label} +

+ )} + + {/* 차트 영역 */} +
+ + {chartType === "bar" ? ( + []} margin={chartMargin}> + + + abbreviateNumber(v)} + /> + + + + ) : chartType === "line" ? ( + []} margin={chartMargin}> + + + abbreviateNumber(v)} + /> + + 250} + /> + + ) : ( + /* pie - 카테고리명 + 값 라벨 표시 */ + + []} + dataKey={yKey} + nameKey={xKey} + cx="50%" + cy="50%" + outerRadius={containerWidth > 400 ? "70%" : "80%"} + label={ + containerWidth > 250 + ? ({ name, value, percent }: { name: string; value: number; percent: number }) => + `${name} ${abbreviateNumber(value)} (${(percent * 100).toFixed(0)}%)` + : false + } + labelLine={containerWidth > 250} + > + {rows.map((_, index) => ( + + ))} + + [abbreviateNumber(value), name]} + /> + {containerWidth > 300 && ( + + )} + + )} + +
+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx new file mode 100644 index 00000000..f76c4832 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx @@ -0,0 +1,158 @@ +"use client"; + +/** + * 게이지 서브타입 컴포넌트 + * + * SVG 기반 반원형 게이지 (외부 라이브러리 불필요) + * min/max/target/current 표시, 달성률 구간별 색상 + */ + +import React from "react"; +import type { DashboardItem, FontSize } from "../../types"; +import { TEXT_ALIGN_CLASSES } from "../../types"; +import { abbreviateNumber } from "../utils/formula"; + +/** FontSize -> SVG 직접 fontSize(px) 매핑 */ +const SVG_FONT_SIZE_MAP: Record = { + xs: 14, + sm: 18, + base: 24, + lg: 32, + xl: 48, +}; + +// ===== Props ===== + +export interface GaugeItemProps { + item: DashboardItem; + data: number | null; + /** 동적 목표값 (targetDataSource로 조회된 값) */ + targetValue?: number | null; +} + +// ===== 게이지 색상 판정 ===== + +function getGaugeColor( + percentage: number, + ranges?: { min: number; max: number; color: string }[] +): string { + if (ranges?.length) { + const match = ranges.find((r) => percentage >= r.min && percentage <= r.max); + if (match) return match.color; + } + // 기본 색상 (달성률 기준) + if (percentage >= 80) return "#10b981"; // emerald + if (percentage >= 50) return "#f59e0b"; // amber + return "#ef4444"; // rose +} + +// ===== 메인 컴포넌트 ===== + +export function GaugeItemComponent({ + item, + data, + targetValue, +}: GaugeItemProps) { + const { visibility, gaugeConfig, itemStyle } = item; + const current = data ?? 0; + const min = gaugeConfig?.min ?? 0; + const max = gaugeConfig?.max ?? 100; + const target = targetValue ?? gaugeConfig?.target ?? max; + + // 라벨 정렬만 사용자 설정 + const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"]; + + // SVG 내부 텍스트는 기본값 고정 (사용자 설정 연동 제거) + const svgValueFontSize = SVG_FONT_SIZE_MAP["base"]; // 24 + const svgSubFontSize = SVG_FONT_SIZE_MAP["xs"]; // 14 + + // 달성률 계산 (0~100) + const range = max - min; + const percentage = range > 0 ? Math.min(100, Math.max(0, ((current - min) / range) * 100)) : 0; + const gaugeColor = getGaugeColor(percentage, gaugeConfig?.colorRanges); + + // SVG 반원 게이지 수치 + const cx = 100; + const cy = 90; + const radius = 70; + // 반원: 180도 -> percentage에 비례한 각도 + const startAngle = Math.PI; // 180도 (왼쪽) + const endAngle = Math.PI - (percentage / 100) * Math.PI; // 0도 (오른쪽) 방향 + + const startX = cx + radius * Math.cos(startAngle); + const startY = cy - radius * Math.sin(startAngle); + const endX = cx + radius * Math.cos(endAngle); + const endY = cy - radius * Math.sin(endAngle); + const largeArcFlag = percentage > 50 ? 1 : 0; + + return ( +
+ {/* 라벨 - 사용자 정렬 적용 */} + {visibility.showLabel && ( +

+ {item.label} +

+ )} + + {/* 게이지 SVG - 높이/너비 모두 반응형 */} +
+ + {/* 배경 반원 (회색) */} + + + {/* 값 반원 (색상) */} + {percentage > 0 && ( + + )} + + {/* 중앙 텍스트 */} + {visibility.showValue && ( + + {abbreviateNumber(current)} + + )} + + {/* 퍼센트 */} + + {percentage.toFixed(1)}% + + +
+ + {/* 목표값 */} + {visibility.showTarget && ( +

+ 목표: {abbreviateNumber(target)} +

+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx new file mode 100644 index 00000000..9e309a7b --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx @@ -0,0 +1,113 @@ +"use client"; + +/** + * KPI 카드 서브타입 컴포넌트 + * + * 큰 숫자 + 단위 + 증감 표시 + * CSS Container Query로 반응형 내부 콘텐츠 + */ + +import React from "react"; +import type { DashboardItem } from "../../types"; +import { TEXT_ALIGN_CLASSES } from "../../types"; +import { abbreviateNumber } from "../utils/formula"; + +// ===== Props ===== + +export interface KpiCardProps { + item: DashboardItem; + data: number | null; + /** 이전 기간 대비 증감 퍼센트 (선택) */ + trendValue?: number | null; + /** 수식 결과 표시 문자열 (formula가 있을 때) */ + formulaDisplay?: string | null; +} + +// ===== 증감 표시 ===== + +function TrendIndicator({ value }: { value: number }) { + const isPositive = value > 0; + const isZero = value === 0; + const color = isPositive + ? "text-emerald-600" + : isZero + ? "text-muted-foreground" + : "text-rose-600"; + const arrow = isPositive ? "↑" : isZero ? "→" : "↓"; + + return ( + + {arrow} + {Math.abs(value).toFixed(1)}% + + ); +} + +// ===== 색상 구간 판정 ===== + +function getColorForValue( + value: number, + ranges?: { min: number; max: number; color: string }[] +): string | undefined { + if (!ranges?.length) return undefined; + const match = ranges.find((r) => value >= r.min && value <= r.max); + return match?.color; +} + +// ===== 메인 컴포넌트 ===== + +export function KpiCardComponent({ + item, + data, + trendValue, + formulaDisplay, +}: KpiCardProps) { + const { visibility, kpiConfig, itemStyle } = item; + const displayValue = data ?? 0; + const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges); + + // 라벨 정렬만 사용자 설정, 나머지는 @container 반응형 자동 + const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"]; + + return ( +
+ {/* 라벨 - 사용자 정렬 적용 */} + {visibility.showLabel && ( +

+ {item.label} +

+ )} + + {/* 메인 값 - @container 반응형 */} + {visibility.showValue && ( +
+ + {formulaDisplay ?? abbreviateNumber(displayValue)} + + + {/* 단위 */} + {visibility.showUnit && kpiConfig?.unit && ( + + {kpiConfig.unit} + + )} +
+ )} + + {/* 증감율 */} + {visibility.showTrend && trendValue != null && ( + + )} + + {/* 보조 라벨 (수식 표시 등) */} + {visibility.showSubLabel && formulaDisplay && ( +

+ {item.formula?.values.map((v) => v.label).join(" / ")} +

+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx new file mode 100644 index 00000000..eeae4dcb --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx @@ -0,0 +1,95 @@ +"use client"; + +/** + * 통계 카드 서브타입 컴포넌트 + * + * 상태별 건수 표시 (대기/진행/완료 등) + * 각 카테고리별 색상 및 링크 지원 + */ + +import React from "react"; +import type { DashboardItem } from "../../types"; +import { TEXT_ALIGN_CLASSES } from "../../types"; +import { abbreviateNumber } from "../utils/formula"; + +// ===== Props ===== + +export interface StatCardProps { + item: DashboardItem; + /** 카테고리별 건수 맵 (카테고리 label -> 건수) */ + categoryData: Record; +} + +// ===== 기본 색상 팔레트 ===== + +const DEFAULT_STAT_COLORS = [ + "#6366f1", // indigo + "#f59e0b", // amber + "#10b981", // emerald + "#ef4444", // rose + "#8b5cf6", // violet +]; + +// ===== 메인 컴포넌트 ===== + +export function StatCardComponent({ item, categoryData }: StatCardProps) { + const { visibility, statConfig, itemStyle } = item; + const categories = statConfig?.categories ?? []; + const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0); + + // 라벨 정렬만 사용자 설정 + const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"]; + + return ( +
+ {/* 라벨 - 사용자 정렬 적용 */} + {visibility.showLabel && ( +

+ {item.label} +

+ )} + + {/* 총합 - @container 반응형 */} + {visibility.showValue && ( +

+ {abbreviateNumber(total)} +

+ )} + + {/* 카테고리별 건수 */} +
+ {categories.map((cat, index) => { + const count = categoryData[cat.label] ?? 0; + const color = + cat.color ?? DEFAULT_STAT_COLORS[index % DEFAULT_STAT_COLORS.length]; + + return ( +
+ {/* 색상 점 */} + + {/* 라벨 + 건수 */} + + {cat.label} + + + {abbreviateNumber(count)} + +
+ ); + })} +
+ + {/* 보조 라벨 (단위 등) */} + {visibility.showSubLabel && ( +

+ {visibility.showUnit && item.kpiConfig?.unit + ? `단위: ${item.kpiConfig.unit}` + : ""} +

+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx new file mode 100644 index 00000000..d91e6ea2 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/ArrowsMode.tsx @@ -0,0 +1,98 @@ +"use client"; + +/** + * 좌우 버튼 표시 모드 + * + * 화살표 버튼으로 아이템을 한 장씩 넘기는 모드 + * 터치 최적화: 최소 44x44px 터치 영역 + */ + +import React, { useState, useCallback } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +// ===== Props ===== + +export interface ArrowsModeProps { + /** 총 아이템 수 */ + itemCount: number; + /** 페이지 인디케이터 표시 여부 */ + showIndicator?: boolean; + /** 현재 인덱스에 해당하는 아이템 렌더링 */ + renderItem: (index: number) => React.ReactNode; +} + +// ===== 메인 컴포넌트 ===== + +export function ArrowsModeComponent({ + itemCount, + showIndicator = true, + renderItem, +}: ArrowsModeProps) { + const [currentIndex, setCurrentIndex] = useState(0); + + const goToPrev = useCallback(() => { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1)); + }, [itemCount]); + + const goToNext = useCallback(() => { + setCurrentIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0)); + }, [itemCount]); + + if (itemCount === 0) { + return ( +
+ 아이템 없음 +
+ ); + } + + return ( +
+ {/* 아이템 (전체 영역 사용) */} +
+ {renderItem(currentIndex)} +
+ + {/* 좌우 화살표 (콘텐츠 위에 겹침) */} + {itemCount > 1 && ( + <> + + + + )} + + {/* 페이지 인디케이터 (콘텐츠 하단에 겹침) */} + {showIndicator && itemCount > 1 && ( +
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx new file mode 100644 index 00000000..cb67255b --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/AutoSlideMode.tsx @@ -0,0 +1,141 @@ +"use client"; + +/** + * 자동 슬라이드 표시 모드 + * + * 전광판처럼 자동 전환, 터치 시 멈춤, 일정 시간 후 재개 + * 컴포넌트 unmount 시 타이머 정리 필수 + */ + +import React, { useState, useEffect, useRef, useCallback } from "react"; + +// ===== Props ===== + +export interface AutoSlideModeProps { + /** 총 아이템 수 */ + itemCount: number; + /** 자동 전환 간격 (초, 기본 5) */ + interval?: number; + /** 터치 후 자동 재개까지 대기 시간 (초, 기본 3) */ + resumeDelay?: number; + /** 페이지 인디케이터 표시 여부 */ + showIndicator?: boolean; + /** 현재 인덱스에 해당하는 아이템 렌더링 */ + renderItem: (index: number) => React.ReactNode; +} + +// ===== 메인 컴포넌트 ===== + +export function AutoSlideModeComponent({ + itemCount, + interval = 5, + resumeDelay = 3, + showIndicator = true, + renderItem, +}: AutoSlideModeProps) { + const [currentIndex, setCurrentIndex] = useState(0); + const [isPaused, setIsPaused] = useState(false); + const intervalRef = useRef | null>(null); + const resumeTimerRef = useRef | null>(null); + + // 타이머 정리 함수 + const clearTimers = useCallback(() => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + if (resumeTimerRef.current) { + clearTimeout(resumeTimerRef.current); + resumeTimerRef.current = null; + } + }, []); + + // 자동 슬라이드 시작 + const startAutoSlide = useCallback(() => { + clearTimers(); + if (itemCount <= 1) return; + + intervalRef.current = setInterval(() => { + setCurrentIndex((prev) => (prev + 1) % itemCount); + }, interval * 1000); + }, [itemCount, interval, clearTimers]); + + // 터치/클릭으로 일시 정지 + const handlePause = useCallback(() => { + setIsPaused(true); + clearTimers(); + + // resumeDelay 후 자동 재개 + resumeTimerRef.current = setTimeout(() => { + setIsPaused(false); + startAutoSlide(); + }, resumeDelay * 1000); + }, [resumeDelay, clearTimers, startAutoSlide]); + + // 마운트 시 자동 슬라이드 시작, unmount 시 정리 + useEffect(() => { + if (!isPaused) { + startAutoSlide(); + } + return clearTimers; + }, [isPaused, startAutoSlide, clearTimers]); + + if (itemCount === 0) { + return ( +
+ 아이템 없음 +
+ ); + } + + return ( +
+ {/* 콘텐츠 (슬라이드 애니메이션) */} +
+
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ {renderItem(i)} +
+ ))} +
+
+ + {/* 인디케이터 (콘텐츠 하단에 겹침) */} + {showIndicator && itemCount > 1 && ( +
+ {isPaused && ( + + 일시정지 + + )} + {Array.from({ length: itemCount }).map((_, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx new file mode 100644 index 00000000..5e339fc5 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx @@ -0,0 +1,177 @@ +"use client"; + +/** + * 그리드 표시 모드 + * + * CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영) + * 각 셀에 @container 적용하여 내부 아이템 반응형 + * + * 반응형 자동 조정: + * - containerWidth에 따라 열 수를 자동 축소 + * - 설정된 열 수가 최대값이고, 공간이 부족하면 줄어듦 + * - 셀당 최소 너비(MIN_CELL_WIDTH) 기준으로 판단 + */ + +import React, { useMemo } from "react"; +import type { DashboardCell } from "../../types"; + +// ===== 상수 ===== + +/** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */ +const MIN_CELL_WIDTH = 80; + +// ===== Props ===== + +export interface GridModeProps { + /** 셀 배치 정보 */ + cells: DashboardCell[]; + /** 설정된 열 수 (최대값) */ + columns: number; + /** 설정된 행 수 */ + rows: number; + /** 아이템 간 간격 (px) */ + gap?: number; + /** 컨테이너 너비 (px, 반응형 자동 조정용) */ + containerWidth?: number; + /** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */ + renderItem: (itemId: string) => React.ReactNode; +} + +// ===== 반응형 열 수 계산 ===== + +/** + * 컨테이너 너비에 맞는 실제 열 수를 계산 + * + * 설정된 columns가 최대값이고, 공간이 부족하면 축소. + * gap도 고려하여 계산. + * + * 예: columns=3, containerWidth=400, gap=8, MIN_CELL_WIDTH=160 + * 사용 가능 너비 = 400 - (3-1)*8 = 384 + * 셀당 너비 = 384/3 = 128 < 160 -> 열 축소 + * columns=2: 사용 가능 = 400 - (2-1)*8 = 392, 셀당 = 196 >= 160 -> OK + */ +function computeResponsiveColumns( + configColumns: number, + containerWidth: number, + gap: number +): number { + if (containerWidth <= 0) return configColumns; + + for (let cols = configColumns; cols >= 1; cols--) { + const totalGap = (cols - 1) * gap; + const cellWidth = (containerWidth - totalGap) / cols; + if (cellWidth >= MIN_CELL_WIDTH) return cols; + } + + return 1; +} + +/** + * 열 수가 줄어들 때 셀 배치를 자동 재배열 + * + * 원본 gridColumn/gridRow를 actualColumns에 맞게 재매핑 + * 셀이 원래 위치를 유지하려고 시도하되, 넘치면 아래로 이동 + */ +function remapCells( + cells: DashboardCell[], + configColumns: number, + actualColumns: number, + configRows: number +): { remappedCells: DashboardCell[]; actualRows: number } { + // 열 수가 같으면 원본 그대로 + if (actualColumns >= configColumns) { + return { remappedCells: cells, actualRows: configRows }; + } + + // 셀을 원래 위치 순서대로 정렬 (행 우선) + const sorted = [...cells].sort((a, b) => { + const aRow = parseInt(a.gridRow.split(" / ")[0]) || 0; + const bRow = parseInt(b.gridRow.split(" / ")[0]) || 0; + if (aRow !== bRow) return aRow - bRow; + const aCol = parseInt(a.gridColumn.split(" / ")[0]) || 0; + const bCol = parseInt(b.gridColumn.split(" / ")[0]) || 0; + return aCol - bCol; + }); + + // 순서대로 새 위치에 배치 + let maxRow = 0; + const remapped = sorted.map((cell, index) => { + const newCol = (index % actualColumns) + 1; + const newRow = Math.floor(index / actualColumns) + 1; + maxRow = Math.max(maxRow, newRow); + return { + ...cell, + gridColumn: `${newCol} / ${newCol + 1}`, + gridRow: `${newRow} / ${newRow + 1}`, + }; + }); + + return { remappedCells: remapped, actualRows: maxRow }; +} + +// ===== 메인 컴포넌트 ===== + +export function GridModeComponent({ + cells, + columns, + rows, + gap = 8, + containerWidth, + renderItem, +}: GridModeProps) { + // 반응형 열 수 계산 + const actualColumns = useMemo( + () => + containerWidth + ? computeResponsiveColumns(columns, containerWidth, gap) + : columns, + [columns, containerWidth, gap] + ); + + // 열 수가 줄었으면 셀 재배열 + const { remappedCells, actualRows } = useMemo( + () => remapCells(cells, columns, actualColumns, rows), + [cells, columns, actualColumns, rows] + ); + + if (!remappedCells.length) { + return ( +
+ 셀 없음 +
+ ); + } + + return ( +
+ {remappedCells.map((cell) => ( +
+ {cell.itemId ? ( + renderItem(cell.itemId) + ) : ( +
+ + 빈 셀 + +
+ )} +
+ ))} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx new file mode 100644 index 00000000..300b637d --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/ScrollMode.tsx @@ -0,0 +1,90 @@ +"use client"; + +/** + * 스크롤 표시 모드 + * + * 가로 스크롤 + CSS scroll-snap으로 아이템 단위 스냅 + * 터치 스와이프 네이티브 지원 + */ + +import React, { useRef, useState, useEffect, useCallback } from "react"; + +// ===== Props ===== + +export interface ScrollModeProps { + /** 총 아이템 수 */ + itemCount: number; + /** 페이지 인디케이터 표시 여부 */ + showIndicator?: boolean; + /** 현재 인덱스에 해당하는 아이템 렌더링 */ + renderItem: (index: number) => React.ReactNode; +} + +// ===== 메인 컴포넌트 ===== + +export function ScrollModeComponent({ + itemCount, + showIndicator = true, + renderItem, +}: ScrollModeProps) { + const scrollRef = useRef(null); + const [activeIndex, setActiveIndex] = useState(0); + + // 스크롤 위치로 현재 인덱스 계산 + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el || !el.clientWidth) return; + const index = Math.round(el.scrollLeft / el.clientWidth); + setActiveIndex(Math.min(index, itemCount - 1)); + }, [itemCount]); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + el.addEventListener("scroll", handleScroll, { passive: true }); + return () => el.removeEventListener("scroll", handleScroll); + }, [handleScroll]); + + if (itemCount === 0) { + return ( +
+ 아이템 없음 +
+ ); + } + + return ( +
+ {/* 스크롤 영역 */} +
+ {Array.from({ length: itemCount }).map((_, i) => ( +
+ {renderItem(i)} +
+ ))} +
+ + {/* 페이지 인디케이터 */} + {showIndicator && itemCount > 1 && ( +
+ {Array.from({ length: itemCount }).map((_, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts new file mode 100644 index 00000000..c2baaa55 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -0,0 +1,367 @@ +/** + * pop-dashboard 데이터 페처 + * + * @INFRA-EXTRACT: useDataSource 완성 후 이 파일 전체를 훅으로 교체 예정 + * 현재는 직접 API 호출. 공통 인프라 완성 후 useDataSource 훅으로 교체. + * + * 보안: + * - SQL 인젝션 방지: 사용자 입력값 이스케이프 처리 + * - 멀티테넌시: autoFilter 자동 전달 + * - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용 + */ + +import { apiClient } from "@/lib/api/client"; +import { dashboardApi } from "@/lib/api/dashboard"; +import { dataApi } from "@/lib/api/data"; +import { tableManagementApi } from "@/lib/api/tableManagement"; +import type { TableInfo } from "@/lib/api/tableManagement"; +import type { DataSourceConfig, DataSourceFilter } from "../../types"; + +// ===== 타입 re-export ===== + +export type { TableInfo }; + +// ===== 반환 타입 ===== + +export interface AggregatedResult { + value: number; + rows?: Record[]; + error?: string; +} + +export interface ColumnInfo { + name: string; + type: string; + udtName: string; +} + +// ===== 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}'`; +} + +// ===== 설정 완료 여부 검증 ===== + +/** + * DataSourceConfig의 필수값이 모두 채워졌는지 검증 + * 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는 + * SQL을 생성하지 않도록 사전 차단 + * + * @returns null이면 유효, 문자열이면 미완료 사유 + */ +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 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */ +function sanitizeIdentifier(name: string): string { + // 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용 + return name.replace(/[^a-zA-Z0-9_.]/g, ""); +} + +// ===== 집계 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(" "); +} + +// ===== 메인 데이터 페처 ===== + +/** + * DataSourceConfig 기반으로 데이터를 조회하여 집계 결과 반환 + * + * API 선택 전략: + * 1. 집계 있음 -> buildAggregationSQL -> dashboardApi.executeQuery() + * 2. 조인만 있음 (2개 테이블) -> dataApi.getJoinedData() (추후 지원) + * 3. 단순 조회 -> dataApi.getTableData() + * + * @INFRA-EXTRACT: useDataSource 완성 후 이 함수를 훅으로 교체 + */ +export async function fetchAggregatedData( + config: DataSourceConfig +): Promise { + try { + // 설정 완료 여부 검증 (미완료 시 SQL 전송 차단) + const validationError = validateDataSourceConfig(config); + if (validationError) { + return { value: 0, rows: [], error: validationError }; + } + + // 집계 또는 조인이 있으면 SQL 직접 실행 + if (config.aggregation || (config.joins && config.joins.length > 0)) { + const sql = buildAggregationSQL(config); + + // API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백 + let queryResult: { columns: string[]; rows: any[] }; + 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 { value: 0, rows: [] }; + } + + // PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨 + // Recharts PieChart 등은 숫자 타입이 필수이므로 변환 처리 + const processedRows = queryResult.rows.map((row: Record) => { + 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 { + value: Number.isFinite(numericValue) ? numericValue : 0, + rows: processedRows, + }; + } + + // 단순 조회 + const tableResult = await dataApi.getTableData(config.tableName, { + page: 1, + size: config.limit ?? 100, + sortBy: config.sort?.[0]?.column, + sortOrder: config.sort?.[0]?.direction, + filters: config.filters?.reduce( + (acc, f) => { + acc[f.column] = f.value; + return acc; + }, + {} as Record + ), + }); + + // 단순 조회 시에는 행 수를 value로 사용 + return { + value: tableResult.total ?? tableResult.data.length, + rows: tableResult.data, + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "데이터 조회 실패"; + return { value: 0, error: message }; + } +} + +// ===== 설정 패널용 헬퍼 ===== + +/** + * 테이블 목록 조회 (설정 패널 드롭다운용) + * dashboardApi.getTableSchema()로 특정 테이블의 스키마를 가져오되, + * 테이블 목록은 별도로 필요하므로 간단히 반환 + */ +export async function fetchTableColumns( + tableName: string +): Promise { + // 1차: tableManagementApi (apiClient/axios 기반, 인증 안정적) + try { + const response = await tableManagementApi.getTableSchema(tableName); + if (response.success && response.data) { + const cols = Array.isArray(response.data) ? response.data : []; + if (cols.length > 0) { + return cols.map((col: any) => ({ + name: col.columnName || col.column_name || col.name, + type: col.dataType || col.data_type || col.type || "unknown", + udtName: col.dbType || col.udt_name || col.udtName || "unknown", + })); + } + } + } catch { + // tableManagementApi 실패 시 dashboardApi로 폴백 + } + + // 2차: dashboardApi (fetch 기반, 폴백) + try { + const schema = await dashboardApi.getTableSchema(tableName); + return schema.columns.map((col) => ({ + name: col.name, + type: col.type, + udtName: col.udtName, + })); + } catch { + return []; + } +} + +/** + * 테이블 목록 조회 (설정 패널 Combobox용) + * tableManagementApi.getTableList() 래핑 + * + * @INFRA-EXTRACT: useDataSource 완성 후 교체 예정 + */ +export async function fetchTableList(): Promise { + try { + const response = await tableManagementApi.getTableList(); + if (response.success && response.data) { + return response.data; + } + return []; + } catch { + return []; + } +} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts new file mode 100644 index 00000000..2ed27a98 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/formula.ts @@ -0,0 +1,259 @@ +/** + * pop-dashboard 수식 파싱 및 평가 유틸리티 + * + * 보안: eval()/new Function() 사용 금지. 직접 사칙연산 파서 구현. + */ + +import type { FormulaConfig, FormulaDisplayFormat } from "../../types"; + +// ===== 토큰 타입 ===== + +type TokenType = "number" | "variable" | "operator" | "lparen" | "rparen"; + +interface Token { + type: TokenType; + value: string; +} + +// ===== 토크나이저 ===== + +/** 수식 문자열을 토큰 배열로 분리 */ +function tokenize(expression: string): Token[] { + const tokens: Token[] = []; + let i = 0; + const expr = expression.replace(/\s+/g, ""); + + while (i < expr.length) { + const ch = expr[i]; + + // 숫자 (정수, 소수) + if (/\d/.test(ch) || (ch === "." && i + 1 < expr.length && /\d/.test(expr[i + 1]))) { + let num = ""; + while (i < expr.length && (/\d/.test(expr[i]) || expr[i] === ".")) { + num += expr[i]; + i++; + } + tokens.push({ type: "number", value: num }); + continue; + } + + // 변수 (A, B, C 등 알파벳) + if (/[A-Za-z]/.test(ch)) { + let varName = ""; + while (i < expr.length && /[A-Za-z0-9_]/.test(expr[i])) { + varName += expr[i]; + i++; + } + tokens.push({ type: "variable", value: varName }); + continue; + } + + // 연산자 + if ("+-*/".includes(ch)) { + tokens.push({ type: "operator", value: ch }); + i++; + continue; + } + + // 괄호 + if (ch === "(") { + tokens.push({ type: "lparen", value: "(" }); + i++; + continue; + } + if (ch === ")") { + tokens.push({ type: "rparen", value: ")" }); + i++; + continue; + } + + // 알 수 없는 문자는 건너뜀 + i++; + } + + return tokens; +} + +// ===== 재귀 하강 파서 ===== + +/** + * 사칙연산 수식을 안전하게 평가 (재귀 하강 파서) + * + * 문법: + * expr = term (('+' | '-') term)* + * term = factor (('*' | '/') factor)* + * factor = NUMBER | VARIABLE | '(' expr ')' + * + * @param expression - 수식 문자열 (예: "A / B * 100") + * @param values - 변수값 맵 (예: { A: 1234, B: 5678 }) + * @returns 계산 결과 (0으로 나누기 시 0 반환) + */ +export function evaluateFormula( + expression: string, + values: Record +): number { + const tokens = tokenize(expression); + let pos = 0; + + function peek(): Token | undefined { + return tokens[pos]; + } + + function consume(): Token { + return tokens[pos++]; + } + + // factor = NUMBER | VARIABLE | '(' expr ')' + function parseFactor(): number { + const token = peek(); + if (!token) return 0; + + if (token.type === "number") { + consume(); + return parseFloat(token.value); + } + + if (token.type === "variable") { + consume(); + return values[token.value] ?? 0; + } + + if (token.type === "lparen") { + consume(); // '(' 소비 + const result = parseExpr(); + if (peek()?.type === "rparen") { + consume(); // ')' 소비 + } + return result; + } + + // 예상치 못한 토큰 + consume(); + return 0; + } + + // term = factor (('*' | '/') factor)* + function parseTerm(): number { + let result = parseFactor(); + while (peek()?.type === "operator" && (peek()!.value === "*" || peek()!.value === "/")) { + const op = consume().value; + const right = parseFactor(); + if (op === "*") { + result *= right; + } else { + // 0으로 나누기 방지 + result = right === 0 ? 0 : result / right; + } + } + return result; + } + + // expr = term (('+' | '-') term)* + function parseExpr(): number { + let result = parseTerm(); + while (peek()?.type === "operator" && (peek()!.value === "+" || peek()!.value === "-")) { + const op = consume().value; + const right = parseTerm(); + result = op === "+" ? result + right : result - right; + } + return result; + } + + const result = parseExpr(); + return Number.isFinite(result) ? result : 0; +} + +/** + * 수식 결과를 displayFormat에 맞게 포맷팅 + * + * @param config - 수식 설정 + * @param values - 변수값 맵 (예: { A: 1234, B: 5678 }) + * @returns 포맷된 문자열 + */ +export function formatFormulaResult( + config: FormulaConfig, + values: Record +): string { + const formatMap: Record string> = { + value: () => { + const result = evaluateFormula(config.expression, values); + return formatNumber(result); + }, + fraction: () => { + // "1,234 / 5,678" 형태 + const ids = config.values.map((v) => v.id); + if (ids.length >= 2) { + return `${formatNumber(values[ids[0]] ?? 0)} / ${formatNumber(values[ids[1]] ?? 0)}`; + } + return formatNumber(evaluateFormula(config.expression, values)); + }, + percent: () => { + const result = evaluateFormula(config.expression, values); + return `${(result * 100).toFixed(1)}%`; + }, + ratio: () => { + // "1,234 : 5,678" 형태 + const ids = config.values.map((v) => v.id); + if (ids.length >= 2) { + return `${formatNumber(values[ids[0]] ?? 0)} : ${formatNumber(values[ids[1]] ?? 0)}`; + } + return formatNumber(evaluateFormula(config.expression, values)); + }, + }; + + return formatMap[config.displayFormat](); +} + +/** + * 수식에 사용된 변수 ID가 모두 존재하는지 검증 + * + * @param expression - 수식 문자열 + * @param availableIds - 사용 가능한 변수 ID 배열 + * @returns 유효 여부 + */ +export function validateExpression( + expression: string, + availableIds: string[] +): boolean { + const tokens = tokenize(expression); + const usedVars = tokens + .filter((t) => t.type === "variable") + .map((t) => t.value); + + return usedVars.every((v) => availableIds.includes(v)); +} + +/** + * 큰 숫자 축약 (Container Query 축소 시 사용) + * + * 1234 -> "1,234" + * 12345 -> "1.2만" + * 1234567 -> "123.5만" + * 123456789 -> "1.2억" + */ +export function abbreviateNumber(value: number): string { + const abs = Math.abs(value); + const sign = value < 0 ? "-" : ""; + + if (abs >= 100_000_000) { + return `${sign}${(abs / 100_000_000).toFixed(1)}억`; + } + if (abs >= 10_000) { + return `${sign}${(abs / 10_000).toFixed(1)}만`; + } + return `${sign}${formatNumber(abs)}`; +} + +// ===== 내부 헬퍼 ===== + +/** 숫자를 천 단위 콤마 포맷 */ +function formatNumber(value: number): string { + if (Number.isInteger(value)) { + return value.toLocaleString("ko-KR"); + } + // 소수점 이하 최대 2자리 + return value.toLocaleString("ko-KR", { + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); +} diff --git a/frontend/lib/registry/pop-components/pop-icon.tsx b/frontend/lib/registry/pop-components/pop-icon.tsx new file mode 100644 index 00000000..3ecb9d49 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-icon.tsx @@ -0,0 +1,990 @@ +"use client"; + +import React, { useState, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { PopComponentRegistry } from "@/lib/registry/PopComponentRegistry"; +import { GridMode } from "@/components/pop/designer/types/pop-layout"; +import { + Home, + ArrowLeft, + Settings, + Search, + Plus, + Check, + X as XIcon, + Edit, + Trash2, + RefreshCw, + type LucideIcon, +} from "lucide-react"; +import { toast } from "sonner"; + +const LUCIDE_ICON_MAP: Record = { + Home, ArrowLeft, Settings, Search, Plus, Check, X: XIcon, + Edit, Trash2, RefreshCw, +}; + +// ======================================== +// 타입 정의 +// ======================================== +export type IconType = "quick" | "emoji" | "image"; +export type IconSizeMode = "auto" | "fixed"; +export type LabelPosition = "bottom" | "right" | "none"; +export type NavigateMode = "none" | "screen" | "url" | "back"; + +export interface IconSizeByMode { + mobile_portrait: number; + mobile_landscape: number; + tablet_portrait: number; + tablet_landscape: number; +} + +export interface GradientConfig { + from: string; + to: string; + direction?: "to-b" | "to-r" | "to-br"; +} + +export interface ImageConfig { + fileObjid?: number; + imageUrl?: string; + // 임시 저장용 (브라우저 캐시) + tempDataUrl?: string; + tempFileName?: string; +} + +export interface PopIconAction { + type: "navigate"; + navigate: { + mode: NavigateMode; + screenId?: string; + url?: string; + }; +} + +export interface QuickSelectItem { + type: "lucide" | "emoji"; + value: string; + label: string; + gradient: GradientConfig; +} + +export interface PopIconConfig { + iconType: IconType; + // 빠른 선택용 + quickSelectType?: "lucide" | "emoji"; + quickSelectValue?: string; + // 이미지용 + imageConfig?: ImageConfig; + imageScale?: number; + // 공통 + label?: string; + labelPosition?: LabelPosition; + labelColor?: string; + labelFontSize?: number; + backgroundColor?: string; + gradient?: GradientConfig; + borderRadiusPercent?: number; + sizeMode: IconSizeMode; + fixedSize?: number; + sizeByMode?: IconSizeByMode; + action: PopIconAction; +} + +// ======================================== +// 상수 +// ======================================== +export const ICON_TYPE_LABELS: Record = { + quick: "빠른 선택", + emoji: "이모지 직접 입력", + image: "이미지", +}; + +// 섹션 구분선 컴포넌트 +function SectionDivider({ label }: { label: string }) { + return ( +
+
+
+ {label} +
+
+
+ ); +} + +export const NAVIGATE_MODE_LABELS: Record = { + none: "없음", + screen: "POP 화면", + url: "외부 URL", + back: "뒤로가기", +}; + +export const LABEL_POSITION_LABELS: Record = { + bottom: "아래", + right: "오른쪽", + none: "없음", +}; + +export const DEFAULT_ICON_SIZE_BY_MODE: IconSizeByMode = { + mobile_portrait: 48, + mobile_landscape: 56, + tablet_portrait: 64, + tablet_landscape: 72, +}; + +// 빠른 선택 아이템 (Lucide 10개 + 이모지) +export const QUICK_SELECT_ITEMS: QuickSelectItem[] = [ + // 기본 아이콘 (Lucide) - 10개 + { type: "lucide", value: "Home", label: "홈", gradient: { from: "#0984e3", to: "#0774c4" } }, + { type: "lucide", value: "ArrowLeft", label: "뒤로", gradient: { from: "#636e72", to: "#525d61" } }, + { type: "lucide", value: "Settings", label: "설정", gradient: { from: "#636e72", to: "#525d61" } }, + { type: "lucide", value: "Search", label: "검색", gradient: { from: "#fdcb6e", to: "#f9b93b" } }, + { type: "lucide", value: "Plus", label: "추가", gradient: { from: "#00b894", to: "#00a86b" } }, + { type: "lucide", value: "Check", label: "확인", gradient: { from: "#00b894", to: "#00a86b" } }, + { type: "lucide", value: "X", label: "취소", gradient: { from: "#e17055", to: "#d35845" } }, + { type: "lucide", value: "Edit", label: "수정", gradient: { from: "#0984e3", to: "#0774c4" } }, + { type: "lucide", value: "Trash2", label: "삭제", gradient: { from: "#e17055", to: "#d35845" } }, + { type: "lucide", value: "RefreshCw", label: "새로고침", gradient: { from: "#4ecdc4", to: "#26a69a" } }, + // 이모지 + { type: "emoji", value: "📋", label: "작업지시", gradient: { from: "#ff6b6b", to: "#ee5a5a" } }, + { type: "emoji", value: "📊", label: "생산실적", gradient: { from: "#4ecdc4", to: "#26a69a" } }, + { type: "emoji", value: "📦", label: "입고", gradient: { from: "#00b894", to: "#00a86b" } }, + { type: "emoji", value: "🚚", label: "출고", gradient: { from: "#0984e3", to: "#0774c4" } }, + { type: "emoji", value: "📈", label: "재고현황", gradient: { from: "#6c5ce7", to: "#5b4cdb" } }, + { type: "emoji", value: "🔍", label: "품질검사", gradient: { from: "#fdcb6e", to: "#f9b93b" } }, + { type: "emoji", value: "⚠️", label: "불량관리", gradient: { from: "#e17055", to: "#d35845" } }, + { type: "emoji", value: "⚙️", label: "설비관리", gradient: { from: "#636e72", to: "#525d61" } }, + { type: "emoji", value: "🦺", label: "안전관리", gradient: { from: "#f39c12", to: "#e67e22" } }, + { type: "emoji", value: "🏭", label: "외주", gradient: { from: "#6c5ce7", to: "#5b4cdb" } }, + { type: "emoji", value: "↩️", label: "반품", gradient: { from: "#e17055", to: "#d35845" } }, + { type: "emoji", value: "🤝", label: "사급자재", gradient: { from: "#fdcb6e", to: "#f9b93b" } }, + { type: "emoji", value: "🔄", label: "교환", gradient: { from: "#4ecdc4", to: "#26a69a" } }, + { type: "emoji", value: "📍", label: "재고이동", gradient: { from: "#4ecdc4", to: "#26a69a" } }, +]; + +// ======================================== +// 헬퍼 함수 +// ======================================== +function getIconSizeForMode(config: PopIconConfig | undefined, gridMode: GridMode): number { + if (!config) return DEFAULT_ICON_SIZE_BY_MODE[gridMode]; + if (config.sizeMode === "fixed" && config.fixedSize) { + return config.fixedSize; + } + const sizes = config.sizeByMode || DEFAULT_ICON_SIZE_BY_MODE; + return sizes[gridMode]; +} + +function buildGradientStyle(gradient?: GradientConfig): React.CSSProperties { + if (!gradient) return {}; + const direction = gradient.direction || "to-b"; + const dirMap: Record = { + "to-b": "to bottom", + "to-r": "to right", + "to-br": "to bottom right" + }; + return { + background: `linear-gradient(${dirMap[direction]}, ${gradient.from}, ${gradient.to})`, + }; +} + +function getImageUrl(imageConfig?: ImageConfig): string | undefined { + if (!imageConfig) return undefined; + // 임시 저장된 이미지 우선 + if (imageConfig.tempDataUrl) return imageConfig.tempDataUrl; + if (imageConfig.fileObjid) return `/api/files/preview/${imageConfig.fileObjid}`; + return imageConfig.imageUrl; +} + +// Lucide 아이콘 동적 렌더링 +function DynamicLucideIcon({ name, size, className }: { name: string; size: number; className?: string }) { + const IconComponent = LUCIDE_ICON_MAP[name]; + if (!IconComponent) return null; + return ; +} + +// screenId에서 실제 ID만 추출 (URL이 입력된 경우 처리) +function extractScreenId(input: string): string { + if (!input) return ""; + + // URL 형태인 경우 (/pop/screens/123 또는 http://...pop/screens/123) + const urlMatch = input.match(/\/pop\/screens\/(\d+)/); + if (urlMatch) { + return urlMatch[1]; + } + + // http:// 또는 https://로 시작하는 경우 (다른 URL 형태) + if (input.startsWith("http://") || input.startsWith("https://")) { + // URL에서 마지막 숫자 부분 추출 시도 + const lastNumberMatch = input.match(/\/(\d+)\/?$/); + if (lastNumberMatch) { + return lastNumberMatch[1]; + } + } + + // 숫자만 있는 경우 그대로 반환 + if (/^\d+$/.test(input.trim())) { + return input.trim(); + } + + // 그 외의 경우 원본 반환 (에러 처리는 호출부에서) + return input; +} + +// ======================================== +// 메인 컴포넌트 +// ======================================== +interface PopIconComponentProps { + config?: PopIconConfig; + label?: string; + isDesignMode?: boolean; + gridMode?: GridMode; +} + +export function PopIconComponent({ + config, + label, + isDesignMode, + gridMode = "tablet_landscape" +}: PopIconComponentProps) { + const router = useRouter(); + const iconType = config?.iconType || "quick"; + const iconSize = getIconSizeForMode(config, gridMode); + + // 디자인 모드 확인 다이얼로그 상태 + const [showNavigateDialog, setShowNavigateDialog] = useState(false); + const [pendingNavigate, setPendingNavigate] = useState<{ mode: string; target: string } | null>(null); + + // 클릭 핸들러 + const handleClick = () => { + const navigate = config?.action?.navigate; + if (!navigate || navigate.mode === "none") return; + + // 디자인 모드: 확인 다이얼로그 표시 + if (isDesignMode) { + if (navigate.mode === "screen") { + if (!navigate.screenId) { + toast.error("화면 ID가 설정되지 않았습니다."); + return; + } + const cleanScreenId = extractScreenId(navigate.screenId); + setPendingNavigate({ mode: "screen", target: cleanScreenId }); + setShowNavigateDialog(true); + } else if (navigate.mode === "url") { + if (!navigate.url) { + toast.error("URL이 설정되지 않았습니다."); + return; + } + setPendingNavigate({ mode: "url", target: navigate.url }); + setShowNavigateDialog(true); + } else if (navigate.mode === "back") { + toast.warning("뒤로가기는 실제 화면에서 테스트해주세요."); + } + return; + } + + // 실제 모드: 직접 실행 + switch (navigate.mode) { + case "screen": + if (navigate.screenId) { + const cleanScreenId = extractScreenId(navigate.screenId); + window.location.href = `/pop/screens/${cleanScreenId}`; + } + break; + case "url": + if (navigate.url) window.location.href = navigate.url; + break; + case "back": + router.back(); + break; + } + }; + + // 확인 후 이동 실행 + const handleConfirmNavigate = () => { + if (!pendingNavigate) return; + + if (pendingNavigate.mode === "screen") { + const targetUrl = `/pop/screens/${pendingNavigate.target}`; + console.log("[PopIcon] 화면 이동:", { target: pendingNavigate.target, url: targetUrl }); + window.location.href = targetUrl; + } else if (pendingNavigate.mode === "url") { + console.log("[PopIcon] URL 이동:", pendingNavigate.target); + window.location.href = pendingNavigate.target; + } + + setShowNavigateDialog(false); + setPendingNavigate(null); + }; + + // 배경 스타일 (이미지 타입일 때는 배경 없음) + const backgroundStyle: React.CSSProperties = iconType === "image" + ? { backgroundColor: "transparent" } + : config?.gradient + ? buildGradientStyle(config.gradient) + : { backgroundColor: config?.backgroundColor || "#e0e0e0" }; + + // 테두리 반경 (0% = 사각형, 100% = 원형) + const radiusPercent = config?.borderRadiusPercent ?? 20; + const borderRadius = iconType === "image" ? "0%" : `${radiusPercent / 2}%`; + + // 라벨 위치에 따른 레이아웃 + const isLabelRight = config?.labelPosition === "right"; + const showLabel = config?.labelPosition !== "none" && (config?.label || label); + + // 아이콘 렌더링 + const renderIcon = () => { + // 빠른 선택 + if (iconType === "quick") { + if (config?.quickSelectType === "lucide" && config?.quickSelectValue) { + return ( + + ); + } else if (config?.quickSelectType === "emoji" && config?.quickSelectValue) { + return {config.quickSelectValue}; + } + // 기본값 + return 📦; + } + + // 이모지 직접 입력 + if (iconType === "emoji") { + if (config?.quickSelectValue) { + return {config.quickSelectValue}; + } + return 📦; + } + + // 이미지 (배경 없이 이미지만 표시) + if (iconType === "image" && config?.imageConfig) { + const scale = config?.imageScale || 100; + return ( + + ); + } + + return 📦; + }; + + return ( +
+ {/* 아이콘 컨테이너 */} +
+ {renderIcon()} +
+ + {/* 라벨 */} + {showLabel && ( + + {config?.label || label} + + )} + + {/* 디자인 모드 네비게이션 확인 다이얼로그 */} + + + + 페이지 이동 확인 + + {pendingNavigate?.mode === "screen" + ? "POP 화면으로 이동합니다." + : "외부 URL로 이동합니다." + } +
+ + ※ 저장하지 않은 변경사항은 사라집니다. + +
+
+ + + 확인 후 이동 + + + +
+
+
+ ); +} + +// ======================================== +// 설정 패널 +// ======================================== +interface PopIconConfigPanelProps { + config: PopIconConfig; + onUpdate: (config: PopIconConfig) => void; +} + +export function PopIconConfigPanel({ config, onUpdate }: PopIconConfigPanelProps) { + const iconType = config?.iconType || "quick"; + + return ( +
+ {/* 아이콘 타입 선택 */} + +
+ +
+ + {/* 타입별 설정 */} + {iconType === "quick" && } + {iconType === "emoji" && } + {iconType === "image" && } + + {/* 라벨 설정 */} + + + + {/* 스타일 설정 (이미지 타입 제외) */} + {iconType !== "image" && ( + <> + + + + )} + + {/* 액션 설정 */} + + +
+ ); +} + +// 빠른 선택 그리드 +function QuickSelectGrid({ config, onUpdate }: PopIconConfigPanelProps) { + return ( +
+ +
+ {QUICK_SELECT_ITEMS.map((item, idx) => ( + + ))} +
+
+ ); +} + +// 이모지 직접 입력 +function EmojiInput({ config, onUpdate }: PopIconConfigPanelProps) { + const [customEmoji, setCustomEmoji] = useState(config?.quickSelectValue || ""); + + const handleEmojiChange = (value: string) => { + setCustomEmoji(value); + // 이모지가 입력되면 바로 적용 + if (value.trim()) { + onUpdate({ + ...config, + quickSelectType: "emoji", + quickSelectValue: value, + gradient: config?.gradient || { from: "#6c5ce7", to: "#5b4cdb" }, + }); + } + }; + + return ( +
+ + handleEmojiChange(e.target.value)} + placeholder="이모지를 입력하세요 (예: 📦, 🚀)" + className="h-8 text-xs" + maxLength={4} + /> +

+ Windows: Win + . / Mac: Ctrl + Cmd + Space +

+ + {/* 배경 그라디언트 설정 */} +
+
+ + onUpdate({ + ...config, + gradient: { ...config?.gradient, from: e.target.value, to: config?.gradient?.to || "#5b4cdb" } + })} + className="h-8 w-full p-1 cursor-pointer" + /> +
+
+ + onUpdate({ + ...config, + gradient: { ...config?.gradient, from: config?.gradient?.from || "#6c5ce7", to: e.target.value } + })} + className="h-8 w-full p-1 cursor-pointer" + /> +
+
+ + {/* 미리보기 */} + {customEmoji && ( +
+ {customEmoji} +
+ )} +
+ ); +} + +// 이미지 업로드 +function ImageUpload({ config, onUpdate }: PopIconConfigPanelProps) { + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + // 파일 선택 시 브라우저 캐시에 임시 저장 (DB 업로드 X) + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setError(null); + + // 이미지 파일 검증 + if (!file.type.startsWith("image/")) { + setError("이미지 파일만 선택할 수 있습니다."); + return; + } + + // 파일 크기 제한 (5MB) + if (file.size > 5 * 1024 * 1024) { + setError("파일 크기는 5MB 이하여야 합니다."); + return; + } + + // FileReader로 Base64 변환 (브라우저 캐시) + const reader = new FileReader(); + reader.onload = () => { + onUpdate({ + ...config, + imageConfig: { + tempDataUrl: reader.result as string, + tempFileName: file.name, + // 기존 DB 파일 정보 제거 + fileObjid: undefined, + imageUrl: undefined, + }, + }); + }; + reader.onerror = () => { + setError("파일을 읽는 중 오류가 발생했습니다."); + }; + reader.readAsDataURL(file); + + // input 초기화 (같은 파일 다시 선택 가능하도록) + e.target.value = ""; + }; + + // 이미지 삭제 + const handleDelete = () => { + onUpdate({ + ...config, + imageConfig: undefined, + imageScale: undefined, + }); + }; + + // 미리보기 URL 가져오기 + const getPreviewUrl = (): string | undefined => { + if (config?.imageConfig?.tempDataUrl) return config.imageConfig.tempDataUrl; + if (config?.imageConfig?.fileObjid) return `/api/files/preview/${config.imageConfig.fileObjid}`; + return config?.imageConfig?.imageUrl; + }; + + const previewUrl = getPreviewUrl(); + const hasImage = !!previewUrl; + const isTemp = !!config?.imageConfig?.tempDataUrl; + + return ( +
+ + + {/* 파일 선택 + 삭제 버튼 */} +
+ + + {hasImage && ( + + )} +
+ + {/* 에러 메시지 */} + {error &&

{error}

} + + {/* 또는 URL 직접 입력 */} + onUpdate({ + ...config, + imageConfig: { + imageUrl: e.target.value, + // URL 입력 시 임시 파일 제거 + tempDataUrl: undefined, + tempFileName: undefined, + fileObjid: undefined, + } + })} + placeholder="또는 URL 직접 입력..." + className="h-8 text-xs" + disabled={isTemp} + /> + + {/* 현재 이미지 미리보기 + 크기 조절 */} + {hasImage && ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + 미리보기 + {isTemp && ( + + 임시 + + )} +
+ {config?.imageConfig?.tempFileName && ( +

+ {config.imageConfig.tempFileName} +

+ )} + + onUpdate({ ...config, imageScale: Number(e.target.value) })} + className="w-full" + /> + {isTemp && ( +

+ ※ 화면 저장 시 서버에 업로드됩니다. +

+ )} +
+ )} +
+ ); +} + +// 라벨 설정 +function LabelSettings({ config, onUpdate }: PopIconConfigPanelProps) { + return ( +
+ onUpdate({ ...config, label: e.target.value })} + placeholder="라벨 텍스트" + className="h-8 text-xs" + /> +
+ + onUpdate({ ...config, labelColor: e.target.value })} + className="h-8 w-12 p-1 cursor-pointer" + /> +
+ {/* 글자 크기 슬라이더 */} + + onUpdate({ ...config, labelFontSize: Number(e.target.value) })} + className="w-full" + /> +
+ ); +} + +// 스타일 설정 +function StyleSettings({ config, onUpdate }: PopIconConfigPanelProps) { + return ( +
+ + onUpdate({ + ...config, + borderRadiusPercent: Number(e.target.value) + })} + className="w-full" + /> +
+ ); +} + +// 액션 설정 +function ActionSettings({ config, onUpdate }: PopIconConfigPanelProps) { + const navigate = config?.action?.navigate || { mode: "none" as NavigateMode }; + + return ( +
+ + + {/* 없음이 아닐 때만 추가 설정 표시 */} + {navigate.mode !== "none" && ( + <> + {navigate.mode === "screen" && ( + onUpdate({ + ...config, + action: { type: "navigate", navigate: { ...navigate, screenId: e.target.value } } + })} + placeholder="화면 ID" + className="h-8 text-xs mt-2" + /> + )} + {navigate.mode === "url" && ( + onUpdate({ + ...config, + action: { type: "navigate", navigate: { ...navigate, url: e.target.value } } + })} + placeholder="https://..." + className="h-8 text-xs mt-2" + /> + )} + + {/* 테스트 버튼 */} + + + )} +
+ ); +} + +// ======================================== +// 미리보기 컴포넌트 +// ======================================== +function PopIconPreviewComponent({ config }: { config?: PopIconConfig }) { + return ( +
+ +
+ ); +} + +// ======================================== +// 레지스트리 등록 +// ======================================== +PopComponentRegistry.registerComponent({ + id: "pop-icon", + name: "아이콘", + description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)", + category: "action", + icon: "MousePointer", + component: PopIconComponent, + configPanel: PopIconConfigPanel, + preview: PopIconPreviewComponent, + defaultProps: { + iconType: "quick", + quickSelectType: "emoji", + quickSelectValue: "📦", + label: "아이콘", + labelPosition: "bottom", + labelColor: "#000000", + labelFontSize: 12, + borderRadiusPercent: 20, + sizeMode: "auto", + action: { type: "navigate", navigate: { mode: "none" } }, + } as PopIconConfig, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx new file mode 100644 index 00000000..380cc103 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchComponent.tsx @@ -0,0 +1,689 @@ +"use client"; + +import { useState, useCallback, useEffect, useRef, useMemo } from "react"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Switch } from "@/components/ui/switch"; +import { Search, ChevronRight, Loader2, X } from "lucide-react"; +import { usePopEvent } from "@/hooks/pop"; +import { dataApi } from "@/lib/api/data"; +import type { + PopSearchConfig, + DatePresetOption, + ModalSelectConfig, + ModalSearchMode, + ModalFilterTab, +} from "./types"; +import { + DATE_PRESET_LABELS, + computeDateRange, + DEFAULT_SEARCH_CONFIG, + normalizeInputType, + MODAL_FILTER_TAB_LABELS, + getGroupKey, +} from "./types"; + +// ======================================== +// 메인 컴포넌트 +// ======================================== + +interface PopSearchComponentProps { + config: PopSearchConfig; + label?: string; + screenId?: string; + componentId?: string; +} + +const DEFAULT_CONFIG = DEFAULT_SEARCH_CONFIG; + +export function PopSearchComponent({ + config: rawConfig, + label, + screenId, + componentId, +}: PopSearchComponentProps) { + const config = { ...DEFAULT_CONFIG, ...(rawConfig || {}) }; + const { publish, subscribe, setSharedData } = usePopEvent(screenId || ""); + const [value, setValue] = useState(config.defaultValue ?? ""); + const [modalDisplayText, setModalDisplayText] = useState(""); + const [simpleModalOpen, setSimpleModalOpen] = useState(false); + + const fieldKey = config.fieldName || componentId || "search"; + const normalizedType = normalizeInputType(config.inputType as string); + const isModalType = normalizedType === "modal"; + + const emitFilterChanged = useCallback( + (newValue: unknown) => { + setValue(newValue); + setSharedData(`search_${fieldKey}`, newValue); + + if (componentId) { + publish(`__comp_output__${componentId}__filter_value`, { + fieldName: fieldKey, + value: newValue, + }); + } + + publish("filter_changed", { [fieldKey]: newValue }); + }, + [fieldKey, publish, setSharedData, componentId] + ); + + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__set_value`, + (payload: unknown) => { + const data = payload as { value?: unknown } | unknown; + const incoming = typeof data === "object" && data && "value" in data + ? (data as { value: unknown }).value + : data; + emitFilterChanged(incoming); + } + ); + return unsub; + }, [componentId, subscribe, emitFilterChanged]); + + const handleModalOpen = useCallback(() => { + if (!config.modalConfig) return; + setSimpleModalOpen(true); + }, [config.modalConfig]); + + const handleSimpleModalSelect = useCallback( + (row: Record) => { + const mc = config.modalConfig; + const display = mc?.displayField ? String(row[mc.displayField] ?? "") : ""; + const filterVal = mc?.valueField ? String(row[mc.valueField] ?? "") : ""; + + setModalDisplayText(display); + emitFilterChanged(filterVal); + setSimpleModalOpen(false); + }, + [config.modalConfig, emitFilterChanged] + ); + + const showLabel = config.labelVisible !== false && !!config.labelText; + + return ( +
+ {showLabel && ( + + {config.labelText} + + )} +
+ +
+ + {isModalType && config.modalConfig && ( + + )} +
+ ); +} + +// ======================================== +// 서브타입 분기 렌더러 +// ======================================== + +interface InputRendererProps { + config: PopSearchConfig; + value: unknown; + onChange: (v: unknown) => void; + modalDisplayText?: string; + onModalOpen?: () => void; +} + +function SearchInputRenderer({ config, value, onChange, modalDisplayText, onModalOpen }: InputRendererProps) { + const normalized = normalizeInputType(config.inputType as string); + switch (normalized) { + case "text": + case "number": + return ; + case "select": + return ; + case "date-preset": + return ; + case "toggle": + return ; + case "modal": + return ; + default: + return ; + } +} + +// ======================================== +// text 서브타입 +// ======================================== + +function TextSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) { + const [inputValue, setInputValue] = useState(value); + const debounceRef = useRef | null>(null); + + useEffect(() => { setInputValue(value); }, [value]); + useEffect(() => { return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, []); + + const handleChange = (e: React.ChangeEvent) => { + const v = e.target.value; + setInputValue(v); + if (debounceRef.current) clearTimeout(debounceRef.current); + const ms = config.debounceMs ?? 500; + if (ms > 0) debounceRef.current = setTimeout(() => onChange(v), ms); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && config.triggerOnEnter !== false) { + if (debounceRef.current) clearTimeout(debounceRef.current); + onChange(inputValue); + } + }; + + const isNumber = config.inputType === "number"; + + return ( +
+ + +
+ ); +} + +// ======================================== +// select 서브타입 +// ======================================== + +function SelectSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: string; onChange: (v: unknown) => void }) { + return ( + + ); +} + +// ======================================== +// date-preset 서브타입 +// ======================================== + +function DatePresetSearchInput({ config, value, onChange }: { config: PopSearchConfig; value: unknown; onChange: (v: unknown) => void }) { + const presets: DatePresetOption[] = config.datePresets || ["today", "this-week", "this-month"]; + const currentPreset = value && typeof value === "object" && "preset" in (value as Record) + ? (value as Record).preset + : value; + + const handleSelect = (preset: DatePresetOption) => { + if (preset === "custom") { onChange({ preset: "custom", from: "", to: "" }); return; } + const range = computeDateRange(preset); + if (range) onChange(range); + }; + + return ( +
+ {presets.map((preset) => ( + + ))} +
+ ); +} + +// ======================================== +// toggle 서브타입 +// ======================================== + +function ToggleSearchInput({ value, onChange }: { value: boolean; onChange: (v: unknown) => void }) { + return ( +
+ onChange(checked)} /> + {value ? "ON" : "OFF"} +
+ ); +} + +// ======================================== +// modal 서브타입: 읽기 전용 표시 + 클릭으로 모달 열기 +// ======================================== + +function ModalSearchInput({ config, displayText, onClick }: { config: PopSearchConfig; displayText: string; onClick?: () => void }) { + return ( +
{ if (e.key === "Enter" || e.key === " ") onClick?.(); }} + > + {displayText || config.placeholder || "선택..."} + +
+ ); +} + +// ======================================== +// 미구현 서브타입 플레이스홀더 +// ======================================== + +function PlaceholderInput({ inputType }: { inputType: string }) { + return ( +
+ {inputType} (후속 구현 예정) +
+ ); +} + +// ======================================== +// 검색 방식별 문자열 매칭 +// ======================================== + +function matchSearchMode(cellValue: string, term: string, mode: ModalSearchMode): boolean { + const lower = cellValue.toLowerCase(); + const tLower = term.toLowerCase(); + switch (mode) { + case "starts-with": return lower.startsWith(tLower); + case "equals": return lower === tLower; + case "contains": + default: return lower.includes(tLower); + } +} + +// ======================================== +// 아이콘 색상 생성 (이름 기반 결정적 색상) +// ======================================== + +const ICON_COLORS = [ + "bg-red-500", "bg-orange-500", "bg-amber-500", "bg-yellow-500", + "bg-lime-500", "bg-green-500", "bg-emerald-500", "bg-teal-500", + "bg-cyan-500", "bg-sky-500", "bg-blue-500", "bg-indigo-500", + "bg-violet-500", "bg-purple-500", "bg-fuchsia-500", "bg-pink-500", +]; + +function getIconColor(text: string): string { + let hash = 0; + for (let i = 0; i < text.length; i++) { + hash = text.charCodeAt(i) + ((hash << 5) - hash); + } + return ICON_COLORS[Math.abs(hash) % ICON_COLORS.length]; +} + +// ======================================== +// 모달 Dialog: 테이블 / 아이콘 뷰 + 필터 탭 +// ======================================== + +interface ModalDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + modalConfig: ModalSelectConfig; + title: string; + onSelect: (row: Record) => void; +} + +function ModalDialog({ open, onOpenChange, modalConfig, title, onSelect }: ModalDialogProps) { + const [searchText, setSearchText] = useState(""); + const [allRows, setAllRows] = useState[]>([]); + const [loading, setLoading] = useState(false); + const [activeFilterTab, setActiveFilterTab] = useState(null); + const debounceRef = useRef | null>(null); + + const { + tableName, + displayColumns, + searchColumns, + searchMode = "contains", + filterTabs, + columnLabels, + displayStyle = "table", + displayField, + } = modalConfig; + + const colsToShow = displayColumns && displayColumns.length > 0 ? displayColumns : []; + const hasFilterTabs = filterTabs && filterTabs.length > 0; + + // 데이터 로드 + const fetchData = useCallback(async () => { + if (!tableName) return; + setLoading(true); + try { + const result = await dataApi.getTableData(tableName, { page: 1, size: 200 }); + setAllRows(result.data || []); + } catch { + setAllRows([]); + } finally { + setLoading(false); + } + }, [tableName]); + + useEffect(() => { + if (open) { + setSearchText(""); + setActiveFilterTab(hasFilterTabs ? filterTabs![0] : null); + fetchData(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, fetchData, hasFilterTabs]); + + // 필터링된 행 계산 + const filteredRows = useMemo(() => { + let items = allRows; + + // 텍스트 검색 필터 + if (searchText.trim()) { + const cols = searchColumns && searchColumns.length > 0 ? searchColumns : colsToShow; + items = items.filter((row) => + cols.some((col) => { + const val = row[col]; + return val != null && matchSearchMode(String(val), searchText, searchMode); + }) + ); + } + + // 필터 탭 (초성/알파벳) 적용 + if (activeFilterTab && displayField) { + items = items.filter((row) => { + const val = row[displayField]; + if (val == null) return false; + const key = getGroupKey(String(val), activeFilterTab); + return key !== "#"; + }); + } + + return items; + }, [allRows, searchText, searchColumns, colsToShow, searchMode, activeFilterTab, displayField]); + + // 그룹화 (필터 탭 활성화 시) + const groupedRows = useMemo(() => { + if (!activeFilterTab || !displayField) return null; + + const groups = new Map[]>(); + for (const row of filteredRows) { + const val = row[displayField]; + const key = val != null ? getGroupKey(String(val), activeFilterTab) : "#"; + if (key === "#") continue; + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(row); + } + + // 정렬 + const sorted = [...groups.entries()].sort(([a], [b]) => a.localeCompare(b, "ko")); + return sorted; + }, [filteredRows, activeFilterTab, displayField]); + + const handleSearchChange = (e: React.ChangeEvent) => { + const v = e.target.value; + setSearchText(v); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => {}, 300); + }; + + const getColLabel = (colName: string) => columnLabels?.[colName] || colName; + + return ( + + + + {title} 선택 + {/* 필터 탭 버튼 */} + {hasFilterTabs && ( +
+ {filterTabs!.map((tab) => ( + + ))} +
+ )} +
+ + {/* 검색 입력 */} +
+ + + {searchText && ( + + )} +
+ + {/* 결과 영역 */} +
+ {loading ? ( +
+ +
+ ) : filteredRows.length === 0 ? ( +
+ {searchText ? "검색 결과가 없습니다" : "데이터가 없습니다"} +
+ ) : displayStyle === "icon" ? ( + + ) : ( + + )} +
+ +

+ {filteredRows.length}건 표시 / {displayStyle === "icon" ? "아이콘" : "행"}을 클릭하면 선택됩니다 +

+
+
+ ); +} + +// ======================================== +// 테이블 뷰 +// ======================================== + +function TableView({ + rows, + groupedRows, + colsToShow, + displayField, + getColLabel, + activeFilterTab, + onSelect, +}: { + rows: Record[]; + groupedRows: [string, Record[]][] | null; + colsToShow: string[]; + displayField: string; + getColLabel: (col: string) => string; + activeFilterTab: ModalFilterTab | null; + onSelect: (row: Record) => void; +}) { + const renderRow = (row: Record, i: number) => ( + onSelect(row)}> + {colsToShow.length > 0 + ? colsToShow.map((col) => ( + {String(row[col] ?? "")} + )) + : Object.entries(row).slice(0, 3).map(([k, v]) => ( + {String(v ?? "")} + ))} + + ); + + if (groupedRows && activeFilterTab) { + return ( +
+ {colsToShow.length > 0 && ( +
+ {colsToShow.map((col) => ( +
+ {getColLabel(col)} +
+ ))} +
+ )} + {groupedRows.map(([groupKey, groupRows]) => ( +
+
+ {groupKey} +
+
+ + + {groupRows.map((row, i) => renderRow(row, i))} + +
+
+ ))} +
+ ); + } + + return ( + + {colsToShow.length > 0 && ( + + + {colsToShow.map((col) => ( + + ))} + + + )} + + {rows.map((row, i) => renderRow(row, i))} + +
+ {getColLabel(col)} +
+ ); +} + +// ======================================== +// 아이콘 뷰 +// ======================================== + +function IconView({ + rows, + groupedRows, + displayField, + onSelect, +}: { + rows: Record[]; + groupedRows: [string, Record[]][] | null; + displayField: string; + onSelect: (row: Record) => void; +}) { + const renderIconCard = (row: Record, i: number) => { + const text = displayField ? String(row[displayField] ?? "") : ""; + const firstChar = text.charAt(0) || "?"; + const color = getIconColor(text); + + return ( +
onSelect(row)} + > +
+ {firstChar} +
+ {text} +
+ ); + }; + + if (groupedRows) { + return ( +
+ {groupedRows.map(([groupKey, groupRows]) => ( +
+
+ {groupKey} +
+
+
+ {groupRows.map((row, i) => renderIconCard(row, i))} +
+
+ ))} +
+ ); + } + + return ( +
+ {rows.map((row, i) => renderIconCard(row, i))} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx new file mode 100644 index 00000000..3993dc48 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-search/PopSearchConfig.tsx @@ -0,0 +1,648 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ChevronLeft, ChevronRight, Plus, Trash2, Loader2 } from "lucide-react"; +import type { + PopSearchConfig, + SearchInputType, + DatePresetOption, + ModalSelectConfig, + ModalDisplayStyle, + ModalSearchMode, + ModalFilterTab, +} from "./types"; +import { + SEARCH_INPUT_TYPE_LABELS, + DATE_PRESET_LABELS, + MODAL_DISPLAY_STYLE_LABELS, + MODAL_SEARCH_MODE_LABELS, + MODAL_FILTER_TAB_LABELS, + normalizeInputType, +} from "./types"; +import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement"; +import type { TableInfo, ColumnTypeInfo } from "@/lib/api/tableManagement"; + +// ======================================== +// 기본값 +// ======================================== + +const DEFAULT_CONFIG: PopSearchConfig = { + inputType: "text", + fieldName: "", + placeholder: "검색어 입력", + debounceMs: 500, + triggerOnEnter: true, + labelPosition: "top", + labelText: "", + labelVisible: true, +}; + +// ======================================== +// 설정 패널 메인 +// ======================================== + +interface ConfigPanelProps { + config: PopSearchConfig | undefined; + onUpdate: (config: PopSearchConfig) => void; +} + +export function PopSearchConfigPanel({ config, onUpdate }: ConfigPanelProps) { + const [step, setStep] = useState(0); + const rawCfg = { ...DEFAULT_CONFIG, ...(config || {}) }; + const cfg: PopSearchConfig = { + ...rawCfg, + inputType: normalizeInputType(rawCfg.inputType as string), + }; + + const update = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + const STEPS = ["기본 설정", "상세 설정"]; + + return ( +
+ {/* Stepper 헤더 */} +
+ {STEPS.map((s, i) => ( + + ))} +
+ + {step === 0 && } + {step === 1 && } + +
+ + +
+
+ ); +} + +// ======================================== +// STEP 1: 기본 설정 +// ======================================== + +interface StepProps { + cfg: PopSearchConfig; + update: (partial: Partial) => void; +} + +function StepBasicSettings({ cfg, update }: StepProps) { + return ( +
+
+ + +
+ +
+ + update({ placeholder: e.target.value })} + placeholder="입력 힌트 텍스트" + className="h-8 text-xs" + /> +
+ +
+ update({ labelVisible: Boolean(checked) })} + /> + +
+ + {cfg.labelVisible !== false && ( + <> +
+ + update({ labelText: e.target.value })} + placeholder="예: 거래처명" + className="h-8 text-xs" + /> +
+
+ + +
+ + )} +
+ ); +} + +// ======================================== +// STEP 2: 타입별 상세 설정 +// ======================================== + +function StepDetailSettings({ cfg, update }: StepProps) { + const normalized = normalizeInputType(cfg.inputType as string); + switch (normalized) { + case "text": + case "number": + return ; + case "select": + return ; + case "date-preset": + return ; + case "modal": + return ; + case "toggle": + return ( +
+

+ 토글은 추가 설정이 없습니다. ON/OFF 값이 바로 전달됩니다. +

+
+ ); + default: + return ( +
+

+ {cfg.inputType} 타입의 상세 설정은 후속 구현 예정입니다. +

+
+ ); + } +} + +// ======================================== +// text/number 상세 설정 +// ======================================== + +function TextDetailSettings({ cfg, update }: StepProps) { + return ( +
+
+ + update({ debounceMs: Math.max(0, Number(e.target.value)) })} + min={0} + max={5000} + step={100} + className="h-8 text-xs" + /> +

+ 입력 후 대기 시간. 0이면 즉시 발행 (권장: 300~500) +

+
+
+ update({ triggerOnEnter: Boolean(checked) })} + /> + +
+
+ ); +} + +// ======================================== +// select 상세 설정 +// ======================================== + +function SelectDetailSettings({ cfg, update }: StepProps) { + const options = cfg.options || []; + + const addOption = () => { + update({ + options: [...options, { value: `opt_${options.length + 1}`, label: `옵션 ${options.length + 1}` }], + }); + }; + + const removeOption = (index: number) => { + update({ options: options.filter((_, i) => i !== index) }); + }; + + const updateOption = (index: number, field: "value" | "label", val: string) => { + update({ options: options.map((opt, i) => (i === index ? { ...opt, [field]: val } : opt)) }); + }; + + return ( +
+ + {options.length === 0 && ( +

옵션이 없습니다. 아래 버튼으로 추가하세요.

+ )} + {options.map((opt, i) => ( +
+ updateOption(i, "value", e.target.value)} placeholder="값" className="h-7 flex-1 text-[10px]" /> + updateOption(i, "label", e.target.value)} placeholder="라벨" className="h-7 flex-1 text-[10px]" /> + +
+ ))} + +
+ ); +} + +// ======================================== +// date-preset 상세 설정 +// ======================================== + +function DatePresetDetailSettings({ cfg, update }: StepProps) { + const ALL_PRESETS: DatePresetOption[] = ["today", "this-week", "this-month", "custom"]; + const activePresets = cfg.datePresets || ["today", "this-week", "this-month"]; + + const togglePreset = (preset: DatePresetOption) => { + const next = activePresets.includes(preset) + ? activePresets.filter((p) => p !== preset) + : [...activePresets, preset]; + update({ datePresets: next.length > 0 ? next : ["today"] }); + }; + + return ( +
+ + {ALL_PRESETS.map((preset) => ( +
+ togglePreset(preset)} + /> + +
+ ))} + {activePresets.includes("custom") && ( +

+ "직접" 선택 시 날짜 입력 UI가 표시됩니다 (후속 구현) +

+ )} +
+ ); +} + +// ======================================== +// modal 상세 설정 +// ======================================== + +const DEFAULT_MODAL_CONFIG: ModalSelectConfig = { + displayStyle: "table", + displayField: "", + valueField: "", + searchMode: "contains", +}; + +function ModalDetailSettings({ cfg, update }: StepProps) { + const mc: ModalSelectConfig = { ...DEFAULT_MODAL_CONFIG, ...(cfg.modalConfig || {}) }; + + const updateModal = (partial: Partial) => { + update({ modalConfig: { ...mc, ...partial } }); + }; + + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [tablesLoading, setTablesLoading] = useState(false); + const [columnsLoading, setColumnsLoading] = useState(false); + + useEffect(() => { + let cancelled = false; + setTablesLoading(true); + tableManagementApi.getTableList().then((res) => { + if (!cancelled && res.success && res.data) setTables(res.data); + }).finally(() => !cancelled && setTablesLoading(false)); + return () => { cancelled = true; }; + }, []); + + useEffect(() => { + if (!mc.tableName) { setColumns([]); return; } + let cancelled = false; + setColumnsLoading(true); + getTableColumns(mc.tableName).then((res) => { + if (!cancelled && res.success && res.data?.columns) setColumns(res.data.columns); + }).finally(() => !cancelled && setColumnsLoading(false)); + return () => { cancelled = true; }; + }, [mc.tableName]); + + const toggleArrayItem = (field: "displayColumns" | "searchColumns", col: string) => { + const current = mc[field] || []; + const next = current.includes(col) ? current.filter((c) => c !== col) : [...current, col]; + updateModal({ [field]: next }); + }; + + const toggleFilterTab = (tab: ModalFilterTab) => { + const current = mc.filterTabs || []; + const next = current.includes(tab) ? current.filter((t) => t !== tab) : [...current, tab]; + updateModal({ filterTabs: next }); + }; + + const updateColumnLabel = (colName: string, label: string) => { + const current = mc.columnLabels || {}; + if (!label.trim()) { + const { [colName]: _, ...rest } = current; + updateModal({ columnLabels: Object.keys(rest).length > 0 ? rest : undefined }); + } else { + updateModal({ columnLabels: { ...current, [colName]: label } }); + } + }; + + const selectedDisplayCols = mc.displayColumns || []; + + return ( +
+ {/* 보여주기 방식 */} +
+ + +

+ 테이블: 표 형태 / 아이콘: 아이콘 카드 형태 +

+
+ + {/* 데이터 테이블 */} +
+ + {tablesLoading ? ( +
+ + 테이블 목록 로딩... +
+ ) : ( + + )} +
+ + {mc.tableName && ( + <> + {/* 표시할 컬럼 */} +
+ + {columnsLoading ? ( +
+ + 컬럼 로딩... +
+ ) : ( +
+ {columns.map((col) => ( +
+ toggleArrayItem("displayColumns", col.columnName)} + /> + +
+ ))} +
+ )} +
+ + {/* 컬럼 헤더 라벨 편집 (표시할 컬럼이 선택된 경우만) */} + {selectedDisplayCols.length > 0 && ( +
+ +
+ {selectedDisplayCols.map((colName) => { + const colInfo = columns.find((c) => c.columnName === colName); + const defaultLabel = colInfo?.displayName || colName; + return ( +
+ + {colName} + + updateColumnLabel(colName, e.target.value)} + placeholder={defaultLabel} + className="h-6 flex-1 text-[10px]" + /> +
+ ); + })} +
+

+ 비워두면 기본 컬럼명이 사용됩니다 +

+
+ )} + + {/* 검색 대상 컬럼 */} +
+ +
+ {columns.map((col) => ( +
+ toggleArrayItem("searchColumns", col.columnName)} + /> + +
+ ))} +
+
+ + {/* 검색 방식 */} +
+ + +

+ 포함: 어디든 일치 / 시작: 앞에서 일치 / 같음: 정확히 일치 +

+
+ + {/* 필터 탭 (가나다/ABC) */} +
+ +
+ {(Object.entries(MODAL_FILTER_TAB_LABELS) as [ModalFilterTab, string][]).map(([key, label]) => ( +
+ toggleFilterTab(key)} + /> + +
+ ))} +
+

+ 모달 상단에 초성(가나다) / 알파벳(ABC) 필터 탭 표시 +

+
+ + {/* 검색창에 보일 값 */} +
+ + +

+ 선택 후 검색 입력란에 표시될 값 (예: 회사명) +

+
+ + {/* 필터에 쓸 값 */} +
+ + +

+ 연결된 리스트를 필터할 때 사용할 값 (예: 회사코드) +

+
+ + )} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-search/index.tsx b/frontend/lib/registry/pop-components/pop-search/index.tsx new file mode 100644 index 00000000..87069f38 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-search/index.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopSearchComponent } from "./PopSearchComponent"; +import { PopSearchConfigPanel } from "./PopSearchConfig"; +import type { PopSearchConfig } from "./types"; +import { DEFAULT_SEARCH_CONFIG } from "./types"; + +function PopSearchPreviewComponent({ config, label }: { config?: PopSearchConfig; label?: string }) { + const cfg = config || DEFAULT_SEARCH_CONFIG; + const displayLabel = cfg.labelText || label || cfg.fieldName || "검색"; + + return ( +
+ + {displayLabel} + +
+ + {cfg.placeholder || cfg.inputType} + +
+
+ ); +} + +PopComponentRegistry.registerComponent({ + id: "pop-search", + name: "검색", + description: "조건 입력 (텍스트/날짜/선택/모달)", + category: "input", + icon: "Search", + component: PopSearchComponent, + configPanel: PopSearchConfigPanel, + preview: PopSearchPreviewComponent, + defaultProps: DEFAULT_SEARCH_CONFIG, + connectionMeta: { + sendable: [ + { key: "filter_value", label: "필터 값", type: "filter_value", description: "입력한 검색 조건을 다른 컴포넌트에 전달" }, + ], + receivable: [ + { key: "set_value", label: "값 설정", type: "filter_value", description: "외부에서 값을 받아 검색 필드에 세팅 (스캔, 모달 선택 등)" }, + ], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-search/types.ts b/frontend/lib/registry/pop-components/pop-search/types.ts new file mode 100644 index 00000000..6c49b1c5 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-search/types.ts @@ -0,0 +1,225 @@ +// ===== pop-search 전용 타입 ===== +// 단일 필드 검색 컴포넌트. 그리드 한 칸 = 검색 필드 하나. + +/** 검색 필드 입력 타입 (9종) */ +export type SearchInputType = + | "text" + | "number" + | "date" + | "date-preset" + | "select" + | "multi-select" + | "combo" + | "modal" + | "toggle"; + +/** 레거시 입력 타입 (DB에 저장된 기존 값 호환용) */ +export type LegacySearchInputType = "modal-table" | "modal-card" | "modal-icon-grid"; + +/** 레거시 타입 -> modal로 정규화 */ +export function normalizeInputType(t: string): SearchInputType { + if (t === "modal-table" || t === "modal-card" || t === "modal-icon-grid") return "modal"; + return t as SearchInputType; +} + +/** 날짜 프리셋 옵션 */ +export type DatePresetOption = "today" | "this-week" | "this-month" | "custom"; + +/** 셀렉트 옵션 (정적 목록) */ +export interface SelectOption { + value: string; + label: string; +} + +/** 셀렉트 옵션 데이터 소스 (DB에서 동적 로딩) */ +export interface SelectDataSource { + tableName: string; + valueColumn: string; + labelColumn: string; + sortColumn?: string; + sortDirection?: "asc" | "desc"; +} + +/** 모달 보여주기 방식: 테이블 or 아이콘 */ +export type ModalDisplayStyle = "table" | "icon"; + +/** 모달 검색 방식 */ +export type ModalSearchMode = "contains" | "starts-with" | "equals"; + +/** 모달 필터 탭 (가나다 초성 / ABC 알파벳) */ +export type ModalFilterTab = "korean" | "alphabet"; + +/** 모달 선택 설정 */ +export interface ModalSelectConfig { + displayStyle?: ModalDisplayStyle; + + tableName?: string; + displayColumns?: string[]; + /** 컬럼별 커스텀 헤더 라벨 { column_name: "표시 라벨" } */ + columnLabels?: Record; + searchColumns?: string[]; + searchMode?: ModalSearchMode; + /** 모달 상단 필터 탭 (가나다 / ABC) */ + filterTabs?: ModalFilterTab[]; + + displayField: string; + valueField: string; +} + +/** pop-search 전체 설정 */ +export interface PopSearchConfig { + inputType: SearchInputType | LegacySearchInputType; + fieldName: string; + placeholder?: string; + defaultValue?: unknown; + + // text/number 전용 + debounceMs?: number; + triggerOnEnter?: boolean; + + // select/multi-select 전용 + options?: SelectOption[]; + optionsDataSource?: SelectDataSource; + + // date-preset 전용 + datePresets?: DatePresetOption[]; + + // modal 전용 + modalConfig?: ModalSelectConfig; + + // 라벨 + labelText?: string; + labelVisible?: boolean; + + // 스타일 + labelPosition?: "top" | "left"; +} + +/** 기본 설정값 (레지스트리 + 컴포넌트 공유) */ +export const DEFAULT_SEARCH_CONFIG: PopSearchConfig = { + inputType: "text", + fieldName: "", + placeholder: "검색어 입력", + debounceMs: 500, + triggerOnEnter: true, + labelPosition: "top", + labelText: "", + labelVisible: true, +}; + +/** 날짜 프리셋 라벨 매핑 */ +export const DATE_PRESET_LABELS: Record = { + today: "오늘", + "this-week": "이번주", + "this-month": "이번달", + custom: "직접", +}; + +/** 입력 타입 라벨 매핑 (설정 패널용) */ +export const SEARCH_INPUT_TYPE_LABELS: Record = { + text: "텍스트", + number: "숫자", + date: "날짜", + "date-preset": "날짜 프리셋", + select: "단일 선택", + "multi-select": "다중 선택", + combo: "자동완성", + modal: "모달", + toggle: "토글", +}; + +/** 모달 보여주기 방식 라벨 */ +export const MODAL_DISPLAY_STYLE_LABELS: Record = { + table: "테이블", + icon: "아이콘", +}; + +/** 모달 검색 방식 라벨 */ +export const MODAL_SEARCH_MODE_LABELS: Record = { + contains: "포함", + "starts-with": "시작", + equals: "같음", +}; + +/** 모달 필터 탭 라벨 */ +export const MODAL_FILTER_TAB_LABELS: Record = { + korean: "가나다", + alphabet: "ABC", +}; + +/** 한글 초성 추출 */ +const KOREAN_CONSONANTS = [ + "ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", + "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ", +]; + +/** 초성 -> 대표 초성 (쌍자음 합침) */ +const CONSONANT_GROUP: Record = { + "ㄱ": "ㄱ", "ㄲ": "ㄱ", + "ㄴ": "ㄴ", + "ㄷ": "ㄷ", "ㄸ": "ㄷ", + "ㄹ": "ㄹ", + "ㅁ": "ㅁ", + "ㅂ": "ㅂ", "ㅃ": "ㅂ", + "ㅅ": "ㅅ", "ㅆ": "ㅅ", + "ㅇ": "ㅇ", + "ㅈ": "ㅈ", "ㅉ": "ㅈ", + "ㅊ": "ㅊ", + "ㅋ": "ㅋ", + "ㅌ": "ㅌ", + "ㅍ": "ㅍ", + "ㅎ": "ㅎ", +}; + +/** 문자열 첫 글자의 그룹 키 추출 (한글 초성 / 영문 대문자 / 기타) */ +export function getGroupKey( + text: string, + mode: ModalFilterTab +): string { + if (!text) return "#"; + const ch = text.charAt(0); + const code = ch.charCodeAt(0); + + if (mode === "korean") { + if (code >= 0xAC00 && code <= 0xD7A3) { + const idx = Math.floor((code - 0xAC00) / (21 * 28)); + const consonant = KOREAN_CONSONANTS[idx]; + return CONSONANT_GROUP[consonant] || consonant; + } + return "#"; + } + + // alphabet + if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) { + return ch.toUpperCase(); + } + return "#"; +} + +/** 날짜 범위 계산 (date-preset -> 실제 날짜) */ +export function computeDateRange( + preset: DatePresetOption +): { preset: DatePresetOption; from: string; to: string } | null { + const now = new Date(); + const fmt = (d: Date) => d.toISOString().split("T")[0]; + + switch (preset) { + case "today": + return { preset, from: fmt(now), to: fmt(now) }; + case "this-week": { + const day = now.getDay(); + const mon = new Date(now); + mon.setDate(now.getDate() - ((day + 6) % 7)); + const sun = new Date(mon); + sun.setDate(mon.getDate() + 6); + return { preset, from: fmt(mon), to: fmt(sun) }; + } + case "this-month": { + const first = new Date(now.getFullYear(), now.getMonth(), 1); + const last = new Date(now.getFullYear(), now.getMonth() + 1, 0); + return { preset, from: fmt(first), to: fmt(last) }; + } + case "custom": + return null; + } +} diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx new file mode 100644 index 00000000..567f6d1d --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListComponent.tsx @@ -0,0 +1,795 @@ +"use client"; + +/** + * pop-string-list 런타임 컴포넌트 + * + * 리스트 모드: 엑셀형 행/열 (CSS Grid) + * 카드 모드: 셀 병합 가능한 카드 (CSS Grid + colSpan/rowSpan) + * 오버플로우: visibleRows 제한 + "더보기" 점진 확장 + */ + +import { useState, useEffect, useCallback, useMemo } from "react"; +import { ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Loader2, AlertCircle, ChevronsUpDown } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { dataApi } from "@/lib/api/data"; +import { executePopAction } from "@/hooks/pop/executePopAction"; +import { usePopEvent } from "@/hooks/pop/usePopEvent"; +import { toast } from "sonner"; +import type { + PopStringListConfig, + CardGridConfig, + ListColumnConfig, + CardCellDefinition, +} from "./types"; + +// ===== 유틸리티 ===== + +/** + * 컬럼명에서 실제 데이터 키를 추출 + * 조인 컬럼은 "테이블명.컬럼명" 형식으로 저장됨 -> "컬럼명"만 추출 + * 일반 컬럼은 그대로 반환 + */ +function resolveColumnName(name: string): string { + if (!name) return name; + const dotIdx = name.lastIndexOf("."); + return dotIdx >= 0 ? name.substring(dotIdx + 1) : name; +} + +// ===== Props ===== + +interface PopStringListComponentProps { + config?: PopStringListConfig; + className?: string; + screenId?: string; + componentId?: string; +} + +// 테이블 행 데이터 타입 +type RowData = Record; + +// ===== 메인 컴포넌트 ===== + +export function PopStringListComponent({ + config, + className, + screenId, + componentId, +}: PopStringListComponentProps) { + const displayMode = config?.displayMode || "list"; + const header = config?.header; + const overflow = config?.overflow; + const dataSource = config?.dataSource; + const listColumns = config?.listColumns || []; + const cardGrid = config?.cardGrid; + const rowClickAction = config?.rowClickAction || "none"; + + // 데이터 상태 + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // 더보기 모드: 현재 표시 중인 행 수 + const [displayCount, setDisplayCount] = useState(0); + // 페이지네이션 모드: 현재 페이지 (1부터 시작) + const [currentPage, setCurrentPage] = useState(1); + + // 카드 버튼 행 단위 로딩 인덱스 (-1 = 로딩 없음) + const [loadingRowIdx, setLoadingRowIdx] = useState(-1); + + // 이벤트 버스 + const { publish, subscribe } = usePopEvent(screenId || ""); + + // 외부 필터 조건 (연결 시스템에서 수신, connectionId별 Map으로 복수 필터 AND 결합) + const [externalFilters, setExternalFilters] = useState< + Map + >(new Map()); + + // 표준 입력 이벤트 구독 + useEffect(() => { + if (!componentId) return; + const unsub = subscribe( + `__comp_input__${componentId}__filter_condition`, + (payload: unknown) => { + const data = payload as { + value?: { fieldName?: string; value?: unknown }; + filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string }; + _connectionId?: string; + }; + const connId = data?._connectionId || "default"; + setExternalFilters(prev => { + const next = new Map(prev); + if (data?.value?.value) { + next.set(connId, { + fieldName: data.value.fieldName || "", + value: data.value.value, + filterConfig: data.filterConfig, + }); + } else { + next.delete(connId); + } + return next; + }); + } + ); + return unsub; + }, [componentId, subscribe]); + + // 카드 버튼 클릭 핸들러 + const handleCardButtonClick = useCallback( + async (cell: CardCellDefinition, row: RowData) => { + if (!cell.buttonAction) return; + + // 확인 다이얼로그 (간단 구현: window.confirm) + if (cell.buttonConfirm?.enabled) { + const msg = cell.buttonConfirm.message || "이 작업을 실행하시겠습니까?"; + if (!window.confirm(msg)) return; + } + + const rowIndex = rows.indexOf(row); + setLoadingRowIdx(rowIndex); + + try { + const result = await executePopAction(cell.buttonAction, row as Record, { + publish, + screenId, + }); + + if (result.success) { + toast.success("작업이 완료되었습니다."); + } else { + toast.error(result.error || "작업에 실패했습니다."); + } + } catch { + toast.error("알 수 없는 오류가 발생했습니다."); + } finally { + setLoadingRowIdx(-1); + } + }, + [rows, publish, screenId] + ); + + // 행 클릭 핸들러 (selected_row 발행 + 모달 닫기 옵션) + const handleRowClick = useCallback( + (row: RowData) => { + if (rowClickAction === "none") return; + + // selected_row 이벤트 발행 + if (componentId) { + publish(`__comp_output__${componentId}__selected_row`, row); + } + + // 모달 내부에서 사용 시: 선택 후 모달 닫기 + 데이터 반환 + if (rowClickAction === "select-and-close-modal") { + publish("__pop_modal_close__", { selectedRow: row }); + } + }, + [rowClickAction, componentId, publish] + ); + + // 오버플로우 설정 (JSON 복원 시 string 유입 방어) + const overflowMode = overflow?.mode || "loadMore"; + const visibleRows = Number(overflow?.visibleRows) || 5; + const loadMoreCount = Number(overflow?.loadMoreCount) || 5; + const maxExpandRows = Number(overflow?.maxExpandRows) || 50; + const showExpandButton = overflow?.showExpandButton ?? true; + const pageSize = Number(overflow?.pageSize) || visibleRows; + const paginationStyle = overflow?.paginationStyle || "bottom"; + + // --- 외부 필터 적용 (복수 필터 AND 결합) --- + const filteredRows = useMemo(() => { + if (externalFilters.size === 0) return rows; + + const matchSingleFilter = ( + row: RowData, + filter: { fieldName: string; value: unknown; filterConfig?: { targetColumn: string; targetColumns?: string[]; filterMode: string } } + ): boolean => { + const searchValue = String(filter.value).toLowerCase(); + if (!searchValue) return true; + + const fc = filter.filterConfig; + const columns: string[] = + fc?.targetColumns?.length + ? fc.targetColumns + : fc?.targetColumn + ? [fc.targetColumn] + : filter.fieldName + ? [filter.fieldName] + : []; + + if (columns.length === 0) return true; + + const mode = fc?.filterMode || "contains"; + + const matchCell = (cellValue: string) => { + switch (mode) { + case "equals": + return cellValue === searchValue; + case "starts_with": + return cellValue.startsWith(searchValue); + case "contains": + default: + return cellValue.includes(searchValue); + } + }; + + return columns.some((col) => matchCell(String(row[col] ?? "").toLowerCase())); + }; + + const allFilters = [...externalFilters.values()]; + return rows.filter((row) => allFilters.every((f) => matchSingleFilter(row, f))); + }, [rows, externalFilters]); + + // --- 더보기 모드 --- + useEffect(() => { + setDisplayCount(visibleRows); + }, [visibleRows]); + + const effectiveLimit = Math.min(displayCount || visibleRows, maxExpandRows, filteredRows.length); + const hasMore = showExpandButton && filteredRows.length > effectiveLimit && effectiveLimit < maxExpandRows; + const isExpanded = effectiveLimit > visibleRows; + + const handleLoadMore = useCallback(() => { + setDisplayCount((prev) => { + const current = prev || visibleRows; + return Math.min(current + loadMoreCount, maxExpandRows, filteredRows.length); + }); + }, [visibleRows, loadMoreCount, maxExpandRows, filteredRows.length]); + + const handleCollapse = useCallback(() => { + setDisplayCount(visibleRows); + }, [visibleRows]); + + // --- 페이지네이션 모드 --- + const totalPages = Math.max(1, Math.ceil(filteredRows.length / pageSize)); + + useEffect(() => { + setCurrentPage(1); + }, [pageSize, filteredRows.length]); + + const handlePageChange = useCallback((page: number) => { + setCurrentPage(Math.max(1, Math.min(page, totalPages))); + }, [totalPages]); + + // --- 모드별 visibleData 결정 --- + const visibleData = useMemo(() => { + if (overflowMode === "pagination") { + const start = (currentPage - 1) * pageSize; + return filteredRows.slice(start, start + pageSize); + } + return filteredRows.slice(0, effectiveLimit); + }, [overflowMode, filteredRows, currentPage, pageSize, effectiveLimit]); + + // dataSource 원시값 추출 (객체 참조 대신 안정적인 의존성 사용) + const dsTableName = dataSource?.tableName; + const dsSortColumn = dataSource?.sort?.column; + const dsSortDirection = dataSource?.sort?.direction; + const dsLimitMode = dataSource?.limit?.mode; + const dsLimitCount = dataSource?.limit?.count; + const dsFiltersKey = useMemo( + () => JSON.stringify(dataSource?.filters || []), + [dataSource?.filters] + ); + + // 데이터 조회 + useEffect(() => { + if (!dsTableName) { + setLoading(false); + setRows([]); + return; + } + + const fetchData = async () => { + setLoading(true); + setError(null); + + try { + // 필터 조건 구성 (설정 패널 고정 필터 + 외부 검색 필터) + const filters: Record = {}; + const parsedFilters = JSON.parse(dsFiltersKey) as Array<{ column?: string; value?: string }>; + if (parsedFilters.length > 0) { + parsedFilters.forEach((f) => { + if (f.column && f.value) { + filters[f.column] = f.value; + } + }); + } + + // 정렬 조건 + const sortBy = dsSortColumn; + const sortOrder = dsSortDirection; + + // 개수 제한 (string 유입 방어: Number 캐스팅) + const size = + dsLimitMode === "limited" && dsLimitCount + ? Number(dsLimitCount) + : maxExpandRows; + + const result = await dataApi.getTableData(dsTableName, { + page: 1, + size, + sortBy: sortOrder ? sortBy : undefined, + sortOrder, + filters: Object.keys(filters).length > 0 ? filters : undefined, + }); + + setRows(result.data || []); + } catch (err) { + const message = + err instanceof Error ? err.message : "데이터 조회 실패"; + setError(message); + setRows([]); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [dsTableName, dsSortColumn, dsSortDirection, dsLimitMode, dsLimitCount, dsFiltersKey, maxExpandRows]); + + // 로딩 상태 + if (loading) { + return ( +
+ +
+ ); + } + + // 에러 상태 + if (error) { + return ( +
+ + {error} +
+ ); + } + + // 테이블 미선택 + if (!dataSource?.tableName) { + return ( +
+ + 테이블을 선택하세요 + +
+ ); + } + + // 데이터 없음 + if (rows.length === 0) { + return ( +
+ + 데이터가 없습니다 + +
+ ); + } + + const isPaginationSide = overflowMode === "pagination" && paginationStyle === "side" && totalPages > 1; + + return ( +
+ {/* 헤더 */} + {header?.enabled && header.label && ( +
+ {header.label} +
+ )} + + {/* 컨텐츠 */} +
+ {displayMode === "list" ? ( + + ) : ( + + )} + {isPaginationSide && ( + <> + + + + )} +
+ + {/* side 모드 페이지 인디케이터 (컨텐츠 아래 별도 영역) */} + {isPaginationSide && ( +
+ + {currentPage} / {totalPages} + +
+ )} + + {/* 더보기 모드 컨트롤 */} + {overflowMode === "loadMore" && showExpandButton && (hasMore || isExpanded) && ( +
+ {hasMore && ( + + )} + {isExpanded && ( + + )} +
+ )} + + {/* 페이지네이션 bottom 모드 컨트롤 */} + {overflowMode === "pagination" && paginationStyle === "bottom" && totalPages > 1 && ( +
+ + {rows.length}건 중 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, rows.length)} + +
+ + + {currentPage} / {totalPages} + + +
+
+ )} +
+ ); +} + +// ===== 리스트 모드 ===== + +interface ListModeViewProps { + columns: ListColumnConfig[]; + data: RowData[]; + onRowClick?: (row: RowData) => void; +} + +function ListModeView({ columns, data, onRowClick }: ListModeViewProps) { + // 런타임 컬럼 전환 상태 + // key: 컬럼 인덱스, value: 현재 활성 컬럼명 (alternateColumns 중 하나 또는 원래 columnName) + const [activeColumns, setActiveColumns] = useState>({}); + + if (columns.length === 0) { + return ( +
+ + 컬럼을 설정하세요 + +
+ ); + } + + const gridCols = columns.map((c) => c.width || "1fr").join(" "); + + return ( +
+ {/* 헤더 행 */} +
+ {columns.map((col, colIdx) => { + const hasAlternates = (col.alternateColumns || []).length > 0; + const currentColName = activeColumns[colIdx] || col.columnName; + // 원래 컬럼이면 기존 라벨, 전환된 컬럼이면 컬럼명 부분만 표시 + const currentLabel = + currentColName === col.columnName + ? col.label + : resolveColumnName(currentColName); + + if (hasAlternates) { + // 전환 가능한 헤더: Popover 드롭다운 + return ( + + + + + +
+ {/* 원래 컬럼 */} + + {/* 대체 컬럼들 */} + {(col.alternateColumns || []).map((altCol) => { + const altLabel = resolveColumnName(altCol); + return ( + + ); + })} +
+
+
+ ); + } + + // 전환 없는 일반 헤더 + return ( +
+ {col.label} +
+ ); + })} +
+ + {/* 데이터 행 */} + {data.map((row, i) => ( +
onRowClick?.(row)} + > + {columns.map((col, colIdx) => { + const currentColName = activeColumns[colIdx] || col.columnName; + const resolvedKey = resolveColumnName(currentColName); + return ( +
+ {String(row[resolvedKey] ?? "")} +
+ ); + })} +
+ ))} +
+ ); +} + +// ===== 카드 모드 ===== + +interface CardModeViewProps { + cardGrid?: CardGridConfig; + data: RowData[]; + handleCardButtonClick?: (cell: CardCellDefinition, row: RowData) => void; + loadingRowId?: number; +} + +function CardModeView({ cardGrid, data, handleCardButtonClick, loadingRowId }: CardModeViewProps) { + if (!cardGrid || (cardGrid.cells || []).length === 0) { + return ( +
+ + 카드 레이아웃을 설정하세요 + +
+ ); + } + + return ( +
+ {data.map((row, i) => ( +
0 + ? cardGrid.colWidths.map((w) => `minmax(30px, ${w || "1fr"})`).join(" ") + : "1fr", + gridTemplateRows: + cardGrid.rowHeights && cardGrid.rowHeights.length > 0 + ? cardGrid.rowHeights + .map((h) => { + if (!h) return "minmax(32px, auto)"; + // px 값 -> minmax(Npx, auto): 최소 높이 보장 + 컨텐츠에 맞게 확장 + if (h.endsWith("px")) { + return `minmax(${h}, auto)`; + } + // fr 값 -> 마이그레이션 호환: px 변환 후 minmax 적용 + const px = Math.round(parseFloat(h) * 32) || 32; + return `minmax(${px}px, auto)`; + }) + .join(" ") + : `repeat(${Number(cardGrid.rows) || 1}, minmax(32px, auto))`, + gap: `${Number(cardGrid.gap) || 0}px`, + }} + > + {(cardGrid.cells || []).map((cell) => { + // 가로 정렬 매핑 + const justifyMap = { left: "flex-start", center: "center", right: "flex-end" } as const; + const alignItemsMap = { top: "flex-start", middle: "center", bottom: "flex-end" } as const; + return ( +
+ {renderCellContent(cell, row, handleCardButtonClick, loadingRowId === i)} +
+ ); + })} +
+ ))} +
+ ); +} + +// ===== 셀 컨텐츠 렌더링 ===== + +function renderCellContent( + cell: CardCellDefinition, + row: RowData, + onButtonClick?: (cell: CardCellDefinition, row: RowData) => void, + isButtonLoading?: boolean, +): React.ReactNode { + const value = row[cell.columnName]; + const displayValue = value != null ? String(value) : ""; + + switch (cell.type) { + case "image": + return displayValue ? ( + {cell.label + ) : ( +
+ No Image +
+ ); + + case "badge": + return ( + + {displayValue} + + ); + + case "button": + return ( + + ); + + case "text": + default: { + // 글자 크기 매핑 + const fontSizeClass = + cell.fontSize === "sm" + ? "text-[10px]" + : cell.fontSize === "lg" + ? "text-sm" + : "text-xs"; // md (기본) + const isLabelLeft = cell.labelPosition === "left"; + + return ( +
+ {cell.label && ( + + {cell.label}{isLabelLeft ? ":" : ""} + + )} + {displayValue} +
+ ); + } + } +} diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx new file mode 100644 index 00000000..4208301b --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListConfig.tsx @@ -0,0 +1,2815 @@ +"use client"; + +/** + * pop-string-list 설정 패널 (Stepper/Wizard 방식) + * + * 6단계 순차 진행: + * 1) 모드 선택 (리스트/카드) + * 2) 헤더 설정 + * 3) 오버플로우 설정 + * 4) 데이터 선택 (테이블 + 컬럼 통합) + * 5) 조인 설정 (선택) + * 6-A) 리스트 컬럼 배치 (리스트 모드) + * 6-B) 카드 그리드 디자이너 (카드 모드) + */ + +import { useState, useEffect, useRef, useCallback, Fragment } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Check, ChevronsUpDown, ChevronLeft, ChevronRight, Plus, Minus, Trash2 } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import type { + PopStringListConfig, + StringListDisplayMode, + ListColumnConfig, + CardGridConfig, + CardCellDefinition, +} from "./types"; +import type { CardListDataSource, CardColumnJoin } from "../types"; +import { + fetchTableList, + fetchTableColumns, + type TableInfo, + type ColumnInfo, +} from "../pop-dashboard/utils/dataFetcher"; + +// ===== Props ===== + +interface ConfigPanelProps { + config: PopStringListConfig | undefined; + onUpdate: (config: PopStringListConfig) => void; +} + +// ===== 기본 설정값 ===== + +const DEFAULT_CONFIG: PopStringListConfig = { + displayMode: "list", + header: { enabled: true, label: "" }, + overflow: { visibleRows: 5, mode: "loadMore", showExpandButton: true, loadMoreCount: 5, maxExpandRows: 50, pageSize: 5, paginationStyle: "bottom" }, + dataSource: { tableName: "" }, + listColumns: [], + cardGrid: undefined, +}; + +// Stepper 단계 정의 +const STEP_LABELS = [ + "모드 선택", + "헤더 설정", + "오버플로우", + "데이터 선택", + "조인 설정", + "레이아웃", +] as const; + +const TOTAL_STEPS = STEP_LABELS.length; + +// ===== 메인 컴포넌트 ===== + +export function PopStringListConfigPanel({ config, onUpdate }: ConfigPanelProps) { + const [step, setStep] = useState(0); + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [selectedColumns, setSelectedColumns] = useState([]); + + // 설정값 (undefined 대비 기본값 병합) + const cfg: PopStringListConfig = { + ...DEFAULT_CONFIG, + ...config, + header: { ...DEFAULT_CONFIG.header, ...config?.header }, + overflow: { ...DEFAULT_CONFIG.overflow, ...config?.overflow }, + dataSource: { ...DEFAULT_CONFIG.dataSource, ...config?.dataSource }, + }; + + // 설정 업데이트 헬퍼 + const update = (partial: Partial) => { + onUpdate({ ...cfg, ...partial }); + }; + + // 테이블 목록 로드 + useEffect(() => { + fetchTableList() + .then(setTables) + .catch(() => setTables([])); // 네트워크 오류 시 빈 배열 + }, []); + + // 테이블 변경 시 컬럼 로드 + useEffect(() => { + if (!cfg.dataSource.tableName) { + setColumns([]); + return; + } + fetchTableColumns(cfg.dataSource.tableName) + .then(setColumns) + .catch(() => setColumns([])); // 네트워크 오류 시 빈 배열 + }, [cfg.dataSource.tableName]); + + // 선택된 컬럼 복원 (config에 저장된 값 우선) + useEffect(() => { + if (cfg.selectedColumns && cfg.selectedColumns.length > 0) { + setSelectedColumns(cfg.selectedColumns); + } else if (cfg.displayMode === "list" && cfg.listColumns) { + setSelectedColumns(cfg.listColumns.map((c) => c.columnName)); + } else if (cfg.displayMode === "card" && cfg.cardGrid) { + setSelectedColumns((cfg.cardGrid.cells || []).map((c) => c.columnName)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cfg.dataSource.tableName]); // 테이블 변경 시에만 복원 + + // 다음/이전 단계 + const canGoNext = (): boolean => { + switch (step) { + case 0: return true; // 모드 선택 (기본값 있음) + case 1: return true; // 헤더 (선택사항) + case 2: return true; // 오버플로우 (기본값 있음) + case 3: return !!cfg.dataSource.tableName && selectedColumns.length > 0; // 테이블 + 컬럼 + case 4: return true; // 조인 (선택사항) + case 5: return true; // 레이아웃 + default: return false; + } + }; + + const goNext = () => { + if (step < TOTAL_STEPS - 1 && canGoNext()) setStep(step + 1); + }; + + const goPrev = () => { + if (step > 0) setStep(step - 1); + }; + + return ( +
+ {/* Stepper 인디케이터 */} +
+ {STEP_LABELS.map((label, i) => ( + + ))} +
+
{STEP_LABELS[step]}
+ + {/* 단계별 컨텐츠 */} +
+ {step === 0 && ( + update({ displayMode })} + /> + )} + {step === 1 && ( + update({ header })} + /> + )} + {step === 2 && ( + update({ overflow })} + /> + )} + {step === 3 && ( + { + setSelectedColumns([]); + update({ + dataSource: { ...cfg.dataSource, tableName }, + selectedColumns: [], + listColumns: [], + cardGrid: undefined, + }); + }} + columns={columns} + selectedColumns={selectedColumns} + onColumnsChange={(cols) => { + setSelectedColumns(cols); + if (cfg.displayMode === "list") { + const currentList = cfg.listColumns || []; + // 기존 리스트에서: 체크 해제된 메인 컬럼만 제거 + // 조인 컬럼 (이름에 "."이 포함)은 항상 보존 + const preserved = currentList.filter( + (lc) => cols.includes(lc.columnName) || lc.columnName.includes(".") + ); + // 새로 체크된 메인 컬럼만 리스트 끝에 추가 + const existingNames = new Set(preserved.map((lc) => lc.columnName)); + const added = cols + .filter((colName) => !existingNames.has(colName)) + .map((colName) => ({ columnName: colName, label: colName } as ListColumnConfig)); + update({ selectedColumns: cols, listColumns: [...preserved, ...added] }); + } else { + update({ selectedColumns: cols }); + } + }} + /> + )} + {step === 4 && ( + { + // 조인 변경 후: 유효한 조인 컬럼명 셋 계산 + const validJoinColNames = new Set( + (dataSource.joins || []).flatMap((j) => + (j.selectedTargetColumns || []).map((col) => `${j.targetTable}.${col}`) + ) + ); + // listColumns에서 고아 조인 컬럼 제거 + alternateColumns 정리 + const currentList = cfg.listColumns || []; + const cleanedList = currentList + .filter((lc) => { + if (!lc.columnName.includes(".")) return true; // 메인 컬럼: 유지 + return validJoinColNames.has(lc.columnName); // 조인 컬럼: 유효한 것만 + }) + .map((lc) => { + const alts = lc.alternateColumns; + if (!alts) return lc; + const cleanedAlts = alts.filter((a) => { + if (!a.includes(".")) return true; // 메인 컬럼: 유지 + return validJoinColNames.has(a); // 조인 컬럼: 유효한 것만 + }); + return { + ...lc, + alternateColumns: cleanedAlts.length > 0 ? cleanedAlts : undefined, + }; + }); + update({ dataSource, listColumns: cleanedList }); + }} + /> + )} + {step === 5 && + (cfg.displayMode === "list" ? ( + + selectedColumns.includes(c.name) + )} + joinedColumns={ + // 조인에서 선택된 대상 컬럼들을 {테이블명.컬럼명} 형태로 수집 + (cfg.dataSource.joins || []).flatMap((j) => + (j.selectedTargetColumns || []).map((col) => ({ + name: `${j.targetTable}.${col}`, + displayName: col, + sourceTable: j.targetTable, + })) + ) + } + onChange={(listColumns) => update({ listColumns })} + /> + ) : ( + update({ cardGrid })} + /> + ))} +
+ + {/* 이전/다음 버튼 */} +
+ + + {step + 1} / {TOTAL_STEPS} + + +
+
+ ); +} + +// ===== STEP 0: 모드 선택 ===== + +function StepModeSelect({ + displayMode, + onChange, +}: { + displayMode: StringListDisplayMode; + onChange: (mode: StringListDisplayMode) => void; +}) { + return ( +
+ + +
+ ); +} + +// ===== STEP 1: 헤더 설정 ===== + +function StepHeader({ + header, + onChange, +}: { + header: PopStringListConfig["header"]; + onChange: (header: PopStringListConfig["header"]) => void; +}) { + return ( +
+
+ + onChange({ ...header, enabled })} + /> +
+ {header.enabled && ( +
+ + onChange({ ...header, label: e.target.value })} + placeholder="리스트 제목 입력" + className="mt-1 h-8 text-xs" + /> +
+ )} +
+ ); +} + +// ===== STEP 2: 오버플로우 설정 ===== + +function StepOverflow({ + overflow, + onChange, +}: { + overflow: PopStringListConfig["overflow"]; + onChange: (overflow: PopStringListConfig["overflow"]) => void; +}) { + const mode = overflow.mode || "loadMore"; + + return ( +
+
+ + + onChange({ ...overflow, visibleRows: Number(e.target.value) || 5 }) + } + className="mt-1 h-8 text-xs" + /> +
+ +
+ + +
+ + {mode === "loadMore" && ( + <> +
+ + + onChange({ ...overflow, showExpandButton }) + } + /> +
+ {overflow.showExpandButton && ( + <> +
+ + + onChange({ + ...overflow, + loadMoreCount: Number(e.target.value) || 5, + }) + } + className="mt-1 h-8 text-xs" + /> +

+ 클릭할 때마다 추가로 표시할 행 수 +

+
+
+ + + onChange({ + ...overflow, + maxExpandRows: Number(e.target.value) || 50, + }) + } + className="mt-1 h-8 text-xs" + /> +
+ + )} + + )} + + {mode === "pagination" && ( + <> +
+ + + onChange({ + ...overflow, + pageSize: Number(e.target.value) || 5, + }) + } + className="mt-1 h-8 text-xs" + /> +
+
+ + +

+ {(overflow.paginationStyle || "bottom") === "bottom" + ? "컴포넌트 하단에 이전/다음 버튼과 페이지 번호 표시" + : "컴포넌트 좌우에 화살표 버튼 표시"} +

+
+ + )} +
+ ); +} + +// ===== STEP 3: 데이터 선택 (테이블 + 컬럼 통합) ===== + +function StepDataSelect({ + tables, + tableName, + onTableChange, + columns, + selectedColumns, + onColumnsChange, +}: { + tables: TableInfo[]; + tableName: string; + onTableChange: (tableName: string) => void; + columns: ColumnInfo[]; + selectedColumns: string[]; + onColumnsChange: (selected: string[]) => void; +}) { + const [open, setOpen] = useState(false); + + const selectedDisplay = tableName + ? tables.find((t) => t.tableName === tableName)?.displayName || tableName + : ""; + + const toggleColumn = (colName: string) => { + if (selectedColumns.includes(colName)) { + onColumnsChange(selectedColumns.filter((c) => c !== colName)); + } else { + onColumnsChange([...selectedColumns, colName]); + } + }; + + return ( +
+ {/* 테이블 선택 */} +
+ + + + + + + + + + + 검색 결과가 없습니다 + + + { + onTableChange(""); + setOpen(false); + }} + className="text-xs" + > + + 선택 안 함 + + {tables.map((t) => ( + { + onTableChange(t.tableName); + setOpen(false); + }} + className="text-xs" + > + +
+ {t.displayName || t.tableName} + {t.displayName && t.displayName !== t.tableName && ( + + {t.tableName} + + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 컬럼 선택 (테이블 선택 후 표시) */} + {tableName && columns.length > 0 && ( +
+ +
+ {columns.map((col) => ( + + ))} +
+
+ )} + + {tableName && columns.length === 0 && ( +

+ 컬럼 로딩 중... +

+ )} +
+ ); +} + +// ===== STEP 4: 조인 설정 (UX 개선 - 자동매칭 + 타입필터링) ===== + +// DB 타입을 짧은 약어로 변환 +const shortType = (t: string): string => { + const lower = t.toLowerCase(); + if (lower.includes("character varying") || lower === "varchar") return "varchar"; + if (lower === "text") return "text"; + if (lower.includes("timestamp")) return "timestamp"; + if (lower === "integer" || lower === "int4") return "int"; + if (lower === "bigint" || lower === "int8") return "bigint"; + if (lower === "numeric" || lower === "decimal") return "numeric"; + if (lower === "boolean" || lower === "bool") return "bool"; + if (lower === "date") return "date"; + if (lower === "uuid") return "uuid"; + if (lower === "jsonb" || lower === "json") return "json"; + return t.length > 12 ? t.slice(0, 10) + ".." : t; +}; + +// 조인 항목 하나를 관리하는 서브 컴포넌트 +function JoinItem({ + join, + index, + tables, + mainColumns, + mainTableName, + onUpdate, + onRemove, +}: { + join: CardColumnJoin; + index: number; + tables: TableInfo[]; + mainColumns: ColumnInfo[]; + mainTableName: string; + onUpdate: (partial: Partial) => void; + onRemove: () => void; +}) { + const [targetColumns, setTargetColumns] = useState([]); + const [tableOpen, setTableOpen] = useState(false); + const [loading, setLoading] = useState(false); + + // 대상 테이블 변경 시 컬럼 로딩 + useEffect(() => { + if (!join.targetTable) { + setTargetColumns([]); + return; + } + setLoading(true); + fetchTableColumns(join.targetTable) + .then(setTargetColumns) + .catch(() => setTargetColumns([])) + .finally(() => setLoading(false)); + }, [join.targetTable]); + + // 자동 매칭: 이름 + 타입이 모두 같은 컬럼 쌍 찾기 + const autoMatches = mainColumns.filter((mc) => + targetColumns.some((tc) => tc.name === mc.name && tc.type === mc.type) + ); + + // 현재 연결된 쌍이 자동매칭 항목인지 확인 + const isAutoMatch = + join.sourceColumn !== "" && + join.sourceColumn === join.targetColumn && + autoMatches.some((m) => m.name === join.sourceColumn); + + // 수동 매칭: 소스 컬럼 선택 시 같은 타입의 대상 컬럼만 필터 + const compatibleTargetCols = join.sourceColumn + ? targetColumns.filter((tc) => { + const srcCol = mainColumns.find((mc) => mc.name === join.sourceColumn); + return srcCol ? tc.type === srcCol.type : true; + }) + : targetColumns; + + // 메인 테이블 제외한 테이블 목록 + const selectableTables = tables.filter((t) => t.tableName !== mainTableName); + + // 연결 조건이 설정되었는지 여부 + const hasJoinCondition = join.sourceColumn !== "" && join.targetColumn !== ""; + + // 선택된 대상 컬럼 관리 (연결 조건 컬럼은 제외한 나머지) + const selectedTargetCols = join.selectedTargetColumns || []; + + // 가져올 수 있는 대상 컬럼 (연결 조건으로 사용된 컬럼 제외) + const pickableTargetCols = targetColumns.filter( + (tc) => tc.name !== join.targetColumn + ); + + const toggleTargetCol = (colName: string) => { + const prev = selectedTargetCols; + const next = prev.includes(colName) + ? prev.filter((c) => c !== colName) + : [...prev, colName]; + onUpdate({ selectedTargetColumns: next }); + }; + + return ( +
+ {/* 헤더 */} +
+ 연결 #{index + 1} + +
+ + {/* 대상 테이블 선택 (검색 가능 Combobox) */} +
+ 연결할 테이블 + + + + + + + + + + 테이블을 찾을 수 없습니다 + + + {selectableTables.map((t) => ( + { + onUpdate({ + targetTable: t.tableName, + sourceColumn: "", + targetColumn: "", + selectedTargetColumns: [], + }); + setTableOpen(false); + }} + className="text-[10px]" + > + +
+ {t.tableName} + {(t.displayName || t.description) && ( + + {t.displayName || t.description} + + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 대상 테이블 선택 후 컬럼 매칭 영역 */} + {join.targetTable && ( + <> + {loading ? ( +

컬럼 불러오는 중...

+ ) : ( + <> + {/* 자동 매칭 결과 - 테이블 헤더 + 컬럼명만 표시 */} + {autoMatches.length > 0 && ( +
+ + 연결 조건 선택 + + {/* 테이블명 헤더 */} +
+
+ {mainTableName} + + {join.targetTable} + +
+ {/* 매칭 행 */} +
+ {autoMatches.map((mc) => { + const isSelected = + join.sourceColumn === mc.name && join.targetColumn === mc.name; + return ( + + ); + })} +
+
+ )} + + {autoMatches.length === 0 && ( +

+ 이름이 같은 컬럼이 없습니다. 아래에서 직접 지정하세요. +

+ )} + + {/* 수동 매칭 (고급) */} + {!isAutoMatch && ( +
+ + 직접 지정 + +
+ + + = + + +
+
+ )} + + )} + + {/* 표시 방식 (JOIN 타입) - 자연어 + 설명 */} +
+ 표시 방식 +
+ + +
+
+ + {/* 가져올 컬럼 선택 (연결 조건 설정 후 활성화) */} + {hasJoinCondition && !loading && ( +
+ + 가져올 컬럼 ({selectedTargetCols.length}개 선택) + + {pickableTargetCols.length > 0 ? ( +
+ {pickableTargetCols.map((tc) => { + const isChecked = selectedTargetCols.includes(tc.name); + return ( + + ); + })} +
+ ) : ( +

+ 가져올 수 있는 컬럼이 없습니다 +

+ )} +
+ )} + + )} +
+ ); +} + +function StepJoinConfig({ + dataSource, + tables, + mainColumns, + onChange, +}: { + dataSource: CardListDataSource; + tables: TableInfo[]; + mainColumns: ColumnInfo[]; + onChange: (dataSource: CardListDataSource) => void; +}) { + const joins = dataSource.joins || []; + + const addJoin = () => { + const newJoin: CardColumnJoin = { + targetTable: "", + joinType: "LEFT", + sourceColumn: "", + targetColumn: "", + }; + onChange({ ...dataSource, joins: [...joins, newJoin] }); + }; + + const removeJoin = (index: number) => { + const next = joins.filter((_, i) => i !== index); + onChange({ ...dataSource, joins: next }); + }; + + const updateJoin = (index: number, partial: Partial) => { + const next = joins.map((j, i) => + i === index ? { ...j, ...partial } : j + ); + onChange({ ...dataSource, joins: next }); + }; + + return ( +
+

+ 다른 테이블의 데이터를 연결하여 함께 표시할 수 있습니다 (선택사항) +

+ {joins.map((join, i) => ( + updateJoin(i, partial)} + onRemove={() => removeJoin(i)} + /> + ))} + +
+ ); +} + +// ===== STEP 6-A: 리스트 컬럼 배치 ===== + +// 조인 테이블 컬럼 정보 +interface JoinedColumnInfo { + name: string; // "테이블명.컬럼명" 형태 + displayName: string; // 컬럼명만 + sourceTable: string; // 테이블명 +} + +function StepListLayout({ + listColumns, + availableColumns, + joinedColumns, + onChange, +}: { + listColumns: ListColumnConfig[]; + availableColumns: ColumnInfo[]; + joinedColumns: JoinedColumnInfo[]; + onChange: (listColumns: ListColumnConfig[]) => void; +}) { + const widthBarRef = useRef(null); + const isDraggingRef = useRef(false); + const columnsRef = useRef(listColumns); + columnsRef.current = listColumns; + const [dragIdx, setDragIdx] = useState(null); + const [dragOverIdx, setDragOverIdx] = useState(null); + // 드래그 핸들에서만 draggable 활성화 (Select/Input 충돌 방지) + const [draggableRow, setDraggableRow] = useState(null); + // 컬럼 전환 설정 펼침 인덱스 + const [expandedAltIdx, setExpandedAltIdx] = useState(null); + + // 리스트에 현재 포함된 컬럼명 셋 + const listColumnNames = new Set(listColumns.map((c) => c.columnName)); + + // 추가 가능한 컬럼: (메인 + 조인) 중 현재 리스트에 없는 것 + const addableColumns = [ + ...availableColumns + .filter((c) => !listColumnNames.has(c.name)) + .map((c) => ({ value: c.name, label: c.name, source: "main" as const })), + ...joinedColumns + .filter((c) => !listColumnNames.has(c.name)) + .map((c) => ({ + value: c.name, + label: `${c.displayName} (${c.sourceTable})`, + source: "join" as const, + })), + ]; + + // 컬럼 추가 (독립 헤더로 추가 시 다른 컬럼의 alternateColumns에서 제거) + const addColumn = (columnValue: string) => { + const joinCol = joinedColumns.find((c) => c.name === columnValue); + const newCol: ListColumnConfig = { + columnName: columnValue, + label: joinCol?.displayName || columnValue, + }; + // 다른 컬럼의 alternateColumns에서 이 컬럼 제거 (독립 헤더가 되므로) + const cleaned = listColumns.map((col) => { + const alts = col.alternateColumns; + if (!alts || !alts.includes(columnValue)) return col; + const newAlts = alts.filter((a) => a !== columnValue); + return { ...col, alternateColumns: newAlts.length > 0 ? newAlts : undefined }; + }); + onChange([...cleaned, newCol]); + }; + + // 컬럼 삭제 (리스트에서만 삭제, STEP 3 체크 유지) + const removeColumn = (index: number) => { + const next = listColumns.filter((_, i) => i !== index); + onChange(next); + // 펼침 인덱스 초기화 (삭제로 인덱스가 밀리므로) + setExpandedAltIdx(null); + }; + + // 전환 후보: (메인 + 조인) - 자기 자신 - 리스트에 독립 헤더로 있는 것 + const getAlternateCandidates = (currentColumnName: string) => { + return [ + ...availableColumns + .filter((c) => c.name !== currentColumnName && !listColumnNames.has(c.name)) + .map((c) => ({ value: c.name, label: c.name, source: "main" as const })), + ...joinedColumns + .filter((c) => c.name !== currentColumnName && !listColumnNames.has(c.name)) + .map((c) => ({ + value: c.name, + label: c.displayName, + source: "join" as const, + sourceTable: c.sourceTable, + })), + ]; + }; + + const updateColumn = (index: number, partial: Partial) => { + const next = listColumns.map((col, i) => + i === index ? { ...col, ...partial } : col + ); + onChange(next); + }; + + // 너비 드래그 핸들러 + const handleWidthDrag = useCallback( + (e: React.MouseEvent, dividerIndex: number) => { + e.preventDefault(); + isDraggingRef.current = true; + const startX = e.clientX; + const bar = widthBarRef.current; + if (!bar) return; + const barWidth = bar.offsetWidth; + if (barWidth === 0) return; + const cols = columnsRef.current; + const startFrs = cols.map((c) => { + const num = parseFloat(c.width || "1"); + return isNaN(num) || num <= 0 ? 1 : num; + }); + const totalFr = startFrs.reduce((a, b) => a + b, 0); + + const onMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX; + const frDelta = (delta / barWidth) * totalFr; + const newFrs = [...startFrs]; + newFrs[dividerIndex] = Math.max(0.3, startFrs[dividerIndex] + frDelta); + newFrs[dividerIndex + 1] = Math.max( + 0.3, + startFrs[dividerIndex + 1] - frDelta + ); + const next = columnsRef.current.map((col, i) => ({ + ...col, + width: `${Math.round(newFrs[i] * 10) / 10}fr`, + })); + onChange(next); + }; + const onUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [onChange] + ); + + // 순서 드래그앤드롭 - 핸들에서 mousedown 시에만 draggable 활성화 + const handleDragStart = (e: React.DragEvent, idx: number) => { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(idx)); + setDragIdx(idx); + }; + + const handleDragOver = (e: React.DragEvent, idx: number) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverIdx(idx); + }; + + const handleDrop = (e: React.DragEvent, idx: number) => { + e.preventDefault(); + if (dragIdx === null || dragIdx === idx) { + setDragIdx(null); + setDragOverIdx(null); + setDraggableRow(null); + return; + } + const next = [...listColumns]; + const [moved] = next.splice(dragIdx, 1); + next.splice(idx, 0, moved); + onChange(next); + setDragIdx(null); + setDragOverIdx(null); + setDraggableRow(null); + }; + + const handleDragEnd = () => { + setDragIdx(null); + setDragOverIdx(null); + setDraggableRow(null); + }; + + if (listColumns.length === 0 && addableColumns.length === 0) { + return ( +

+ 컬럼을 먼저 선택하세요 +

+ ); + } + + return ( +
+ {/* 컬럼 너비 드래그 바 */} +
+ {listColumns.map((col, i) => { + const fr = parseFloat(col.width || "1") || 1; + return ( + +
+ {col.label || col.columnName} +
+ {i < listColumns.length - 1 && ( +
handleWidthDrag(e, i)} + title="드래그하여 너비 조정" + /> + )} + + ); + })} +
+ + {/* 컬럼별 설정 (드래그 순서 + 컬럼 선택 + 라벨 + 정렬) */} +
+ {listColumns.map((col, i) => ( + +
handleDragStart(e, i)} + onDragOver={(e) => handleDragOver(e, i)} + onDrop={(e) => handleDrop(e, i)} + onDragEnd={handleDragEnd} + className={cn( + "flex items-center gap-1 rounded px-1 py-0.5 transition-colors", + dragIdx === i && "opacity-40", + dragOverIdx === i && dragIdx !== i && "bg-primary/10 border-t-2 border-primary" + )} + > + {/* 드래그 핸들 - mousedown 시에만 행 draggable 활성화 */} +
setDraggableRow(i)} + onMouseUp={() => setDraggableRow(null)} + > +
+
+
+
+
+
+ + {/* 컬럼 선택 드롭다운 (메인 + 조인 테이블 컬럼) */} + + + {/* 라벨 */} + updateColumn(i, { label: e.target.value })} + placeholder="라벨" + className="h-7 flex-1 text-[10px]" + /> + + {/* 정렬 */} + + + {/* 컬럼 전환 버튼 (전환 후보가 있을 때만) */} + {getAlternateCandidates(col.columnName).length > 0 && ( + + )} + + {/* 컬럼 삭제 버튼 */} + +
+ + {/* 전환 가능 컬럼 (펼침 시만 표시, 메인+조인 중 리스트 미포함분) */} + {expandedAltIdx === i && (() => { + const candidates = getAlternateCandidates(col.columnName); + if (candidates.length === 0) return null; + return ( +
+ 전환: + {candidates.map((cand) => { + const alts = col.alternateColumns || []; + const isAlt = alts.includes(cand.value); + return ( + + ); + })} +
+ ); + })()} + + ))} +
+ + {/* 컬럼 추가 */} + {addableColumns.length > 0 && ( + + )} + +

+ 행을 드래그하여 순서 변경 | 상단 바 경계를 드래그하여 너비 조정 +

+
+ ); +} + +// ===== STEP 6-B: 시각적 카드 그리드 디자이너 ===== + +// fr 문자열을 숫자로 파싱 (예: "2fr" -> 2, "1fr" -> 1) +const parseFr = (v: string): number => { + const num = parseFloat(v); + return isNaN(num) || num <= 0 ? 1 : num; +}; + +// 카드 그리드 반응형 안전 제약 +// - 6열 초과: 모바일(320px)에서 셀 30px 미만 → 텍스트 깨짐 +// - 6행 초과: 카드 1장 높이 과도 → 스크롤 과다 +// - gap 16px 초과: 셀 공간 부족 +// - fr 0.3 미만: 셀 보이지 않음 +const GRID_LIMITS = { + cols: { min: 1, max: 6 }, + rows: { min: 1, max: 6 }, + gap: { min: 0, max: 16 }, + minFr: 0.3, +} as const; + +// 행 높이 기본값 (px 기반 고정 높이) +const DEFAULT_ROW_HEIGHT = 32; +const MIN_ROW_HEIGHT = 24; + +// px 문자열에서 숫자 추출 (예: "32px" → 32) +const parsePx = (v: string): number => { + const num = parseInt(v); + return isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num; +}; + +// fr → px 마이그레이션 (기존 저장 데이터 호환) +const migrateRowHeight = (v: string): string => { + if (!v || v.endsWith("fr")) { + return `${Math.round(parseFr(v) * DEFAULT_ROW_HEIGHT)}px`; + } + if (v.endsWith("px")) return v; + // 단위 없는 숫자인 경우 + const num = parseInt(v); + return `${isNaN(num) || num < MIN_ROW_HEIGHT ? DEFAULT_ROW_HEIGHT : num}px`; +}; + +function StepCardDesigner({ + cardGrid, + columns, + selectedColumns, + onChange, +}: { + cardGrid: CardGridConfig | undefined; + columns: ColumnInfo[]; + selectedColumns: string[]; + onChange: (cardGrid: CardGridConfig) => void; +}) { + // 셀에서 컬럼 선택 시 사용자가 선택한 컬럼만 표시 + const availableColumns = columns.filter((c) => + selectedColumns.includes(c.name) + ); + const [selectedCellId, setSelectedCellId] = useState(null); + const [mergeMode, setMergeMode] = useState(false); + const [mergeCellKeys, setMergeCellKeys] = useState>(new Set()); + const widthBarRef = useRef(null); + const rowBarRef = useRef(null); + const gridRef = useRef(null); + const gridConfigRef = useRef(undefined); + const isDraggingRef = useRef(false); + const [gridLines, setGridLines] = useState<{ + colLines: number[]; + rowLines: number[]; + }>({ colLines: [], rowLines: [] }); + + // 기본 카드 그리드 (rowHeights는 px 기반 고정 높이) + const rawGrid: CardGridConfig = cardGrid || { + rows: 1, + cols: 1, + colWidths: ["1fr"], + rowHeights: [`${DEFAULT_ROW_HEIGHT}px`], + gap: 4, + showBorder: true, + cells: [], + }; + + // 기존 fr 데이터 → px 자동 마이그레이션 + 길이 정규화 + const migratedRowHeights = ( + rawGrid.rowHeights || Array(rawGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`) + ).map(migrateRowHeight); + + // colWidths/rowHeights 배열 길이와 cols/rows 수 불일치 보정 + const safeColWidths = rawGrid.colWidths || []; + const normalizedColWidths = + safeColWidths.length >= rawGrid.cols + ? safeColWidths.slice(0, rawGrid.cols) + : [ + ...safeColWidths, + ...Array(rawGrid.cols - safeColWidths.length).fill("1fr"), + ]; + const normalizedRowHeights = + migratedRowHeights.length >= rawGrid.rows + ? migratedRowHeights.slice(0, rawGrid.rows) + : [ + ...migratedRowHeights, + ...Array(rawGrid.rows - migratedRowHeights.length).fill( + `${DEFAULT_ROW_HEIGHT}px` + ), + ]; + + const grid: CardGridConfig = { + ...rawGrid, + colWidths: normalizedColWidths, + rowHeights: normalizedRowHeights, + }; + + gridConfigRef.current = grid; + + const updateGrid = (partial: Partial) => { + onChange({ ...grid, ...partial }); + }; + + // ---- 점유 맵 ---- + + const buildOccupationMap = (): Record => { + const map: Record = {}; + grid.cells.forEach((cell) => { + const rs = Number(cell.rowSpan) || 1; + const cs = Number(cell.colSpan) || 1; + for (let r = cell.row; r < cell.row + rs; r++) { + for (let c = cell.col; c < cell.col + cs; c++) { + map[`${r}-${c}`] = cell.id; + } + } + }); + return map; + }; + + const occupationMap = buildOccupationMap(); + + const getCellByOrigin = (r: number, c: number) => + grid.cells.find((cell) => cell.row === r && cell.col === c); + + // ---- 셀 CRUD ---- + + const addCellAt = (row: number, col: number) => { + const newCell: CardCellDefinition = { + id: `cell-${Date.now()}`, + row, + col, + rowSpan: 1, + colSpan: 1, + columnName: "", + type: "text", + }; + updateGrid({ cells: [...grid.cells, newCell] }); + setSelectedCellId(newCell.id); + }; + + const removeCell = (id: string) => { + updateGrid({ cells: grid.cells.filter((c) => c.id !== id) }); + if (selectedCellId === id) setSelectedCellId(null); + }; + + const updateCell = (id: string, partial: Partial) => { + updateGrid({ + cells: grid.cells.map((c) => (c.id === id ? { ...c, ...partial } : c)), + }); + }; + + // ---- 병합 모드 ---- + + const toggleMergeMode = () => { + if (mergeMode) { + setMergeMode(false); + setMergeCellKeys(new Set()); + } else { + setMergeMode(true); + setMergeCellKeys(new Set()); + setSelectedCellId(null); + } + }; + + const toggleMergeCell = (row: number, col: number) => { + const key = `${row}-${col}`; + if (occupationMap[key]) return; // 점유된 위치 무시 + const next = new Set(mergeCellKeys); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + setMergeCellKeys(next); + }; + + const validateMergeSelection = (): { + minRow: number; + maxRow: number; + minCol: number; + maxCol: number; + } | null => { + if (mergeCellKeys.size < 2) return null; + const positions = Array.from(mergeCellKeys).map((key) => { + const [r, c] = key.split("-").map(Number); + return { row: r, col: c }; + }); + const minRow = Math.min(...positions.map((p) => p.row)); + const maxRow = Math.max(...positions.map((p) => p.row)); + const minCol = Math.min(...positions.map((p) => p.col)); + const maxCol = Math.max(...positions.map((p) => p.col)); + const expectedCount = (maxRow - minRow + 1) * (maxCol - minCol + 1); + if (mergeCellKeys.size !== expectedCount) return null; + for (const key of mergeCellKeys) { + if (occupationMap[key]) return null; + } + return { minRow, maxRow, minCol, maxCol }; + }; + + const confirmMerge = () => { + const bbox = validateMergeSelection(); + if (!bbox) return; + const newCell: CardCellDefinition = { + id: `cell-${Date.now()}`, + row: bbox.minRow, + col: bbox.minCol, + rowSpan: bbox.maxRow - bbox.minRow + 1, + colSpan: bbox.maxCol - bbox.minCol + 1, + columnName: "", + type: "text", + }; + updateGrid({ cells: [...grid.cells, newCell] }); + setSelectedCellId(newCell.id); + setMergeMode(false); + setMergeCellKeys(new Set()); + }; + + const cancelMerge = () => { + setMergeMode(false); + setMergeCellKeys(new Set()); + }; + + const mergeValid = validateMergeSelection(); + + // ---- 셀 분할 ---- + + // 칸 나누기 (좌/우 분할 = 열 방향) + const splitCellHorizontally = (cell: CardCellDefinition) => { + const cs = Number(cell.colSpan) || 1; + const rs = Number(cell.rowSpan) || 1; + + if (cs >= 2) { + // colSpan 2 이상: 그리드 변경 없이 셀만 분할 + const leftSpan = Math.ceil(cs / 2); + const rightSpan = cs - leftSpan; + const newCell: CardCellDefinition = { + id: `cell-${Date.now()}`, + row: cell.row, + col: cell.col + leftSpan, + rowSpan: rs, + colSpan: rightSpan, + columnName: "", + type: "text", + }; + const updatedCells = grid.cells.map((c) => + c.id === cell.id ? { ...c, colSpan: leftSpan } : c + ); + updateGrid({ cells: [...updatedCells, newCell] }); + setSelectedCellId(newCell.id); + } else { + // colSpan 1: 새 열 삽입하여 분할 + if (grid.cols >= GRID_LIMITS.cols.max) return; + const insertPos = cell.col + 1; + const updatedCells = grid.cells.map((c) => { + if (c.id === cell.id) return c; // 원본 유지 + const cEnd = c.col + (Number(c.colSpan) || 1) - 1; + if (c.col >= insertPos) return { ...c, col: c.col + 1 }; + if (cEnd >= insertPos) + return { ...c, colSpan: (Number(c.colSpan) || 1) + 1 }; + return c; + }); + const newCell: CardCellDefinition = { + id: `cell-${Date.now()}`, + row: cell.row, + col: insertPos, + rowSpan: rs, + colSpan: 1, + columnName: "", + type: "text", + }; + // 열 너비: 기존 열을 반으로 분할 + const colIdx = cell.col - 1; + if (colIdx < 0 || colIdx >= grid.colWidths.length) return; // 범위 초과 방어 + const currentFr = parseFr(grid.colWidths[colIdx]); + const halfFr = Math.max(GRID_LIMITS.minFr, currentFr / 2); + const frStr = `${Math.round(halfFr * 10) / 10}fr`; + const newWidths = [...grid.colWidths]; + newWidths[colIdx] = frStr; + newWidths.splice(colIdx + 1, 0, frStr); + updateGrid({ + cols: grid.cols + 1, + colWidths: newWidths, + cells: [...updatedCells, newCell], + }); + setSelectedCellId(newCell.id); + } + }; + + // 줄 나누기 (위/아래 분할 = 행 방향) + const splitCellVertically = (cell: CardCellDefinition) => { + const rs = Number(cell.rowSpan) || 1; + const cs = Number(cell.colSpan) || 1; + const heights = grid.rowHeights || Array(grid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`); + + if (rs >= 2) { + // rowSpan 2 이상: 그리드 변경 없이 셀만 분할 + const topSpan = Math.ceil(rs / 2); + const bottomSpan = rs - topSpan; + const newCell: CardCellDefinition = { + id: `cell-${Date.now()}`, + row: cell.row + topSpan, + col: cell.col, + rowSpan: bottomSpan, + colSpan: cs, + columnName: "", + type: "text", + }; + const updatedCells = grid.cells.map((c) => + c.id === cell.id ? { ...c, rowSpan: topSpan } : c + ); + updateGrid({ cells: [...updatedCells, newCell] }); + setSelectedCellId(newCell.id); + } else { + // rowSpan 1: 새 행 삽입하여 분할 (기존 행 높이 유지, 새 행은 기본 높이) + if (grid.rows >= GRID_LIMITS.rows.max) return; + const insertPos = cell.row + 1; + const updatedCells = grid.cells.map((c) => { + if (c.id === cell.id) return c; + const cEnd = c.row + (Number(c.rowSpan) || 1) - 1; + if (c.row >= insertPos) return { ...c, row: c.row + 1 }; + if (cEnd >= insertPos) + return { ...c, rowSpan: (Number(c.rowSpan) || 1) + 1 }; + return c; + }); + const newCell: CardCellDefinition = { + id: `cell-${Date.now()}`, + row: insertPos, + col: cell.col, + rowSpan: 1, + colSpan: cs, + columnName: "", + type: "text", + }; + // 기존 행 높이 유지, 새 행은 기본 px 높이로 삽입 + const rowIdx = cell.row - 1; + const newHeights = [...heights]; + newHeights.splice(rowIdx + 1, 0, `${DEFAULT_ROW_HEIGHT}px`); + updateGrid({ + rows: grid.rows + 1, + rowHeights: newHeights, + cells: [...updatedCells, newCell], + }); + setSelectedCellId(newCell.id); + } + }; + + // ---- 클릭 핸들러 ---- + + const handleEmptyCellClick = (row: number, col: number) => { + if (mergeMode) { + toggleMergeCell(row, col); + } else { + addCellAt(row, col); + } + }; + + const handleCellClick = (cell: CardCellDefinition) => { + if (mergeMode) return; // 병합 모드에서 기존 셀 클릭 무시 + setSelectedCellId(selectedCellId === cell.id ? null : cell.id); + }; + + // ---- 열 너비 드래그 (상단 바 - 일괄) ---- + + const handleColDragStart = useCallback( + (e: React.MouseEvent, dividerIndex: number) => { + e.preventDefault(); + isDraggingRef.current = true; + const startX = e.clientX; + const bar = widthBarRef.current; + if (!bar) return; + const barWidth = bar.offsetWidth; + if (barWidth === 0) return; // 0으로 나누기 방어 + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + const startFrs = (currentGrid.colWidths || []).map(parseFr); + const totalFr = startFrs.reduce((a, b) => a + b, 0); + + const onMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX; + const frDelta = (delta / barWidth) * totalFr; + const newFrs = [...startFrs]; + newFrs[dividerIndex] = Math.max( + GRID_LIMITS.minFr, + startFrs[dividerIndex] + frDelta + ); + newFrs[dividerIndex + 1] = Math.max( + GRID_LIMITS.minFr, + startFrs[dividerIndex + 1] - frDelta + ); + const newWidths = newFrs.map( + (fr) => `${Math.round(fr * 10) / 10}fr` + ); + onChange({ ...currentGrid, colWidths: newWidths }); + }; + const onUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [onChange] + ); + + // ---- 행 높이 드래그 (좌측 바 - 일괄) ---- + + const handleRowDragStart = useCallback( + (e: React.MouseEvent, dividerIndex: number) => { + e.preventDefault(); + isDraggingRef.current = true; + const startY = e.clientY; + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + // px 기반: 픽셀 델타를 직접 적용 (fr 변환 불필요 → 안정적) + const heights = ( + currentGrid.rowHeights || + Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`) + ).map(parsePx); + if (dividerIndex < 0 || dividerIndex + 1 >= heights.length) return; + + const onMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientY - startY; + const newHeights = [...heights]; + newHeights[dividerIndex] = Math.max( + MIN_ROW_HEIGHT, + heights[dividerIndex] + delta + ); + newHeights[dividerIndex + 1] = Math.max( + MIN_ROW_HEIGHT, + heights[dividerIndex + 1] - delta + ); + const newRowHeights = newHeights.map((h) => `${Math.round(h)}px`); + onChange({ ...currentGrid, rowHeights: newRowHeights }); + }; + const onUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [onChange] + ); + + // ---- 내부 셀 경계 드래그 (개별) ---- + + // 그리드 라인 위치 측정 (ResizeObserver) + useEffect(() => { + const gridEl = gridRef.current; + if (!gridEl) return; + + const measure = () => { + if (isDraggingRef.current) return; + const style = window.getComputedStyle(gridEl); + const colSizes = style.gridTemplateColumns + .split(" ") + .map(parseFloat) + .filter((v) => !isNaN(v)); + const rowSizes = style.gridTemplateRows + .split(" ") + .map(parseFloat) + .filter((v) => !isNaN(v)); + const gapSize = + parseFloat(style.gap) || parseFloat(style.columnGap) || 0; + + const colLines: number[] = []; + let x = 0; + for (let i = 0; i < colSizes.length - 1; i++) { + x += colSizes[i] + gapSize; + colLines.push(x - gapSize / 2); + } + + const rowLines: number[] = []; + let y = 0; + for (let i = 0; i < rowSizes.length - 1; i++) { + y += rowSizes[i] + gapSize; + rowLines.push(y - gapSize / 2); + } + + setGridLines({ colLines, rowLines }); + }; + + const observer = new ResizeObserver(measure); + observer.observe(gridEl); + measure(); + + return () => observer.disconnect(); + // 배열 참조가 매 렌더 변경되므로, join으로 안정적인 값 비교 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [grid.colWidths.join(","), grid.rowHeights?.join(","), grid.gap, grid.cols, grid.rows]); + + // 경계선 가시성 (병합 셀 내부는 숨김) + const isColLineVisible = (lineIdx: number): boolean => { + const leftCol = lineIdx + 1; + const rightCol = lineIdx + 2; + for (let r = 1; r <= grid.rows; r++) { + const left = occupationMap[`${r}-${leftCol}`]; + const right = occupationMap[`${r}-${rightCol}`]; + if (left !== right) return true; + if (!left && !right) return true; + } + return false; + }; + + const isRowLineVisible = (lineIdx: number): boolean => { + const topRow = lineIdx + 1; + const bottomRow = lineIdx + 2; + for (let c = 1; c <= grid.cols; c++) { + const top = occupationMap[`${topRow}-${c}`]; + const bottom = occupationMap[`${bottomRow}-${c}`]; + if (top !== bottom) return true; + if (!top && !bottom) return true; + } + return false; + }; + + // 내부 열 경계 드래그 + const handleInternalColDrag = useCallback( + (e: React.MouseEvent, lineIdx: number) => { + e.preventDefault(); + e.stopPropagation(); + isDraggingRef.current = true; + const startX = e.clientX; + const gridEl = gridRef.current; + if (!gridEl) return; + const gridWidth = gridEl.offsetWidth; + if (gridWidth === 0) return; // 0으로 나누기 방어 + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + const startFrs = (currentGrid.colWidths || []).map(parseFr); + const totalFr = startFrs.reduce((a, b) => a + b, 0); + + const onMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientX - startX; + const frDelta = (delta / gridWidth) * totalFr; + const newFrs = [...startFrs]; + newFrs[lineIdx] = Math.max( + GRID_LIMITS.minFr, + startFrs[lineIdx] + frDelta + ); + newFrs[lineIdx + 1] = Math.max( + GRID_LIMITS.minFr, + startFrs[lineIdx + 1] - frDelta + ); + const newWidths = newFrs.map( + (fr) => `${Math.round(fr * 10) / 10}fr` + ); + onChange({ ...currentGrid, colWidths: newWidths }); + }; + const onUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [onChange] + ); + + // 내부 행 경계 드래그 (px 기반 직접 조정) + const handleInternalRowDrag = useCallback( + (e: React.MouseEvent, lineIdx: number) => { + e.preventDefault(); + e.stopPropagation(); + isDraggingRef.current = true; + const startY = e.clientY; + const currentGrid = gridConfigRef.current; + if (!currentGrid) return; + // px 기반: 픽셀 델타를 직접 적용 + const heights = ( + currentGrid.rowHeights || + Array(currentGrid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`) + ).map(parsePx); + if (lineIdx < 0 || lineIdx + 1 >= heights.length) return; + + const onMove = (moveEvent: MouseEvent) => { + const delta = moveEvent.clientY - startY; + const newHeights = [...heights]; + newHeights[lineIdx] = Math.max( + MIN_ROW_HEIGHT, + heights[lineIdx] + delta + ); + newHeights[lineIdx + 1] = Math.max( + MIN_ROW_HEIGHT, + heights[lineIdx + 1] - delta + ); + const newRowHeights = newHeights.map((h) => `${Math.round(h)}px`); + onChange({ ...currentGrid, rowHeights: newRowHeights }); + }; + const onUp = () => { + isDraggingRef.current = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }, + [onChange] + ); + + // ---- 선택된 셀 ---- + + const selectedCell = selectedCellId + ? grid.cells.find((c) => c.id === selectedCellId) + : null; + + useEffect(() => { + if (selectedCellId && !grid.cells.find((c) => c.id === selectedCellId)) { + setSelectedCellId(null); + } + }, [grid.cells, selectedCellId]); + + // ---- 그리드 위치 ---- + + const gridPositions: { row: number; col: number }[] = []; + for (let r = 1; r <= grid.rows; r++) { + for (let c = 1; c <= grid.cols; c++) { + gridPositions.push({ row: r, col: c }); + } + } + + const rowHeightsArr = grid.rowHeights || Array(grid.rows).fill(`${DEFAULT_ROW_HEIGHT}px`); + + // ---- 바 그룹핑 (병합 셀 내부 경계는 하나로 묶음) ---- + + type BarGroup = { startIdx: number; count: number; totalFr: number }; + + const colGroups: BarGroup[] = (() => { + const groups: BarGroup[] = []; + if (grid.colWidths.length === 0) return groups; // 빈 배열 방어 + let cur: BarGroup = { + startIdx: 0, + count: 1, + totalFr: parseFr(grid.colWidths[0]), + }; + for (let i = 0; i < grid.cols - 1; i++) { + if (isColLineVisible(i)) { + groups.push(cur); + cur = { + startIdx: i + 1, + count: 1, + totalFr: parseFr(grid.colWidths[i + 1]), + }; + } else { + cur.count++; + cur.totalFr += parseFr(grid.colWidths[i + 1]); + } + } + groups.push(cur); + return groups; + })(); + + const rowGroups: BarGroup[] = (() => { + const groups: BarGroup[] = []; + if (rowHeightsArr.length === 0) return groups; // 빈 배열 방어 + // totalFr 필드를 px 값의 합산으로 사용 (flex 비율로 활용) + let cur: BarGroup = { + startIdx: 0, + count: 1, + totalFr: parsePx(rowHeightsArr[0]), + }; + for (let i = 0; i < grid.rows - 1; i++) { + if (isRowLineVisible(i)) { + groups.push(cur); + cur = { + startIdx: i + 1, + count: 1, + totalFr: parsePx(rowHeightsArr[i + 1]), + }; + } else { + cur.count++; + cur.totalFr += parsePx(rowHeightsArr[i + 1]); + } + } + groups.push(cur); + return groups; + })(); + + return ( +
+ {/* 인라인 툴바: 보더 + 간격 + 병합 + 나누기 */} +
+
+ +
+ 간격 + + {grid.gap}px + +
+
+ +
+ + +
+ + {/* 병합 모드 안내 */} + {mergeMode && ( +
+ + {mergeCellKeys.size > 0 + ? `${mergeCellKeys.size}칸 선택됨${mergeValid ? " (병합 가능)" : " (직사각형으로 선택)"}` + : "빈 셀을 클릭하여 선택"} + + + +
+ )} + + {/* 열 너비 드래그 바 (일괄 조정, 병합 트랙 묶음) */} +
+
+
+ {colGroups.map((group, gi) => ( + +
+ {group.count > 1 + ? `${Math.round(group.totalFr * 10) / 10}fr` + : grid.colWidths[group.startIdx]} +
+ {gi < colGroups.length - 1 && ( +
+ handleColDragStart(e, group.startIdx + group.count - 1) + } + title="드래그하여 열 너비 일괄 조정" + /> + )} + + ))} +
+
+ + {/* 메인: 행 높이 바 (왼쪽) + 그리드 (오른쪽) */} +
+ {/* 행 높이 드래그 바 (일괄 조정, 병합 트랙 묶음) */} +
+ {rowGroups.map((group, gi) => ( + +
1 + ? `${Math.round(group.totalFr)}px` + : rowHeightsArr[group.startIdx] + } + > + {Math.round(group.totalFr)} +
+ {gi < rowGroups.length - 1 && ( +
+ handleRowDragStart(e, group.startIdx + group.count - 1) + } + title="드래그하여 행 높이 일괄 조정" + /> + )} + + ))} +
+ + {/* 인터랙티브 그리드 + 내부 드래그 오버레이 */} +
+
0 + ? grid.colWidths + .map((w) => `minmax(30px, ${w})`) + .join(" ") + : "1fr", + gridTemplateRows: rowHeightsArr.join(" "), + gap: `${Number(grid.gap) || 0}px`, + }} + > + {gridPositions.map(({ row, col }) => { + const cellAtOrigin = getCellByOrigin(row, col); + const occupiedBy = occupationMap[`${row}-${col}`]; + const isMergeSelected = mergeCellKeys.has(`${row}-${col}`); + + // span으로 점유된 위치 + if (occupiedBy && !cellAtOrigin) return null; + + // 셀 원점 + if (cellAtOrigin) { + const isSelected = selectedCellId === cellAtOrigin.id; + return ( +
handleCellClick(cellAtOrigin)} + > +
+ + {cellAtOrigin.columnName || "미지정"} + + + {cellAtOrigin.type} + +
+
+ ); + } + + // 빈 위치 + return ( +
handleEmptyCellClick(row, col)} + title={ + mergeMode ? "클릭하여 병합 선택" : "클릭하여 셀 추가" + } + > + {isMergeSelected ? ( + + ) : ( + + )} +
+ ); + })} +
+ + {/* 내부 경계 드래그 오버레이 (개별 조정) */} +
+ {gridLines.colLines.map((x, i) => { + if (!isColLineVisible(i)) return null; + return ( +
handleInternalColDrag(e, i)} + title="드래그하여 열 너비 개별 조정" + /> + ); + })} + {gridLines.rowLines.map((y, i) => { + if (!isRowLineVisible(i)) return null; + return ( +
handleInternalRowDrag(e, i)} + title="드래그하여 행 높이 개별 조정" + /> + ); + })} +
+
+
+ + {/* 선택된 셀 설정 패널 */} + {selectedCell && !mergeMode && ( +
+
+ + 셀 (행{selectedCell.row} 열{selectedCell.col} + {((Number(selectedCell.colSpan) || 1) > 1 || + (Number(selectedCell.rowSpan) || 1) > 1) && + `, ${Number(selectedCell.colSpan) || 1}x${Number(selectedCell.rowSpan) || 1}`} + ) + + +
+ + {/* 컬럼 + 타입 */} +
+ + +
+ + {/* 라벨 + 라벨 위치 */} +
+ + updateCell(selectedCell.id, { label: e.target.value }) + } + placeholder="라벨 (선택)" + className="h-7 flex-1 text-[10px]" + /> + +
+ + {/* 글자 크기 + 가로 정렬 + 세로 정렬 */} +
+ + + +
+ +
+ )} + + {/* 반응형 안내 */} +

+ {grid.cols}열 x {grid.rows}행 (최대 {GRID_LIMITS.cols.max}x + {GRID_LIMITS.rows.max}) | 상단/좌측: 일괄 | 셀 경계: 개별 조정 +

+
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-string-list/PopStringListPreview.tsx b/frontend/lib/registry/pop-components/pop-string-list/PopStringListPreview.tsx new file mode 100644 index 00000000..26e7cc67 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-string-list/PopStringListPreview.tsx @@ -0,0 +1,176 @@ +"use client"; + +/** + * pop-string-list 디자이너 미리보기 + * + * 디자인 모드에서 캔버스에 표시되는 간략한 미리보기. + * 실제 데이터는 가져오지 않고 더미 데이터로 레이아웃만 시각화. + */ + +import type { PopStringListConfig } from "./types"; + +interface PopStringListPreviewProps { + config?: PopStringListConfig; +} + +export function PopStringListPreviewComponent({ + config, +}: PopStringListPreviewProps) { + const displayMode = config?.displayMode || "list"; + const header = config?.header; + const tableName = config?.dataSource?.tableName; + const listColumns = config?.listColumns || []; + const cardGrid = config?.cardGrid; + + // 테이블 미선택 + if (!tableName) { + return ( +
+ + 테이블을 선택하세요 + +
+ ); + } + + return ( +
+ {/* 헤더 */} + {header?.enabled && ( +
+ + {header.label || "리스트 목록"} + +
+ )} + + {/* 모드별 미리보기 */} +
+ {displayMode === "list" ? ( + + ) : ( + + )} +
+ + {/* 모드 라벨 */} +
+ + {displayMode === "list" ? "리스트" : "카드"} | {tableName} + +
+
+ ); +} + +// ===== 리스트 미리보기 ===== + +function ListPreview({ + columns, +}: { + columns: PopStringListConfig["listColumns"]; +}) { + const cols = columns || []; + + if (cols.length === 0) { + return ( +
+ 컬럼 미설정 +
+ ); + } + + const gridCols = cols.map((c) => c.width || "1fr").join(" "); + const dummyRows = 3; + + return ( +
+ {/* 헤더 */} +
+ {cols.map((col) => ( +
+ {col.label} +
+ ))} +
+ {/* 더미 행 */} + {Array.from({ length: dummyRows }).map((_, i) => ( +
+ {cols.map((col) => ( +
+
+
+ ))} +
+ ))} +
+ ); +} + +// ===== 카드 미리보기 ===== + +function CardPreview({ + cardGrid, +}: { + cardGrid: PopStringListConfig["cardGrid"]; +}) { + if (!cardGrid || cardGrid.cells.length === 0) { + return ( +
+ + 카드 레이아웃 미설정 + +
+ ); + } + + // 더미 카드 2장 + return ( +
+ {[0, 1].map((i) => ( +
+ {cardGrid.cells.map((cell) => ( +
+
+
+ ))} +
+ ))} +
+ ); +} diff --git a/frontend/lib/registry/pop-components/pop-string-list/index.tsx b/frontend/lib/registry/pop-components/pop-string-list/index.tsx new file mode 100644 index 00000000..4bf6c638 --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-string-list/index.tsx @@ -0,0 +1,46 @@ +"use client"; + +/** + * pop-string-list 컴포넌트 레지스트리 등록 진입점 + * + * 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨 + */ + +import { PopComponentRegistry } from "../../PopComponentRegistry"; +import { PopStringListComponent } from "./PopStringListComponent"; +import { PopStringListConfigPanel } from "./PopStringListConfig"; +import { PopStringListPreviewComponent } from "./PopStringListPreview"; +import type { PopStringListConfig } from "./types"; + +// 기본 설정값 +const defaultConfig: PopStringListConfig = { + displayMode: "list", + header: { enabled: true, label: "" }, + overflow: { visibleRows: 5, mode: "loadMore", showExpandButton: true, loadMoreCount: 5, maxExpandRows: 50, pageSize: 5, paginationStyle: "bottom" }, + dataSource: { tableName: "" }, + listColumns: [], + cardGrid: undefined, +}; + +// 레지스트리 등록 +PopComponentRegistry.registerComponent({ + id: "pop-string-list", + name: "리스트 목록", + description: "테이블 데이터를 리스트 또는 카드 형태로 표시", + category: "display", + icon: "List", + component: PopStringListComponent, + configPanel: PopStringListConfigPanel, + preview: PopStringListPreviewComponent, + defaultProps: defaultConfig, + connectionMeta: { + sendable: [ + { key: "selected_row", label: "선택된 행", type: "selected_row", description: "사용자가 선택한 행 데이터를 전달" }, + ], + receivable: [ + { key: "filter_condition", label: "필터 조건", type: "filter_value", description: "외부 컴포넌트에서 받은 필터 조건으로 목록 필터링" }, + ], + }, + touchOptimized: true, + supportedDevices: ["mobile", "tablet"], +}); diff --git a/frontend/lib/registry/pop-components/pop-string-list/types.ts b/frontend/lib/registry/pop-components/pop-string-list/types.ts new file mode 100644 index 00000000..f28a221a --- /dev/null +++ b/frontend/lib/registry/pop-components/pop-string-list/types.ts @@ -0,0 +1,88 @@ +// ===== pop-string-list 전용 타입 ===== +// pop-card-list와 완전 독립. 공유하는 것은 CardListDataSource 타입만 import. + +import type { CardListDataSource } from "../types"; +import type { ButtonMainAction, ButtonVariant, ConfirmConfig } from "../pop-button"; + +/** 표시 모드 */ +export type StringListDisplayMode = "list" | "card"; + +/** 행 클릭 동작 */ +export type RowClickAction = "none" | "publish" | "select-and-close-modal"; + +/** 카드 내부 셀 1개 정의 */ +export interface CardCellDefinition { + id: string; + row: number; // 1부터 + col: number; // 1부터 + rowSpan: number; // 행 병합 (기본 1) + colSpan: number; // 열 병합 (기본 1) + columnName: string; // 바인딩할 DB 컬럼명 + label?: string; // 셀 위에 표시할 라벨 (선택) + labelPosition?: "top" | "left"; // 라벨 위치 (기본 top) + type: "text" | "image" | "badge" | "button"; // 셀 렌더링 타입 + fontSize?: "sm" | "md" | "lg"; // 글자 크기: 작게(10px) / 보통(12px) / 크게(14px) + align?: "left" | "center" | "right"; // 가로 정렬 (기본 left) + verticalAlign?: "top" | "middle" | "bottom"; // 세로 정렬 (기본 top) + // button 타입 전용 (pop-button 로직 재사용) + buttonAction?: ButtonMainAction; // 클릭 시 실행할 액션 + buttonVariant?: ButtonVariant; // 버튼 스타일 + buttonConfirm?: ConfirmConfig; // 확인 다이얼로그 설정 +} + +/** 카드 그리드 레이아웃 설정 */ +export interface CardGridConfig { + rows: number; // 행 수 + cols: number; // 열 수 + colWidths: string[]; // fr 단위 배열 (예: ["2fr", "1fr", "1fr"]) + rowHeights?: string[]; // px 단위 배열 (예: ["32px", "48px"], 기본 32px 균등) + gap: number; // 셀 간격 (px, 2~8 권장) + showBorder: boolean; // 셀 보더 표시 + cells: CardCellDefinition[]; // 셀 목록 +} + +/** 리스트 모드 컬럼 1개 설정 */ +export interface ListColumnConfig { + columnName: string; // DB 컬럼명 + label: string; // 헤더 라벨 + width?: string; // fr 단위 (기본 "1fr") + align?: "left" | "center" | "right"; // 정렬 + alternateColumns?: string[]; // 런타임에서 전환 가능한 대체 컬럼 목록 +} + +/** 오버플로우 방식 */ +export type OverflowMode = "loadMore" | "pagination"; + +/** 페이지네이션 네비게이션 스타일 */ +export type PaginationStyle = "bottom" | "side"; + +/** 오버플로우 설정 */ +export interface OverflowConfig { + visibleRows: number; // 기본 표시 행 수 + mode: OverflowMode; // 오버플로우 방식 + // 더보기 모드 전용 + showExpandButton: boolean; // "더보기" 버튼 표시 + loadMoreCount: number; // 더보기 클릭 시 추가 로딩 행 수 + maxExpandRows: number; // 최대 표시 행 수 (무한 확장 방지) + // 페이지네이션 모드 전용 + pageSize: number; // 페이지당 표시 행 수 + paginationStyle: PaginationStyle; // bottom: 하단 페이지 표시, side: 좌우 화살표 +} + +/** 헤더 설정 */ +export interface StringListHeaderConfig { + enabled: boolean; // 헤더 표시 여부 + label?: string; // 헤더 라벨 텍스트 +} + +/** pop-string-list 전체 설정 */ +export interface PopStringListConfig { + displayMode: StringListDisplayMode; + header: StringListHeaderConfig; + overflow: OverflowConfig; + dataSource: CardListDataSource; // 기존 타입 재활용 + selectedColumns?: string[]; // 사용자가 선택한 컬럼명 목록 (모드 무관 영속) + listColumns?: ListColumnConfig[]; // 리스트 모드 전용 + cardGrid?: CardGridConfig; // 카드 모드 전용 + rowClickAction?: RowClickAction; // 행 클릭 시 동작 (기본: "none") +} diff --git a/frontend/lib/registry/pop-components/pop-text.tsx b/frontend/lib/registry/pop-components/pop-text.tsx index 8cad19ad..969a8a13 100644 --- a/frontend/lib/registry/pop-components/pop-text.tsx +++ b/frontend/lib/registry/pop-components/pop-text.tsx @@ -265,15 +265,18 @@ function DateTimePreview({ config }: { config?: PopTextConfig }) { ); } -// 시간/날짜 (실시간 지원) +// 시간/날짜 (항상 실시간) function DateTimeDisplay({ config }: { config?: PopTextConfig }) { const [now, setNow] = useState(new Date()); + // isRealtime 기본값: true (설정 패널 UI와 일치) + const isRealtime = config?.isRealtime ?? true; + useEffect(() => { - if (!config?.isRealtime) return; + if (!isRealtime) return; const timer = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(timer); - }, [config?.isRealtime]); + }, [isRealtime]); // 빌더 설정 또는 기존 dateFormat 사용 (하위 호환) const dateFormat = config?.dateTimeConfig @@ -391,6 +394,19 @@ interface PopTextConfigPanelProps { onUpdate: (config: PopTextConfig) => void; } +// 섹션 구분선 컴포넌트 +function SectionDivider({ label }: { label: string }) { + return ( +
+
+
+ {label} +
+
+
+ ); +} + export function PopTextConfigPanel({ config, onUpdate, @@ -398,10 +414,10 @@ export function PopTextConfigPanel({ const textType = config?.textType || "text"; return ( -
+
{/* 텍스트 타입 선택 */} +
-