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>
This commit is contained in:
SeongHyun Kim 2026-02-10 16:12:29 +09:00
parent dc523d86c3
commit 578cca2687
11 changed files with 1030 additions and 150 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>

48
STATUS.md Normal file
View File

@ -0,0 +1,48 @@
# 프로젝트 상태 추적
> **최종 업데이트**: 2026-02-10
---
## 현재 진행 중
### pop-dashboard 4가지 아이템 모드 완성
**상태**: 코딩 완료, 브라우저 테스트 대기
**계획서**: [PLAN.MD](./PLAN.MD)
---
## 다음 작업
| 순서 | 작업 | 파일 | 상태 |
|------|------|------|------|
| 7 | 브라우저 테스트 (차트 groupBy / 통계카드 카테고리) | - | [ ] 대기 |
---
## 완료된 작업 (최근)
| 날짜 | 작업 | 비고 |
|------|------|------|
| 2026-02-10 | A-1: groupBy 설정 UI 추가 | DataSourceEditor에 Combobox 방식 그룹핑 컬럼 선택 UI |
| 2026-02-10 | A-2: 차트 xAxisColumn/yAxisColumn 입력 UI | 차트 설정 섹션에 X/Y축 컬럼 입력 필드 |
| 2026-02-10 | A-3: 통계 카드 카테고리 설정 UI | 카테고리 추가/삭제/편집 인라인 에디터 |
| 2026-02-10 | B-1: 차트 xAxisColumn 자동 보정 | groupBy 있으면 xAxisColumn 자동 설정 |
| 2026-02-10 | B-2: 통계 카드 카테고리별 필터 적용 | rows 필터링으로 카테고리별 독립 건수 표시 버그 수정 |
| 2026-02-10 | fetchTableColumns 폴백 추가 | tableManagementApi 우선 사용으로 컬럼 로딩 안정화 |
| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 |
| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent |
| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 |
---
## 알려진 이슈
| # | 이슈 | 심각도 | 상태 |
|---|------|--------|------|
| 1 | ~~차트 groupBy 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-1) |
| 2 | ~~차트 xAxisColumn 미설정 시 빈 차트~~ | ~~높음~~ | 수정 완료 (A-2, B-1) |
| 3 | ~~통계 카드 카테고리 설정 UI 없음~~ | ~~높음~~ | 수정 완료 (A-3) |
| 4 | ~~통계 카드 카테고리별 필터 미적용 버그~~ | ~~높음~~ | 수정 완료 (B-2) |
| 5 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 |
| 6 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 |

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

@ -187,6 +187,9 @@ export function PopDashboardComponent({
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// 아이템 ID 목록 문자열 키 (참조 안정성 보장 - 매 렌더마다 새 배열 참조 방지)
const visibleItemIds = JSON.stringify(visibleItems.map((i) => i.id));
// 데이터 로딩 함수 // 데이터 로딩 함수
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const fetchAllData = useCallback(async () => { const fetchAllData = useCallback(async () => {
@ -214,15 +217,18 @@ export function PopDashboardComponent({
setDataMap(newDataMap); setDataMap(newDataMap);
setLoading(false); setLoading(false);
}, [JSON.stringify(visibleItems.map((i) => i.id))]); }, [visibleItemIds]);
// 초기 로딩 + 주기적 새로고침 // 초기 로딩 + 주기적 새로고침
useEffect(() => { useEffect(() => {
fetchAllData(); fetchAllData();
// refreshInterval 적용 (첫 번째 아이템 기준) // refreshInterval 적용 (첫 번째 아이템 기준, 최소 5초 강제)
const refreshSec = visibleItems[0]?.dataSource.refreshInterval; const rawRefreshSec = visibleItems[0]?.dataSource.refreshInterval;
if (refreshSec && refreshSec > 0) { const refreshSec = rawRefreshSec && rawRefreshSec > 0
? Math.max(5, rawRefreshSec)
: 0;
if (refreshSec > 0) {
refreshTimerRef.current = setInterval(fetchAllData, refreshSec * 1000); refreshTimerRef.current = setInterval(fetchAllData, refreshSec * 1000);
} }
@ -232,7 +238,9 @@ export function PopDashboardComponent({
refreshTimerRef.current = null; refreshTimerRef.current = null;
} }
}; };
}, [fetchAllData, visibleItems]); // visibleItemIds로 아이템 변경 감지 (visibleItems 객체 참조 대신 문자열 키 사용)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fetchAllData, visibleItemIds]);
// 빈 설정 (모든 hooks 이후에 early return) // 빈 설정 (모든 hooks 이후에 early return)
if (!config || !config.items?.length) { if (!config || !config.items?.length) {
@ -273,23 +281,55 @@ export function PopDashboardComponent({
formulaDisplay={itemData.formulaDisplay} formulaDisplay={itemData.formulaDisplay}
/> />
); );
case "chart": case "chart": {
// groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정
const chartItem = { ...item };
if (
item.dataSource.aggregation?.groupBy?.length &&
!item.chartConfig?.xAxisColumn
) {
chartItem.chartConfig = {
...chartItem.chartConfig,
chartType: chartItem.chartConfig?.chartType ?? "bar",
xAxisColumn: item.dataSource.aggregation.groupBy[0],
};
}
return ( return (
<ChartItemComponent <ChartItemComponent
item={item} item={chartItem}
rows={itemData.rows} rows={itemData.rows}
containerWidth={containerWidth} containerWidth={containerWidth}
/> />
); );
}
case "gauge": case "gauge":
return <GaugeItemComponent item={item} data={itemData.value} />; return <GaugeItemComponent item={item} data={itemData.value} />;
case "stat-card": { case "stat-card": {
// StatCard: 카테고리별 건수 맵 구성 // StatCard: 카테고리별 건수 맵 구성 (필터 적용)
const categoryData: Record<string, number> = {}; const categoryData: Record<string, number> = {};
if (item.statConfig?.categories) { if (item.statConfig?.categories) {
for (const cat of item.statConfig.categories) { for (const cat of item.statConfig.categories) {
// 각 카테고리 행에서 건수 추출 (간단 구현: 행 수 기준) if (cat.filter.column && cat.filter.value !== undefined && cat.filter.value !== "") {
categoryData[cat.label] = itemData.rows.length; // 카테고리 필터로 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 ( return (

View File

@ -152,6 +152,10 @@ function DataSourceEditor({
// 컬럼 목록 (집계 대상 컬럼용) // 컬럼 목록 (집계 대상 컬럼용)
const [columns, setColumns] = useState<ColumnInfo[]>([]); const [columns, setColumns] = useState<ColumnInfo[]>([]);
const [loadingCols, setLoadingCols] = useState(false); const [loadingCols, setLoadingCols] = useState(false);
const [columnOpen, setColumnOpen] = useState(false);
// 그룹핑 컬럼 (차트 X축용)
const [groupByOpen, setGroupByOpen] = useState(false);
// 마운트 시 테이블 목록 로드 // 마운트 시 테이블 목록 로드
useEffect(() => { useEffect(() => {
@ -285,32 +289,156 @@ function DataSourceEditor({
{dataSource.aggregation && ( {dataSource.aggregation && (
<div> <div>
<Label className="text-xs"> </Label> <Label className="text-xs"> </Label>
<Select <Popover open={columnOpen} onOpenChange={setColumnOpen}>
value={dataSource.aggregation.column} <PopoverTrigger asChild>
onValueChange={(val) => <Button
onChange({ variant="outline"
...dataSource, role="combobox"
aggregation: { ...dataSource.aggregation!, column: val }, aria-expanded={columnOpen}
}) disabled={loadingCols}
} className="h-8 w-full justify-between text-xs"
> >
<SelectTrigger className="h-8 text-xs"> {loadingCols
<SelectValue ? "로딩..."
placeholder={loadingCols ? "로딩..." : "선택"} : dataSource.aggregation.column
/> ? columns.find(
</SelectTrigger> (c) => c.name === dataSource.aggregation!.column
<SelectContent> )
{columns.map((col) => ( ? `${dataSource.aggregation.column} (${columns.find((c) => c.name === dataSource.aggregation!.column)?.type})`
<SelectItem key={col.name} value={col.name}> : dataSource.aggregation.column
{col.name} ({col.type}) : "선택"}
</SelectItem> <ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
))} </Button>
</SelectContent> </PopoverTrigger>
</Select> <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} ${col.type}`}
onSelect={() => {
onChange({
...dataSource,
aggregation: {
...dataSource.aggregation!,
column: col.name,
},
});
setColumnOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
dataSource.aggregation?.column === 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>
</div> </div>
)} )}
</div> </div>
{/* 그룹핑 (차트 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={`groupby-${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>
)}
{/* 자동 새로고침 (Switch + 주기 입력) */} {/* 자동 새로고침 (Switch + 주기 입력) */}
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -1135,29 +1263,77 @@ function ItemEditor({
)} )}
{item.subType === "chart" && ( {item.subType === "chart" && (
<div> <div className="space-y-2">
<Label className="text-xs"> </Label> <div>
<Select <Label className="text-xs"> </Label>
value={item.chartConfig?.chartType ?? "bar"} <Select
onValueChange={(val) => value={item.chartConfig?.chartType ?? "bar"}
onUpdate({ onValueChange={(val) =>
...item, onUpdate({
chartConfig: { ...item,
...item.chartConfig, chartConfig: {
chartType: val as "bar" | "pie" | "line", ...item.chartConfig,
}, chartType: val as "bar" | "pie" | "line",
}) },
} })
> }
<SelectTrigger className="h-8 text-xs"> >
<SelectValue /> <SelectTrigger className="h-8 text-xs">
</SelectTrigger> <SelectValue />
<SelectContent> </SelectTrigger>
<SelectItem value="bar"> </SelectItem> <SelectContent>
<SelectItem value="pie"> </SelectItem> <SelectItem value="bar"> </SelectItem>
<SelectItem value="line"> </SelectItem> <SelectItem value="pie"> </SelectItem>
</SelectContent> <SelectItem value="line"> </SelectItem>
</Select> </SelectContent>
</Select>
</div>
{/* 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>
{/* Y축 컬럼 */}
<div>
<Label className="text-xs">Y축 </Label>
<Input
value={item.chartConfig?.yAxisColumn ?? ""}
onChange={(e) =>
onUpdate({
...item,
chartConfig: {
...item.chartConfig,
chartType: item.chartConfig?.chartType ?? "bar",
yAxisColumn: e.target.value || undefined,
},
})
}
placeholder="집계 결과 컬럼명 (비우면 자동)"
className="h-8 text-xs"
/>
<p className="mt-0.5 text-[10px] text-muted-foreground">
.
</p>
</div>
</div> </div>
)} )}
@ -1220,6 +1396,152 @@ function ItemEditor({
</div> </div>
</div> </div>
)} )}
{/* 통계 카드 카테고리 설정 */}
{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>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -84,10 +84,10 @@ export function ChartItemComponent({
} }
return ( return (
<div className="@container flex h-full w-full flex-col p-1"> <div className="@container flex h-full w-full flex-col p-2">
{/* 라벨 */} {/* 라벨 */}
{visibility.showLabel && ( {visibility.showLabel && (
<p className="mb-1 truncate text-[10px] text-muted-foreground @[200px]:text-xs"> <p className="mb-1 text-xs text-muted-foreground @[250px]:text-sm">
{item.label} {item.label}
</p> </p>
)} )}

View File

@ -69,10 +69,10 @@ export function GaugeItemComponent({
const largeArcFlag = percentage > 50 ? 1 : 0; const largeArcFlag = percentage > 50 ? 1 : 0;
return ( return (
<div className="@container flex h-full w-full flex-col items-center justify-center p-2"> <div className="@container flex h-full w-full flex-col items-center justify-center p-3">
{/* 라벨 */} {/* 라벨 */}
{visibility.showLabel && ( {visibility.showLabel && (
<p className="mb-1 truncate text-[10px] text-muted-foreground @[150px]:text-xs"> <p className="mb-1 text-xs text-muted-foreground @[250px]:text-sm">
{item.label} {item.label}
</p> </p>
)} )}
@ -128,7 +128,7 @@ export function GaugeItemComponent({
{/* 목표값 */} {/* 목표값 */}
{visibility.showTarget && ( {visibility.showTarget && (
<p className="hidden text-[10px] text-muted-foreground @[150px]:block @[200px]:text-xs"> <p className="text-xs text-muted-foreground">
: {abbreviateNumber(target)} : {abbreviateNumber(target)}
</p> </p>
)} )}

View File

@ -66,10 +66,10 @@ export function KpiCardComponent({
const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges); const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges);
return ( return (
<div className="@container flex h-full w-full flex-col justify-center p-2"> <div className="@container flex h-full w-full flex-col justify-center p-3">
{/* 라벨 */} {/* 라벨 */}
{visibility.showLabel && ( {visibility.showLabel && (
<p className="truncate text-[10px] text-muted-foreground @[150px]:text-xs @[250px]:text-sm"> <p className="text-xs text-muted-foreground @[250px]:text-sm">
{item.label} {item.label}
</p> </p>
)} )}
@ -78,7 +78,7 @@ export function KpiCardComponent({
{visibility.showValue && ( {visibility.showValue && (
<div className="flex items-baseline gap-1"> <div className="flex items-baseline gap-1">
<span <span
className="text-lg font-bold @[120px]:text-xl @[200px]:text-3xl @[350px]:text-4xl @[500px]:text-5xl" className="text-xl font-bold @[200px]:text-3xl @[350px]:text-4xl @[500px]:text-5xl"
style={valueColor ? { color: valueColor } : undefined} style={valueColor ? { color: valueColor } : undefined}
> >
{formulaDisplay ?? abbreviateNumber(displayValue)} {formulaDisplay ?? abbreviateNumber(displayValue)}
@ -86,7 +86,7 @@ export function KpiCardComponent({
{/* 단위 */} {/* 단위 */}
{visibility.showUnit && kpiConfig?.unit && ( {visibility.showUnit && kpiConfig?.unit && (
<span className="hidden text-xs text-muted-foreground @[120px]:inline @[200px]:text-sm"> <span className="text-xs text-muted-foreground @[200px]:text-sm">
{kpiConfig.unit} {kpiConfig.unit}
</span> </span>
)} )}
@ -95,14 +95,12 @@ export function KpiCardComponent({
{/* 증감율 */} {/* 증감율 */}
{visibility.showTrend && trendValue != null && ( {visibility.showTrend && trendValue != null && (
<div className="hidden @[200px]:block"> <TrendIndicator value={trendValue} />
<TrendIndicator value={trendValue} />
</div>
)} )}
{/* 보조 라벨 (수식 표시 등) */} {/* 보조 라벨 (수식 표시 등) */}
{visibility.showSubLabel && formulaDisplay && ( {visibility.showSubLabel && formulaDisplay && (
<p className="hidden truncate text-xs text-muted-foreground @[350px]:block"> <p className="text-xs text-muted-foreground @[350px]:text-sm">
{item.formula?.values.map((v) => v.label).join(" / ")} {item.formula?.values.map((v) => v.label).join(" / ")}
</p> </p>
)} )}

View File

@ -37,10 +37,10 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) {
const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0); const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0);
return ( return (
<div className="@container flex h-full w-full flex-col p-2"> <div className="@container flex h-full w-full flex-col p-3">
{/* 라벨 */} {/* 라벨 */}
{visibility.showLabel && ( {visibility.showLabel && (
<p className="mb-1 truncate text-[10px] text-muted-foreground @[150px]:text-xs @[250px]:text-sm"> <p className="mb-1 text-xs text-muted-foreground @[250px]:text-sm">
{item.label} {item.label}
</p> </p>
)} )}
@ -80,7 +80,7 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) {
{/* 보조 라벨 (단위 등) */} {/* 보조 라벨 (단위 등) */}
{visibility.showSubLabel && ( {visibility.showSubLabel && (
<p className="mt-1 hidden text-[10px] text-muted-foreground @[250px]:block"> <p className="mt-1 text-xs text-muted-foreground">
{visibility.showUnit && item.kpiConfig?.unit {visibility.showUnit && item.kpiConfig?.unit
? `단위: ${item.kpiConfig.unit}` ? `단위: ${item.kpiConfig.unit}`
: ""} : ""}

View File

@ -18,7 +18,7 @@ import type { DashboardCell } from "../../types";
// ===== 상수 ===== // ===== 상수 =====
/** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */ /** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */
const MIN_CELL_WIDTH = 160; const MIN_CELL_WIDTH = 80;
// ===== Props ===== // ===== Props =====

View File

@ -46,13 +46,55 @@ function escapeSQL(value: unknown): string {
return `'${str}'`; 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 생성 ===== // ===== 필터 조건 SQL 생성 =====
/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */ /** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */
function buildWhereClause(filters: DataSourceFilter[]): string { function buildWhereClause(filters: DataSourceFilter[]): string {
if (!filters.length) return ""; // 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어)
const validFilters = filters.filter((f) => f.column?.trim());
if (!validFilters.length) return "";
const conditions = filters.map((f) => { const conditions = validFilters.map((f) => {
const col = sanitizeIdentifier(f.column); const col = sanitizeIdentifier(f.column);
switch (f.operator) { switch (f.operator) {
@ -98,8 +140,18 @@ export function buildAggregationSQL(config: DataSourceConfig): string {
let selectClause: string; let selectClause: string;
if (config.aggregation) { if (config.aggregation) {
const aggType = config.aggregation.type.toUpperCase(); const aggType = config.aggregation.type.toUpperCase();
const aggCol = sanitizeIdentifier(config.aggregation.column); const aggCol = config.aggregation.column?.trim()
selectClause = `${aggType}(${aggCol}) as value`; ? 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에 포함 // GROUP BY가 있으면 해당 컬럼도 SELECT에 포함
if (config.aggregation.groupBy?.length) { if (config.aggregation.groupBy?.length) {
@ -110,10 +162,14 @@ export function buildAggregationSQL(config: DataSourceConfig): string {
selectClause = "*"; selectClause = "*";
} }
// FROM 절 (조인 포함) // FROM 절 (조인 포함 - 조건이 완전한 조인만 적용)
let fromClause = tableName; let fromClause = tableName;
if (config.joins?.length) { if (config.joins?.length) {
for (const join of config.joins) { 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 joinTable = sanitizeIdentifier(join.targetTable);
const joinType = join.joinType.toUpperCase(); const joinType = join.joinType.toUpperCase();
const srcCol = sanitizeIdentifier(join.on.sourceColumn); const srcCol = sanitizeIdentifier(join.on.sourceColumn);
@ -173,6 +229,12 @@ export async function fetchAggregatedData(
config: DataSourceConfig config: DataSourceConfig
): Promise<AggregatedResult> { ): Promise<AggregatedResult> {
try { try {
// 설정 완료 여부 검증 (미완료 시 SQL 전송 차단)
const validationError = validateDataSourceConfig(config);
if (validationError) {
return { value: 0, rows: [], error: validationError };
}
// 집계 또는 조인이 있으면 SQL 직접 실행 // 집계 또는 조인이 있으면 SQL 직접 실행
if (config.aggregation || (config.joins && config.joins.length > 0)) { if (config.aggregation || (config.joins && config.joins.length > 0)) {
const sql = buildAggregationSQL(config); const sql = buildAggregationSQL(config);
@ -228,6 +290,24 @@ export async function fetchAggregatedData(
export async function fetchTableColumns( export async function fetchTableColumns(
tableName: string tableName: string
): Promise<ColumnInfo[]> { ): 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 { try {
const schema = await dashboardApi.getTableSchema(tableName); const schema = await dashboardApi.getTableSchema(tableName);
return schema.columns.map((col) => ({ return schema.columns.map((col) => ({