19 KiB
현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성
작성일: 2026-02-10 상태: 코딩 완료 (방어 로직 패치 포함) 목적: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성
1. 문제 요약
pop-dashboard 컴포넌트의 4가지 아이템 모드 중 설정 UI가 누락되거나 데이터 처리 로직에 버그가 있어 실제 테스트 불가.
| # | 문제 | 심각도 | 영향 |
|---|---|---|---|
| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 |
| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 |
| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 |
| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 |
2. 수정 대상 파일 (2개)
파일 A: frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx
변경 유형: 설정 UI 추가 3건
변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래)
집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가.
추가할 위치: {/* 집계 함수 + 대상 컬럼 */} 블록 다음, {/* 자동 새로고침 */} 블록 이전
추가할 코드 (약 50줄):
{/* 그룹핑 (차트용 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>
)}
필요한 state 추가 (DataSourceEditor 내부, 기존 state 옆):
const [groupByOpen, setGroupByOpen] = useState(false);
변경 A-2: 차트 타입 설정에 xAxisColumn/yAxisColumn 추가 (라인 1187~1212 부근)
추가할 위치: {item.subType === "chart" && ( 블록 내부, 차트 유형 Select 다음
추가할 코드 (약 30줄):
{/* 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 인라인 블록
{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):
case "chart":
return (
<ChartItemComponent
item={item}
rows={itemData.rows}
containerWidth={containerWidth}
/>
);
변경 코드:
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}
/>
);
}
변경 B-2: 통계 카드 카테고리별 독립 데이터 조회 (라인 286~297)
현재 코드 (버그):
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} />
);
}
변경 코드:
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} />
);
}
주의: 이 방식은 rows에 전체 데이터가 있어야 작동함. 통계 카드의 dataSource에 집계 함수를 설정하지 않고(또는 groupBy를 사용하여) 원시 rows를 가져와야 한다.
3. 구현 순서 (의존성 기반)
| 순서 | 작업 | 파일 | 의존성 | 상태 |
|---|---|---|---|---|
| 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. 사전 충돌 검사 결과
새로 추가할 식별자 목록
| 식별자 | 타입 | 정의 파일 | 사용 파일 | 충돌 여부 |
|---|---|---|---|---|
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: 차트에 groupBy만 설정하고 xAxisColumn을 비우면
ChartItem은 기본 xKey로 "name"을 사용하는데, groupBy 결과 행은 {status: "수주", value: 79} 형태.
name 키가 없으므로 X축이 빈 채로 렌더링됨.
B-1의 자동 보정 로직이 필수. 순서 4를 빠뜨리면 차트가 깨짐.
함정 2: 통계 카드에 집계 함수를 설정하면
집계(COUNT 등)가 설정되면 rows에 [{value: 87}] 하나만 들어옴.
카테고리별 필터링이 작동하려면 집계 함수를 "없음"으로 두거나, groupBy를 설정해야 개별 행이 rows에 포함됨.
통계 카드에서는 집계를 사용하지 않는 것이 올바른 사용법.
설정 가이드 문서에 이 점을 명시해야 함.
함정 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. 검증 방법
차트 (BUG-1, BUG-2)
- 아이템 추가 > "차트" 선택
- 테이블:
sales_order_mng, 집계: COUNT, 컬럼:id, 그룹핑:status - 차트 유형: 막대 차트
- 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1
통계 카드 (BUG-3, BUG-4)
- 아이템 추가 > "통계 카드" 선택
- 테이블:
sales_order_mng, 집계: 없음 (중요!) - 카테고리 추가:
- "수주" / status / = / 수주
- "진행중" / status / = / 진행중
- "완료" / status / = / 완료
- 기대 결과: 수주 79, 진행중 7, 완료 1
이전 완료 계획 (아카이브)
POP 뷰어 스크롤 수정 (완료)
- 라인 185: overflow-hidden 제거
- 라인 266: overflow-auto 공통 적용
- 라인 275: 일반 모드 min-h-full 추가
- 린트 검사 통과
POP 뷰어 실제 컴포넌트 렌더링 (완료)
- 뷰어 페이지에 레지스트리 초기화 import 추가
- renderActualComponent() 실제 컴포넌트 렌더링으로 교체
- 린트 검사 통과
V2/V2 컴포넌트 설정 스키마 정비 (완료)
- 레거시 컴포넌트 스키마 제거
- V2 컴포넌트 overrides 스키마 정의 (16개)
- V2 컴포넌트 overrides 스키마 정의 (9개)
- componentConfig.ts 한 파일에서 통합 관리
화면 복제 기능 개선 (진행 중)
- [완료] DB 구조 개편 (menu_objid 의존성 제거)
- [완료] 복제 옵션 정리
- [완료] 화면 간 연결 복제 버그 수정
- [대기] 화면 간 연결 복제 테스트
- [대기] 제어관리 복제 테스트
- [대기] 추가 옵션 복제 테스트