Compare commits

..

9 Commits

Author SHA1 Message Date
SeongHyun Kim 0ef0332e08 merge: ksh-dashboard를 ksh-v2-work에 병합 (pop-icon + pop-dashboard 통합)
- pop-icon (아이콘 네비게이션) + pop-dashboard (KPI/차트/게이지) 양쪽 기능 통합
- PopComponentType에 pop-icon, pop-dashboard 모두 등록
- PopRenderer: currentScreenId(아이콘) + previewPageIndex(대시보드) 양쪽 props 공존
- ComponentEditorPanel: previewPageIndex/onPreviewPage props 추가 + 스크롤 CSS 개선 유지
- pop-text: isRealtime 조건부 타이머 로직 적용 (ksh-dashboard 개선 채택)
- COMPONENT_TYPE_LABELS에 pop-icon 라벨 추가 (타입 안전성 보정)

충돌 해결 7개 파일, 17개 지점 - 모두 양쪽 의도 보존

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-11 14:48:59 +09:00
SeongHyun Kim 960b1c9946 feat(pop-dashboard): 라벨 정렬 + 페이지 미리보기 + 차트 디자인 개선
- 라벨 정렬(좌/중/우) 기능 추가 (KPI, 차트, 게이지, 통계카드)
- 글자 크기 커스텀 제거 (컨테이너 반응형 자동 적용)
- 페이지별 미리보기 버튼 추가 (디자이너 캔버스에 즉시 반영)
- 아이템 스타일 에디터 접기/펼치기 지원
- 차트 디자인: CartesianGrid, 대각선 X축 라벨, 숫자 약어(K/M), 축 여백
- handleUpdateComponent stale closure 버그 수정 (함수적 setState)
- 디버그 console.log 전량 제거

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-11 14:23:20 +09:00
SeongHyun Kim 6f45efef03 디자이너 캔버스 UX 개선: 헤더 제거 + 실제 데이터 렌더링 + 컴포넌트 목록
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 18:02:30 +09:00
SeongHyun Kim bd7bf69a99 fix(pop-dashboard): 아이템/모드 레이아웃 수정 및 게이지 설정 버그 수정
설정 패널 버그 수정 (PopDashboardConfig):
- gaugeConfig 스프레드 순서 수정: min/max/target 값이 기존값에 덮어씌워지는 문제 해결
- 스프레드를 먼저 적용 후 변경 필드를 뒤에 배치하여 올바르게 반영

아이템 레이아웃 개선:
- KpiCard/StatCard: items-center justify-center 추가로 셀 내 중앙 정렬
- GaugeItem: SVG를 flex-1 영역에서 반응형 렌더링 (h-full w-auto)
- GaugeItem: preserveAspectRatio로 비율 유지, 라벨/목표값 shrink-0

모드 레이아웃 개선:
- ArrowsMode: 아이템이 전체 영역 사용, 화살표/인디케이터를 overlay로 변경
- ArrowsMode: 화살표 크기 축소 (h-11 -> h-8), backdrop-blur 추가
- AutoSlideMode: 슬라이드 컨테이너를 absolute inset-0으로 전체 영역 활용
- AutoSlideMode: 인디케이터를 하단 overlay로 변경

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 17:18:00 +09:00
SeongHyun Kim 7a71fc6ca7 fix(pop-dashboard): 차트 X/Y축 자동 적용 및 데이터 처리 안정화
설정 패널 간소화:
- 차트 X축/Y축 수동 입력 필드 제거 (자동 적용 안내 문구로 대체)
- groupBy 선택 시 X축 자동, 집계 결과를 Y축(value)으로 자동 매핑

차트 렌더링 개선 (ChartItem):
- PieChart에 카테고리명+값+비율 라벨 표시
- Legend 컴포넌트 추가 (containerWidth 300px 이상 시)
- Tooltip formatter로 이름/값 쌍 표시

데이터 fetcher 안정화 (dataFetcher):
- apiClient(axios) 우선 호출, dashboardApi(fetch) 폴백 패턴 적용
- PostgreSQL bigint/numeric 문자열 -> 숫자 자동 변환 처리
- Recharts가 숫자 타입을 요구하는 문제 해결

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 16:55:34 +09:00
SeongHyun Kim 578cca2687 feat(pop-dashboard): 4가지 아이템 모드 완성 - 설정 UI 추가 및 버그 수정
설정 패널 (PopDashboardConfig):
- groupBy(X축 분류) Combobox 설정 UI 추가
- 차트 xAxisColumn/yAxisColumn 입력 UI 추가
- 통계 카드 카테고리 추가/삭제/편집 인라인 에디터 추가
- 대상 컬럼 Select를 Combobox(검색 가능)로 개선

데이터 처리 버그 수정 (PopDashboardComponent):
- 차트: groupBy 있을 때 xAxisColumn 자동 보정 로직 추가
- 통계 카드: 카테고리별 필터 실제 적용 (기존: 모든 카테고리에 rows.length 동일 입력)
- useCallback 의존성 안정화 (visibleItemIds 문자열 키 사용)
- refreshInterval 최소 5초 강제

데이터 fetcher 방어 로직 (dataFetcher.ts):
- validateDataSourceConfig() 추가: 설정 미완료 시 SQL 전송 차단
- 빈 필터/불완전 조인 건너뜀 처리
- COUNT 컬럼 미선택 시 COUNT(*) 자동 처리
- fetchTableColumns() 이중 폴백 (tableManagementApi -> dashboardApi)

아이템 UI 개선:
- KPI/차트/게이지/통계 카드 패딩 및 폰트 크기 조정
- 작은 셀에서도 라벨/단위/증감율 표시되도록 hidden 제거

기타:
- GridMode MIN_CELL_WIDTH 160 -> 80 축소
- PLAN.MD: 대시보드 4가지 아이템 모드 완성 계획으로 갱신
- STATUS.md: 프로젝트 상태 추적 파일 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 16:12:29 +09:00
SeongHyun Kim dc523d86c3 feat(pop-dashboard): 페이지 기반 구조 전환 및 설정 패널 고도화
구조 변경:
- grid 모드를 독립 displayMode에서 페이지 내부 그리드 레이아웃으로 전환
- DashboardPage 타입 추가 (각 페이지가 독립 그리드 보유)
- migrateConfig()로 기존 grid/useGridLayout 설정 자동 마이그레이션

설정 패널 (PopDashboardConfig):
- 드롭다운 기반 집계 설정 UI 전면 재작성 (+917줄)
- 테이블/컬럼 선택 Combobox, 페이지 관리, 셀 배치 편집기
- fetchTableList() 추가 (테이블 목록 조회)

컴포넌트/모드 개선:
- GridMode: 반응형 자동 열 축소 (MIN_CELL_WIDTH 기준)
- PopDashboardComponent: 페이지 기반 렌더링 로직
- PopDashboardPreview: 페이지 뱃지 표시

기타:
- ComponentEditorPanel: 탭 콘텐츠 스크롤 수정 (min-h-0 추가)
- types.ts: grid를 displayMode에서 제거, DashboardPage 타입 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 14:22:30 +09:00
SeongHyun Kim 73e3d56381 fix(pop-dashboard): React Hooks 규칙 위반 수정 + ConfigPanel props 정합성 + 방어 코드 강화
- PopDashboardComponent: early return을 모든 hooks 이후로 이동 (Rules of Hooks)
- PopDashboardConfigPanel: onChange -> onUpdate prop 이름 정합, 빈 객체 config 방어
- PopDashboardPreview: Array.isArray 방어 추가

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-10 12:20:44 +09:00
SeongHyun Kim 4f3e9ec19e feat(pop-dashboard): Phase 0 공통 타입 + Phase 1 대시보드 컴포넌트 구현
Phase 0: 공통 인프라 타입 정의
- ColumnBinding, JoinConfig, DataSourceConfig, PopActionConfig 등
- FilterOperator, AggregationType, SortConfig 타입

Phase 1: pop-dashboard 컴포넌트
- 4개 서브타입: KpiCard, ChartItem, GaugeItem, StatCard
- 4개 표시모드: ArrowsMode, AutoSlideMode, GridMode, ScrollMode
- 설정패널(PopDashboardConfig), 미리보기(PopDashboardPreview)
- 계산식 엔진(formula.ts), 데이터 조회(dataFetcher.ts)
- 팔레트/렌더러/타입 시스템 연동

fix(pop-text): DateTimeDisplay isRealtime 기본값 true로 수정
EOF
2026-02-10 11:04:18 +09:00
26 changed files with 5435 additions and 92 deletions

536
PLAN.MD
View File

@ -1,135 +1,527 @@
# 현재 구현 계획: POP 뷰어 스크롤 수정 # 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성
> **작성일**: 2026-02-09 > **작성일**: 2026-02-10
> **상태**: 계획 완료, 코딩 대기 > **상태**: 코딩 완료 (방어 로직 패치 포함)
> **목적**: 뷰어에서 화면 높이를 초과하는 컴포넌트가 잘리지 않고 스크롤 가능하도록 수정 > **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성
--- ---
## 1. 문제 요약 ## 1. 문제 요약
설계(디자이너)에서 컴포넌트를 아래로 배치하면 캔버스가 늘어나고 스크롤이 되지만, pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가.
뷰어(`/pop/screens/4114`)에서는 화면 높이를 초과하는 컴포넌트가 잘려서 안 보임.
**근본 원인**: CSS 컨테이너 구조가 스크롤을 차단 | # | 문제 | 심각도 | 영향 |
|---|------|--------|------|
| # | 컨테이너 (라인) | 현재 클래스 | 문제 | | BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 |
|---|----------------|-------------|------| | BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 |
| 1 | 최외곽 (185) | `h-screen ... overflow-hidden` | 넘치는 콘텐츠를 잘라냄 | | BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 |
| 2 | 컨텐츠 영역 (266) | 일반 모드에 `overflow-auto` 없음 | 스크롤 불가 | | BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 |
| 3 | 백색 배경 (275) | 일반 모드에 `min-h-full` 없음 | 짧은 콘텐츠 시 배경 불완전 |
--- ---
## 2. 수정 대상 파일 (1개) ## 2. 수정 대상 파일 (2개)
### `frontend/app/(pop)/pop/screens/[screenId]/page.tsx` ### 파일 A: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx`
**변경 유형**: CSS 클래스 문자열 수정 3곳 (새 변수/함수/타입 추가 없음) **변경 유형**: 설정 UI 추가 3건
#### 변경 1: 라인 185 - 최외곽 컨테이너 #### 변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래)
**현재 코드**: 집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가.
**추가할 위치**: `{/* 집계 함수 + 대상 컬럼 */}` 블록 다음, `{/* 자동 새로고침 */}` 블록 이전
**추가할 코드** (약 50줄):
```tsx
{/* 그룹핑 (차트용 X축 분류) */}
{dataSource.aggregation && (
<div>
<Label className="text-xs">그룹핑 (X축)</Label>
<Popover open={groupByOpen} onOpenChange={setGroupByOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={groupByOpen}
disabled={loadingCols}
className="h-8 w-full justify-between text-xs"
>
{dataSource.aggregation.groupBy?.length
? dataSource.aggregation.groupBy.join(", ")
: "없음 (단일 값)"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs">
컬럼을 찾을 수 없습니다.
</CommandEmpty>
<CommandGroup>
{columns.map((col) => (
<CommandItem
key={col.name}
value={col.name}
onSelect={() => {
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"
>
<Check
className={cn(
"mr-2 h-3 w-3",
dataSource.aggregation?.groupBy?.includes(col.name)
? "opacity-100"
: "opacity-0"
)}
/>
<span className="font-medium">{col.name}</span>
<span className="ml-1 text-muted-foreground">({col.type})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="mt-0.5 text-[10px] text-muted-foreground">
차트에서 X축 카테고리로 사용됩니다
</p>
</div>
)}
``` ```
<div className="h-screen bg-gray-100 flex flex-col overflow-hidden">
**필요한 state 추가** (DataSourceEditor 내부, 기존 state 옆):
```tsx
const [groupByOpen, setGroupByOpen] = useState(false);
```
#### 변경 A-2: 차트 타입 설정에 xAxisColumn/yAxisColumn 추가 (라인 1187~1212 부근)
**추가할 위치**: `{item.subType === "chart" && (` 블록 내부, 차트 유형 Select 다음
**추가할 코드** (약 30줄):
```tsx
{/* X축 컬럼 */}
<div>
<Label className="text-xs">X축 컬럼</Label>
<Input
value={item.chartConfig?.xAxisColumn ?? ""}
onChange={(e) =>
onUpdate({
...item,
chartConfig: {
...item.chartConfig,
chartType: item.chartConfig?.chartType ?? "bar",
xAxisColumn: e.target.value || undefined,
},
})
}
placeholder="groupBy 컬럼명 (비우면 자동)"
className="h-8 text-xs"
/>
<p className="mt-0.5 text-[10px] text-muted-foreground">
그룹핑 컬럼명과 동일하게 입력. 비우면 첫 번째 groupBy 컬럼 사용
</p>
</div>
```
#### 변경 A-3: 통계 카드 타입에 카테고리 설정 UI 추가 (라인 1272 부근, 게이지 설정 다음)
**추가할 위치**: `{item.subType === "gauge" && (` 블록 다음에 새 블록 추가
**추가할 코드** (약 100줄): `StatCategoryEditor` 인라인 블록
```tsx
{item.subType === "stat-card" && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">카테고리 설정</Label>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={() => {
const currentCats = item.statConfig?.categories ?? [];
onUpdate({
...item,
statConfig: {
...item.statConfig,
categories: [
...currentCats,
{
label: `카테고리 ${currentCats.length + 1}`,
filter: { column: "", operator: "=", value: "" },
},
],
},
});
}}
>
<Plus className="mr-1 h-3 w-3" />
카테고리 추가
</Button>
</div>
{(item.statConfig?.categories ?? []).map((cat, catIdx) => (
<div key={catIdx} className="space-y-1 rounded border p-2">
<div className="flex items-center gap-1">
<Input
value={cat.label}
onChange={(e) => {
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"
/>
<Input
value={cat.color ?? ""}
onChange={(e) => {
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"
/>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive"
onClick={() => {
const newCats = (item.statConfig?.categories ?? []).filter(
(_, i) => i !== catIdx
);
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
{/* 필터 조건: 컬럼 / 연산자 / 값 */}
<div className="flex items-center gap-1 text-[10px]">
<Input
value={cat.filter.column}
onChange={(e) => {
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]"
/>
<Select
value={cat.filter.operator}
onValueChange={(val) => {
const newCats = [...(item.statConfig?.categories ?? [])];
newCats[catIdx] = {
...cat,
filter: { ...cat.filter, operator: val as FilterOperator },
};
onUpdate({
...item,
statConfig: { ...item.statConfig, categories: newCats },
});
}}
>
<SelectTrigger className="h-6 w-16 text-[10px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=" className="text-xs">= 같음</SelectItem>
<SelectItem value="!=" className="text-xs">!= 다름</SelectItem>
<SelectItem value="like" className="text-xs">LIKE</SelectItem>
</SelectContent>
</Select>
<Input
value={String(cat.filter.value ?? "")}
onChange={(e) => {
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]"
/>
</div>
</div>
))}
{(item.statConfig?.categories ?? []).length === 0 && (
<p className="text-[10px] text-muted-foreground">
카테고리를 추가하면 각 조건에 맞는 건수가 표시됩니다
</p>
)}
</div>
)}
```
---
### 파일 B: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardComponent.tsx`
**변경 유형**: 데이터 처리 로직 수정 2건
#### 변경 B-1: 차트 xAxisColumn 자동 설정 (라인 276~283 부근)
차트에 groupBy가 있지만 xAxisColumn이 설정되지 않은 경우, 첫 번째 groupBy 컬럼을 자동으로 xAxisColumn에 반영.
**현재 코드** (라인 276~283):
```tsx
case "chart":
return (
<ChartItemComponent
item={item}
rows={itemData.rows}
containerWidth={containerWidth}
/>
);
``` ```
**변경 코드**: **변경 코드**:
``` ```tsx
<div className="h-screen bg-gray-100 flex flex-col"> 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 (
<ChartItemComponent
item={chartItem}
rows={itemData.rows}
containerWidth={containerWidth}
/>
);
}
``` ```
**변경 내용**: `overflow-hidden` 제거 #### 변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297)
**이유**: 이 div는 프리뷰 툴바 + 컨텐츠의 flex 컨테이너 역할만 하면 됨. `overflow-hidden`이 자식의 스크롤까지 차단하므로 제거
#### 변경 2: 라인 266 - 컨텐츠 영역 **현재 코드** (버그):
```tsx
**현재 코드**: case "stat-card": {
``` const categoryData: Record<string, number> = {};
<div className={`flex-1 flex flex-col ${isPreviewMode ? "py-4 overflow-auto items-center" : ""}`}> if (item.statConfig?.categories) {
for (const cat of item.statConfig.categories) {
categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값
}
}
return (
<StatCardComponent item={item} categoryData={categoryData} />
);
}
``` ```
**변경 코드**: **변경 코드**:
``` ```tsx
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : ""}`}> case "stat-card": {
const categoryData: Record<string, number> = {};
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 (
<StatCardComponent item={item} categoryData={categoryData} />
);
}
``` ```
**변경 내용**: `overflow-auto`를 조건문 밖으로 이동 (공통 적용) **주의**: 이 방식은 rows에 전체 데이터가 있어야 작동함. 통계 카드의 dataSource에 집계 함수를 설정하지 않고(또는 groupBy를 사용하여) 원시 rows를 가져와야 한다.
**이유**: 프리뷰/일반 모드 모두 스크롤이 필요함
#### 변경 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` 추가
**이유**: 컴포넌트가 적어 콘텐츠가 짧을 때에도 흰색 배경이 화면 전체를 채우도록 보장
--- ---
## 3. 구현 순서 (의존성 기반) ## 3. 구현 순서 (의존성 기반)
| 순서 | 작업 | 라인 | 의존성 | 상태 | | 순서 | 작업 | 파일 | 의존성 | 상태 |
|------|------|------|--------|------| |------|------|------|--------|------|
| 1 | 라인 185: `overflow-hidden` 제거 | 185 | 없음 | [x] 완료 | | 1 | A-1: DataSourceEditor에 groupBy UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
| 2 | 라인 266: `overflow-auto` 공통 적용 | 266 | 순서 1 | [x] 완료 | | 2 | A-2: 차트 xAxisColumn 입력 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
| 3 | 라인 275: 일반 모드 `min-h-full` 추가 | 275 | 순서 2 | [x] 완료 | | 3 | A-3: 통계 카드 카테고리 설정 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
| 4 | 린트 검사 | - | 순서 1~3 | [x] 통과 | | 4 | B-1: 차트 xAxisColumn 자동 보정 로직 | PopDashboardComponent.tsx | 순서 1 | [x] |
| 5 | 브라우저 검증 | - | 순서 4 | [ ] 대기 | | 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. 사전 충돌 검사 결과 ## 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. 에러 함정 경고 ## 5. 에러 함정 경고
### 함정 1: 순서 1만 하고 순서 2를 빼먹으면 ### 함정 1: 차트에 groupBy만 설정하고 xAxisColumn을 비우면
`overflow-hidden`만 제거하면 콘텐츠가 화면 밖으로 넘쳐 보이지만 스크롤은 안 됨. ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `{status: "수주", value: 79}` 형태.
부모는 열었지만 자식에 스크롤 속성이 없는 상태. `name` 키가 없으므로 X축이 빈 채로 렌더링됨.
**B-1의 자동 보정 로직이 필수**. 순서 4를 빠뜨리면 차트가 깨짐.
### 함정 2: 순서 2만 하고 순서 1을 빼먹으면 ### 함정 2: 통계 카드에 집계 함수를 설정하면
자식에 `overflow-auto`를 넣어도 부모가 `overflow-hidden`으로 잘라내므로 여전히 스크롤 안 됨. 집계(COUNT 등)가 설정되면 rows에 `[{value: 87}]` 하나만 들어옴.
**반드시 순서 1과 2를 함께 적용해야 함.** 카테고리별 필터링이 작동하려면 **집계 함수를 "없음"으로 두거나, groupBy를 설정**해야 개별 행이 rows에 포함됨.
통계 카드에서는 **집계를 사용하지 않는 것이 올바른 사용법**.
설정 가이드 문서에 이 점을 명시해야 함.
### 함정 3: 프리뷰 모드 영향 ### 함정 3: PopDashboardConfig.tsx의 import 누락
프리뷰 모드는 이미 자체적으로 `overflow-auto`가 있으므로 이 수정에 영향 없음. 현재 `FilterOperator`는 이미 import되어 있음 (라인 54).
`overflow-auto`가 중복 적용되어도 CSS에서 문제 없음. `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. 검증 방법 ## 6. 검증 방법
1. `localhost:9771/pop/screens/4114` 접속 (iPhone SE 375px 기준) ### 차트 (BUG-1, BUG-2)
2. 화면 아래로 스크롤 가능한지 확인 1. 아이템 추가 > "차트" 선택
3. 맨 아래에 이미지(pop-text 5, 6)가 보이는지 확인 2. 테이블: `sales_order_mng`, 집계: COUNT, 컬럼: `id`, 그룹핑: `status`
4. 프리뷰 모드(`?preview=true`)에서도 기존처럼 정상 동작하는지 확인 3. 차트 유형: 막대 차트
5. 컴포넌트가 적은 화면에서 흰색 배경이 화면 전체를 채우는지 확인 4. 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1
### 통계 카드 (BUG-3, BUG-4)
1. 아이템 추가 > "통계 카드" 선택
2. 테이블: `sales_order_mng`, **집계: 없음** (중요!)
3. 카테고리 추가:
- "수주" / status / = / 수주
- "진행중" / status / = / 진행중
- "완료" / status / = / 완료
4. 기대 결과: 수주 79, 진행중 7, 완료 1
--- ---
## 이전 완료 계획 (아카이브) ## 이전 완료 계획 (아카이브)
<details>
<summary>POP 뷰어 스크롤 수정 (완료)</summary>
- [x] 라인 185: overflow-hidden 제거
- [x] 라인 266: overflow-auto 공통 적용
- [x] 라인 275: 일반 모드 min-h-full 추가
- [x] 린트 검사 통과
</details>
<details> <details>
<summary>POP 뷰어 실제 컴포넌트 렌더링 (완료)</summary> <summary>POP 뷰어 실제 컴포넌트 렌더링 (완료)</summary>
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가 - [x] 뷰어 페이지에 레지스트리 초기화 import 추가
- [x] `renderActualComponent()` 실제 컴포넌트 렌더링으로 교체 - [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체
- [x] 린트 검사 통과 - [x] 린트 검사 통과
- 브라우저 검증: 컴포넌트 표시 정상, 스크롤 문제 발견 -> 별도 수정
</details> </details>

46
STATUS.md Normal file
View File

@ -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`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 |

View File

@ -2839,4 +2839,4 @@ export const ensurePopRootGroup = async (req: AuthenticatedRequest, res: Respons
logger.error("POP 루트 그룹 확보 실패:", error); logger.error("POP 루트 그룹 확보 실패:", error);
res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message }); res.status(500).json({ success: false, message: "POP 루트 그룹 확보에 실패했습니다.", error: error.message });
} }
}; };

View File

@ -112,6 +112,8 @@ interface PopCanvasProps {
onLockLayout?: () => void; onLockLayout?: () => void;
onResetOverride?: (mode: GridMode) => void; onResetOverride?: (mode: GridMode) => void;
onChangeGapPreset?: (preset: GapPreset) => void; onChangeGapPreset?: (preset: GapPreset) => void;
/** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */
previewPageIndex?: number;
} }
// ======================================== // ========================================
@ -135,6 +137,7 @@ export default function PopCanvas({
onLockLayout, onLockLayout,
onResetOverride, onResetOverride,
onChangeGapPreset, onChangeGapPreset,
previewPageIndex,
}: PopCanvasProps) { }: PopCanvasProps) {
// 줌 상태 // 줌 상태
const [canvasScale, setCanvasScale] = useState(0.8); const [canvasScale, setCanvasScale] = useState(0.8);
@ -690,6 +693,7 @@ export default function PopCanvas({
onComponentResizeEnd={onResizeEnd} onComponentResizeEnd={onResizeEnd}
overrideGap={adjustedGap} overrideGap={adjustedGap}
overridePadding={adjustedPadding} overridePadding={adjustedPadding}
previewPageIndex={previewPageIndex}
/> />
)} )}
</div> </div>

View File

@ -69,6 +69,9 @@ export default function PopDesigner({
// 선택 상태 // 선택 상태
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null); const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
// 대시보드 페이지 미리보기 인덱스 (-1 = 기본 모드)
const [previewPageIndex, setPreviewPageIndex] = useState<number>(-1);
// 그리드 모드 (4개 프리셋) // 그리드 모드 (4개 프리셋)
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape"); const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
@ -217,24 +220,28 @@ export default function PopDesigner({
const handleUpdateComponent = useCallback( const handleUpdateComponent = useCallback(
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => { (componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
const existingComponent = layout.components[componentId]; // 함수적 업데이트로 stale closure 방지
if (!existingComponent) return; setLayout((prev) => {
const existingComponent = prev.components[componentId];
if (!existingComponent) return prev;
const newLayout = { const newComponent = {
...layout, ...existingComponent,
components: { ...updates,
...layout.components, };
[componentId]: { const newLayout = {
...existingComponent, ...prev,
...updates, components: {
...prev.components,
[componentId]: newComponent,
}, },
}, };
}; saveToHistory(newLayout);
setLayout(newLayout); return newLayout;
saveToHistory(newLayout); });
setHasChanges(true); setHasChanges(true);
}, },
[layout, saveToHistory] [saveToHistory]
); );
const handleDeleteComponent = useCallback( const handleDeleteComponent = useCallback(
@ -637,6 +644,7 @@ export default function PopDesigner({
onLockLayout={handleLockLayout} onLockLayout={handleLockLayout}
onResetOverride={handleResetOverride} onResetOverride={handleResetOverride}
onChangeGapPreset={handleChangeGapPreset} onChangeGapPreset={handleChangeGapPreset}
previewPageIndex={previewPageIndex}
/> />
</ResizablePanel> </ResizablePanel>
@ -655,6 +663,8 @@ export default function PopDesigner({
allComponents={Object.values(layout.components)} allComponents={Object.values(layout.components)}
onSelectComponent={setSelectedComponentId} onSelectComponent={setSelectedComponentId}
selectedComponentId={selectedComponentId} selectedComponentId={selectedComponentId}
previewPageIndex={previewPageIndex}
onPreviewPage={setPreviewPageIndex}
/> />
</ResizablePanel> </ResizablePanel>
</ResizablePanelGroup> </ResizablePanelGroup>

View File

@ -42,6 +42,10 @@ interface ComponentEditorPanelProps {
onSelectComponent?: (componentId: string) => void; onSelectComponent?: (componentId: string) => void;
/** 현재 선택된 컴포넌트 ID */ /** 현재 선택된 컴포넌트 ID */
selectedComponentId?: string | null; selectedComponentId?: string | null;
/** 대시보드 페이지 미리보기 인덱스 */
previewPageIndex?: number;
/** 페이지 미리보기 요청 콜백 */
onPreviewPage?: (pageIndex: number) => void;
} }
// ======================================== // ========================================
@ -50,6 +54,7 @@ interface ComponentEditorPanelProps {
const COMPONENT_TYPE_LABELS: Record<string, string> = { const COMPONENT_TYPE_LABELS: Record<string, string> = {
"pop-sample": "샘플", "pop-sample": "샘플",
"pop-text": "텍스트", "pop-text": "텍스트",
"pop-icon": "아이콘",
"pop-dashboard": "대시보드", "pop-dashboard": "대시보드",
"pop-field": "필드", "pop-field": "필드",
"pop-button": "버튼", "pop-button": "버튼",
@ -73,6 +78,8 @@ export default function ComponentEditorPanel({
allComponents, allComponents,
onSelectComponent, onSelectComponent,
selectedComponentId, selectedComponentId,
previewPageIndex,
onPreviewPage,
}: ComponentEditorPanelProps) { }: ComponentEditorPanelProps) {
const breakpoint = GRID_BREAKPOINTS[currentMode]; const breakpoint = GRID_BREAKPOINTS[currentMode];
@ -182,6 +189,8 @@ export default function ComponentEditorPanel({
<ComponentSettingsForm <ComponentSettingsForm
component={component} component={component}
onUpdate={onUpdateComponent} onUpdate={onUpdateComponent}
previewPageIndex={previewPageIndex}
onPreviewPage={onPreviewPage}
/> />
</TabsContent> </TabsContent>
@ -362,9 +371,11 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
interface ComponentSettingsFormProps { interface ComponentSettingsFormProps {
component: PopComponentDefinitionV5; component: PopComponentDefinitionV5;
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void; onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
previewPageIndex?: number;
onPreviewPage?: (pageIndex: number) => void;
} }
function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) { function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage }: ComponentSettingsFormProps) {
// PopComponentRegistry에서 configPanel 가져오기 // PopComponentRegistry에서 configPanel 가져오기
const registeredComp = PopComponentRegistry.getComponent(component.type); const registeredComp = PopComponentRegistry.getComponent(component.type);
const ConfigPanel = registeredComp?.configPanel; const ConfigPanel = registeredComp?.configPanel;
@ -393,6 +404,8 @@ function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormPro
<ConfigPanel <ConfigPanel
config={component.config || {}} config={component.config || {}}
onUpdate={handleConfigUpdate} onUpdate={handleConfigUpdate}
onPreviewPage={onPreviewPage}
previewPageIndex={previewPageIndex}
/> />
) : ( ) : (
<div className="rounded-lg bg-gray-50 p-3"> <div className="rounded-lg bg-gray-50 p-3">

View File

@ -3,7 +3,7 @@
import { useDrag } from "react-dnd"; import { useDrag } from "react-dnd";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PopComponentType } from "../types/pop-layout"; 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"; import { DND_ITEM_TYPES } from "../constants";
// 컴포넌트 정의 // 컴포넌트 정의
@ -33,6 +33,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
icon: MousePointer, icon: MousePointer,
description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)", description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)",
}, },
{
type: "pop-dashboard",
label: "대시보드",
icon: BarChart3,
description: "KPI, 차트, 게이지, 통계 집계",
},
]; ];
// 드래그 가능한 컴포넌트 아이템 // 드래그 가능한 컴포넌트 아이템

View File

@ -56,6 +56,8 @@ interface PopRendererProps {
className?: string; className?: string;
/** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */ /** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */
currentScreenId?: number; currentScreenId?: number;
/** 대시보드 페이지 미리보기 인덱스 */
previewPageIndex?: number;
} }
// ======================================== // ========================================
@ -64,6 +66,9 @@ interface PopRendererProps {
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = { const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
"pop-sample": "샘플", "pop-sample": "샘플",
"pop-text": "텍스트",
"pop-icon": "아이콘",
"pop-dashboard": "대시보드",
}; };
// ======================================== // ========================================
@ -86,6 +91,7 @@ export default function PopRenderer({
overridePadding, overridePadding,
className, className,
currentScreenId, currentScreenId,
previewPageIndex,
}: PopRendererProps) { }: PopRendererProps) {
const { gridConfig, components, overrides } = layout; const { gridConfig, components, overrides } = layout;
@ -251,6 +257,7 @@ export default function PopRenderer({
onComponentMove={onComponentMove} onComponentMove={onComponentMove}
onComponentResize={onComponentResize} onComponentResize={onComponentResize}
onComponentResizeEnd={onComponentResizeEnd} onComponentResizeEnd={onComponentResizeEnd}
previewPageIndex={previewPageIndex}
/> />
); );
} }
@ -294,6 +301,7 @@ interface DraggableComponentProps {
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void; onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void; onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
onComponentResizeEnd?: (componentId: string) => void; onComponentResizeEnd?: (componentId: string) => void;
previewPageIndex?: number;
} }
function DraggableComponent({ function DraggableComponent({
@ -311,6 +319,7 @@ function DraggableComponent({
onComponentMove, onComponentMove,
onComponentResize, onComponentResize,
onComponentResizeEnd, onComponentResizeEnd,
previewPageIndex,
}: DraggableComponentProps) { }: DraggableComponentProps) {
const [{ isDragging }, drag] = useDrag( const [{ isDragging }, drag] = useDrag(
() => ({ () => ({
@ -349,6 +358,7 @@ function DraggableComponent({
effectivePosition={position} effectivePosition={position}
isDesignMode={isDesignMode} isDesignMode={isDesignMode}
isSelected={isSelected} isSelected={isSelected}
previewPageIndex={previewPageIndex}
/> />
{/* 리사이즈 핸들 (선택된 컴포넌트만) */} {/* 리사이즈 핸들 (선택된 컴포넌트만) */}
@ -499,9 +509,10 @@ interface ComponentContentProps {
effectivePosition: PopGridPosition; effectivePosition: PopGridPosition;
isDesignMode: boolean; isDesignMode: boolean;
isSelected: 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; const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
// PopComponentRegistry에서 등록된 컴포넌트 가져오기 // PopComponentRegistry에서 등록된 컴포넌트 가져오기
@ -526,6 +537,7 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
config={component.config} config={component.config}
label={component.label} label={component.label}
isDesignMode={isDesignMode} isDesignMode={isDesignMode}
previewPageIndex={previewPageIndex}
/> />
</div> </div>
); );

View File

@ -9,7 +9,7 @@
/** /**
* POP * 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<PopComponentType, { colSpan: nu
"pop-sample": { colSpan: 2, rowSpan: 1 }, "pop-sample": { colSpan: 2, rowSpan: 1 },
"pop-text": { colSpan: 3, rowSpan: 1 }, "pop-text": { colSpan: 3, rowSpan: 1 },
"pop-icon": { colSpan: 1, rowSpan: 2 }, "pop-icon": { colSpan: 1, rowSpan: 2 },
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
}; };
/** /**

View File

@ -14,6 +14,7 @@ export * from "./types";
// POP 컴포넌트 등록 // POP 컴포넌트 등록
import "./pop-text"; import "./pop-text";
import "./pop-icon"; import "./pop-icon";
import "./pop-dashboard";
// 향후 추가될 컴포넌트들: // 향후 추가될 컴포넌트들:
// import "./pop-field"; // import "./pop-field";

View File

@ -0,0 +1,459 @@
"use client";
/**
* pop-dashboard ()
*
* 컨테이너: 여러
*
* @INFRA-EXTRACT :
* - fetchAggregatedData -> useDataSource로
* - filter_changed -> usePopEvent로
*/
import React, { useState, useEffect, useCallback, useRef } from "react";
import type {
PopDashboardConfig,
DashboardItem,
DashboardPage,
} from "../types";
import { fetchAggregatedData } from "./utils/dataFetcher";
import {
evaluateFormula,
formatFormulaResult,
} from "./utils/formula";
// 서브타입 아이템 컴포넌트
import { KpiCardComponent } from "./items/KpiCard";
import { ChartItemComponent } from "./items/ChartItem";
import { GaugeItemComponent } from "./items/GaugeItem";
import { StatCardComponent } from "./items/StatCard";
// 표시 모드 컴포넌트
import { ArrowsModeComponent } from "./modes/ArrowsMode";
import { AutoSlideModeComponent } from "./modes/AutoSlideMode";
import { GridModeComponent } from "./modes/GridMode";
import { ScrollModeComponent } from "./modes/ScrollMode";
// ===== 마이그레이션: 기존 config -> 페이지 기반 구조 변환 =====
/**
* config를 .
* ( config ).
*
* 시나리오1: displayMode="grid" ( )
* 시나리오2: useGridLayout=true ( )
* 시나리오3: pages ( ) ->
* 시나리오4: pages + grid -> pages ( )
*/
export function migrateConfig(
raw: Record<string, unknown>
): PopDashboardConfig {
const config = { ...raw } as PopDashboardConfig & Record<string, unknown>;
// 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<string, unknown>).useGridLayout === true;
if (wasGrid) {
const cols =
((config as Record<string, unknown>).gridColumns as number) ?? 2;
const rows =
((config as Record<string, unknown>).gridRows as number) ?? 2;
const cells =
((config as Record<string, unknown>).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<string, unknown>).displayMode = "arrows";
}
}
return config as PopDashboardConfig;
}
// ===== 내부 타입 =====
interface ItemData {
/** 단일 집계 값 */
value: number;
/** 데이터 행 (차트용) */
rows: Record<string, unknown>[];
/** 수식 결과 표시 문자열 */
formulaDisplay: string | null;
/** 에러 메시지 */
error: string | null;
}
// ===== 데이터 로딩 함수 =====
/** 단일 아이템의 데이터를 조회 */
async function loadItemData(item: DashboardItem): Promise<ItemData> {
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<string, number> = {};
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<Record<string, ItemData>>({});
const [loading, setLoading] = useState(true);
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const containerRef = useRef<HTMLDivElement>(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<string, ItemData> = {};
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 (
<div className="flex h-full w-full items-center justify-center bg-muted/20">
<span className="text-sm text-muted-foreground">
</span>
</div>
);
}
// 단일 아이템 렌더링
const renderSingleItem = (item: DashboardItem) => {
const itemData = dataMap[item.id];
if (!itemData) {
return (
<div className="flex h-full w-full items-center justify-center">
<span className="text-xs text-muted-foreground"> ...</span>
</div>
);
}
if (itemData.error) {
return (
<div className="flex h-full w-full items-center justify-center">
<span className="text-xs text-destructive">{itemData.error}</span>
</div>
);
}
switch (item.subType) {
case "kpi-card":
return (
<KpiCardComponent
item={item}
data={itemData.value}
formulaDisplay={itemData.formulaDisplay}
/>
);
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 (
<ChartItemComponent
item={chartItem}
rows={itemData.rows}
containerWidth={containerWidth}
/>
);
}
case "gauge":
return <GaugeItemComponent item={item} data={itemData.value} />;
case "stat-card": {
// StatCard: 카테고리별 건수 맵 구성 (필터 적용)
const categoryData: Record<string, number> = {};
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 (
<StatCardComponent item={item} categoryData={categoryData} />
);
}
default:
return (
<div className="flex h-full w-full items-center justify-center">
<span className="text-xs text-muted-foreground">
: {item.subType}
</span>
</div>
);
}
};
// 로딩 상태
if (loading && !Object.keys(dataMap).length) {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
// 마이그레이션: 기존 config를 페이지 기반으로 변환
const migrated = migrateConfig(config as unknown as Record<string, unknown>);
const pages = migrated.pages ?? [];
const displayMode = migrated.displayMode;
// 페이지 하나를 GridModeComponent로 렌더링
const renderPageContent = (page: DashboardPage) => {
return (
<GridModeComponent
cells={page.gridCells}
columns={page.gridColumns}
rows={page.gridRows}
gap={config.gap}
containerWidth={containerWidth}
renderItem={(itemId) => {
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 (
<div
ref={containerRef}
className="h-full w-full"
style={
config.backgroundColor
? { backgroundColor: config.backgroundColor }
: undefined
}
>
{renderPageContent(pages[previewPageIndex])}
</div>
);
}
// 표시 모드별 렌더링
return (
<div
ref={containerRef}
className="h-full w-full"
style={
config.backgroundColor
? { backgroundColor: config.backgroundColor }
: undefined
}
>
{displayMode === "arrows" && (
<ArrowsModeComponent
itemCount={slideCount}
showIndicator={config.showIndicator}
renderItem={renderSlide}
/>
)}
{displayMode === "auto-slide" && (
<AutoSlideModeComponent
itemCount={slideCount}
interval={config.autoSlideInterval}
resumeDelay={config.autoSlideResumeDelay}
showIndicator={config.showIndicator}
renderItem={renderSlide}
/>
)}
{displayMode === "scroll" && (
<ScrollModeComponent
itemCount={slideCount}
showIndicator={config.showIndicator}
renderItem={renderSlide}
/>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -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<DashboardSubType, React.ReactNode> = {
"kpi-card": <BarChart3 className="h-4 w-4" />,
chart: <PieChart className="h-4 w-4" />,
gauge: <Gauge className="h-4 w-4" />,
"stat-card": <LayoutList className="h-4 w-4" />,
};
const SUBTYPE_LABELS: Record<DashboardSubType, string> = {
"kpi-card": "KPI",
chart: "차트",
gauge: "게이지",
"stat-card": "통계",
};
// ===== 모드 라벨 =====
const MODE_LABELS: Record<string, string> = {
arrows: "좌우 버튼",
"auto-slide": "자동 슬라이드",
scroll: "스크롤",
};
// ===== 더미 아이템 프리뷰 =====
function DummyItemPreview({
subType,
label,
}: {
subType: DashboardSubType;
label: string;
}) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 rounded border border-dashed border-muted-foreground/30 bg-muted/20 p-2">
<span className="text-muted-foreground">
{SUBTYPE_ICONS[subType]}
</span>
<span className="truncate text-[10px] text-muted-foreground">
{label || SUBTYPE_LABELS[subType]}
</span>
</div>
);
}
// ===== 메인 미리보기 =====
export function PopDashboardPreviewComponent({
config,
}: {
config?: PopDashboardConfig;
}) {
// config가 빈 객체 {} 또는 items가 없는 경우 방어
if (!config || !Array.isArray(config.items) || !config.items.length) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 overflow-hidden">
<BarChart3 className="h-6 w-6 text-muted-foreground/50" />
<span className="text-[10px] text-muted-foreground"></span>
</div>
);
}
const visibleItems = config.items.filter((i) => i.visible);
// 마이그레이션 적용
const migrated = migrateConfig(config as unknown as Record<string, unknown>);
const pages = migrated.pages ?? [];
const hasPages = pages.length > 0;
return (
<div className="flex h-full w-full flex-col overflow-hidden p-1">
{/* 모드 + 페이지 뱃지 */}
<div className="mb-1 flex items-center gap-1">
<span className="rounded bg-primary/10 px-1 py-0.5 text-[8px] font-medium text-primary">
{MODE_LABELS[migrated.displayMode] ?? migrated.displayMode}
</span>
{hasPages && (
<span className="rounded bg-muted px-1 py-0.5 text-[8px] font-medium text-muted-foreground">
{pages.length}
</span>
)}
<span className="text-[8px] text-muted-foreground">
{visibleItems.length}
</span>
</div>
{/* 미리보기 */}
<div className="min-h-0 flex-1">
{hasPages ? (
// 첫 번째 페이지 그리드 미리보기
<div
className="h-full w-full gap-1"
style={{
display: "grid",
gridTemplateColumns: `repeat(${pages[0].gridColumns}, 1fr)`,
gridTemplateRows: `repeat(${pages[0].gridRows}, 1fr)`,
}}
>
{pages[0].gridCells.length > 0
? pages[0].gridCells.map((cell) => {
const item = visibleItems.find(
(i) => i.id === cell.itemId
);
return (
<div
key={cell.id}
style={{
gridColumn: cell.gridColumn,
gridRow: cell.gridRow,
}}
>
{item ? (
<DummyItemPreview
subType={item.subType}
label={item.label}
/>
) : (
<div className="h-full rounded border border-dashed border-muted-foreground/20" />
)}
</div>
);
})
: visibleItems.slice(0, 4).map((item) => (
<DummyItemPreview
key={item.id}
subType={item.subType}
label={item.label}
/>
))}
</div>
) : (
// 페이지 미설정: 첫 번째 아이템만 크게 표시
<div className="relative h-full">
{visibleItems[0] && (
<DummyItemPreview
subType={visibleItems[0].subType}
label={visibleItems[0].label}
/>
)}
{visibleItems.length > 1 && (
<div className="absolute bottom-1 right-1 rounded-full bg-primary/80 px-1.5 py-0.5 text-[8px] font-medium text-primary-foreground">
+{visibleItems.length - 1}
</div>
)}
</div>
)}
</div>
</div>
);
}

View File

@ -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"],
});

View File

@ -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<string, unknown>[];
/** 컨테이너 너비 (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 (
<div className="flex h-full w-full items-center justify-center p-1">
<span className="text-[10px] text-muted-foreground"></span>
</div>
);
}
// 데이터 없음
if (!rows.length) {
return (
<div className="flex h-full w-full items-center justify-center">
<span className="text-xs text-muted-foreground"> </span>
</div>
);
}
// 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 (
<div className="@container flex h-full w-full flex-col p-2">
{/* 라벨 - 사용자 정렬 적용 */}
{visibility.showLabel && (
<p className={`w-full mb-1 text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
{item.label}
</p>
)}
{/* 차트 영역 */}
<div className="min-h-0 flex-1">
<ResponsiveContainer width="100%" height="100%">
{chartType === "bar" ? (
<BarChart data={rows as Record<string, string | number>[]} margin={chartMargin}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey={xKey}
tick={xAxisTickProps}
hide={containerWidth < 200}
/>
<YAxis
tick={{ fontSize: 10 }}
hide={containerWidth < 200}
tickFormatter={(v: number) => abbreviateNumber(v)}
/>
<Tooltip />
<Bar dataKey={yKey} fill={colors[0]} radius={[2, 2, 0, 0]} />
</BarChart>
) : chartType === "line" ? (
<LineChart data={rows as Record<string, string | number>[]} margin={chartMargin}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis
dataKey={xKey}
tick={xAxisTickProps}
hide={containerWidth < 200}
/>
<YAxis
tick={{ fontSize: 10 }}
hide={containerWidth < 200}
tickFormatter={(v: number) => abbreviateNumber(v)}
/>
<Tooltip />
<Line
type="monotone"
dataKey={yKey}
stroke={colors[0]}
strokeWidth={2}
dot={containerWidth > 250}
/>
</LineChart>
) : (
/* pie - 카테고리명 + 값 라벨 표시 */
<PieChart>
<Pie
data={rows as Record<string, string | number>[]}
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) => (
<Cell
key={`cell-${index}`}
fill={colors[index % colors.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value: number, name: string) => [abbreviateNumber(value), name]}
/>
{containerWidth > 300 && (
<Legend
wrapperStyle={{ fontSize: 11 }}
iconSize={10}
/>
)}
</PieChart>
)}
</ResponsiveContainer>
</div>
</div>
);
}

View File

@ -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<FontSize, number> = {
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 (
<div className="@container flex h-full w-full flex-col items-center justify-center p-2">
{/* 라벨 - 사용자 정렬 적용 */}
{visibility.showLabel && (
<p className={`w-full shrink-0 truncate text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
{item.label}
</p>
)}
{/* 게이지 SVG - 높이/너비 모두 반응형 */}
<div className="flex min-h-0 flex-1 items-center justify-center w-full">
<svg
viewBox="0 0 200 110"
className="h-full w-auto max-w-full"
preserveAspectRatio="xMidYMid meet"
>
{/* 배경 반원 (회색) */}
<path
d={`M ${cx - radius} ${cy} A ${radius} ${radius} 0 0 1 ${cx + radius} ${cy}`}
fill="none"
stroke="#e5e7eb"
strokeWidth="12"
strokeLinecap="round"
/>
{/* 값 반원 (색상) */}
{percentage > 0 && (
<path
d={`M ${startX} ${startY} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`}
fill="none"
stroke={gaugeColor}
strokeWidth="12"
strokeLinecap="round"
/>
)}
{/* 중앙 텍스트 */}
{visibility.showValue && (
<text
x={cx}
y={cy - 10}
textAnchor="middle"
className="fill-foreground font-bold"
fontSize={svgValueFontSize}
>
{abbreviateNumber(current)}
</text>
)}
{/* 퍼센트 */}
<text
x={cx}
y={cy + 10}
textAnchor="middle"
className="fill-muted-foreground"
fontSize={svgSubFontSize}
>
{percentage.toFixed(1)}%
</text>
</svg>
</div>
{/* 목표값 */}
{visibility.showTarget && (
<p className="shrink-0 text-xs text-muted-foreground">
: {abbreviateNumber(target)}
</p>
)}
</div>
);
}

View File

@ -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 (
<span className={`inline-flex items-center gap-0.5 text-xs font-medium ${color}`}>
<span>{arrow}</span>
<span>{Math.abs(value).toFixed(1)}%</span>
</span>
);
}
// ===== 색상 구간 판정 =====
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 (
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
{/* 라벨 - 사용자 정렬 적용 */}
{visibility.showLabel && (
<p className={`w-full text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
{item.label}
</p>
)}
{/* 메인 값 - @container 반응형 */}
{visibility.showValue && (
<div className="flex items-baseline gap-1">
<span
className="text-xl font-bold @[200px]:text-3xl @[350px]:text-4xl @[500px]:text-5xl"
style={valueColor ? { color: valueColor } : undefined}
>
{formulaDisplay ?? abbreviateNumber(displayValue)}
</span>
{/* 단위 */}
{visibility.showUnit && kpiConfig?.unit && (
<span className="text-xs text-muted-foreground @[200px]:text-sm">
{kpiConfig.unit}
</span>
)}
</div>
)}
{/* 증감율 */}
{visibility.showTrend && trendValue != null && (
<TrendIndicator value={trendValue} />
)}
{/* 보조 라벨 (수식 표시 등) */}
{visibility.showSubLabel && formulaDisplay && (
<p className="text-xs text-muted-foreground @[200px]:text-sm">
{item.formula?.values.map((v) => v.label).join(" / ")}
</p>
)}
</div>
);
}

View File

@ -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<string, number>;
}
// ===== 기본 색상 팔레트 =====
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 (
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
{/* 라벨 - 사용자 정렬 적용 */}
{visibility.showLabel && (
<p className={`w-full mb-1 text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
{item.label}
</p>
)}
{/* 총합 - @container 반응형 */}
{visibility.showValue && (
<p className="mb-2 text-lg font-bold @[200px]:text-2xl @[350px]:text-3xl">
{abbreviateNumber(total)}
</p>
)}
{/* 카테고리별 건수 */}
<div className="flex flex-wrap gap-2 @[200px]:gap-3">
{categories.map((cat, index) => {
const count = categoryData[cat.label] ?? 0;
const color =
cat.color ?? DEFAULT_STAT_COLORS[index % DEFAULT_STAT_COLORS.length];
return (
<div key={cat.label} className="flex items-center gap-1">
{/* 색상 점 */}
<span
className="inline-block h-2 w-2 rounded-full @[200px]:h-2.5 @[200px]:w-2.5"
style={{ backgroundColor: color }}
/>
{/* 라벨 + 건수 */}
<span className="text-[10px] text-muted-foreground @[150px]:text-xs">
{cat.label}
</span>
<span className="text-[10px] font-medium @[150px]:text-xs">
{abbreviateNumber(count)}
</span>
</div>
);
})}
</div>
{/* 보조 라벨 (단위 등) */}
{visibility.showSubLabel && (
<p className="mt-1 text-[10px] text-muted-foreground @[150px]:text-xs">
{visibility.showUnit && item.kpiConfig?.unit
? `단위: ${item.kpiConfig.unit}`
: ""}
</p>
)}
</div>
);
}

View File

@ -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 (
<div className="flex h-full w-full items-center justify-center">
<span className="text-xs text-muted-foreground"> </span>
</div>
);
}
return (
<div className="relative h-full w-full">
{/* 아이템 (전체 영역 사용) */}
<div className="h-full w-full">
{renderItem(currentIndex)}
</div>
{/* 좌우 화살표 (콘텐츠 위에 겹침) */}
{itemCount > 1 && (
<>
<button
type="button"
onClick={goToPrev}
className="absolute left-1 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-background/70 shadow-sm backdrop-blur-sm transition-all hover:bg-background/90 active:scale-95"
aria-label="이전"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
type="button"
onClick={goToNext}
className="absolute right-1 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-background/70 shadow-sm backdrop-blur-sm transition-all hover:bg-background/90 active:scale-95"
aria-label="다음"
>
<ChevronRight className="h-4 w-4" />
</button>
</>
)}
{/* 페이지 인디케이터 (콘텐츠 하단에 겹침) */}
{showIndicator && itemCount > 1 && (
<div className="absolute bottom-1 left-0 right-0 z-10 flex items-center justify-center gap-1.5">
{Array.from({ length: itemCount }).map((_, i) => (
<button
type="button"
key={i}
onClick={() => 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}번째 아이템`}
/>
))}
</div>
)}
</div>
);
}

View File

@ -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<ReturnType<typeof setInterval> | null>(null);
const resumeTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div className="flex h-full w-full items-center justify-center">
<span className="text-xs text-muted-foreground"> </span>
</div>
);
}
return (
<div
className="relative h-full w-full"
onClick={handlePause}
onTouchStart={handlePause}
role="presentation"
>
{/* 콘텐츠 (슬라이드 애니메이션) */}
<div className="absolute inset-0 overflow-hidden">
<div
className="flex h-full transition-transform duration-500 ease-in-out"
style={{
width: `${itemCount * 100}%`,
transform: `translateX(-${currentIndex * (100 / itemCount)}%)`,
}}
>
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className="h-full"
style={{ width: `${100 / itemCount}%` }}
>
{renderItem(i)}
</div>
))}
</div>
</div>
{/* 인디케이터 (콘텐츠 하단에 겹침) */}
{showIndicator && itemCount > 1 && (
<div className="absolute bottom-1 left-0 right-0 z-10 flex items-center justify-center gap-1.5">
{isPaused && (
<span className="mr-2 text-[10px] text-muted-foreground/70">
</span>
)}
{Array.from({ length: itemCount }).map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full transition-all ${
i === currentIndex
? "w-4 bg-primary"
: "w-1.5 bg-muted-foreground/30"
}`}
/>
))}
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="flex h-full w-full items-center justify-center">
<span className="text-xs text-muted-foreground"> </span>
</div>
);
}
return (
<div
className="h-full w-full"
style={{
display: "grid",
gridTemplateColumns: `repeat(${actualColumns}, 1fr)`,
gridTemplateRows: `repeat(${actualRows}, 1fr)`,
gap: `${gap}px`,
}}
>
{remappedCells.map((cell) => (
<div
key={cell.id}
className="@container min-h-0 min-w-0 overflow-hidden rounded-md border border-border/50 bg-card"
style={{
gridColumn: cell.gridColumn,
gridRow: cell.gridRow,
}}
>
{cell.itemId ? (
renderItem(cell.itemId)
) : (
<div className="flex h-full w-full items-center justify-center">
<span className="text-[10px] text-muted-foreground/50">
</span>
</div>
)}
</div>
))}
</div>
);
}

View File

@ -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<HTMLDivElement>(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 (
<div className="flex h-full w-full items-center justify-center">
<span className="text-xs text-muted-foreground"> </span>
</div>
);
}
return (
<div className="flex h-full w-full flex-col">
{/* 스크롤 영역 */}
<div
ref={scrollRef}
className="flex min-h-0 flex-1 snap-x snap-mandatory overflow-x-auto scrollbar-none"
>
{Array.from({ length: itemCount }).map((_, i) => (
<div
key={i}
className="h-full w-full shrink-0 snap-center"
>
{renderItem(i)}
</div>
))}
</div>
{/* 페이지 인디케이터 */}
{showIndicator && itemCount > 1 && (
<div className="flex items-center justify-center gap-1.5 py-1">
{Array.from({ length: itemCount }).map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full transition-all ${
i === activeIndex
? "w-4 bg-primary"
: "w-1.5 bg-muted-foreground/30"
}`}
/>
))}
</div>
)}
</div>
);
}

View File

@ -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<string, unknown>[];
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<AggregatedResult> {
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<string, unknown>) => {
const converted: Record<string, unknown> = { ...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<string, unknown>
),
});
// 단순 조회 시에는 행 수를 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<ColumnInfo[]> {
// 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<TableInfo[]> {
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
return response.data;
}
return [];
} catch {
return [];
}
}

View File

@ -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<string, number>
): 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, number>
): string {
const formatMap: Record<FormulaDisplayFormat, () => 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,
});
}

View File

@ -269,10 +269,14 @@ function DateTimePreview({ config }: { config?: PopTextConfig }) {
function DateTimeDisplay({ config }: { config?: PopTextConfig }) { function DateTimeDisplay({ config }: { config?: PopTextConfig }) {
const [now, setNow] = useState(new Date()); const [now, setNow] = useState(new Date());
// isRealtime 기본값: true (설정 패널 UI와 일치)
const isRealtime = config?.isRealtime ?? true;
useEffect(() => { useEffect(() => {
if (!isRealtime) return;
const timer = setInterval(() => setNow(new Date()), 1000); const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, []); }, [isRealtime]);
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환) // 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
const dateFormat = config?.dateTimeConfig const dateFormat = config?.dateTimeConfig

View File

@ -51,6 +51,7 @@ export const FONT_SIZE_CLASSES: Record<FontSize, string> = {
xl: "text-[64px]", xl: "text-[64px]",
}; };
export const FONT_WEIGHT_CLASSES: Record<FontWeight, string> = { export const FONT_WEIGHT_CLASSES: Record<FontWeight, string> = {
normal: "font-normal", normal: "font-normal",
medium: "font-medium", medium: "font-medium",
@ -86,3 +87,258 @@ export const JUSTIFY_CLASSES: Record<string, string> = {
center: "justify-center", center: "justify-center",
right: "justify-end", 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<string, string>;
// modal
modalScreenId?: string;
modalTitle?: string;
// save/delete
targetTable?: string;
confirmMessage?: string;
// api
apiEndpoint?: string;
apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
// event
eventName?: string;
eventPayload?: Record<string, unknown>;
}
// =============================================
// 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;
}