diff --git a/PLAN.MD b/PLAN.MD index e4f4e424..45468fa4 100644 --- a/PLAN.MD +++ b/PLAN.MD @@ -1,135 +1,527 @@ -# 현재 구현 계획: POP 뷰어 스크롤 수정 +# 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성 -> **작성일**: 2026-02-09 -> **상태**: 계획 완료, 코딩 대기 -> **목적**: 뷰어에서 화면 높이를 초과하는 컴포넌트가 잘리지 않고 스크롤 가능하도록 수정 +> **작성일**: 2026-02-10 +> **상태**: 코딩 완료 (방어 로직 패치 포함) +> **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성 --- ## 1. 문제 요약 -설계(디자이너)에서 컴포넌트를 아래로 배치하면 캔버스가 늘어나고 스크롤이 되지만, -뷰어(`/pop/screens/4114`)에서는 화면 높이를 초과하는 컴포넌트가 잘려서 안 보임. +pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가. -**근본 원인**: CSS 컨테이너 구조가 스크롤을 차단 - -| # | 컨테이너 (라인) | 현재 클래스 | 문제 | -|---|----------------|-------------|------| -| 1 | 최외곽 (185) | `h-screen ... overflow-hidden` | 넘치는 콘텐츠를 잘라냄 | -| 2 | 컨텐츠 영역 (266) | 일반 모드에 `overflow-auto` 없음 | 스크롤 불가 | -| 3 | 백색 배경 (275) | 일반 모드에 `min-h-full` 없음 | 짧은 콘텐츠 시 배경 불완전 | +| # | 문제 | 심각도 | 영향 | +|---|------|--------|------| +| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 | +| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 | +| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 | +| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 | --- -## 2. 수정 대상 파일 (1개) +## 2. 수정 대상 파일 (2개) -### `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` +### 파일 A: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx` -**변경 유형**: CSS 클래스 문자열 수정 3곳 (새 변수/함수/타입 추가 없음) +**변경 유형**: 설정 UI 추가 3건 -#### 변경 1: 라인 185 - 최외곽 컨테이너 +#### 변경 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 ( + + ); +} ``` -**변경 내용**: `overflow-hidden` 제거 -**이유**: 이 div는 프리뷰 툴바 + 컨텐츠의 flex 컨테이너 역할만 하면 됨. `overflow-hidden`이 자식의 스크롤까지 차단하므로 제거 +#### 변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297) -#### 변경 2: 라인 266 - 컨텐츠 영역 - -**현재 코드**: -``` -
+**현재 코드** (버그): +```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 ( + + ); +} ``` -**변경 내용**: `overflow-auto`를 조건문 밖으로 이동 (공통 적용) -**이유**: 프리뷰/일반 모드 모두 스크롤이 필요함 - -#### 변경 3: 라인 275 - 백색 배경 컨테이너 - -**현재 코드**: -``` -className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full"}`} -``` - -**변경 코드**: -``` -className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`} -``` - -**변경 내용**: 일반 모드에 `min-h-full` 추가 -**이유**: 컴포넌트가 적어 콘텐츠가 짧을 때에도 흰색 배경이 화면 전체를 채우도록 보장 +**주의**: 이 방식은 rows에 전체 데이터가 있어야 작동함. 통계 카드의 dataSource에 집계 함수를 설정하지 않고(또는 groupBy를 사용하여) 원시 rows를 가져와야 한다. --- ## 3. 구현 순서 (의존성 기반) -| 순서 | 작업 | 라인 | 의존성 | 상태 | +| 순서 | 작업 | 파일 | 의존성 | 상태 | |------|------|------|--------|------| -| 1 | 라인 185: `overflow-hidden` 제거 | 185 | 없음 | [x] 완료 | -| 2 | 라인 266: `overflow-auto` 공통 적용 | 266 | 순서 1 | [x] 완료 | -| 3 | 라인 275: 일반 모드 `min-h-full` 추가 | 275 | 순서 2 | [x] 완료 | -| 4 | 린트 검사 | - | 순서 1~3 | [x] 통과 | -| 5 | 브라우저 검증 | - | 순서 4 | [ ] 대기 | +| 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. 사전 충돌 검사 결과 -**새로 추가할 변수/함수/타입: 없음** +### 새로 추가할 식별자 목록 -이번 수정은 기존 Tailwind CSS 클래스 문자열만 변경합니다. -새로운 식별자(변수, 함수, 타입)를 추가하지 않으므로 충돌 검사 대상이 없습니다. +| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 | +|--------|------|-----------|-----------|-----------| +| `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: 순서 1만 하고 순서 2를 빼먹으면 -`overflow-hidden`만 제거하면 콘텐츠가 화면 밖으로 넘쳐 보이지만 스크롤은 안 됨. -부모는 열었지만 자식에 스크롤 속성이 없는 상태. +### 함정 1: 차트에 groupBy만 설정하고 xAxisColumn을 비우면 +ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `{status: "수주", value: 79}` 형태. +`name` 키가 없으므로 X축이 빈 채로 렌더링됨. +**B-1의 자동 보정 로직이 필수**. 순서 4를 빠뜨리면 차트가 깨짐. -### 함정 2: 순서 2만 하고 순서 1을 빼먹으면 -자식에 `overflow-auto`를 넣어도 부모가 `overflow-hidden`으로 잘라내므로 여전히 스크롤 안 됨. -**반드시 순서 1과 2를 함께 적용해야 함.** +### 함정 2: 통계 카드에 집계 함수를 설정하면 +집계(COUNT 등)가 설정되면 rows에 `[{value: 87}]` 하나만 들어옴. +카테고리별 필터링이 작동하려면 **집계 함수를 "없음"으로 두거나, groupBy를 설정**해야 개별 행이 rows에 포함됨. +통계 카드에서는 **집계를 사용하지 않는 것이 올바른 사용법**. +설정 가이드 문서에 이 점을 명시해야 함. -### 함정 3: 프리뷰 모드 영향 -프리뷰 모드는 이미 자체적으로 `overflow-auto`가 있으므로 이 수정에 영향 없음. -`overflow-auto`가 중복 적용되어도 CSS에서 문제 없음. +### 함정 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. 검증 방법 -1. `localhost:9771/pop/screens/4114` 접속 (iPhone SE 375px 기준) -2. 화면 아래로 스크롤 가능한지 확인 -3. 맨 아래에 이미지(pop-text 5, 6)가 보이는지 확인 -4. 프리뷰 모드(`?preview=true`)에서도 기존처럼 정상 동작하는지 확인 -5. 컴포넌트가 적은 화면에서 흰색 배경이 화면 전체를 채우는지 확인 +### 차트 (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] renderActualComponent() 실제 컴포넌트 렌더링으로 교체 - [x] 린트 검사 통과 -- 브라우저 검증: 컴포넌트 표시 정상, 스크롤 문제 발견 -> 별도 수정
diff --git a/STATUS.md b/STATUS.md new file mode 100644 index 00000000..5e83ff11 --- /dev/null +++ b/STATUS.md @@ -0,0 +1,48 @@ +# 프로젝트 상태 추적 + +> **최종 업데이트**: 2026-02-10 + +--- + +## 현재 진행 중 + +### pop-dashboard 4가지 아이템 모드 완성 +**상태**: 코딩 완료, 브라우저 테스트 대기 +**계획서**: [PLAN.MD](./PLAN.MD) + +--- + +## 다음 작업 + +| 순서 | 작업 | 파일 | 상태 | +|------|------|------|------| +| 7 | 브라우저 테스트 (차트 groupBy / 통계카드 카테고리) | - | [ ] 대기 | + +--- + +## 완료된 작업 (최근) + +| 날짜 | 작업 | 비고 | +|------|------|------| +| 2026-02-10 | A-1: groupBy 설정 UI 추가 | DataSourceEditor에 Combobox 방식 그룹핑 컬럼 선택 UI | +| 2026-02-10 | A-2: 차트 xAxisColumn/yAxisColumn 입력 UI | 차트 설정 섹션에 X/Y축 컬럼 입력 필드 | +| 2026-02-10 | A-3: 통계 카드 카테고리 설정 UI | 카테고리 추가/삭제/편집 인라인 에디터 | +| 2026-02-10 | B-1: 차트 xAxisColumn 자동 보정 | groupBy 있으면 xAxisColumn 자동 설정 | +| 2026-02-10 | B-2: 통계 카드 카테고리별 필터 적용 | rows 필터링으로 카테고리별 독립 건수 표시 버그 수정 | +| 2026-02-10 | fetchTableColumns 폴백 추가 | tableManagementApi 우선 사용으로 컬럼 로딩 안정화 | +| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 | +| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent | +| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 | + +--- + +## 알려진 이슈 + +| # | 이슈 | 심각도 | 상태 | +|---|------|--------|------| +| 1 | ~~차트 groupBy 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-1) | +| 2 | ~~차트 xAxisColumn 미설정 시 빈 차트~~ | ~~높음~~ | 수정 완료 (A-2, B-1) | +| 3 | ~~통계 카드 카테고리 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-3) | +| 4 | ~~통계 카드 카테고리별 필터 미적용 버그~~ | ~~높음~~ | 수정 완료 (B-2) | +| 5 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 | +| 6 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 | diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index 88230f48..b53454b9 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -2839,4 +2839,4 @@ export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Respons logger.error("POP 루트 그룹 확보 실패:", error); res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message }); } -}; +}; \ No newline at end of file diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx index 0f1aba1a..97c4df97 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx @@ -187,6 +187,9 @@ export function PopDashboardComponent({ 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 () => { @@ -214,15 +217,18 @@ export function PopDashboardComponent({ setDataMap(newDataMap); setLoading(false); - }, [JSON.stringify(visibleItems.map((i) => i.id))]); + }, [visibleItemIds]); // 초기 로딩 + 주기적 새로고침 useEffect(() => { fetchAllData(); - // refreshInterval 적용 (첫 번째 아이템 기준) - const refreshSec = visibleItems[0]?.dataSource.refreshInterval; - if (refreshSec && refreshSec > 0) { + // 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); } @@ -232,7 +238,9 @@ export function PopDashboardComponent({ refreshTimerRef.current = null; } }; - }, [fetchAllData, visibleItems]); + // visibleItemIds로 아이템 변경 감지 (visibleItems 객체 참조 대신 문자열 키 사용) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchAllData, visibleItemIds]); // 빈 설정 (모든 hooks 이후에 early return) if (!config || !config.items?.length) { @@ -273,23 +281,55 @@ export function PopDashboardComponent({ formulaDisplay={itemData.formulaDisplay} /> ); - case "chart": + 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: 카테고리별 건수 맵 구성 + // StatCard: 카테고리별 건수 맵 구성 (필터 적용) const categoryData: Record = {}; if (item.statConfig?.categories) { for (const cat of item.statConfig.categories) { - // 각 카테고리 행에서 건수 추출 (간단 구현: 행 수 기준) - categoryData[cat.label] = itemData.rows.length; + 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 ( diff --git a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx index 1b0ec03c..66a56876 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx @@ -152,6 +152,10 @@ function DataSourceEditor({ // 컬럼 목록 (집계 대상 컬럼용) const [columns, setColumns] = useState([]); const [loadingCols, setLoadingCols] = useState(false); + const [columnOpen, setColumnOpen] = useState(false); + + // 그룹핑 컬럼 (차트 X축용) + const [groupByOpen, setGroupByOpen] = useState(false); // 마운트 시 테이블 목록 로드 useEffect(() => { @@ -285,32 +289,156 @@ function DataSourceEditor({ {dataSource.aggregation && (
- + + + + + + + + + + 컬럼을 찾을 수 없습니다. + + + {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축 카테고리로 사용됩니다 +

+
+ )} + {/* 자동 새로고침 (Switch + 주기 입력) */}
@@ -1135,29 +1263,77 @@ function ItemEditor({ )} {item.subType === "chart" && ( -
- - +
+
+ + +
+ + {/* X축 컬럼 */} +
+ + + onUpdate({ + ...item, + chartConfig: { + ...item.chartConfig, + chartType: item.chartConfig?.chartType ?? "bar", + xAxisColumn: e.target.value || undefined, + }, + }) + } + placeholder="groupBy 컬럼명 (비우면 자동)" + className="h-8 text-xs" + /> +

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

+
+ + {/* Y축 컬럼 */} +
+ + + onUpdate({ + ...item, + chartConfig: { + ...item.chartConfig, + chartType: item.chartConfig?.chartType ?? "bar", + yAxisColumn: e.target.value || undefined, + }, + }) + } + placeholder="집계 결과 컬럼명 (비우면 자동)" + className="h-8 text-xs" + /> +

+ 집계 결과 컬럼. 비우면 집계 함수 결과를 자동 사용 +

+
)} @@ -1220,6 +1396,152 @@ function ItemEditor({
)} + + {/* 통계 카드 카테고리 설정 */} + {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 && ( +

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

+ )} +
+ )}
)}
diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx index 66694a58..c1fbd6b6 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/ChartItem.tsx @@ -84,10 +84,10 @@ export function ChartItemComponent({ } return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx index e2b5dd30..c7313a85 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/GaugeItem.tsx @@ -69,10 +69,10 @@ export function GaugeItemComponent({ const largeArcFlag = percentage > 50 ? 1 : 0; return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -128,7 +128,7 @@ export function GaugeItemComponent({ {/* 목표값 */} {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 index 1cb09e74..29db2791 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx @@ -66,10 +66,10 @@ export function KpiCardComponent({ const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges); return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -78,7 +78,7 @@ export function KpiCardComponent({ {visibility.showValue && (
{formulaDisplay ?? abbreviateNumber(displayValue)} @@ -86,7 +86,7 @@ export function KpiCardComponent({ {/* 단위 */} {visibility.showUnit && kpiConfig?.unit && ( - + {kpiConfig.unit} )} @@ -95,14 +95,12 @@ export function KpiCardComponent({ {/* 증감율 */} {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 index f12e4e05..c3c02e7b 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/StatCard.tsx @@ -37,10 +37,10 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) { const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0); return ( -
+
{/* 라벨 */} {visibility.showLabel && ( -

+

{item.label}

)} @@ -80,7 +80,7 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) { {/* 보조 라벨 (단위 등) */} {visibility.showSubLabel && ( -

+

{visibility.showUnit && item.kpiConfig?.unit ? `단위: ${item.kpiConfig.unit}` : ""} diff --git a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx index 66c4f5e9..5e339fc5 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/modes/GridMode.tsx @@ -18,7 +18,7 @@ import type { DashboardCell } from "../../types"; // ===== 상수 ===== /** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */ -const MIN_CELL_WIDTH = 160; +const MIN_CELL_WIDTH = 80; // ===== Props ===== diff --git a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts index 64860699..4746b69b 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts +++ b/frontend/lib/registry/pop-components/pop-dashboard/utils/dataFetcher.ts @@ -46,13 +46,55 @@ function escapeSQL(value: unknown): string { 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 { - if (!filters.length) return ""; + // 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어) + const validFilters = filters.filter((f) => f.column?.trim()); + if (!validFilters.length) return ""; - const conditions = filters.map((f) => { + const conditions = validFilters.map((f) => { const col = sanitizeIdentifier(f.column); switch (f.operator) { @@ -98,8 +140,18 @@ export function buildAggregationSQL(config: DataSourceConfig): string { let selectClause: string; if (config.aggregation) { const aggType = config.aggregation.type.toUpperCase(); - const aggCol = sanitizeIdentifier(config.aggregation.column); - selectClause = `${aggType}(${aggCol}) as value`; + 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) { @@ -110,10 +162,14 @@ export function buildAggregationSQL(config: DataSourceConfig): string { selectClause = "*"; } - // FROM 절 (조인 포함) + // 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); @@ -173,6 +229,12 @@ 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); @@ -228,6 +290,24 @@ export async function fetchAggregatedData( 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) => ({