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:
parent
dc523d86c3
commit
578cca2687
536
PLAN.MD
536
PLAN.MD
|
|
@ -1,135 +1,527 @@
|
|||
# 현재 구현 계획: POP 뷰어 스크롤 수정
|
||||
# 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성
|
||||
|
||||
> **작성일**: 2026-02-09
|
||||
> **상태**: 계획 완료, 코딩 대기
|
||||
> **목적**: 뷰어에서 화면 높이를 초과하는 컴포넌트가 잘리지 않고 스크롤 가능하도록 수정
|
||||
> **작성일**: 2026-02-10
|
||||
> **상태**: 코딩 완료 (방어 로직 패치 포함)
|
||||
> **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성
|
||||
|
||||
---
|
||||
|
||||
## 1. 문제 요약
|
||||
|
||||
설계(디자이너)에서 컴포넌트를 아래로 배치하면 캔버스가 늘어나고 스크롤이 되지만,
|
||||
뷰어(`/pop/screens/4114`)에서는 화면 높이를 초과하는 컴포넌트가 잘려서 안 보임.
|
||||
pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가.
|
||||
|
||||
**근본 원인**: CSS 컨테이너 구조가 스크롤을 차단
|
||||
|
||||
| # | 컨테이너 (라인) | 현재 클래스 | 문제 |
|
||||
|---|----------------|-------------|------|
|
||||
| 1 | 최외곽 (185) | `h-screen ... overflow-hidden` | 넘치는 콘텐츠를 잘라냄 |
|
||||
| 2 | 컨텐츠 영역 (266) | 일반 모드에 `overflow-auto` 없음 | 스크롤 불가 |
|
||||
| 3 | 백색 배경 (275) | 일반 모드에 `min-h-full` 없음 | 짧은 콘텐츠 시 배경 불완전 |
|
||||
| # | 문제 | 심각도 | 영향 |
|
||||
|---|------|--------|------|
|
||||
| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 |
|
||||
| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 |
|
||||
| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 |
|
||||
| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 수정 대상 파일 (1개)
|
||||
## 2. 수정 대상 파일 (2개)
|
||||
|
||||
### `frontend/app/(pop)/pop/screens/[screenId]/page.tsx`
|
||||
### 파일 A: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx`
|
||||
|
||||
**변경 유형**: CSS 클래스 문자열 수정 3곳 (새 변수/함수/타입 추가 없음)
|
||||
**변경 유형**: 설정 UI 추가 3건
|
||||
|
||||
#### 변경 1: 라인 185 - 최외곽 컨테이너
|
||||
#### 변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래)
|
||||
|
||||
**현재 코드**:
|
||||
집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가.
|
||||
|
||||
**추가할 위치**: `{/* 집계 함수 + 대상 컬럼 */}` 블록 다음, `{/* 자동 새로고침 */}` 블록 이전
|
||||
|
||||
**추가할 코드** (약 50줄):
|
||||
|
||||
```tsx
|
||||
{/* 그룹핑 (차트용 X축 분류) */}
|
||||
{dataSource.aggregation && (
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
**변경 코드**:
|
||||
```
|
||||
<div className="h-screen bg-gray-100 flex flex-col">
|
||||
```tsx
|
||||
case "chart": {
|
||||
// groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정
|
||||
const chartItem = { ...item };
|
||||
if (
|
||||
item.dataSource.aggregation?.groupBy?.length &&
|
||||
!item.chartConfig?.xAxisColumn
|
||||
) {
|
||||
chartItem.chartConfig = {
|
||||
...chartItem.chartConfig,
|
||||
chartType: chartItem.chartConfig?.chartType ?? "bar",
|
||||
xAxisColumn: item.dataSource.aggregation.groupBy[0],
|
||||
};
|
||||
}
|
||||
return (
|
||||
<ChartItemComponent
|
||||
item={chartItem}
|
||||
rows={itemData.rows}
|
||||
containerWidth={containerWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**변경 내용**: `overflow-hidden` 제거
|
||||
**이유**: 이 div는 프리뷰 툴바 + 컨텐츠의 flex 컨테이너 역할만 하면 됨. `overflow-hidden`이 자식의 스크롤까지 차단하므로 제거
|
||||
#### 변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297)
|
||||
|
||||
#### 변경 2: 라인 266 - 컨텐츠 영역
|
||||
|
||||
**현재 코드**:
|
||||
```
|
||||
<div className={`flex-1 flex flex-col ${isPreviewMode ? "py-4 overflow-auto items-center" : ""}`}>
|
||||
**현재 코드** (버그):
|
||||
```tsx
|
||||
case "stat-card": {
|
||||
const categoryData: Record<string, number> = {};
|
||||
if (item.statConfig?.categories) {
|
||||
for (const cat of item.statConfig.categories) {
|
||||
categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값
|
||||
}
|
||||
}
|
||||
return (
|
||||
<StatCardComponent item={item} categoryData={categoryData} />
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**변경 코드**:
|
||||
```
|
||||
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : ""}`}>
|
||||
```tsx
|
||||
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`를 조건문 밖으로 이동 (공통 적용)
|
||||
**이유**: 프리뷰/일반 모드 모두 스크롤이 필요함
|
||||
|
||||
#### 변경 3: 라인 275 - 백색 배경 컨테이너
|
||||
|
||||
**현재 코드**:
|
||||
```
|
||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full"}`}
|
||||
```
|
||||
|
||||
**변경 코드**:
|
||||
```
|
||||
className={`bg-white transition-all duration-300 ${isPreviewMode ? "shadow-2xl rounded-3xl overflow-auto border-8 border-gray-800" : "w-full min-h-full"}`}
|
||||
```
|
||||
|
||||
**변경 내용**: 일반 모드에 `min-h-full` 추가
|
||||
**이유**: 컴포넌트가 적어 콘텐츠가 짧을 때에도 흰색 배경이 화면 전체를 채우도록 보장
|
||||
**주의**: 이 방식은 rows에 전체 데이터가 있어야 작동함. 통계 카드의 dataSource에 집계 함수를 설정하지 않고(또는 groupBy를 사용하여) 원시 rows를 가져와야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 구현 순서 (의존성 기반)
|
||||
|
||||
| 순서 | 작업 | 라인 | 의존성 | 상태 |
|
||||
| 순서 | 작업 | 파일 | 의존성 | 상태 |
|
||||
|------|------|------|--------|------|
|
||||
| 1 | 라인 185: `overflow-hidden` 제거 | 185 | 없음 | [x] 완료 |
|
||||
| 2 | 라인 266: `overflow-auto` 공통 적용 | 266 | 순서 1 | [x] 완료 |
|
||||
| 3 | 라인 275: 일반 모드 `min-h-full` 추가 | 275 | 순서 2 | [x] 완료 |
|
||||
| 4 | 린트 검사 | - | 순서 1~3 | [x] 통과 |
|
||||
| 5 | 브라우저 검증 | - | 순서 4 | [ ] 대기 |
|
||||
| 1 | A-1: DataSourceEditor에 groupBy UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
||||
| 2 | A-2: 차트 xAxisColumn 입력 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
||||
| 3 | A-3: 통계 카드 카테고리 설정 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
||||
| 4 | B-1: 차트 xAxisColumn 자동 보정 로직 | PopDashboardComponent.tsx | 순서 1 | [x] |
|
||||
| 5 | B-2: 통계 카드 카테고리별 필터 적용 | PopDashboardComponent.tsx | 순서 3 | [x] |
|
||||
| 6 | 린트 검사 | 전체 | 순서 1~5 | [x] |
|
||||
| 7 | C-1: SQL 빌더 방어 로직 (빈 컬럼/테이블 차단) | dataFetcher.ts | 없음 | [x] |
|
||||
| 8 | C-2: refreshInterval 최소값 강제 (5초) | PopDashboardComponent.tsx | 없음 | [x] |
|
||||
| 9 | 브라우저 테스트 | - | 순서 1~8 | [ ] |
|
||||
|
||||
순서 1, 2, 3은 서로 독립이므로 병렬 가능.
|
||||
순서 4는 순서 1의 groupBy 값이 있어야 의미 있음.
|
||||
순서 5는 순서 3의 카테고리 설정이 있어야 의미 있음.
|
||||
순서 7, 8은 백엔드 부하 방지를 위한 방어 패치.
|
||||
|
||||
---
|
||||
|
||||
## 4. 사전 충돌 검사 결과
|
||||
|
||||
**새로 추가할 변수/함수/타입: 없음**
|
||||
### 새로 추가할 식별자 목록
|
||||
|
||||
이번 수정은 기존 Tailwind CSS 클래스 문자열만 변경합니다.
|
||||
새로운 식별자(변수, 함수, 타입)를 추가하지 않으므로 충돌 검사 대상이 없습니다.
|
||||
| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 |
|
||||
|--------|------|-----------|-----------|-----------|
|
||||
| `groupByOpen` | state (boolean) | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 |
|
||||
| `setGroupByOpen` | state setter | PopDashboardConfig.tsx DataSourceEditor 내부 | 동일 함수 내부 | 충돌 없음 |
|
||||
| `chartItem` | const (DashboardItem) | PopDashboardComponent.tsx renderSingleItem 내부 | 동일 함수 내부 | 충돌 없음 |
|
||||
|
||||
**Grep 검색 결과** (전체 pop-dashboard 폴더):
|
||||
- `groupByOpen`: 0건 - 충돌 없음
|
||||
- `setGroupByOpen`: 0건 - 충돌 없음
|
||||
- `groupByColumns`: 0건 - 충돌 없음
|
||||
- `chartItem`: 0건 - 충돌 없음
|
||||
- `StatCategoryEditor`: 0건 - 충돌 없음
|
||||
- `loadCategoryData`: 0건 - 충돌 없음
|
||||
|
||||
### 기존 타입/함수 재사용 목록
|
||||
|
||||
| 기존 식별자 | 정의 위치 | 이번 수정에서 사용하는 곳 |
|
||||
|------------|-----------|------------------------|
|
||||
| `DataSourceConfig.aggregation.groupBy` | types.ts 라인 155 | A-1 UI에서 읽기/쓰기 |
|
||||
| `ChartItemConfig.xAxisColumn` | types.ts 라인 248 | A-2 UI, B-1 자동 보정 |
|
||||
| `StatCategory` | types.ts 라인 261 | A-3 카테고리 편집 |
|
||||
| `StatCardConfig.categories` | types.ts 라인 268 | A-3 UI에서 읽기/쓰기 |
|
||||
| `FilterOperator` | types.ts (import 이미 존재) | A-3 카테고리 필터 Select |
|
||||
| `columns` (state) | PopDashboardConfig.tsx DataSourceEditor 내부 | A-1 groupBy 컬럼 목록 |
|
||||
|
||||
**사용처 있는데 정의 누락된 항목: 없음**
|
||||
|
||||
---
|
||||
|
||||
## 5. 에러 함정 경고
|
||||
|
||||
### 함정 1: 순서 1만 하고 순서 2를 빼먹으면
|
||||
`overflow-hidden`만 제거하면 콘텐츠가 화면 밖으로 넘쳐 보이지만 스크롤은 안 됨.
|
||||
부모는 열었지만 자식에 스크롤 속성이 없는 상태.
|
||||
### 함정 1: 차트에 groupBy만 설정하고 xAxisColumn을 비우면
|
||||
ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `{status: "수주", value: 79}` 형태.
|
||||
`name` 키가 없으므로 X축이 빈 채로 렌더링됨.
|
||||
**B-1의 자동 보정 로직이 필수**. 순서 4를 빠뜨리면 차트가 깨짐.
|
||||
|
||||
### 함정 2: 순서 2만 하고 순서 1을 빼먹으면
|
||||
자식에 `overflow-auto`를 넣어도 부모가 `overflow-hidden`으로 잘라내므로 여전히 스크롤 안 됨.
|
||||
**반드시 순서 1과 2를 함께 적용해야 함.**
|
||||
### 함정 2: 통계 카드에 집계 함수를 설정하면
|
||||
집계(COUNT 등)가 설정되면 rows에 `[{value: 87}]` 하나만 들어옴.
|
||||
카테고리별 필터링이 작동하려면 **집계 함수를 "없음"으로 두거나, groupBy를 설정**해야 개별 행이 rows에 포함됨.
|
||||
통계 카드에서는 **집계를 사용하지 않는 것이 올바른 사용법**.
|
||||
설정 가이드 문서에 이 점을 명시해야 함.
|
||||
|
||||
### 함정 3: 프리뷰 모드 영향
|
||||
프리뷰 모드는 이미 자체적으로 `overflow-auto`가 있으므로 이 수정에 영향 없음.
|
||||
`overflow-auto`가 중복 적용되어도 CSS에서 문제 없음.
|
||||
### 함정 3: PopDashboardConfig.tsx의 import 누락
|
||||
현재 `FilterOperator`는 이미 import되어 있음 (라인 54).
|
||||
`StatCategory`는 직접 사용하지 않고 `item.statConfig.categories` 구조로 접근하므로 import 불필요.
|
||||
**새로운 import 추가 필요 없음.**
|
||||
|
||||
### 함정 4: 통계 카드 카테고리 필터에서 숫자 비교
|
||||
`String(row[col])` vs `String(filter.value)` 비교이므로, 숫자 컬럼도 문자열로 비교됨.
|
||||
`"100" === "100"`은 정상 동작하지만, `"100.00" !== "100"`이 될 수 있음.
|
||||
현재 대부분 컬럼이 varchar이므로 문제없지만, numeric 컬럼 사용 시 주의.
|
||||
|
||||
### 함정 5: DataSourceEditor의 columns state 타이밍
|
||||
`groupByOpen` Popover에서 `columns` 배열을 사용하는데, 테이블 선택 직후 columns가 아직 로딩 중일 수 있음.
|
||||
기존 코드에서 `loadingCols` 상태로 버튼을 disabled 처리하고 있으므로 문제없음.
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 방법
|
||||
|
||||
1. `localhost:9771/pop/screens/4114` 접속 (iPhone SE 375px 기준)
|
||||
2. 화면 아래로 스크롤 가능한지 확인
|
||||
3. 맨 아래에 이미지(pop-text 5, 6)가 보이는지 확인
|
||||
4. 프리뷰 모드(`?preview=true`)에서도 기존처럼 정상 동작하는지 확인
|
||||
5. 컴포넌트가 적은 화면에서 흰색 배경이 화면 전체를 채우는지 확인
|
||||
### 차트 (BUG-1, BUG-2)
|
||||
1. 아이템 추가 > "차트" 선택
|
||||
2. 테이블: `sales_order_mng`, 집계: COUNT, 컬럼: `id`, 그룹핑: `status`
|
||||
3. 차트 유형: 막대 차트
|
||||
4. 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1
|
||||
|
||||
### 통계 카드 (BUG-3, BUG-4)
|
||||
1. 아이템 추가 > "통계 카드" 선택
|
||||
2. 테이블: `sales_order_mng`, **집계: 없음** (중요!)
|
||||
3. 카테고리 추가:
|
||||
- "수주" / status / = / 수주
|
||||
- "진행중" / status / = / 진행중
|
||||
- "완료" / status / = / 완료
|
||||
4. 기대 결과: 수주 79, 진행중 7, 완료 1
|
||||
|
||||
---
|
||||
|
||||
## 이전 완료 계획 (아카이브)
|
||||
|
||||
<details>
|
||||
<summary>POP 뷰어 스크롤 수정 (완료)</summary>
|
||||
|
||||
- [x] 라인 185: overflow-hidden 제거
|
||||
- [x] 라인 266: overflow-auto 공통 적용
|
||||
- [x] 라인 275: 일반 모드 min-h-full 추가
|
||||
- [x] 린트 검사 통과
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>POP 뷰어 실제 컴포넌트 렌더링 (완료)</summary>
|
||||
|
||||
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가
|
||||
- [x] `renderActualComponent()` 실제 컴포넌트 렌더링으로 교체
|
||||
- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체
|
||||
- [x] 린트 검사 통과
|
||||
- 브라우저 검증: 컴포넌트 표시 정상, 스크롤 문제 발견 -> 별도 수정
|
||||
|
||||
</details>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) 미구현 | 낮음 | 향후 구현 |
|
||||
|
|
@ -187,6 +187,9 @@ export function PopDashboardComponent({
|
|||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 아이템 ID 목록 문자열 키 (참조 안정성 보장 - 매 렌더마다 새 배열 참조 방지)
|
||||
const visibleItemIds = JSON.stringify(visibleItems.map((i) => i.id));
|
||||
|
||||
// 데이터 로딩 함수
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const fetchAllData = useCallback(async () => {
|
||||
|
|
@ -214,15 +217,18 @@ export function PopDashboardComponent({
|
|||
|
||||
setDataMap(newDataMap);
|
||||
setLoading(false);
|
||||
}, [JSON.stringify(visibleItems.map((i) => i.id))]);
|
||||
}, [visibleItemIds]);
|
||||
|
||||
// 초기 로딩 + 주기적 새로고침
|
||||
useEffect(() => {
|
||||
fetchAllData();
|
||||
|
||||
// refreshInterval 적용 (첫 번째 아이템 기준)
|
||||
const refreshSec = visibleItems[0]?.dataSource.refreshInterval;
|
||||
if (refreshSec && refreshSec > 0) {
|
||||
// refreshInterval 적용 (첫 번째 아이템 기준, 최소 5초 강제)
|
||||
const rawRefreshSec = visibleItems[0]?.dataSource.refreshInterval;
|
||||
const refreshSec = rawRefreshSec && rawRefreshSec > 0
|
||||
? Math.max(5, rawRefreshSec)
|
||||
: 0;
|
||||
if (refreshSec > 0) {
|
||||
refreshTimerRef.current = setInterval(fetchAllData, refreshSec * 1000);
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +238,9 @@ export function PopDashboardComponent({
|
|||
refreshTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [fetchAllData, visibleItems]);
|
||||
// visibleItemIds로 아이템 변경 감지 (visibleItems 객체 참조 대신 문자열 키 사용)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fetchAllData, visibleItemIds]);
|
||||
|
||||
// 빈 설정 (모든 hooks 이후에 early return)
|
||||
if (!config || !config.items?.length) {
|
||||
|
|
@ -273,23 +281,55 @@ export function PopDashboardComponent({
|
|||
formulaDisplay={itemData.formulaDisplay}
|
||||
/>
|
||||
);
|
||||
case "chart":
|
||||
case "chart": {
|
||||
// groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정
|
||||
const chartItem = { ...item };
|
||||
if (
|
||||
item.dataSource.aggregation?.groupBy?.length &&
|
||||
!item.chartConfig?.xAxisColumn
|
||||
) {
|
||||
chartItem.chartConfig = {
|
||||
...chartItem.chartConfig,
|
||||
chartType: chartItem.chartConfig?.chartType ?? "bar",
|
||||
xAxisColumn: item.dataSource.aggregation.groupBy[0],
|
||||
};
|
||||
}
|
||||
return (
|
||||
<ChartItemComponent
|
||||
item={item}
|
||||
item={chartItem}
|
||||
rows={itemData.rows}
|
||||
containerWidth={containerWidth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "gauge":
|
||||
return <GaugeItemComponent item={item} data={itemData.value} />;
|
||||
case "stat-card": {
|
||||
// StatCard: 카테고리별 건수 맵 구성
|
||||
// StatCard: 카테고리별 건수 맵 구성 (필터 적용)
|
||||
const categoryData: Record<string, number> = {};
|
||||
if (item.statConfig?.categories) {
|
||||
for (const cat of item.statConfig.categories) {
|
||||
// 각 카테고리 행에서 건수 추출 (간단 구현: 행 수 기준)
|
||||
categoryData[cat.label] = itemData.rows.length;
|
||||
if (cat.filter.column && cat.filter.value !== undefined && cat.filter.value !== "") {
|
||||
// 카테고리 필터로 rows 필터링
|
||||
const filtered = itemData.rows.filter((row) => {
|
||||
const cellValue = String(row[cat.filter.column] ?? "");
|
||||
const filterValue = String(cat.filter.value ?? "");
|
||||
switch (cat.filter.operator) {
|
||||
case "=":
|
||||
return cellValue === filterValue;
|
||||
case "!=":
|
||||
return cellValue !== filterValue;
|
||||
case "like":
|
||||
return cellValue.toLowerCase().includes(filterValue.toLowerCase());
|
||||
default:
|
||||
return cellValue === filterValue;
|
||||
}
|
||||
});
|
||||
categoryData[cat.label] = filtered.length;
|
||||
} else {
|
||||
// 필터 미설정 시 전체 건수
|
||||
categoryData[cat.label] = itemData.rows.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -152,6 +152,10 @@ function DataSourceEditor({
|
|||
// 컬럼 목록 (집계 대상 컬럼용)
|
||||
const [columns, setColumns] = useState<ColumnInfo[]>([]);
|
||||
const [loadingCols, setLoadingCols] = useState(false);
|
||||
const [columnOpen, setColumnOpen] = useState(false);
|
||||
|
||||
// 그룹핑 컬럼 (차트 X축용)
|
||||
const [groupByOpen, setGroupByOpen] = useState(false);
|
||||
|
||||
// 마운트 시 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -285,32 +289,156 @@ function DataSourceEditor({
|
|||
{dataSource.aggregation && (
|
||||
<div>
|
||||
<Label className="text-xs">대상 컬럼</Label>
|
||||
<Select
|
||||
value={dataSource.aggregation.column}
|
||||
onValueChange={(val) =>
|
||||
onChange({
|
||||
...dataSource,
|
||||
aggregation: { ...dataSource.aggregation!, column: val },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue
|
||||
placeholder={loadingCols ? "로딩..." : "선택"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.name} ({col.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover open={columnOpen} onOpenChange={setColumnOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={columnOpen}
|
||||
disabled={loadingCols}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{loadingCols
|
||||
? "로딩..."
|
||||
: dataSource.aggregation.column
|
||||
? columns.find(
|
||||
(c) => c.name === dataSource.aggregation!.column
|
||||
)
|
||||
? `${dataSource.aggregation.column} (${columns.find((c) => c.name === dataSource.aggregation!.column)?.type})`
|
||||
: dataSource.aggregation.column
|
||||
: "선택"}
|
||||
<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} ${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>
|
||||
|
||||
{/* 그룹핑 (차트 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 + 주기 입력) */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -1135,29 +1263,77 @@ function ItemEditor({
|
|||
)}
|
||||
|
||||
{item.subType === "chart" && (
|
||||
<div>
|
||||
<Label className="text-xs">차트 유형</Label>
|
||||
<Select
|
||||
value={item.chartConfig?.chartType ?? "bar"}
|
||||
onValueChange={(val) =>
|
||||
onUpdate({
|
||||
...item,
|
||||
chartConfig: {
|
||||
...item.chartConfig,
|
||||
chartType: val as "bar" | "pie" | "line",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bar">막대 차트</SelectItem>
|
||||
<SelectItem value="pie">원형 차트</SelectItem>
|
||||
<SelectItem value="line">라인 차트</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs">차트 유형</Label>
|
||||
<Select
|
||||
value={item.chartConfig?.chartType ?? "bar"}
|
||||
onValueChange={(val) =>
|
||||
onUpdate({
|
||||
...item,
|
||||
chartConfig: {
|
||||
...item.chartConfig,
|
||||
chartType: val as "bar" | "pie" | "line",
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="bar">막대 차트</SelectItem>
|
||||
<SelectItem value="pie">원형 차트</SelectItem>
|
||||
<SelectItem value="line">라인 차트</SelectItem>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
|
@ -1220,6 +1396,152 @@ function ItemEditor({
|
|||
</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>
|
||||
|
|
|
|||
|
|
@ -84,10 +84,10 @@ export function ChartItemComponent({
|
|||
}
|
||||
|
||||
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 && (
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -69,10 +69,10 @@ export function GaugeItemComponent({
|
|||
const largeArcFlag = percentage > 50 ? 1 : 0;
|
||||
|
||||
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 && (
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -128,7 +128,7 @@ export function GaugeItemComponent({
|
|||
|
||||
{/* 목표값 */}
|
||||
{visibility.showTarget && (
|
||||
<p className="hidden text-[10px] text-muted-foreground @[150px]:block @[200px]:text-xs">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
목표: {abbreviateNumber(target)}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -66,10 +66,10 @@ export function KpiCardComponent({
|
|||
const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges);
|
||||
|
||||
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 && (
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -78,7 +78,7 @@ export function KpiCardComponent({
|
|||
{visibility.showValue && (
|
||||
<div className="flex items-baseline gap-1">
|
||||
<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}
|
||||
>
|
||||
{formulaDisplay ?? abbreviateNumber(displayValue)}
|
||||
|
|
@ -86,7 +86,7 @@ export function KpiCardComponent({
|
|||
|
||||
{/* 단위 */}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
|
|
@ -95,14 +95,12 @@ export function KpiCardComponent({
|
|||
|
||||
{/* 증감율 */}
|
||||
{visibility.showTrend && trendValue != null && (
|
||||
<div className="hidden @[200px]:block">
|
||||
<TrendIndicator value={trendValue} />
|
||||
</div>
|
||||
<TrendIndicator value={trendValue} />
|
||||
)}
|
||||
|
||||
{/* 보조 라벨 (수식 표시 등) */}
|
||||
{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(" / ")}
|
||||
</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -37,10 +37,10 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) {
|
|||
const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0);
|
||||
|
||||
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 && (
|
||||
<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}
|
||||
</p>
|
||||
)}
|
||||
|
|
@ -80,7 +80,7 @@ export function StatCardComponent({ item, categoryData }: StatCardProps) {
|
|||
|
||||
{/* 보조 라벨 (단위 등) */}
|
||||
{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
|
||||
? `단위: ${item.kpiConfig.unit}`
|
||||
: ""}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import type { DashboardCell } from "../../types";
|
|||
// ===== 상수 =====
|
||||
|
||||
/** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */
|
||||
const MIN_CELL_WIDTH = 160;
|
||||
const MIN_CELL_WIDTH = 80;
|
||||
|
||||
// ===== Props =====
|
||||
|
||||
|
|
|
|||
|
|
@ -46,13 +46,55 @@ function escapeSQL(value: unknown): string {
|
|||
return `'${str}'`;
|
||||
}
|
||||
|
||||
// ===== 설정 완료 여부 검증 =====
|
||||
|
||||
/**
|
||||
* DataSourceConfig의 필수값이 모두 채워졌는지 검증
|
||||
* 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는
|
||||
* SQL을 생성하지 않도록 사전 차단
|
||||
*
|
||||
* @returns null이면 유효, 문자열이면 미완료 사유
|
||||
*/
|
||||
function validateDataSourceConfig(config: DataSourceConfig): string | null {
|
||||
// 테이블명 필수
|
||||
if (!config.tableName || !config.tableName.trim()) {
|
||||
return "테이블이 선택되지 않았습니다";
|
||||
}
|
||||
|
||||
// 집계 함수가 설정되었으면 대상 컬럼도 필수
|
||||
// (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능)
|
||||
if (config.aggregation) {
|
||||
const aggType = config.aggregation.type?.toLowerCase();
|
||||
const aggCol = config.aggregation.column?.trim();
|
||||
if (aggType !== "count" && !aggCol) {
|
||||
return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`;
|
||||
}
|
||||
}
|
||||
|
||||
// 조인이 있으면 조인 조건 필수
|
||||
if (config.joins?.length) {
|
||||
for (const join of config.joins) {
|
||||
if (!join.targetTable?.trim()) {
|
||||
return "조인 대상 테이블이 선택되지 않았습니다";
|
||||
}
|
||||
if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
|
||||
return "조인 조건 컬럼이 설정되지 않았습니다";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===== 필터 조건 SQL 생성 =====
|
||||
|
||||
/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */
|
||||
function buildWhereClause(filters: DataSourceFilter[]): string {
|
||||
if (!filters.length) return "";
|
||||
// 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어)
|
||||
const validFilters = filters.filter((f) => f.column?.trim());
|
||||
if (!validFilters.length) return "";
|
||||
|
||||
const conditions = filters.map((f) => {
|
||||
const conditions = validFilters.map((f) => {
|
||||
const col = sanitizeIdentifier(f.column);
|
||||
|
||||
switch (f.operator) {
|
||||
|
|
@ -98,8 +140,18 @@ export function buildAggregationSQL(config: DataSourceConfig): string {
|
|||
let selectClause: string;
|
||||
if (config.aggregation) {
|
||||
const aggType = config.aggregation.type.toUpperCase();
|
||||
const aggCol = sanitizeIdentifier(config.aggregation.column);
|
||||
selectClause = `${aggType}(${aggCol}) as value`;
|
||||
const aggCol = config.aggregation.column?.trim()
|
||||
? sanitizeIdentifier(config.aggregation.column)
|
||||
: "";
|
||||
|
||||
// COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수
|
||||
if (!aggCol) {
|
||||
selectClause = aggType === "COUNT"
|
||||
? "COUNT(*) as value"
|
||||
: `${aggType}(${tableName}.*) as value`;
|
||||
} else {
|
||||
selectClause = `${aggType}(${aggCol}) as value`;
|
||||
}
|
||||
|
||||
// GROUP BY가 있으면 해당 컬럼도 SELECT에 포함
|
||||
if (config.aggregation.groupBy?.length) {
|
||||
|
|
@ -110,10 +162,14 @@ export function buildAggregationSQL(config: DataSourceConfig): string {
|
|||
selectClause = "*";
|
||||
}
|
||||
|
||||
// FROM 절 (조인 포함)
|
||||
// FROM 절 (조인 포함 - 조건이 완전한 조인만 적용)
|
||||
let fromClause = tableName;
|
||||
if (config.joins?.length) {
|
||||
for (const join of config.joins) {
|
||||
// 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어)
|
||||
if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
|
||||
continue;
|
||||
}
|
||||
const joinTable = sanitizeIdentifier(join.targetTable);
|
||||
const joinType = join.joinType.toUpperCase();
|
||||
const srcCol = sanitizeIdentifier(join.on.sourceColumn);
|
||||
|
|
@ -173,6 +229,12 @@ export async function fetchAggregatedData(
|
|||
config: DataSourceConfig
|
||||
): Promise<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);
|
||||
|
|
@ -228,6 +290,24 @@ export async function fetchAggregatedData(
|
|||
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) => ({
|
||||
|
|
|
|||
Loading…
Reference in New Issue