= {};
+ 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..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/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/components/pop/designer/PopCanvas.tsx b/frontend/components/pop/designer/PopCanvas.tsx
index 7753a992..6ac2cea9 100644
--- a/frontend/components/pop/designer/PopCanvas.tsx
+++ b/frontend/components/pop/designer/PopCanvas.tsx
@@ -112,6 +112,8 @@ interface PopCanvasProps {
onLockLayout?: () => void;
onResetOverride?: (mode: GridMode) => void;
onChangeGapPreset?: (preset: GapPreset) => void;
+ /** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */
+ previewPageIndex?: number;
}
// ========================================
@@ -135,6 +137,7 @@ export default function PopCanvas({
onLockLayout,
onResetOverride,
onChangeGapPreset,
+ previewPageIndex,
}: PopCanvasProps) {
// 줌 상태
const [canvasScale, setCanvasScale] = useState(0.8);
@@ -690,6 +693,7 @@ export default function PopCanvas({
onComponentResizeEnd={onResizeEnd}
overrideGap={adjustedGap}
overridePadding={adjustedPadding}
+ previewPageIndex={previewPageIndex}
/>
)}
diff --git a/frontend/components/pop/designer/PopDesigner.tsx b/frontend/components/pop/designer/PopDesigner.tsx
index f4dfd3fa..16fa0da8 100644
--- a/frontend/components/pop/designer/PopDesigner.tsx
+++ b/frontend/components/pop/designer/PopDesigner.tsx
@@ -69,6 +69,9 @@ export default function PopDesigner({
// 선택 상태
const [selectedComponentId, setSelectedComponentId] = useState(null);
+ // 대시보드 페이지 미리보기 인덱스 (-1 = 기본 모드)
+ const [previewPageIndex, setPreviewPageIndex] = useState(-1);
+
// 그리드 모드 (4개 프리셋)
const [currentMode, setCurrentMode] = useState("tablet_landscape");
@@ -217,24 +220,28 @@ export default function PopDesigner({
const handleUpdateComponent = useCallback(
(componentId: string, updates: Partial) => {
- const existingComponent = layout.components[componentId];
- if (!existingComponent) return;
+ // 함수적 업데이트로 stale closure 방지
+ setLayout((prev) => {
+ const existingComponent = prev.components[componentId];
+ if (!existingComponent) return prev;
- const newLayout = {
- ...layout,
- components: {
- ...layout.components,
- [componentId]: {
- ...existingComponent,
- ...updates,
+ const newComponent = {
+ ...existingComponent,
+ ...updates,
+ };
+ const newLayout = {
+ ...prev,
+ components: {
+ ...prev.components,
+ [componentId]: newComponent,
},
- },
- };
- setLayout(newLayout);
- saveToHistory(newLayout);
+ };
+ saveToHistory(newLayout);
+ return newLayout;
+ });
setHasChanges(true);
},
- [layout, saveToHistory]
+ [saveToHistory]
);
const handleDeleteComponent = useCallback(
@@ -637,6 +644,7 @@ export default function PopDesigner({
onLockLayout={handleLockLayout}
onResetOverride={handleResetOverride}
onChangeGapPreset={handleChangeGapPreset}
+ previewPageIndex={previewPageIndex}
/>
@@ -655,6 +663,8 @@ export default function PopDesigner({
allComponents={Object.values(layout.components)}
onSelectComponent={setSelectedComponentId}
selectedComponentId={selectedComponentId}
+ previewPageIndex={previewPageIndex}
+ onPreviewPage={setPreviewPageIndex}
/>
diff --git a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx
index 847d8aed..bc779848 100644
--- a/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx
+++ b/frontend/components/pop/designer/panels/ComponentEditorPanel.tsx
@@ -42,6 +42,10 @@ interface ComponentEditorPanelProps {
onSelectComponent?: (componentId: string) => void;
/** 현재 선택된 컴포넌트 ID */
selectedComponentId?: string | null;
+ /** 대시보드 페이지 미리보기 인덱스 */
+ previewPageIndex?: number;
+ /** 페이지 미리보기 요청 콜백 */
+ onPreviewPage?: (pageIndex: number) => void;
}
// ========================================
@@ -50,6 +54,7 @@ interface ComponentEditorPanelProps {
const COMPONENT_TYPE_LABELS: Record = {
"pop-sample": "샘플",
"pop-text": "텍스트",
+ "pop-icon": "아이콘",
"pop-dashboard": "대시보드",
"pop-field": "필드",
"pop-button": "버튼",
@@ -73,6 +78,8 @@ export default function ComponentEditorPanel({
allComponents,
onSelectComponent,
selectedComponentId,
+ previewPageIndex,
+ onPreviewPage,
}: ComponentEditorPanelProps) {
const breakpoint = GRID_BREAKPOINTS[currentMode];
@@ -182,6 +189,8 @@ export default function ComponentEditorPanel({
@@ -362,9 +371,11 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
interface ComponentSettingsFormProps {
component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial) => void;
+ previewPageIndex?: number;
+ onPreviewPage?: (pageIndex: number) => void;
}
-function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) {
+function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage }: ComponentSettingsFormProps) {
// PopComponentRegistry에서 configPanel 가져오기
const registeredComp = PopComponentRegistry.getComponent(component.type);
const ConfigPanel = registeredComp?.configPanel;
@@ -393,6 +404,8 @@ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormPro
) : (
diff --git a/frontend/components/pop/designer/panels/ComponentPalette.tsx b/frontend/components/pop/designer/panels/ComponentPalette.tsx
index a0d628aa..9c7af27a 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, MousePointer } from "lucide-react";
+import { Square, FileText, MousePointer, BarChart3 } from "lucide-react";
import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의
@@ -33,6 +33,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: MousePointer,
description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)",
},
+ {
+ type: "pop-dashboard",
+ label: "대시보드",
+ icon: BarChart3,
+ description: "KPI, 차트, 게이지, 통계 집계",
+ },
];
// 드래그 가능한 컴포넌트 아이템
diff --git a/frontend/components/pop/designer/renderers/PopRenderer.tsx b/frontend/components/pop/designer/renderers/PopRenderer.tsx
index 7f5a570b..f67a7d7c 100644
--- a/frontend/components/pop/designer/renderers/PopRenderer.tsx
+++ b/frontend/components/pop/designer/renderers/PopRenderer.tsx
@@ -56,6 +56,8 @@ interface PopRendererProps {
className?: string;
/** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */
currentScreenId?: number;
+ /** 대시보드 페이지 미리보기 인덱스 */
+ previewPageIndex?: number;
}
// ========================================
@@ -64,6 +66,9 @@ interface PopRendererProps {
const COMPONENT_TYPE_LABELS: Record
= {
"pop-sample": "샘플",
+ "pop-text": "텍스트",
+ "pop-icon": "아이콘",
+ "pop-dashboard": "대시보드",
};
// ========================================
@@ -86,6 +91,7 @@ export default function PopRenderer({
overridePadding,
className,
currentScreenId,
+ previewPageIndex,
}: PopRendererProps) {
const { gridConfig, components, overrides } = layout;
@@ -251,6 +257,7 @@ export default function PopRenderer({
onComponentMove={onComponentMove}
onComponentResize={onComponentResize}
onComponentResizeEnd={onComponentResizeEnd}
+ previewPageIndex={previewPageIndex}
/>
);
}
@@ -294,6 +301,7 @@ interface DraggableComponentProps {
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
onComponentResizeEnd?: (componentId: string) => void;
+ previewPageIndex?: number;
}
function DraggableComponent({
@@ -311,6 +319,7 @@ function DraggableComponent({
onComponentMove,
onComponentResize,
onComponentResizeEnd,
+ previewPageIndex,
}: DraggableComponentProps) {
const [{ isDragging }, drag] = useDrag(
() => ({
@@ -349,6 +358,7 @@ function DraggableComponent({
effectivePosition={position}
isDesignMode={isDesignMode}
isSelected={isSelected}
+ previewPageIndex={previewPageIndex}
/>
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
@@ -499,9 +509,10 @@ interface ComponentContentProps {
effectivePosition: PopGridPosition;
isDesignMode: boolean;
isSelected: boolean;
+ previewPageIndex?: number;
}
-function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) {
+function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex }: ComponentContentProps) {
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
@@ -526,6 +537,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
config={component.config}
label={component.label}
isDesignMode={isDesignMode}
+ previewPageIndex={previewPageIndex}
/>
);
diff --git a/frontend/components/pop/designer/types/pop-layout.ts b/frontend/components/pop/designer/types/pop-layout.ts
index f92791c9..def9dbfc 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" | "pop-icon"; // 테스트용 샘플 박스, 텍스트 컴포넌트, 아이콘 컴포넌트
+export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard";
/**
* 데이터 흐름 정의
@@ -343,6 +343,7 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record 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..52f18d27
--- /dev/null
+++ b/frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx
@@ -0,0 +1,2247 @@
+"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,
+} 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 FILTER_OPERATOR_LABELS: Record = {
+ "=": "같음 (=)",
+ "!=": "다름 (!=)",
+ ">": "초과 (>)",
+ ">=": "이상 (>=)",
+ "<": "미만 (<)",
+ "<=": "이하 (<=)",
+ like: "포함 (LIKE)",
+ in: "목록 (IN)",
+ between: "범위 (BETWEEN)",
+};
+
+// ===== 데이터 소스 편집기 =====
+
+function DataSourceEditor({
+ dataSource,
+ onChange,
+}: {
+ dataSource: DataSourceConfig;
+ onChange: (ds: DataSourceConfig) => void;
+}) {
+ // 테이블 목록 (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) */}
+
+
테이블
+
+
+
+ {dataSource.tableName
+ ? (tables.find((t) => t.tableName === dataSource.tableName)
+ ?.displayName ?? dataSource.tableName)
+ : loadingTables
+ ? "로딩..."
+ : "테이블 선택"}
+
+
+
+
+
+
+
+
+ 테이블을 찾을 수 없습니다
+
+
+ {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}
+
+ )}
+
+
+ ))}
+
+
+
+
+
+
+
+ {/* 집계 함수 + 대상 컬럼 */}
+
+
+ 집계 함수
+
+ onChange({
+ ...dataSource,
+ aggregation: val
+ ? {
+ type: val as NonNullable<
+ DataSourceConfig["aggregation"]
+ >["type"],
+ column: dataSource.aggregation?.column ?? "",
+ }
+ : undefined,
+ })
+ }
+ >
+
+
+
+
+ 건수 (COUNT)
+ 합계 (SUM)
+ 평균 (AVG)
+ 최소 (MIN)
+ 최대 (MAX)
+
+
+
+
+ {dataSource.aggregation && (
+
+
대상 컬럼
+
+
+
+ {loadingCols
+ ? "로딩..."
+ : dataSource.aggregation.column
+ ? columns.find(
+ (c) => c.name === dataSource.aggregation!.column
+ )
+ ? `${dataSource.aggregation.column} (${columns.find((c) => c.name === dataSource.aggregation!.column)?.type})`
+ : dataSource.aggregation.column
+ : "선택"}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {columns.map((col) => (
+ {
+ onChange({
+ ...dataSource,
+ aggregation: {
+ ...dataSource.aggregation!,
+ column: col.name,
+ },
+ });
+ setColumnOpen(false);
+ }}
+ className="text-xs"
+ >
+
+ {col.name}
+
+ ({col.type})
+
+
+ ))}
+
+
+
+
+
+
+ )}
+
+
+ {/* 그룹핑 (차트 X축 분류) */}
+ {dataSource.aggregation && (
+
+
그룹핑 (X축)
+
+
+
+ {dataSource.aggregation.groupBy?.length
+ ? dataSource.aggregation.groupBy.join(", ")
+ : "없음 (단일 값)"}
+
+
+
+
+
+
+
+
+ 컬럼을 찾을 수 없습니다.
+
+
+ {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 + 주기 입력) */}
+
+
+ 자동 새로고침
+ 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 (
+
+
+ {/* 조인 타입 */}
+
onUpdate({ joinType: val as JoinType })}
+ >
+
+
+
+
+ {Object.entries(JOIN_TYPE_LABELS).map(([val, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ {/* 조인 대상 테이블 (Combobox) */}
+
+
+
+ {join.targetTable
+ ? (tables.find((t) => t.tableName === join.targetTable)
+ ?.displayName ?? join.targetTable)
+ : "테이블 선택"}
+
+
+
+
+
+
+
+
+ 없음
+
+
+ {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
+
+ onUpdate({ on: { ...join.on, sourceColumn: val } })
+ }
+ >
+
+
+
+
+ {sourceColumns.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+ =
+
+ onUpdate({ on: { ...join.on, targetColumn: val } })
+ }
+ >
+
+
+
+
+ {targetColumns.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
+
+// ===== 필터 편집기 =====
+
+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) => (
+
+ {/* 컬럼 선택 */}
+
updateFilter(index, { column: val })}
+ >
+
+
+
+
+ {columns.map((c) => (
+
+ {c.name}
+
+ ))}
+
+
+
+ {/* 연산자 */}
+
+ updateFilter(index, { operator: val as FilterOperator })
+ }
+ >
+
+
+
+
+ {Object.entries(FILTER_OPERATOR_LABELS).map(([op, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+ {/* 값 입력 (연산자에 따라 다른 UI) */}
+
+
+ {/* 삭제 */}
+
removeFilter(index)}
+ >
+
+
+
+ ))}
+
+ );
+}
+
+// ===== 수식 편집기 =====
+
+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.filter(
+ (_, i) => i !== index
+ );
+ onChange({ ...formula, values: newValues });
+ }}
+ >
+
+
+ )}
+
+
{
+ const newValues = [...formula.values];
+ newValues[index] = { ...fv, dataSource: ds };
+ onChange({ ...formula, values: newValues });
+ }}
+ />
+
+ ))}
+
+ {/* 값 추가 */}
+
{
+ const nextId = String.fromCharCode(65 + formula.values.length);
+ onChange({
+ ...formula,
+ values: [
+ ...formula.values,
+ {
+ id: nextId,
+ label: "",
+ dataSource: { ...DEFAULT_DATASOURCE },
+ },
+ ],
+ });
+ }}
+ >
+
+ 값 추가
+
+
+ {/* 수식 입력 */}
+
+
수식
+
+ onChange({ ...formula, expression: e.target.value })
+ }
+ placeholder="예: A / B * 100"
+ className={`h-8 text-xs ${!isValid ? "border-destructive" : ""}`}
+ />
+ {!isValid && (
+
+ 수식에 정의되지 않은 변수가 있습니다
+
+ )}
+
+
+ {/* 표시 형태 */}
+
+ 표시 형태
+
+ onChange({
+ ...formula,
+ displayFormat: val as FormulaConfig["displayFormat"],
+ })
+ }
+ >
+
+
+
+
+ 계산 결과 숫자
+ 분수 (1,234 / 5,678)
+ 퍼센트 (21.7%)
+ 비율 (1,234 : 5,678)
+
+
+
+
+ );
+}
+
+// ===== 아이템 편집기 =====
+
+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"
+ />
+
+ setExpanded(!expanded)}
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {/* 상세 설정 */}
+ {expanded && (
+
+
+ 타입
+
+ onUpdate({ ...item, subType: val as DashboardSubType })
+ }
+ >
+
+
+
+
+ KPI 카드
+ 차트
+ 게이지
+ 통계 카드
+
+
+
+
+
+ 데이터 모드
+ {
+ const mode = val as "single" | "formula";
+ setDataMode(mode);
+ if (mode === "formula" && !item.formula) {
+ onUpdate({
+ ...item,
+ formula: {
+ enabled: true,
+ values: [
+ {
+ id: "A",
+ label: "",
+ dataSource: { ...DEFAULT_DATASOURCE },
+ },
+ {
+ id: "B",
+ label: "",
+ dataSource: { ...DEFAULT_DATASOURCE },
+ },
+ ],
+ expression: "A / B",
+ displayFormat: "value",
+ },
+ });
+ } else if (mode === "single") {
+ onUpdate({ ...item, formula: undefined });
+ }
+ }}
+ >
+
+
+
+
+ 단일 집계
+ 계산식
+
+
+
+
+ {dataMode === "formula" && item.formula ? (
+
onUpdate({ ...item, formula: f })}
+ />
+ ) : (
+ onUpdate({ ...item, dataSource: ds })}
+ />
+ )}
+
+ {/* 요소별 보이기/숨기기 */}
+
+
표시 요소
+
+ {(
+ [
+ ["showLabel", "라벨"],
+ ["showValue", "값"],
+ ["showUnit", "단위"],
+ ["showTrend", "증감율"],
+ ["showSubLabel", "보조라벨"],
+ ["showTarget", "목표값"],
+ ] as const
+ ).map(([key, label]) => (
+
+
+ onUpdate({
+ ...item,
+ visibility: {
+ ...item.visibility,
+ [key]: e.target.checked,
+ },
+ })
+ }
+ className="h-3 w-3 rounded border-input"
+ />
+ {label}
+
+ ))}
+
+
+
+ {/* 서브타입별 추가 설정 */}
+ {item.subType === "kpi-card" && (
+
+ 단위
+
+ onUpdate({
+ ...item,
+ kpiConfig: { ...item.kpiConfig, unit: e.target.value },
+ })
+ }
+ placeholder="EA, 톤, 원"
+ className="h-8 text-xs"
+ />
+
+ )}
+
+ {item.subType === "chart" && (
+
+
+ 차트 유형
+
+ onUpdate({
+ ...item,
+ chartConfig: {
+ ...item.chartConfig,
+ chartType: val as "bar" | "pie" | "line",
+ },
+ })
+ }
+ >
+
+
+
+
+ 막대 차트
+ 원형 차트
+ 라인 차트
+
+
+
+
+ {/* 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" && (
+
+
+
카테고리 설정
+
{
+ const currentCats = item.statConfig?.categories ?? [];
+ onUpdate({
+ ...item,
+ statConfig: {
+ ...item.statConfig,
+ categories: [
+ ...currentCats,
+ {
+ label: `카테고리 ${currentCats.length + 1}`,
+ filter: { column: "", operator: "=", value: "" },
+ },
+ ],
+ },
+ });
+ }}
+ >
+
+ 카테고리 추가
+
+
+
+ {(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 ?? []).filter(
+ (_, i) => i !== catIdx
+ );
+ onUpdate({
+ ...item,
+ statConfig: { ...item.statConfig, categories: newCats },
+ });
+ }}
+ >
+
+
+
+ {/* 필터 조건: 컬럼 / 연산자 / 값 */}
+
+ {
+ 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, operator: val as FilterOperator },
+ };
+ onUpdate({
+ ...item,
+ statConfig: { ...item.statConfig, categories: newCats },
+ });
+ }}
+ >
+
+
+
+
+ = 같음
+ != 다름
+ LIKE
+
+
+ {
+ 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 (
+
+ {/* 헤더 - 클릭으로 접기/펼치기 */}
+
setExpanded(!expanded)}
+ >
+
+ {item.label || item.id}
+
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+ {/* 내용 - 접기/펼치기 */}
+ {expanded && (
+
+ {/* 라벨 정렬 */}
+
+
+ 라벨 정렬
+
+
+ {(["left", "center", "right"] as const).map((align) => (
+ updateStyle({ labelAlign: align })}
+ >
+ {TEXT_ALIGN_LABELS[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 (
+
+ {/* 행/열 조절 버튼 */}
+
+
+ 열
+ {
+ if (gridColumns > 1) {
+ const newCells = ensuredCells.filter((c) => {
+ const col = parseInt(c.gridColumn.split(" / ")[0]);
+ return col <= gridColumns - 1;
+ });
+ onChange(newCells, gridColumns - 1, gridRows);
+ }
+ }}
+ disabled={gridColumns <= 1}
+ >
+ -
+
+
+ {gridColumns}
+
+ {
+ if (gridColumns < 6) {
+ const newCells = [...ensuredCells];
+ for (let r = 0; r < gridRows; r++) {
+ newCells.push({
+ id: `cell-${r}-${gridColumns}`,
+ gridColumn: `${gridColumns + 1} / ${gridColumns + 2}`,
+ gridRow: `${r + 1} / ${r + 2}`,
+ itemId: null,
+ });
+ }
+ onChange(newCells, gridColumns + 1, gridRows);
+ }
+ }}
+ disabled={gridColumns >= 6}
+ >
+ +
+
+
+
+
+ 행
+ {
+ if (gridRows > 1) {
+ const newCells = ensuredCells.filter((c) => {
+ const row = parseInt(c.gridRow.split(" / ")[0]);
+ return row <= gridRows - 1;
+ });
+ onChange(newCells, gridColumns, gridRows - 1);
+ }
+ }}
+ disabled={gridRows <= 1}
+ >
+ -
+
+
+ {gridRows}
+
+ {
+ if (gridRows < 6) {
+ const newCells = [...ensuredCells];
+ for (let c = 0; c < gridColumns; c++) {
+ newCells.push({
+ id: `cell-${gridRows}-${c}`,
+ gridColumn: `${c + 1} / ${c + 2}`,
+ gridRow: `${gridRows + 1} / ${gridRows + 2}`,
+ itemId: null,
+ });
+ }
+ onChange(newCells, gridColumns, gridRows + 1);
+ }
+ }}
+ disabled={gridRows >= 6}
+ >
+ +
+
+
+
+
+ onChange(
+ generateDefaultCells(gridColumns, gridRows),
+ gridColumns,
+ gridRows
+ )
+ }
+ >
+ 초기화
+
+
+
+ {/* 시각적 그리드 프리뷰 + 아이템 배정 */}
+
+ {ensuredCells.map((cell) => (
+
+ {
+ const newCells = ensuredCells.map((c) =>
+ c.id === cell.id
+ ? { ...c, itemId: val === "empty" ? null : val }
+ : c
+ );
+ onChange(newCells, gridColumns, gridRows);
+ }}
+ >
+
+
+
+
+ 빈 셀
+ {items.map((item) => (
+
+ {item.label || item.id}
+
+ ))}
+
+
+
+ ))}
+
+
+
+ 각 셀을 클릭하여 아이템을 배정하세요. +/- 버튼으로 행/열을
+ 추가/삭제할 수 있습니다.
+
+
+ {/* 배정된 아이템별 스타일 설정 */}
+ {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}
+
+ onPreview?.()}
+ title="이 페이지 미리보기"
+ >
+
+
+ setExpanded(!expanded)}
+ >
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ {/* 상세 */}
+ {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]) => (
+ setActiveTab(key)}
+ className={`rounded-t px-2 py-1 text-xs font-medium transition-colors ${
+ activeTab === key
+ ? "bg-primary text-primary-foreground"
+ : "text-muted-foreground hover:bg-muted"
+ }`}
+ >
+ {label}
+
+ ))}
+
+
+ {/* ===== 기본 설정 탭 ===== */}
+ {activeTab === "basic" && (
+
+ {/* 표시 모드 */}
+
+ 표시 모드
+
+ updateConfig({
+ displayMode: val as DashboardDisplayMode,
+ })
+ }
+ >
+
+
+
+
+ {Object.entries(DISPLAY_MODE_LABELS).map(([val, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ {/* 자동 슬라이드 설정 */}
+ {cfg.displayMode === "auto-slide" && (
+
+ )}
+
+ {/* 인디케이터 */}
+
+ 페이지 인디케이터
+
+ updateConfig({ showIndicator: checked })
+ }
+ />
+
+
+ {/* 간격 */}
+
+ 아이템 간격 (px)
+
+ 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) => (
+
addItem(subType)}
+ >
+
+ {SUBTYPE_LABELS[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);
+ }
+ }}
+ />
+ ))}
+
+ {/* 페이지 추가 버튼 */}
+ {
+ const newPage: DashboardPage = {
+ id: `page-${Date.now()}`,
+ label: `페이지 ${(cfg.pages?.length ?? 0) + 1}`,
+ gridColumns: 2,
+ gridRows: 2,
+ gridCells: generateDefaultCells(2, 2),
+ };
+ updateConfig({ pages: [...(cfg.pages ?? []), newPage] });
+ }}
+ >
+
+ 페이지 추가
+
+
+ {(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) => (
+ setCurrentIndex(i)}
+ className={`h-1.5 rounded-full transition-all ${
+ i === currentIndex
+ ? "w-4 bg-primary"
+ : "w-1.5 bg-muted-foreground/30"
+ }`}
+ aria-label={`${i + 1}번째 아이템`}
+ />
+ ))}
+
+ )}
+
+ );
+}
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-text.tsx b/frontend/lib/registry/pop-components/pop-text.tsx
index ac37cac5..969a8a13 100644
--- a/frontend/lib/registry/pop-components/pop-text.tsx
+++ b/frontend/lib/registry/pop-components/pop-text.tsx
@@ -269,10 +269,14 @@ 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 (!isRealtime) return;
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
- }, []);
+ }, [isRealtime]);
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
const dateFormat = config?.dateTimeConfig
diff --git a/frontend/lib/registry/pop-components/types.ts b/frontend/lib/registry/pop-components/types.ts
index f743d766..cee5fb06 100644
--- a/frontend/lib/registry/pop-components/types.ts
+++ b/frontend/lib/registry/pop-components/types.ts
@@ -51,6 +51,7 @@ export const FONT_SIZE_CLASSES: Record = {
xl: "text-[64px]",
};
+
export const FONT_WEIGHT_CLASSES: Record = {
normal: "font-normal",
medium: "font-medium",
@@ -86,3 +87,258 @@ export const JUSTIFY_CLASSES: Record = {
center: "justify-center",
right: "justify-end",
};
+
+// =============================================
+// Phase 0 공통 타입 (모든 POP 컴포넌트 공용)
+// =============================================
+
+// ----- 컬럼 바인딩: 컬럼별 읽기/쓰기 제어 -----
+
+export type ColumnMode = "read" | "write" | "readwrite" | "hidden";
+
+export interface ColumnBinding {
+ columnName: string;
+ sourceTable?: string;
+ mode: ColumnMode;
+ label?: string;
+ defaultValue?: unknown;
+}
+
+// ----- 조인 설정: 테이블 간 관계 정의 -----
+
+export type JoinType = "inner" | "left" | "right";
+
+export interface JoinConfig {
+ targetTable: string;
+ joinType: JoinType;
+ on: {
+ sourceColumn: string;
+ targetColumn: string;
+ };
+ columns?: string[];
+}
+
+// ----- 데이터 소스: 테이블 조회/집계 통합 설정 -----
+
+export type AggregationType = "count" | "sum" | "avg" | "min" | "max";
+export type FilterOperator =
+ | "="
+ | "!="
+ | ">"
+ | ">="
+ | "<"
+ | "<="
+ | "like"
+ | "in"
+ | "between";
+
+export interface DataSourceFilter {
+ column: string;
+ operator: FilterOperator;
+ value: unknown; // between이면 [from, to]
+}
+
+export interface SortConfig {
+ column: string;
+ direction: "asc" | "desc";
+}
+
+export interface DataSourceConfig {
+ tableName: string;
+ columns?: ColumnBinding[];
+ filters?: DataSourceFilter[];
+ sort?: SortConfig[];
+ aggregation?: {
+ type: AggregationType;
+ column: string;
+ groupBy?: string[];
+ };
+ joins?: JoinConfig[];
+ refreshInterval?: number; // 초 단위, 0이면 비활성
+ limit?: number;
+}
+
+// ----- 액션 설정: 버튼/링크 클릭 시 동작 정의 -----
+
+export interface PopActionConfig {
+ type:
+ | "navigate"
+ | "modal"
+ | "save"
+ | "delete"
+ | "api"
+ | "event"
+ | "refresh";
+ // navigate
+ targetScreenId?: string;
+ params?: Record;
+ // modal
+ modalScreenId?: string;
+ modalTitle?: string;
+ // save/delete
+ targetTable?: string;
+ confirmMessage?: string;
+ // api
+ apiEndpoint?: string;
+ apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
+ // event
+ eventName?: string;
+ eventPayload?: Record;
+}
+
+// =============================================
+// pop-dashboard 전용 타입
+// =============================================
+
+// ----- 표시 모드 / 서브타입 -----
+
+export type DashboardDisplayMode =
+ | "arrows"
+ | "auto-slide"
+ | "scroll";
+export type DashboardSubType = "kpi-card" | "chart" | "gauge" | "stat-card";
+export type FormulaDisplayFormat = "value" | "fraction" | "percent" | "ratio";
+export type ChartType = "bar" | "pie" | "line";
+export type TrendPeriod = "daily" | "weekly" | "monthly";
+
+// ----- 색상 구간 -----
+
+export interface ColorRange {
+ min: number;
+ max: number;
+ color: string; // hex 또는 Tailwind 색상
+}
+
+// ----- 수식(계산식) 설정 -----
+
+export interface FormulaValue {
+ id: string; // "A", "B" 등
+ dataSource: DataSourceConfig;
+ label: string; // "생산량", "총재고량"
+}
+
+export interface FormulaConfig {
+ enabled: boolean;
+ values: FormulaValue[];
+ expression: string; // "A / B", "A + B", "A / B * 100"
+ displayFormat: FormulaDisplayFormat;
+}
+
+// ----- 아이템 내 요소별 보이기/숨기기 -----
+
+export interface ItemVisibility {
+ showLabel: boolean;
+ showValue: boolean;
+ showUnit: boolean;
+ showTrend: boolean;
+ showSubLabel: boolean;
+ showTarget: boolean;
+}
+
+// ----- 서브타입별 설정 -----
+
+export interface KpiCardConfig {
+ unit?: string; // "EA", "톤", "원"
+ colorRanges?: ColorRange[];
+ showTrend?: boolean;
+ trendPeriod?: TrendPeriod;
+}
+
+export interface ChartItemConfig {
+ chartType: ChartType;
+ xAxisColumn?: string;
+ yAxisColumn?: string;
+ colors?: string[];
+}
+
+export interface GaugeConfig {
+ min: number;
+ max: number;
+ target?: number; // 고정 목표값
+ targetDataSource?: DataSourceConfig; // 동적 목표값
+ colorRanges?: ColorRange[];
+}
+
+export interface StatCategory {
+ label: string; // "대기", "진행", "완료"
+ filter: DataSourceFilter;
+ color?: string;
+}
+
+export interface StatCardConfig {
+ categories: StatCategory[];
+ showLink?: boolean;
+ linkAction?: PopActionConfig;
+}
+
+// ----- 그리드 모드 셀 (엑셀형 분할/병합) -----
+
+export interface DashboardCell {
+ id: string;
+ gridColumn: string; // CSS Grid 값: "1 / 3"
+ gridRow: string; // CSS Grid 값: "1 / 2"
+ itemId: string | null; // null이면 빈 셀
+}
+
+// ----- 대시보드 페이지(슬라이드) -----
+
+/** 대시보드 한 페이지(슬라이드) - 독립적인 그리드 레이아웃 보유 */
+export interface DashboardPage {
+ id: string;
+ label?: string; // 디자이너에서 표시할 라벨 (예: "페이지 1")
+ gridColumns: number; // 이 페이지의 열 수
+ gridRows: number; // 이 페이지의 행 수
+ gridCells: DashboardCell[]; // 이 페이지의 셀 배치 (각 셀에 itemId 지정)
+}
+
+// ----- 대시보드 아이템 스타일 설정 -----
+
+export interface ItemStyleConfig {
+ /** 라벨 텍스트 정렬 (기본: center) */
+ labelAlign?: TextAlign;
+}
+
+// ----- 대시보드 아이템 -----
+
+export interface DashboardItem {
+ id: string;
+ label: string; // pop-system 보이기/숨기기용
+ visible: boolean;
+ subType: DashboardSubType;
+ dataSource: DataSourceConfig;
+
+ // 요소별 보이기/숨기기
+ visibility: ItemVisibility;
+
+ // 계산식 (선택사항)
+ formula?: FormulaConfig;
+
+ // 서브타입별 설정 (subType에 따라 하나만 사용)
+ kpiConfig?: KpiCardConfig;
+ chartConfig?: ChartItemConfig;
+ gaugeConfig?: GaugeConfig;
+ statConfig?: StatCardConfig;
+
+ /** 스타일 설정 (정렬, 글자 크기 3그룹) */
+ itemStyle?: ItemStyleConfig;
+}
+
+// ----- 대시보드 전체 설정 -----
+
+export interface PopDashboardConfig {
+ items: DashboardItem[];
+ pages?: DashboardPage[]; // 페이지 배열 (각 페이지가 독립 그리드 레이아웃)
+ displayMode: DashboardDisplayMode; // 페이지 간 전환 방식
+
+ // 모드별 설정
+ autoSlideInterval?: number; // 초 (기본 5)
+ autoSlideResumeDelay?: number; // 터치 후 재개 대기 초 (기본 3)
+
+ // 공통 스타일
+ showIndicator?: boolean; // 페이지 인디케이터
+ gap?: number; // 아이템 간 간격 px
+ backgroundColor?: string;
+
+ // 데이터 소스 (아이템 공통)
+ dataSource?: DataSourceConfig;
+}