Compare commits
No commits in common. "0ef0332e08fcdf20fd0dd7737ca60373d3488bb1" and "bae50ffda15d573ef9168c1c8be7e958229a0357" have entirely different histories.
0ef0332e08
...
bae50ffda1
536
PLAN.MD
536
PLAN.MD
|
|
@ -1,527 +1,135 @@
|
||||||
# 현재 구현 계획: pop-dashboard 4가지 아이템 모드 완성
|
# 현재 구현 계획: POP 뷰어 스크롤 수정
|
||||||
|
|
||||||
> **작성일**: 2026-02-10
|
> **작성일**: 2026-02-09
|
||||||
> **상태**: 코딩 완료 (방어 로직 패치 포함)
|
> **상태**: 계획 완료, 코딩 대기
|
||||||
> **목적**: 대시보드 설정 패널의 미구현/버그 4건을 수정하여 KPI카드, 차트, 게이지, 통계카드 모두 실제 데이터로 동작하도록 완성
|
> **목적**: 뷰어에서 화면 높이를 초과하는 컴포넌트가 잘리지 않고 스크롤 가능하도록 수정
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 문제 요약
|
## 1. 문제 요약
|
||||||
|
|
||||||
pop-dashboard 컴포넌트의 4가지 아이템 모드 중 **설정 UI가 누락**되거나 **데이터 처리 로직에 버그**가 있어 실제 테스트 불가.
|
설계(디자이너)에서 컴포넌트를 아래로 배치하면 캔버스가 늘어나고 스크롤이 되지만,
|
||||||
|
뷰어(`/pop/screens/4114`)에서는 화면 높이를 초과하는 컴포넌트가 잘려서 안 보임.
|
||||||
|
|
||||||
| # | 문제 | 심각도 | 영향 |
|
**근본 원인**: CSS 컨테이너 구조가 스크롤을 차단
|
||||||
|---|------|--------|------|
|
|
||||||
| BUG-1 | 차트: groupBy 설정 UI 없음 | 높음 | 차트가 단일 값만 표시, X축 카테고리 분류 불가 |
|
| # | 컨테이너 (라인) | 현재 클래스 | 문제 |
|
||||||
| BUG-2 | 차트: xAxisColumn 설정 UI 없음 | 높음 | groupBy 결과의 컬럼명과 xKey 불일치로 차트 빈 화면 |
|
|---|----------------|-------------|------|
|
||||||
| BUG-3 | 통계 카드: 카테고리 설정 UI 없음 | 높음 | statConfig.categories를 설정할 방법 없음 |
|
| 1 | 최외곽 (185) | `h-screen ... overflow-hidden` | 넘치는 콘텐츠를 잘라냄 |
|
||||||
| BUG-4 | 통계 카드: 카테고리별 필터 미적용 | 높음 | 모든 카테고리에 동일 값(rows.length) 입력되는 버그 |
|
| 2 | 컨텐츠 영역 (266) | 일반 모드에 `overflow-auto` 없음 | 스크롤 불가 |
|
||||||
|
| 3 | 백색 배경 (275) | 일반 모드에 `min-h-full` 없음 | 짧은 콘텐츠 시 배경 불완전 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 수정 대상 파일 (2개)
|
## 2. 수정 대상 파일 (1개)
|
||||||
|
|
||||||
### 파일 A: `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx`
|
### `frontend/app/(pop)/pop/screens/[screenId]/page.tsx`
|
||||||
|
|
||||||
**변경 유형**: 설정 UI 추가 3건
|
**변경 유형**: CSS 클래스 문자열 수정 3곳 (새 변수/함수/타입 추가 없음)
|
||||||
|
|
||||||
#### 변경 A-1: DataSourceEditor에 groupBy 설정 추가 (라인 253~362 부근, 집계 함수 아래)
|
#### 변경 1: 라인 185 - 최외곽 컨테이너
|
||||||
|
|
||||||
집계 함수가 선택된 상태에서 "그룹핑 컬럼"을 선택할 수 있는 드롭다운 추가.
|
**현재 코드**:
|
||||||
|
|
||||||
**추가할 위치**: `{/* 집계 함수 + 대상 컬럼 */}` 블록 다음, `{/* 자동 새로고침 */}` 블록 이전
|
|
||||||
|
|
||||||
**추가할 코드** (약 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
|
```
|
||||||
case "chart": {
|
<div className="h-screen bg-gray-100 flex flex-col">
|
||||||
// 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)
|
**변경 내용**: `overflow-hidden` 제거
|
||||||
|
**이유**: 이 div는 프리뷰 툴바 + 컨텐츠의 flex 컨테이너 역할만 하면 됨. `overflow-hidden`이 자식의 스크롤까지 차단하므로 제거
|
||||||
|
|
||||||
**현재 코드** (버그):
|
#### 변경 2: 라인 266 - 컨텐츠 영역
|
||||||
```tsx
|
|
||||||
case "stat-card": {
|
**현재 코드**:
|
||||||
const categoryData: Record<string, number> = {};
|
```
|
||||||
if (item.statConfig?.categories) {
|
<div className={`flex-1 flex flex-col ${isPreviewMode ? "py-4 overflow-auto items-center" : ""}`}>
|
||||||
for (const cat of item.statConfig.categories) {
|
|
||||||
categoryData[cat.label] = itemData.rows.length; // BUG: 모든 카테고리에 동일 값
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<StatCardComponent item={item} categoryData={categoryData} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**변경 코드**:
|
**변경 코드**:
|
||||||
```tsx
|
```
|
||||||
case "stat-card": {
|
<div className={`flex-1 flex flex-col overflow-auto ${isPreviewMode ? "py-4 items-center" : ""}`}>
|
||||||
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를 가져와야 한다.
|
**변경 내용**: `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` 추가
|
||||||
|
**이유**: 컴포넌트가 적어 콘텐츠가 짧을 때에도 흰색 배경이 화면 전체를 채우도록 보장
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 구현 순서 (의존성 기반)
|
## 3. 구현 순서 (의존성 기반)
|
||||||
|
|
||||||
| 순서 | 작업 | 파일 | 의존성 | 상태 |
|
| 순서 | 작업 | 라인 | 의존성 | 상태 |
|
||||||
|------|------|------|--------|------|
|
|------|------|------|--------|------|
|
||||||
| 1 | A-1: DataSourceEditor에 groupBy UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
| 1 | 라인 185: `overflow-hidden` 제거 | 185 | 없음 | [x] 완료 |
|
||||||
| 2 | A-2: 차트 xAxisColumn 입력 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
| 2 | 라인 266: `overflow-auto` 공통 적용 | 266 | 순서 1 | [x] 완료 |
|
||||||
| 3 | A-3: 통계 카드 카테고리 설정 UI 추가 | PopDashboardConfig.tsx | 없음 | [x] |
|
| 3 | 라인 275: 일반 모드 `min-h-full` 추가 | 275 | 순서 2 | [x] 완료 |
|
||||||
| 4 | B-1: 차트 xAxisColumn 자동 보정 로직 | PopDashboardComponent.tsx | 순서 1 | [x] |
|
| 4 | 린트 검사 | - | 순서 1~3 | [x] 통과 |
|
||||||
| 5 | B-2: 통계 카드 카테고리별 필터 적용 | PopDashboardComponent.tsx | 순서 3 | [x] |
|
| 5 | 브라우저 검증 | - | 순서 4 | [ ] 대기 |
|
||||||
| 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: 차트에 groupBy만 설정하고 xAxisColumn을 비우면
|
### 함정 1: 순서 1만 하고 순서 2를 빼먹으면
|
||||||
ChartItem은 기본 xKey로 `"name"`을 사용하는데, groupBy 결과 행은 `{status: "수주", value: 79}` 형태.
|
`overflow-hidden`만 제거하면 콘텐츠가 화면 밖으로 넘쳐 보이지만 스크롤은 안 됨.
|
||||||
`name` 키가 없으므로 X축이 빈 채로 렌더링됨.
|
부모는 열었지만 자식에 스크롤 속성이 없는 상태.
|
||||||
**B-1의 자동 보정 로직이 필수**. 순서 4를 빠뜨리면 차트가 깨짐.
|
|
||||||
|
|
||||||
### 함정 2: 통계 카드에 집계 함수를 설정하면
|
### 함정 2: 순서 2만 하고 순서 1을 빼먹으면
|
||||||
집계(COUNT 등)가 설정되면 rows에 `[{value: 87}]` 하나만 들어옴.
|
자식에 `overflow-auto`를 넣어도 부모가 `overflow-hidden`으로 잘라내므로 여전히 스크롤 안 됨.
|
||||||
카테고리별 필터링이 작동하려면 **집계 함수를 "없음"으로 두거나, groupBy를 설정**해야 개별 행이 rows에 포함됨.
|
**반드시 순서 1과 2를 함께 적용해야 함.**
|
||||||
통계 카드에서는 **집계를 사용하지 않는 것이 올바른 사용법**.
|
|
||||||
설정 가이드 문서에 이 점을 명시해야 함.
|
|
||||||
|
|
||||||
### 함정 3: PopDashboardConfig.tsx의 import 누락
|
### 함정 3: 프리뷰 모드 영향
|
||||||
현재 `FilterOperator`는 이미 import되어 있음 (라인 54).
|
프리뷰 모드는 이미 자체적으로 `overflow-auto`가 있으므로 이 수정에 영향 없음.
|
||||||
`StatCategory`는 직접 사용하지 않고 `item.statConfig.categories` 구조로 접근하므로 import 불필요.
|
`overflow-auto`가 중복 적용되어도 CSS에서 문제 없음.
|
||||||
**새로운 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. 검증 방법
|
||||||
|
|
||||||
### 차트 (BUG-1, BUG-2)
|
1. `localhost:9771/pop/screens/4114` 접속 (iPhone SE 375px 기준)
|
||||||
1. 아이템 추가 > "차트" 선택
|
2. 화면 아래로 스크롤 가능한지 확인
|
||||||
2. 테이블: `sales_order_mng`, 집계: COUNT, 컬럼: `id`, 그룹핑: `status`
|
3. 맨 아래에 이미지(pop-text 5, 6)가 보이는지 확인
|
||||||
3. 차트 유형: 막대 차트
|
4. 프리뷰 모드(`?preview=true`)에서도 기존처럼 정상 동작하는지 확인
|
||||||
4. 기대 결과: X축에 "수주", "진행중", "완료" / Y축에 79, 7, 1
|
5. 컴포넌트가 적은 화면에서 흰색 배경이 화면 전체를 채우는지 확인
|
||||||
|
|
||||||
### 통계 카드 (BUG-3, BUG-4)
|
|
||||||
1. 아이템 추가 > "통계 카드" 선택
|
|
||||||
2. 테이블: `sales_order_mng`, **집계: 없음** (중요!)
|
|
||||||
3. 카테고리 추가:
|
|
||||||
- "수주" / status / = / 수주
|
|
||||||
- "진행중" / status / = / 진행중
|
|
||||||
- "완료" / status / = / 완료
|
|
||||||
4. 기대 결과: 수주 79, 진행중 7, 완료 1
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 이전 완료 계획 (아카이브)
|
## 이전 완료 계획 (아카이브)
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>POP 뷰어 스크롤 수정 (완료)</summary>
|
|
||||||
|
|
||||||
- [x] 라인 185: overflow-hidden 제거
|
|
||||||
- [x] 라인 266: overflow-auto 공통 적용
|
|
||||||
- [x] 라인 275: 일반 모드 min-h-full 추가
|
|
||||||
- [x] 린트 검사 통과
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>POP 뷰어 실제 컴포넌트 렌더링 (완료)</summary>
|
<summary>POP 뷰어 실제 컴포넌트 렌더링 (완료)</summary>
|
||||||
|
|
||||||
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가
|
- [x] 뷰어 페이지에 레지스트리 초기화 import 추가
|
||||||
- [x] renderActualComponent() 실제 컴포넌트 렌더링으로 교체
|
- [x] `renderActualComponent()` 실제 컴포넌트 렌더링으로 교체
|
||||||
- [x] 린트 검사 통과
|
- [x] 린트 검사 통과
|
||||||
|
- 브라우저 검증: 컴포넌트 표시 정상, 스크롤 문제 발견 -> 별도 수정
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|
|
||||||
46
STATUS.md
46
STATUS.md
|
|
@ -1,46 +0,0 @@
|
||||||
# 프로젝트 상태 추적
|
|
||||||
|
|
||||||
> **최종 업데이트**: 2026-02-11
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 현재 진행 중
|
|
||||||
|
|
||||||
### pop-dashboard 스타일 정리
|
|
||||||
**상태**: 코딩 완료, 브라우저 확인 대기
|
|
||||||
**계획서**: [popdocs/PLAN.md](./popdocs/PLAN.md)
|
|
||||||
**내용**: 글자 크기 커스텀 제거 + 라벨 정렬만 유지 + stale closure 수정
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 다음 작업
|
|
||||||
|
|
||||||
| 순서 | 작업 | 상태 |
|
|
||||||
|------|------|------|
|
|
||||||
| 1 | 브라우저 확인 (라벨 정렬 동작, 반응형 자동 크기, 미리보기 반영) | [ ] 대기 |
|
|
||||||
| 2 | 게이지/통계카드 테스트 시나리오 동작 확인 | [ ] 대기 |
|
|
||||||
| 3 | Phase 2 계획 수립 (pop-button, pop-icon) | [ ] 대기 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 완료된 작업 (최근)
|
|
||||||
|
|
||||||
| 날짜 | 작업 | 비고 |
|
|
||||||
|------|------|------|
|
|
||||||
| 2026-02-11 | 대시보드 스타일 정리 | FONT_SIZE_PX/글자 크기 Select 삭제, ItemStyleConfig -> labelAlign만, stale closure 수정 |
|
|
||||||
| 2026-02-10 | 디자이너 캔버스 UX 개선 | 헤더 제거, 실제 데이터 렌더링, 컴포넌트 목록 |
|
|
||||||
| 2026-02-10 | 차트/게이지/네비게이션/정렬 디자인 개선 | CartesianGrid, abbreviateNumber, 오버레이 화살표/인디케이터 |
|
|
||||||
| 2026-02-10 | 대시보드 4가지 아이템 모드 완성 | groupBy UI, xAxisColumn, 통계카드 카테고리, 필터 버그 수정 |
|
|
||||||
| 2026-02-09 | POP 뷰어 스크롤 수정 | overflow-hidden 제거, overflow-auto 공통 적용 |
|
|
||||||
| 2026-02-09 | POP 뷰어 실제 컴포넌트 렌더링 | 레지스트리 초기화 + renderActualComponent |
|
|
||||||
| 2026-02-08 | V2/V2 컴포넌트 스키마 정비 | componentConfig.ts 통합 관리 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 알려진 이슈
|
|
||||||
|
|
||||||
| # | 이슈 | 심각도 | 상태 |
|
|
||||||
|---|------|--------|------|
|
|
||||||
| 1 | KPI 증감율(trendValue) 미구현 | 낮음 | 향후 구현 |
|
|
||||||
| 2 | 게이지 동적 목표값(targetDataSource) 미구현 | 낮음 | 향후 구현 |
|
|
||||||
| 3 | 기존 저장 데이터의 `itemStyle.align`이 `labelAlign`으로 마이그레이션 안 됨 | 낮음 | 이전에 작동 안 했으므로 실질 영향 없음 |
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -112,8 +112,6 @@ interface PopCanvasProps {
|
||||||
onLockLayout?: () => void;
|
onLockLayout?: () => void;
|
||||||
onResetOverride?: (mode: GridMode) => void;
|
onResetOverride?: (mode: GridMode) => void;
|
||||||
onChangeGapPreset?: (preset: GapPreset) => void;
|
onChangeGapPreset?: (preset: GapPreset) => void;
|
||||||
/** 대시보드 페이지 미리보기 인덱스 (-1이면 기본 모드) */
|
|
||||||
previewPageIndex?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -137,7 +135,6 @@ export default function PopCanvas({
|
||||||
onLockLayout,
|
onLockLayout,
|
||||||
onResetOverride,
|
onResetOverride,
|
||||||
onChangeGapPreset,
|
onChangeGapPreset,
|
||||||
previewPageIndex,
|
|
||||||
}: PopCanvasProps) {
|
}: PopCanvasProps) {
|
||||||
// 줌 상태
|
// 줌 상태
|
||||||
const [canvasScale, setCanvasScale] = useState(0.8);
|
const [canvasScale, setCanvasScale] = useState(0.8);
|
||||||
|
|
@ -693,7 +690,6 @@ export default function PopCanvas({
|
||||||
onComponentResizeEnd={onResizeEnd}
|
onComponentResizeEnd={onResizeEnd}
|
||||||
overrideGap={adjustedGap}
|
overrideGap={adjustedGap}
|
||||||
overridePadding={adjustedPadding}
|
overridePadding={adjustedPadding}
|
||||||
previewPageIndex={previewPageIndex}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -69,9 +69,6 @@ export default function PopDesigner({
|
||||||
// 선택 상태
|
// 선택 상태
|
||||||
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
const [selectedComponentId, setSelectedComponentId] = useState<string | null>(null);
|
||||||
|
|
||||||
// 대시보드 페이지 미리보기 인덱스 (-1 = 기본 모드)
|
|
||||||
const [previewPageIndex, setPreviewPageIndex] = useState<number>(-1);
|
|
||||||
|
|
||||||
// 그리드 모드 (4개 프리셋)
|
// 그리드 모드 (4개 프리셋)
|
||||||
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
const [currentMode, setCurrentMode] = useState<GridMode>("tablet_landscape");
|
||||||
|
|
||||||
|
|
@ -220,28 +217,24 @@ export default function PopDesigner({
|
||||||
|
|
||||||
const handleUpdateComponent = useCallback(
|
const handleUpdateComponent = useCallback(
|
||||||
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
|
(componentId: string, updates: Partial<PopComponentDefinitionV5>) => {
|
||||||
// 함수적 업데이트로 stale closure 방지
|
const existingComponent = layout.components[componentId];
|
||||||
setLayout((prev) => {
|
if (!existingComponent) return;
|
||||||
const existingComponent = prev.components[componentId];
|
|
||||||
if (!existingComponent) return prev;
|
|
||||||
|
|
||||||
const newComponent = {
|
const newLayout = {
|
||||||
...existingComponent,
|
...layout,
|
||||||
...updates,
|
components: {
|
||||||
};
|
...layout.components,
|
||||||
const newLayout = {
|
[componentId]: {
|
||||||
...prev,
|
...existingComponent,
|
||||||
components: {
|
...updates,
|
||||||
...prev.components,
|
|
||||||
[componentId]: newComponent,
|
|
||||||
},
|
},
|
||||||
};
|
},
|
||||||
saveToHistory(newLayout);
|
};
|
||||||
return newLayout;
|
setLayout(newLayout);
|
||||||
});
|
saveToHistory(newLayout);
|
||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
},
|
},
|
||||||
[saveToHistory]
|
[layout, saveToHistory]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteComponent = useCallback(
|
const handleDeleteComponent = useCallback(
|
||||||
|
|
@ -644,7 +637,6 @@ export default function PopDesigner({
|
||||||
onLockLayout={handleLockLayout}
|
onLockLayout={handleLockLayout}
|
||||||
onResetOverride={handleResetOverride}
|
onResetOverride={handleResetOverride}
|
||||||
onChangeGapPreset={handleChangeGapPreset}
|
onChangeGapPreset={handleChangeGapPreset}
|
||||||
previewPageIndex={previewPageIndex}
|
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
||||||
|
|
@ -663,8 +655,6 @@ export default function PopDesigner({
|
||||||
allComponents={Object.values(layout.components)}
|
allComponents={Object.values(layout.components)}
|
||||||
onSelectComponent={setSelectedComponentId}
|
onSelectComponent={setSelectedComponentId}
|
||||||
selectedComponentId={selectedComponentId}
|
selectedComponentId={selectedComponentId}
|
||||||
previewPageIndex={previewPageIndex}
|
|
||||||
onPreviewPage={setPreviewPageIndex}
|
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,6 @@ interface ComponentEditorPanelProps {
|
||||||
onSelectComponent?: (componentId: string) => void;
|
onSelectComponent?: (componentId: string) => void;
|
||||||
/** 현재 선택된 컴포넌트 ID */
|
/** 현재 선택된 컴포넌트 ID */
|
||||||
selectedComponentId?: string | null;
|
selectedComponentId?: string | null;
|
||||||
/** 대시보드 페이지 미리보기 인덱스 */
|
|
||||||
previewPageIndex?: number;
|
|
||||||
/** 페이지 미리보기 요청 콜백 */
|
|
||||||
onPreviewPage?: (pageIndex: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -54,7 +50,6 @@ interface ComponentEditorPanelProps {
|
||||||
const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
const COMPONENT_TYPE_LABELS: Record<string, string> = {
|
||||||
"pop-sample": "샘플",
|
"pop-sample": "샘플",
|
||||||
"pop-text": "텍스트",
|
"pop-text": "텍스트",
|
||||||
"pop-icon": "아이콘",
|
|
||||||
"pop-dashboard": "대시보드",
|
"pop-dashboard": "대시보드",
|
||||||
"pop-field": "필드",
|
"pop-field": "필드",
|
||||||
"pop-button": "버튼",
|
"pop-button": "버튼",
|
||||||
|
|
@ -78,8 +73,6 @@ export default function ComponentEditorPanel({
|
||||||
allComponents,
|
allComponents,
|
||||||
onSelectComponent,
|
onSelectComponent,
|
||||||
selectedComponentId,
|
selectedComponentId,
|
||||||
previewPageIndex,
|
|
||||||
onPreviewPage,
|
|
||||||
}: ComponentEditorPanelProps) {
|
}: ComponentEditorPanelProps) {
|
||||||
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
const breakpoint = GRID_BREAKPOINTS[currentMode];
|
||||||
|
|
||||||
|
|
@ -189,8 +182,6 @@ export default function ComponentEditorPanel({
|
||||||
<ComponentSettingsForm
|
<ComponentSettingsForm
|
||||||
component={component}
|
component={component}
|
||||||
onUpdate={onUpdateComponent}
|
onUpdate={onUpdateComponent}
|
||||||
previewPageIndex={previewPageIndex}
|
|
||||||
onPreviewPage={onPreviewPage}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|
@ -371,11 +362,9 @@ function PositionForm({ component, currentMode, isDefaultMode, columns, onUpdate
|
||||||
interface ComponentSettingsFormProps {
|
interface ComponentSettingsFormProps {
|
||||||
component: PopComponentDefinitionV5;
|
component: PopComponentDefinitionV5;
|
||||||
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
onUpdate?: (updates: Partial<PopComponentDefinitionV5>) => void;
|
||||||
previewPageIndex?: number;
|
|
||||||
onPreviewPage?: (pageIndex: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPreviewPage }: ComponentSettingsFormProps) {
|
function ComponentSettingsForm({ component, onUpdate }: ComponentSettingsFormProps) {
|
||||||
// PopComponentRegistry에서 configPanel 가져오기
|
// PopComponentRegistry에서 configPanel 가져오기
|
||||||
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
const registeredComp = PopComponentRegistry.getComponent(component.type);
|
||||||
const ConfigPanel = registeredComp?.configPanel;
|
const ConfigPanel = registeredComp?.configPanel;
|
||||||
|
|
@ -404,8 +393,6 @@ function ComponentSettingsForm({ component, onUpdate, previewPageIndex, onPrevie
|
||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
config={component.config || {}}
|
config={component.config || {}}
|
||||||
onUpdate={handleConfigUpdate}
|
onUpdate={handleConfigUpdate}
|
||||||
onPreviewPage={onPreviewPage}
|
|
||||||
previewPageIndex={previewPageIndex}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg bg-gray-50 p-3">
|
<div className="rounded-lg bg-gray-50 p-3">
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { useDrag } from "react-dnd";
|
import { useDrag } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PopComponentType } from "../types/pop-layout";
|
import { PopComponentType } from "../types/pop-layout";
|
||||||
import { Square, FileText, MousePointer, BarChart3 } from "lucide-react";
|
import { Square, FileText, MousePointer } from "lucide-react";
|
||||||
import { DND_ITEM_TYPES } from "../constants";
|
import { DND_ITEM_TYPES } from "../constants";
|
||||||
|
|
||||||
// 컴포넌트 정의
|
// 컴포넌트 정의
|
||||||
|
|
@ -33,12 +33,6 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: MousePointer,
|
icon: MousePointer,
|
||||||
description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)",
|
description: "네비게이션 아이콘 (화면 이동, URL, 뒤로가기)",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: "pop-dashboard",
|
|
||||||
label: "대시보드",
|
|
||||||
icon: BarChart3,
|
|
||||||
description: "KPI, 차트, 게이지, 통계 집계",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,6 @@ interface PopRendererProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
/** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */
|
/** 현재 편집 중인 화면 ID (아이콘 네비게이션용) */
|
||||||
currentScreenId?: number;
|
currentScreenId?: number;
|
||||||
/** 대시보드 페이지 미리보기 인덱스 */
|
|
||||||
previewPageIndex?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -66,9 +64,6 @@ interface PopRendererProps {
|
||||||
|
|
||||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
"pop-sample": "샘플",
|
"pop-sample": "샘플",
|
||||||
"pop-text": "텍스트",
|
|
||||||
"pop-icon": "아이콘",
|
|
||||||
"pop-dashboard": "대시보드",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -91,7 +86,6 @@ export default function PopRenderer({
|
||||||
overridePadding,
|
overridePadding,
|
||||||
className,
|
className,
|
||||||
currentScreenId,
|
currentScreenId,
|
||||||
previewPageIndex,
|
|
||||||
}: PopRendererProps) {
|
}: PopRendererProps) {
|
||||||
const { gridConfig, components, overrides } = layout;
|
const { gridConfig, components, overrides } = layout;
|
||||||
|
|
||||||
|
|
@ -257,7 +251,6 @@ export default function PopRenderer({
|
||||||
onComponentMove={onComponentMove}
|
onComponentMove={onComponentMove}
|
||||||
onComponentResize={onComponentResize}
|
onComponentResize={onComponentResize}
|
||||||
onComponentResizeEnd={onComponentResizeEnd}
|
onComponentResizeEnd={onComponentResizeEnd}
|
||||||
previewPageIndex={previewPageIndex}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -301,7 +294,6 @@ interface DraggableComponentProps {
|
||||||
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
|
onComponentMove?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
onComponentResize?: (componentId: string, newPosition: PopGridPosition) => void;
|
||||||
onComponentResizeEnd?: (componentId: string) => void;
|
onComponentResizeEnd?: (componentId: string) => void;
|
||||||
previewPageIndex?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraggableComponent({
|
function DraggableComponent({
|
||||||
|
|
@ -319,7 +311,6 @@ function DraggableComponent({
|
||||||
onComponentMove,
|
onComponentMove,
|
||||||
onComponentResize,
|
onComponentResize,
|
||||||
onComponentResizeEnd,
|
onComponentResizeEnd,
|
||||||
previewPageIndex,
|
|
||||||
}: DraggableComponentProps) {
|
}: DraggableComponentProps) {
|
||||||
const [{ isDragging }, drag] = useDrag(
|
const [{ isDragging }, drag] = useDrag(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -358,7 +349,6 @@ function DraggableComponent({
|
||||||
effectivePosition={position}
|
effectivePosition={position}
|
||||||
isDesignMode={isDesignMode}
|
isDesignMode={isDesignMode}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
previewPageIndex={previewPageIndex}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
|
{/* 리사이즈 핸들 (선택된 컴포넌트만) */}
|
||||||
|
|
@ -509,10 +499,9 @@ interface ComponentContentProps {
|
||||||
effectivePosition: PopGridPosition;
|
effectivePosition: PopGridPosition;
|
||||||
isDesignMode: boolean;
|
isDesignMode: boolean;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
previewPageIndex?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ComponentContent({ component, effectivePosition, isDesignMode, isSelected, previewPageIndex }: ComponentContentProps) {
|
function ComponentContent({ component, effectivePosition, isDesignMode, isSelected }: ComponentContentProps) {
|
||||||
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
const typeLabel = COMPONENT_TYPE_LABELS[component.type] || component.type;
|
||||||
|
|
||||||
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
|
// PopComponentRegistry에서 등록된 컴포넌트 가져오기
|
||||||
|
|
@ -537,7 +526,6 @@ function ComponentContent({ component, effectivePosition, isDesignMode, isSelect
|
||||||
config={component.config}
|
config={component.config}
|
||||||
label={component.label}
|
label={component.label}
|
||||||
isDesignMode={isDesignMode}
|
isDesignMode={isDesignMode}
|
||||||
previewPageIndex={previewPageIndex}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 타입
|
* POP 컴포넌트 타입
|
||||||
*/
|
*/
|
||||||
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon" | "pop-dashboard";
|
export type PopComponentType = "pop-sample" | "pop-text" | "pop-icon"; // 테스트용 샘플 박스, 텍스트 컴포넌트, 아이콘 컴포넌트
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 흐름 정의
|
* 데이터 흐름 정의
|
||||||
|
|
@ -343,7 +343,6 @@ export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: nu
|
||||||
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
||||||
"pop-text": { colSpan: 3, rowSpan: 1 },
|
"pop-text": { colSpan: 3, rowSpan: 1 },
|
||||||
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
"pop-icon": { colSpan: 1, rowSpan: 2 },
|
||||||
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ export * from "./types";
|
||||||
// POP 컴포넌트 등록
|
// POP 컴포넌트 등록
|
||||||
import "./pop-text";
|
import "./pop-text";
|
||||||
import "./pop-icon";
|
import "./pop-icon";
|
||||||
import "./pop-dashboard";
|
|
||||||
|
|
||||||
// 향후 추가될 컴포넌트들:
|
// 향후 추가될 컴포넌트들:
|
||||||
// import "./pop-field";
|
// import "./pop-field";
|
||||||
|
|
|
||||||
|
|
@ -1,459 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* pop-dashboard 메인 컴포넌트 (뷰어용)
|
|
||||||
*
|
|
||||||
* 멀티 아이템 컨테이너: 여러 집계 아이템을 묶어서 다양한 표시 모드로 렌더링
|
|
||||||
*
|
|
||||||
* @INFRA-EXTRACT 대상:
|
|
||||||
* - fetchAggregatedData 호출부 -> useDataSource로 교체 예정
|
|
||||||
* - filter_changed 이벤트 수신 -> usePopEvent로 교체 예정
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import type {
|
|
||||||
PopDashboardConfig,
|
|
||||||
DashboardItem,
|
|
||||||
DashboardPage,
|
|
||||||
} from "../types";
|
|
||||||
import { fetchAggregatedData } from "./utils/dataFetcher";
|
|
||||||
import {
|
|
||||||
evaluateFormula,
|
|
||||||
formatFormulaResult,
|
|
||||||
} from "./utils/formula";
|
|
||||||
|
|
||||||
// 서브타입 아이템 컴포넌트
|
|
||||||
import { KpiCardComponent } from "./items/KpiCard";
|
|
||||||
import { ChartItemComponent } from "./items/ChartItem";
|
|
||||||
import { GaugeItemComponent } from "./items/GaugeItem";
|
|
||||||
import { StatCardComponent } from "./items/StatCard";
|
|
||||||
|
|
||||||
// 표시 모드 컴포넌트
|
|
||||||
import { ArrowsModeComponent } from "./modes/ArrowsMode";
|
|
||||||
import { AutoSlideModeComponent } from "./modes/AutoSlideMode";
|
|
||||||
import { GridModeComponent } from "./modes/GridMode";
|
|
||||||
import { ScrollModeComponent } from "./modes/ScrollMode";
|
|
||||||
|
|
||||||
// ===== 마이그레이션: 기존 config -> 페이지 기반 구조 변환 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기존 config를 페이지 기반 구조로 마이그레이션.
|
|
||||||
* 런타임에서만 사용 (저장된 config 원본은 변경하지 않음).
|
|
||||||
*
|
|
||||||
* 시나리오1: displayMode="grid" (가장 오래된 형태)
|
|
||||||
* 시나리오2: useGridLayout=true (직전 마이그레이션 결과)
|
|
||||||
* 시나리오3: pages 이미 있음 (새 형태) -> 변환 불필요
|
|
||||||
* 시나리오4: pages 없음 + grid 아님 -> 빈 pages (아이템 하나씩 슬라이드)
|
|
||||||
*/
|
|
||||||
export function migrateConfig(
|
|
||||||
raw: Record<string, unknown>
|
|
||||||
): PopDashboardConfig {
|
|
||||||
const config = { ...raw } as PopDashboardConfig & Record<string, unknown>;
|
|
||||||
|
|
||||||
// pages가 이미 있으면 마이그레이션 불필요
|
|
||||||
if (
|
|
||||||
Array.isArray(config.pages) &&
|
|
||||||
config.pages.length > 0
|
|
||||||
) {
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시나리오1: displayMode="grid" / 시나리오2: useGridLayout=true
|
|
||||||
const wasGrid =
|
|
||||||
config.displayMode === ("grid" as string) ||
|
|
||||||
(config as Record<string, unknown>).useGridLayout === true;
|
|
||||||
|
|
||||||
if (wasGrid) {
|
|
||||||
const cols =
|
|
||||||
((config as Record<string, unknown>).gridColumns as number) ?? 2;
|
|
||||||
const rows =
|
|
||||||
((config as Record<string, unknown>).gridRows as number) ?? 2;
|
|
||||||
const cells =
|
|
||||||
((config as Record<string, unknown>).gridCells as DashboardPage["gridCells"]) ?? [];
|
|
||||||
|
|
||||||
const page: DashboardPage = {
|
|
||||||
id: "migrated-page-1",
|
|
||||||
label: "페이지 1",
|
|
||||||
gridColumns: cols,
|
|
||||||
gridRows: rows,
|
|
||||||
gridCells: cells,
|
|
||||||
};
|
|
||||||
|
|
||||||
config.pages = [page];
|
|
||||||
|
|
||||||
// displayMode="grid" 보정
|
|
||||||
if (config.displayMode === ("grid" as string)) {
|
|
||||||
(config as Record<string, unknown>).displayMode = "arrows";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return config as PopDashboardConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 내부 타입 =====
|
|
||||||
|
|
||||||
interface ItemData {
|
|
||||||
/** 단일 집계 값 */
|
|
||||||
value: number;
|
|
||||||
/** 데이터 행 (차트용) */
|
|
||||||
rows: Record<string, unknown>[];
|
|
||||||
/** 수식 결과 표시 문자열 */
|
|
||||||
formulaDisplay: string | null;
|
|
||||||
/** 에러 메시지 */
|
|
||||||
error: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 데이터 로딩 함수 =====
|
|
||||||
|
|
||||||
/** 단일 아이템의 데이터를 조회 */
|
|
||||||
async function loadItemData(item: DashboardItem): Promise<ItemData> {
|
|
||||||
try {
|
|
||||||
// 수식 모드
|
|
||||||
if (item.formula?.enabled && item.formula.values.length > 0) {
|
|
||||||
// 각 값(A, B, ...)을 병렬 조회
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
item.formula.values.map((fv) => fetchAggregatedData(fv.dataSource))
|
|
||||||
);
|
|
||||||
|
|
||||||
const valueMap: Record<string, number> = {};
|
|
||||||
for (let i = 0; i < item.formula.values.length; i++) {
|
|
||||||
const result = results[i];
|
|
||||||
const fv = item.formula.values[i];
|
|
||||||
valueMap[fv.id] =
|
|
||||||
result.status === "fulfilled" ? result.value.value : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const calculatedValue = evaluateFormula(
|
|
||||||
item.formula.expression,
|
|
||||||
valueMap
|
|
||||||
);
|
|
||||||
const formulaDisplay = formatFormulaResult(item.formula, valueMap);
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: calculatedValue,
|
|
||||||
rows: [],
|
|
||||||
formulaDisplay,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 단일 집계 모드
|
|
||||||
const result = await fetchAggregatedData(item.dataSource);
|
|
||||||
if (result.error) {
|
|
||||||
return { value: 0, rows: [], formulaDisplay: null, error: result.error };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: result.value,
|
|
||||||
rows: result.rows ?? [],
|
|
||||||
formulaDisplay: null,
|
|
||||||
error: null,
|
|
||||||
};
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : "데이터 로딩 실패";
|
|
||||||
return { value: 0, rows: [], formulaDisplay: null, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 메인 컴포넌트 =====
|
|
||||||
|
|
||||||
export function PopDashboardComponent({
|
|
||||||
config,
|
|
||||||
previewPageIndex,
|
|
||||||
}: {
|
|
||||||
config?: PopDashboardConfig;
|
|
||||||
/** 디자이너 페이지 미리보기: 이 인덱스의 페이지만 단독 렌더링 (-1이면 기본 모드) */
|
|
||||||
previewPageIndex?: number;
|
|
||||||
}) {
|
|
||||||
const [dataMap, setDataMap] = useState<Record<string, ItemData>>({});
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const refreshTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [containerWidth, setContainerWidth] = useState(300);
|
|
||||||
|
|
||||||
// 보이는 아이템만 필터링 (hooks 이전에 early return 불가하므로 빈 배열 허용)
|
|
||||||
const visibleItems = Array.isArray(config?.items)
|
|
||||||
? config.items.filter((item) => item.visible)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// 컨테이너 크기 감지
|
|
||||||
useEffect(() => {
|
|
||||||
const el = containerRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
const observer = new ResizeObserver((entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
setContainerWidth(entry.contentRect.width);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
observer.observe(el);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 아이템 ID 목록 문자열 키 (참조 안정성 보장 - 매 렌더마다 새 배열 참조 방지)
|
|
||||||
const visibleItemIds = JSON.stringify(visibleItems.map((i) => i.id));
|
|
||||||
|
|
||||||
// 데이터 로딩 함수
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
const fetchAllData = useCallback(async () => {
|
|
||||||
if (!visibleItems.length) {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// 모든 아이템 병렬 로딩 (하나 실패해도 나머지 표시)
|
|
||||||
// @INFRA-EXTRACT: useDataSource로 교체 예정
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
visibleItems.map((item) => loadItemData(item))
|
|
||||||
);
|
|
||||||
|
|
||||||
const newDataMap: Record<string, ItemData> = {};
|
|
||||||
for (let i = 0; i < visibleItems.length; i++) {
|
|
||||||
const result = results[i];
|
|
||||||
newDataMap[visibleItems[i].id] =
|
|
||||||
result.status === "fulfilled"
|
|
||||||
? result.value
|
|
||||||
: { value: 0, rows: [], formulaDisplay: null, error: "로딩 실패" };
|
|
||||||
}
|
|
||||||
|
|
||||||
setDataMap(newDataMap);
|
|
||||||
setLoading(false);
|
|
||||||
}, [visibleItemIds]);
|
|
||||||
|
|
||||||
// 초기 로딩 + 주기적 새로고침
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAllData();
|
|
||||||
|
|
||||||
// refreshInterval 적용 (첫 번째 아이템 기준, 최소 5초 강제)
|
|
||||||
const rawRefreshSec = visibleItems[0]?.dataSource.refreshInterval;
|
|
||||||
const refreshSec = rawRefreshSec && rawRefreshSec > 0
|
|
||||||
? Math.max(5, rawRefreshSec)
|
|
||||||
: 0;
|
|
||||||
if (refreshSec > 0) {
|
|
||||||
refreshTimerRef.current = setInterval(fetchAllData, refreshSec * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (refreshTimerRef.current) {
|
|
||||||
clearInterval(refreshTimerRef.current);
|
|
||||||
refreshTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// visibleItemIds로 아이템 변경 감지 (visibleItems 객체 참조 대신 문자열 키 사용)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [fetchAllData, visibleItemIds]);
|
|
||||||
|
|
||||||
// 빈 설정 (모든 hooks 이후에 early return)
|
|
||||||
if (!config || !config.items?.length) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center bg-muted/20">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
대시보드 아이템을 추가하세요
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 단일 아이템 렌더링
|
|
||||||
const renderSingleItem = (item: DashboardItem) => {
|
|
||||||
const itemData = dataMap[item.id];
|
|
||||||
if (!itemData) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<span className="text-xs text-muted-foreground">로딩 중...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemData.error) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<span className="text-xs text-destructive">{itemData.error}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (item.subType) {
|
|
||||||
case "kpi-card":
|
|
||||||
return (
|
|
||||||
<KpiCardComponent
|
|
||||||
item={item}
|
|
||||||
data={itemData.value}
|
|
||||||
formulaDisplay={itemData.formulaDisplay}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case "chart": {
|
|
||||||
// groupBy가 있지만 xAxisColumn이 미설정이면 자동 보정
|
|
||||||
const chartItem = { ...item };
|
|
||||||
if (
|
|
||||||
item.dataSource.aggregation?.groupBy?.length &&
|
|
||||||
!item.chartConfig?.xAxisColumn
|
|
||||||
) {
|
|
||||||
chartItem.chartConfig = {
|
|
||||||
...chartItem.chartConfig,
|
|
||||||
chartType: chartItem.chartConfig?.chartType ?? "bar",
|
|
||||||
xAxisColumn: item.dataSource.aggregation.groupBy[0],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<ChartItemComponent
|
|
||||||
item={chartItem}
|
|
||||||
rows={itemData.rows}
|
|
||||||
containerWidth={containerWidth}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "gauge":
|
|
||||||
return <GaugeItemComponent item={item} data={itemData.value} />;
|
|
||||||
case "stat-card": {
|
|
||||||
// StatCard: 카테고리별 건수 맵 구성 (필터 적용)
|
|
||||||
const categoryData: Record<string, number> = {};
|
|
||||||
if (item.statConfig?.categories) {
|
|
||||||
for (const cat of item.statConfig.categories) {
|
|
||||||
if (cat.filter.column && cat.filter.value !== undefined && cat.filter.value !== "") {
|
|
||||||
// 카테고리 필터로 rows 필터링
|
|
||||||
const filtered = itemData.rows.filter((row) => {
|
|
||||||
const cellValue = String(row[cat.filter.column] ?? "");
|
|
||||||
const filterValue = String(cat.filter.value ?? "");
|
|
||||||
switch (cat.filter.operator) {
|
|
||||||
case "=":
|
|
||||||
return cellValue === filterValue;
|
|
||||||
case "!=":
|
|
||||||
return cellValue !== filterValue;
|
|
||||||
case "like":
|
|
||||||
return cellValue.toLowerCase().includes(filterValue.toLowerCase());
|
|
||||||
default:
|
|
||||||
return cellValue === filterValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
categoryData[cat.label] = filtered.length;
|
|
||||||
} else {
|
|
||||||
// 필터 미설정 시 전체 건수
|
|
||||||
categoryData[cat.label] = itemData.rows.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<StatCardComponent item={item} categoryData={categoryData} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
미지원 타입: {item.subType}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 로딩 상태
|
|
||||||
if (loading && !Object.keys(dataMap).length) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 마이그레이션: 기존 config를 페이지 기반으로 변환
|
|
||||||
const migrated = migrateConfig(config as unknown as Record<string, unknown>);
|
|
||||||
const pages = migrated.pages ?? [];
|
|
||||||
const displayMode = migrated.displayMode;
|
|
||||||
|
|
||||||
// 페이지 하나를 GridModeComponent로 렌더링
|
|
||||||
const renderPageContent = (page: DashboardPage) => {
|
|
||||||
return (
|
|
||||||
<GridModeComponent
|
|
||||||
cells={page.gridCells}
|
|
||||||
columns={page.gridColumns}
|
|
||||||
rows={page.gridRows}
|
|
||||||
gap={config.gap}
|
|
||||||
containerWidth={containerWidth}
|
|
||||||
renderItem={(itemId) => {
|
|
||||||
const item = visibleItems.find((i) => i.id === itemId);
|
|
||||||
if (!item) return null;
|
|
||||||
return renderSingleItem(item);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 슬라이드 수: pages가 있으면 페이지 수, 없으면 아이템 수 (기존 동작)
|
|
||||||
const slideCount = pages.length > 0 ? pages.length : visibleItems.length;
|
|
||||||
|
|
||||||
// 슬라이드 렌더 콜백: pages가 있으면 페이지 렌더, 없으면 단일 아이템
|
|
||||||
const renderSlide = (index: number) => {
|
|
||||||
if (pages.length > 0 && pages[index]) {
|
|
||||||
return renderPageContent(pages[index]);
|
|
||||||
}
|
|
||||||
// fallback: 아이템 하나씩 (기존 동작 - pages 미설정 시)
|
|
||||||
if (visibleItems[index]) {
|
|
||||||
return renderSingleItem(visibleItems[index]);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 페이지 미리보기 모드: 특정 페이지만 단독 렌더링 (디자이너에서 사용)
|
|
||||||
if (
|
|
||||||
typeof previewPageIndex === "number" &&
|
|
||||||
previewPageIndex >= 0 &&
|
|
||||||
pages[previewPageIndex]
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="h-full w-full"
|
|
||||||
style={
|
|
||||||
config.backgroundColor
|
|
||||||
? { backgroundColor: config.backgroundColor }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{renderPageContent(pages[previewPageIndex])}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 표시 모드별 렌더링
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="h-full w-full"
|
|
||||||
style={
|
|
||||||
config.backgroundColor
|
|
||||||
? { backgroundColor: config.backgroundColor }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{displayMode === "arrows" && (
|
|
||||||
<ArrowsModeComponent
|
|
||||||
itemCount={slideCount}
|
|
||||||
showIndicator={config.showIndicator}
|
|
||||||
renderItem={renderSlide}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{displayMode === "auto-slide" && (
|
|
||||||
<AutoSlideModeComponent
|
|
||||||
itemCount={slideCount}
|
|
||||||
interval={config.autoSlideInterval}
|
|
||||||
resumeDelay={config.autoSlideResumeDelay}
|
|
||||||
showIndicator={config.showIndicator}
|
|
||||||
renderItem={renderSlide}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{displayMode === "scroll" && (
|
|
||||||
<ScrollModeComponent
|
|
||||||
itemCount={slideCount}
|
|
||||||
showIndicator={config.showIndicator}
|
|
||||||
renderItem={renderSlide}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,164 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* pop-dashboard 디자이너 미리보기 컴포넌트
|
|
||||||
*
|
|
||||||
* 실제 데이터 없이 더미 레이아웃으로 미리보기 표시
|
|
||||||
* 디자이너가 설정 변경 시 즉시 미리보기 확인 가능
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react";
|
|
||||||
import type { PopDashboardConfig, DashboardSubType } from "../types";
|
|
||||||
import { migrateConfig } from "./PopDashboardComponent";
|
|
||||||
|
|
||||||
// ===== 서브타입별 아이콘 매핑 =====
|
|
||||||
|
|
||||||
const SUBTYPE_ICONS: Record<DashboardSubType, React.ReactNode> = {
|
|
||||||
"kpi-card": <BarChart3 className="h-4 w-4" />,
|
|
||||||
chart: <PieChart className="h-4 w-4" />,
|
|
||||||
gauge: <Gauge className="h-4 w-4" />,
|
|
||||||
"stat-card": <LayoutList className="h-4 w-4" />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const SUBTYPE_LABELS: Record<DashboardSubType, string> = {
|
|
||||||
"kpi-card": "KPI",
|
|
||||||
chart: "차트",
|
|
||||||
gauge: "게이지",
|
|
||||||
"stat-card": "통계",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== 모드 라벨 =====
|
|
||||||
|
|
||||||
const MODE_LABELS: Record<string, string> = {
|
|
||||||
arrows: "좌우 버튼",
|
|
||||||
"auto-slide": "자동 슬라이드",
|
|
||||||
scroll: "스크롤",
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== 더미 아이템 프리뷰 =====
|
|
||||||
|
|
||||||
function DummyItemPreview({
|
|
||||||
subType,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
subType: DashboardSubType;
|
|
||||||
label: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 rounded border border-dashed border-muted-foreground/30 bg-muted/20 p-2">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{SUBTYPE_ICONS[subType]}
|
|
||||||
</span>
|
|
||||||
<span className="truncate text-[10px] text-muted-foreground">
|
|
||||||
{label || SUBTYPE_LABELS[subType]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 메인 미리보기 =====
|
|
||||||
|
|
||||||
export function PopDashboardPreviewComponent({
|
|
||||||
config,
|
|
||||||
}: {
|
|
||||||
config?: PopDashboardConfig;
|
|
||||||
}) {
|
|
||||||
// config가 빈 객체 {} 또는 items가 없는 경우 방어
|
|
||||||
if (!config || !Array.isArray(config.items) || !config.items.length) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-1 overflow-hidden">
|
|
||||||
<BarChart3 className="h-6 w-6 text-muted-foreground/50" />
|
|
||||||
<span className="text-[10px] text-muted-foreground">대시보드</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleItems = config.items.filter((i) => i.visible);
|
|
||||||
|
|
||||||
// 마이그레이션 적용
|
|
||||||
const migrated = migrateConfig(config as unknown as Record<string, unknown>);
|
|
||||||
const pages = migrated.pages ?? [];
|
|
||||||
const hasPages = pages.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col overflow-hidden p-1">
|
|
||||||
{/* 모드 + 페이지 뱃지 */}
|
|
||||||
<div className="mb-1 flex items-center gap-1">
|
|
||||||
<span className="rounded bg-primary/10 px-1 py-0.5 text-[8px] font-medium text-primary">
|
|
||||||
{MODE_LABELS[migrated.displayMode] ?? migrated.displayMode}
|
|
||||||
</span>
|
|
||||||
{hasPages && (
|
|
||||||
<span className="rounded bg-muted px-1 py-0.5 text-[8px] font-medium text-muted-foreground">
|
|
||||||
{pages.length}페이지
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[8px] text-muted-foreground">
|
|
||||||
{visibleItems.length}개
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 미리보기 */}
|
|
||||||
<div className="min-h-0 flex-1">
|
|
||||||
{hasPages ? (
|
|
||||||
// 첫 번째 페이지 그리드 미리보기
|
|
||||||
<div
|
|
||||||
className="h-full w-full gap-1"
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: `repeat(${pages[0].gridColumns}, 1fr)`,
|
|
||||||
gridTemplateRows: `repeat(${pages[0].gridRows}, 1fr)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{pages[0].gridCells.length > 0
|
|
||||||
? pages[0].gridCells.map((cell) => {
|
|
||||||
const item = visibleItems.find(
|
|
||||||
(i) => i.id === cell.itemId
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={cell.id}
|
|
||||||
style={{
|
|
||||||
gridColumn: cell.gridColumn,
|
|
||||||
gridRow: cell.gridRow,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item ? (
|
|
||||||
<DummyItemPreview
|
|
||||||
subType={item.subType}
|
|
||||||
label={item.label}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="h-full rounded border border-dashed border-muted-foreground/20" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: visibleItems.slice(0, 4).map((item) => (
|
|
||||||
<DummyItemPreview
|
|
||||||
key={item.id}
|
|
||||||
subType={item.subType}
|
|
||||||
label={item.label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
// 페이지 미설정: 첫 번째 아이템만 크게 표시
|
|
||||||
<div className="relative h-full">
|
|
||||||
{visibleItems[0] && (
|
|
||||||
<DummyItemPreview
|
|
||||||
subType={visibleItems[0].subType}
|
|
||||||
label={visibleItems[0].label}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{visibleItems.length > 1 && (
|
|
||||||
<div className="absolute bottom-1 right-1 rounded-full bg-primary/80 px-1.5 py-0.5 text-[8px] font-medium text-primary-foreground">
|
|
||||||
+{visibleItems.length - 1}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* pop-dashboard 컴포넌트 레지스트리 등록 진입점
|
|
||||||
*
|
|
||||||
* 이 파일을 import하면 side-effect로 PopComponentRegistry에 자동 등록됨
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { PopComponentRegistry } from "../../PopComponentRegistry";
|
|
||||||
import { PopDashboardComponent } from "./PopDashboardComponent";
|
|
||||||
import { PopDashboardConfigPanel } from "./PopDashboardConfig";
|
|
||||||
import { PopDashboardPreviewComponent } from "./PopDashboardPreview";
|
|
||||||
|
|
||||||
// 레지스트리 등록
|
|
||||||
PopComponentRegistry.registerComponent({
|
|
||||||
id: "pop-dashboard",
|
|
||||||
name: "대시보드",
|
|
||||||
description: "여러 집계 아이템을 묶어서 다양한 방식으로 보여줌",
|
|
||||||
category: "display",
|
|
||||||
icon: "BarChart3",
|
|
||||||
component: PopDashboardComponent,
|
|
||||||
configPanel: PopDashboardConfigPanel,
|
|
||||||
preview: PopDashboardPreviewComponent,
|
|
||||||
defaultProps: {
|
|
||||||
items: [],
|
|
||||||
pages: [],
|
|
||||||
displayMode: "arrows",
|
|
||||||
autoSlideInterval: 5,
|
|
||||||
autoSlideResumeDelay: 3,
|
|
||||||
showIndicator: true,
|
|
||||||
gap: 8,
|
|
||||||
},
|
|
||||||
touchOptimized: true,
|
|
||||||
supportedDevices: ["mobile", "tablet"],
|
|
||||||
});
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 차트 서브타입 컴포넌트
|
|
||||||
*
|
|
||||||
* Recharts 기반 막대/원형/라인 차트
|
|
||||||
* 컨테이너 크기가 너무 작으면 "차트 표시 불가" 메시지
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
|
||||||
CartesianGrid,
|
|
||||||
} from "recharts";
|
|
||||||
import type { DashboardItem } from "../../types";
|
|
||||||
import { TEXT_ALIGN_CLASSES } from "../../types";
|
|
||||||
import { abbreviateNumber } from "../utils/formula";
|
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
|
|
||||||
export interface ChartItemProps {
|
|
||||||
item: DashboardItem;
|
|
||||||
/** 차트에 표시할 데이터 행 */
|
|
||||||
rows: Record<string, unknown>[];
|
|
||||||
/** 컨테이너 너비 (px) - 최소 크기 판단용 */
|
|
||||||
containerWidth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 기본 색상 팔레트 =====
|
|
||||||
|
|
||||||
const DEFAULT_COLORS = [
|
|
||||||
"#6366f1", // indigo
|
|
||||||
"#8b5cf6", // violet
|
|
||||||
"#06b6d4", // cyan
|
|
||||||
"#10b981", // emerald
|
|
||||||
"#f59e0b", // amber
|
|
||||||
"#ef4444", // rose
|
|
||||||
"#ec4899", // pink
|
|
||||||
"#14b8a6", // teal
|
|
||||||
];
|
|
||||||
|
|
||||||
// ===== 최소 표시 크기 =====
|
|
||||||
|
|
||||||
const MIN_CHART_WIDTH = 120;
|
|
||||||
|
|
||||||
// ===== 메인 컴포넌트 =====
|
|
||||||
|
|
||||||
export function ChartItemComponent({
|
|
||||||
item,
|
|
||||||
rows,
|
|
||||||
containerWidth,
|
|
||||||
}: ChartItemProps) {
|
|
||||||
const { chartConfig, visibility, itemStyle } = item;
|
|
||||||
const chartType = chartConfig?.chartType ?? "bar";
|
|
||||||
const colors = chartConfig?.colors?.length
|
|
||||||
? chartConfig.colors
|
|
||||||
: DEFAULT_COLORS;
|
|
||||||
const xKey = chartConfig?.xAxisColumn ?? "name";
|
|
||||||
const yKey = chartConfig?.yAxisColumn ?? "value";
|
|
||||||
|
|
||||||
// 라벨 정렬만 사용자 설정
|
|
||||||
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
|
|
||||||
|
|
||||||
// 컨테이너가 너무 작으면 메시지 표시
|
|
||||||
if (containerWidth < MIN_CHART_WIDTH) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center p-1">
|
|
||||||
<span className="text-[10px] text-muted-foreground">차트</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 데이터 없음
|
|
||||||
if (!rows.length) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<span className="text-xs text-muted-foreground">데이터 없음</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// X축 라벨이 긴지 판정 (7자 이상이면 대각선)
|
|
||||||
const hasLongLabels = rows.some(
|
|
||||||
(r) => String(r[xKey] ?? "").length > 7
|
|
||||||
);
|
|
||||||
const xAxisTickProps = hasLongLabels
|
|
||||||
? { fontSize: 10, angle: -45, textAnchor: "end" as const }
|
|
||||||
: { fontSize: 10 };
|
|
||||||
// 긴 라벨이 있으면 하단 여백 확보
|
|
||||||
const chartMargin = hasLongLabels
|
|
||||||
? { top: 5, right: 10, bottom: 40, left: 10 }
|
|
||||||
: { top: 5, right: 10, bottom: 5, left: 10 };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="@container flex h-full w-full flex-col p-2">
|
|
||||||
{/* 라벨 - 사용자 정렬 적용 */}
|
|
||||||
{visibility.showLabel && (
|
|
||||||
<p className={`w-full mb-1 text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
|
|
||||||
{item.label}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 차트 영역 */}
|
|
||||||
<div className="min-h-0 flex-1">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
{chartType === "bar" ? (
|
|
||||||
<BarChart data={rows as Record<string, string | number>[]} margin={chartMargin}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
|
||||||
<XAxis
|
|
||||||
dataKey={xKey}
|
|
||||||
tick={xAxisTickProps}
|
|
||||||
hide={containerWidth < 200}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 10 }}
|
|
||||||
hide={containerWidth < 200}
|
|
||||||
tickFormatter={(v: number) => abbreviateNumber(v)}
|
|
||||||
/>
|
|
||||||
<Tooltip />
|
|
||||||
<Bar dataKey={yKey} fill={colors[0]} radius={[2, 2, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
) : chartType === "line" ? (
|
|
||||||
<LineChart data={rows as Record<string, string | number>[]} margin={chartMargin}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
|
||||||
<XAxis
|
|
||||||
dataKey={xKey}
|
|
||||||
tick={xAxisTickProps}
|
|
||||||
hide={containerWidth < 200}
|
|
||||||
/>
|
|
||||||
<YAxis
|
|
||||||
tick={{ fontSize: 10 }}
|
|
||||||
hide={containerWidth < 200}
|
|
||||||
tickFormatter={(v: number) => abbreviateNumber(v)}
|
|
||||||
/>
|
|
||||||
<Tooltip />
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey={yKey}
|
|
||||||
stroke={colors[0]}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={containerWidth > 250}
|
|
||||||
/>
|
|
||||||
</LineChart>
|
|
||||||
) : (
|
|
||||||
/* pie - 카테고리명 + 값 라벨 표시 */
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={rows as Record<string, string | number>[]}
|
|
||||||
dataKey={yKey}
|
|
||||||
nameKey={xKey}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
outerRadius={containerWidth > 400 ? "70%" : "80%"}
|
|
||||||
label={
|
|
||||||
containerWidth > 250
|
|
||||||
? ({ name, value, percent }: { name: string; value: number; percent: number }) =>
|
|
||||||
`${name} ${abbreviateNumber(value)} (${(percent * 100).toFixed(0)}%)`
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
labelLine={containerWidth > 250}
|
|
||||||
>
|
|
||||||
{rows.map((_, index) => (
|
|
||||||
<Cell
|
|
||||||
key={`cell-${index}`}
|
|
||||||
fill={colors[index % colors.length]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
formatter={(value: number, name: string) => [abbreviateNumber(value), name]}
|
|
||||||
/>
|
|
||||||
{containerWidth > 300 && (
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: 11 }}
|
|
||||||
iconSize={10}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</PieChart>
|
|
||||||
)}
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,158 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 게이지 서브타입 컴포넌트
|
|
||||||
*
|
|
||||||
* SVG 기반 반원형 게이지 (외부 라이브러리 불필요)
|
|
||||||
* min/max/target/current 표시, 달성률 구간별 색상
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import type { DashboardItem, FontSize } from "../../types";
|
|
||||||
import { TEXT_ALIGN_CLASSES } from "../../types";
|
|
||||||
import { abbreviateNumber } from "../utils/formula";
|
|
||||||
|
|
||||||
/** FontSize -> SVG 직접 fontSize(px) 매핑 */
|
|
||||||
const SVG_FONT_SIZE_MAP: Record<FontSize, number> = {
|
|
||||||
xs: 14,
|
|
||||||
sm: 18,
|
|
||||||
base: 24,
|
|
||||||
lg: 32,
|
|
||||||
xl: 48,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
|
|
||||||
export interface GaugeItemProps {
|
|
||||||
item: DashboardItem;
|
|
||||||
data: number | null;
|
|
||||||
/** 동적 목표값 (targetDataSource로 조회된 값) */
|
|
||||||
targetValue?: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 게이지 색상 판정 =====
|
|
||||||
|
|
||||||
function getGaugeColor(
|
|
||||||
percentage: number,
|
|
||||||
ranges?: { min: number; max: number; color: string }[]
|
|
||||||
): string {
|
|
||||||
if (ranges?.length) {
|
|
||||||
const match = ranges.find((r) => percentage >= r.min && percentage <= r.max);
|
|
||||||
if (match) return match.color;
|
|
||||||
}
|
|
||||||
// 기본 색상 (달성률 기준)
|
|
||||||
if (percentage >= 80) return "#10b981"; // emerald
|
|
||||||
if (percentage >= 50) return "#f59e0b"; // amber
|
|
||||||
return "#ef4444"; // rose
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 메인 컴포넌트 =====
|
|
||||||
|
|
||||||
export function GaugeItemComponent({
|
|
||||||
item,
|
|
||||||
data,
|
|
||||||
targetValue,
|
|
||||||
}: GaugeItemProps) {
|
|
||||||
const { visibility, gaugeConfig, itemStyle } = item;
|
|
||||||
const current = data ?? 0;
|
|
||||||
const min = gaugeConfig?.min ?? 0;
|
|
||||||
const max = gaugeConfig?.max ?? 100;
|
|
||||||
const target = targetValue ?? gaugeConfig?.target ?? max;
|
|
||||||
|
|
||||||
// 라벨 정렬만 사용자 설정
|
|
||||||
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
|
|
||||||
|
|
||||||
// SVG 내부 텍스트는 기본값 고정 (사용자 설정 연동 제거)
|
|
||||||
const svgValueFontSize = SVG_FONT_SIZE_MAP["base"]; // 24
|
|
||||||
const svgSubFontSize = SVG_FONT_SIZE_MAP["xs"]; // 14
|
|
||||||
|
|
||||||
// 달성률 계산 (0~100)
|
|
||||||
const range = max - min;
|
|
||||||
const percentage = range > 0 ? Math.min(100, Math.max(0, ((current - min) / range) * 100)) : 0;
|
|
||||||
const gaugeColor = getGaugeColor(percentage, gaugeConfig?.colorRanges);
|
|
||||||
|
|
||||||
// SVG 반원 게이지 수치
|
|
||||||
const cx = 100;
|
|
||||||
const cy = 90;
|
|
||||||
const radius = 70;
|
|
||||||
// 반원: 180도 -> percentage에 비례한 각도
|
|
||||||
const startAngle = Math.PI; // 180도 (왼쪽)
|
|
||||||
const endAngle = Math.PI - (percentage / 100) * Math.PI; // 0도 (오른쪽) 방향
|
|
||||||
|
|
||||||
const startX = cx + radius * Math.cos(startAngle);
|
|
||||||
const startY = cy - radius * Math.sin(startAngle);
|
|
||||||
const endX = cx + radius * Math.cos(endAngle);
|
|
||||||
const endY = cy - radius * Math.sin(endAngle);
|
|
||||||
const largeArcFlag = percentage > 50 ? 1 : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="@container flex h-full w-full flex-col items-center justify-center p-2">
|
|
||||||
{/* 라벨 - 사용자 정렬 적용 */}
|
|
||||||
{visibility.showLabel && (
|
|
||||||
<p className={`w-full shrink-0 truncate text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
|
|
||||||
{item.label}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 게이지 SVG - 높이/너비 모두 반응형 */}
|
|
||||||
<div className="flex min-h-0 flex-1 items-center justify-center w-full">
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 200 110"
|
|
||||||
className="h-full w-auto max-w-full"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
>
|
|
||||||
{/* 배경 반원 (회색) */}
|
|
||||||
<path
|
|
||||||
d={`M ${cx - radius} ${cy} A ${radius} ${radius} 0 0 1 ${cx + radius} ${cy}`}
|
|
||||||
fill="none"
|
|
||||||
stroke="#e5e7eb"
|
|
||||||
strokeWidth="12"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 값 반원 (색상) */}
|
|
||||||
{percentage > 0 && (
|
|
||||||
<path
|
|
||||||
d={`M ${startX} ${startY} A ${radius} ${radius} 0 ${largeArcFlag} 1 ${endX} ${endY}`}
|
|
||||||
fill="none"
|
|
||||||
stroke={gaugeColor}
|
|
||||||
strokeWidth="12"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 중앙 텍스트 */}
|
|
||||||
{visibility.showValue && (
|
|
||||||
<text
|
|
||||||
x={cx}
|
|
||||||
y={cy - 10}
|
|
||||||
textAnchor="middle"
|
|
||||||
className="fill-foreground font-bold"
|
|
||||||
fontSize={svgValueFontSize}
|
|
||||||
>
|
|
||||||
{abbreviateNumber(current)}
|
|
||||||
</text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 퍼센트 */}
|
|
||||||
<text
|
|
||||||
x={cx}
|
|
||||||
y={cy + 10}
|
|
||||||
textAnchor="middle"
|
|
||||||
className="fill-muted-foreground"
|
|
||||||
fontSize={svgSubFontSize}
|
|
||||||
>
|
|
||||||
{percentage.toFixed(1)}%
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 목표값 */}
|
|
||||||
{visibility.showTarget && (
|
|
||||||
<p className="shrink-0 text-xs text-muted-foreground">
|
|
||||||
목표: {abbreviateNumber(target)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* KPI 카드 서브타입 컴포넌트
|
|
||||||
*
|
|
||||||
* 큰 숫자 + 단위 + 증감 표시
|
|
||||||
* CSS Container Query로 반응형 내부 콘텐츠
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import type { DashboardItem } from "../../types";
|
|
||||||
import { TEXT_ALIGN_CLASSES } from "../../types";
|
|
||||||
import { abbreviateNumber } from "../utils/formula";
|
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
|
|
||||||
export interface KpiCardProps {
|
|
||||||
item: DashboardItem;
|
|
||||||
data: number | null;
|
|
||||||
/** 이전 기간 대비 증감 퍼센트 (선택) */
|
|
||||||
trendValue?: number | null;
|
|
||||||
/** 수식 결과 표시 문자열 (formula가 있을 때) */
|
|
||||||
formulaDisplay?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 증감 표시 =====
|
|
||||||
|
|
||||||
function TrendIndicator({ value }: { value: number }) {
|
|
||||||
const isPositive = value > 0;
|
|
||||||
const isZero = value === 0;
|
|
||||||
const color = isPositive
|
|
||||||
? "text-emerald-600"
|
|
||||||
: isZero
|
|
||||||
? "text-muted-foreground"
|
|
||||||
: "text-rose-600";
|
|
||||||
const arrow = isPositive ? "↑" : isZero ? "→" : "↓";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center gap-0.5 text-xs font-medium ${color}`}>
|
|
||||||
<span>{arrow}</span>
|
|
||||||
<span>{Math.abs(value).toFixed(1)}%</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 색상 구간 판정 =====
|
|
||||||
|
|
||||||
function getColorForValue(
|
|
||||||
value: number,
|
|
||||||
ranges?: { min: number; max: number; color: string }[]
|
|
||||||
): string | undefined {
|
|
||||||
if (!ranges?.length) return undefined;
|
|
||||||
const match = ranges.find((r) => value >= r.min && value <= r.max);
|
|
||||||
return match?.color;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 메인 컴포넌트 =====
|
|
||||||
|
|
||||||
export function KpiCardComponent({
|
|
||||||
item,
|
|
||||||
data,
|
|
||||||
trendValue,
|
|
||||||
formulaDisplay,
|
|
||||||
}: KpiCardProps) {
|
|
||||||
const { visibility, kpiConfig, itemStyle } = item;
|
|
||||||
const displayValue = data ?? 0;
|
|
||||||
const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges);
|
|
||||||
|
|
||||||
// 라벨 정렬만 사용자 설정, 나머지는 @container 반응형 자동
|
|
||||||
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
|
|
||||||
{/* 라벨 - 사용자 정렬 적용 */}
|
|
||||||
{visibility.showLabel && (
|
|
||||||
<p className={`w-full text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
|
|
||||||
{item.label}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 메인 값 - @container 반응형 */}
|
|
||||||
{visibility.showValue && (
|
|
||||||
<div className="flex items-baseline gap-1">
|
|
||||||
<span
|
|
||||||
className="text-xl font-bold @[200px]:text-3xl @[350px]:text-4xl @[500px]:text-5xl"
|
|
||||||
style={valueColor ? { color: valueColor } : undefined}
|
|
||||||
>
|
|
||||||
{formulaDisplay ?? abbreviateNumber(displayValue)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* 단위 */}
|
|
||||||
{visibility.showUnit && kpiConfig?.unit && (
|
|
||||||
<span className="text-xs text-muted-foreground @[200px]:text-sm">
|
|
||||||
{kpiConfig.unit}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 증감율 */}
|
|
||||||
{visibility.showTrend && trendValue != null && (
|
|
||||||
<TrendIndicator value={trendValue} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 보조 라벨 (수식 표시 등) */}
|
|
||||||
{visibility.showSubLabel && formulaDisplay && (
|
|
||||||
<p className="text-xs text-muted-foreground @[200px]:text-sm">
|
|
||||||
{item.formula?.values.map((v) => v.label).join(" / ")}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 통계 카드 서브타입 컴포넌트
|
|
||||||
*
|
|
||||||
* 상태별 건수 표시 (대기/진행/완료 등)
|
|
||||||
* 각 카테고리별 색상 및 링크 지원
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import type { DashboardItem } from "../../types";
|
|
||||||
import { TEXT_ALIGN_CLASSES } from "../../types";
|
|
||||||
import { abbreviateNumber } from "../utils/formula";
|
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
|
|
||||||
export interface StatCardProps {
|
|
||||||
item: DashboardItem;
|
|
||||||
/** 카테고리별 건수 맵 (카테고리 label -> 건수) */
|
|
||||||
categoryData: Record<string, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 기본 색상 팔레트 =====
|
|
||||||
|
|
||||||
const DEFAULT_STAT_COLORS = [
|
|
||||||
"#6366f1", // indigo
|
|
||||||
"#f59e0b", // amber
|
|
||||||
"#10b981", // emerald
|
|
||||||
"#ef4444", // rose
|
|
||||||
"#8b5cf6", // violet
|
|
||||||
];
|
|
||||||
|
|
||||||
// ===== 메인 컴포넌트 =====
|
|
||||||
|
|
||||||
export function StatCardComponent({ item, categoryData }: StatCardProps) {
|
|
||||||
const { visibility, statConfig, itemStyle } = item;
|
|
||||||
const categories = statConfig?.categories ?? [];
|
|
||||||
const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0);
|
|
||||||
|
|
||||||
// 라벨 정렬만 사용자 설정
|
|
||||||
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="@container flex h-full w-full flex-col items-center justify-center p-3">
|
|
||||||
{/* 라벨 - 사용자 정렬 적용 */}
|
|
||||||
{visibility.showLabel && (
|
|
||||||
<p className={`w-full mb-1 text-muted-foreground text-xs @[250px]:text-sm ${labelAlignClass}`}>
|
|
||||||
{item.label}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 총합 - @container 반응형 */}
|
|
||||||
{visibility.showValue && (
|
|
||||||
<p className="mb-2 text-lg font-bold @[200px]:text-2xl @[350px]:text-3xl">
|
|
||||||
{abbreviateNumber(total)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 카테고리별 건수 */}
|
|
||||||
<div className="flex flex-wrap gap-2 @[200px]:gap-3">
|
|
||||||
{categories.map((cat, index) => {
|
|
||||||
const count = categoryData[cat.label] ?? 0;
|
|
||||||
const color =
|
|
||||||
cat.color ?? DEFAULT_STAT_COLORS[index % DEFAULT_STAT_COLORS.length];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={cat.label} className="flex items-center gap-1">
|
|
||||||
{/* 색상 점 */}
|
|
||||||
<span
|
|
||||||
className="inline-block h-2 w-2 rounded-full @[200px]:h-2.5 @[200px]:w-2.5"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
{/* 라벨 + 건수 */}
|
|
||||||
<span className="text-[10px] text-muted-foreground @[150px]:text-xs">
|
|
||||||
{cat.label}
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-medium @[150px]:text-xs">
|
|
||||||
{abbreviateNumber(count)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 보조 라벨 (단위 등) */}
|
|
||||||
{visibility.showSubLabel && (
|
|
||||||
<p className="mt-1 text-[10px] text-muted-foreground @[150px]:text-xs">
|
|
||||||
{visibility.showUnit && item.kpiConfig?.unit
|
|
||||||
? `단위: ${item.kpiConfig.unit}`
|
|
||||||
: ""}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 좌우 버튼 표시 모드
|
|
||||||
*
|
|
||||||
* 화살표 버튼으로 아이템을 한 장씩 넘기는 모드
|
|
||||||
* 터치 최적화: 최소 44x44px 터치 영역
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useCallback } from "react";
|
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
|
|
||||||
export interface ArrowsModeProps {
|
|
||||||
/** 총 아이템 수 */
|
|
||||||
itemCount: number;
|
|
||||||
/** 페이지 인디케이터 표시 여부 */
|
|
||||||
showIndicator?: boolean;
|
|
||||||
/** 현재 인덱스에 해당하는 아이템 렌더링 */
|
|
||||||
renderItem: (index: number) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 메인 컴포넌트 =====
|
|
||||||
|
|
||||||
export function ArrowsModeComponent({
|
|
||||||
itemCount,
|
|
||||||
showIndicator = true,
|
|
||||||
renderItem,
|
|
||||||
}: ArrowsModeProps) {
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
|
|
||||||
const goToPrev = useCallback(() => {
|
|
||||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : itemCount - 1));
|
|
||||||
}, [itemCount]);
|
|
||||||
|
|
||||||
const goToNext = useCallback(() => {
|
|
||||||
setCurrentIndex((prev) => (prev < itemCount - 1 ? prev + 1 : 0));
|
|
||||||
}, [itemCount]);
|
|
||||||
|
|
||||||
if (itemCount === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<span className="text-xs text-muted-foreground">아이템 없음</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative h-full w-full">
|
|
||||||
{/* 아이템 (전체 영역 사용) */}
|
|
||||||
<div className="h-full w-full">
|
|
||||||
{renderItem(currentIndex)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 좌우 화살표 (콘텐츠 위에 겹침) */}
|
|
||||||
{itemCount > 1 && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goToPrev}
|
|
||||||
className="absolute left-1 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-background/70 shadow-sm backdrop-blur-sm transition-all hover:bg-background/90 active:scale-95"
|
|
||||||
aria-label="이전"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goToNext}
|
|
||||||
className="absolute right-1 top-1/2 z-10 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full bg-background/70 shadow-sm backdrop-blur-sm transition-all hover:bg-background/90 active:scale-95"
|
|
||||||
aria-label="다음"
|
|
||||||
>
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 페이지 인디케이터 (콘텐츠 하단에 겹침) */}
|
|
||||||
{showIndicator && itemCount > 1 && (
|
|
||||||
<div className="absolute bottom-1 left-0 right-0 z-10 flex items-center justify-center gap-1.5">
|
|
||||||
{Array.from({ length: itemCount }).map((_, i) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={i}
|
|
||||||
onClick={() => setCurrentIndex(i)}
|
|
||||||
className={`h-1.5 rounded-full transition-all ${
|
|
||||||
i === currentIndex
|
|
||||||
? "w-4 bg-primary"
|
|
||||||
: "w-1.5 bg-muted-foreground/30"
|
|
||||||
}`}
|
|
||||||
aria-label={`${i + 1}번째 아이템`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,141 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 자동 슬라이드 표시 모드
|
|
||||||
*
|
|
||||||
* 전광판처럼 자동 전환, 터치 시 멈춤, 일정 시간 후 재개
|
|
||||||
* 컴포넌트 unmount 시 타이머 정리 필수
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
|
|
||||||
export interface AutoSlideModeProps {
|
|
||||||
/** 총 아이템 수 */
|
|
||||||
itemCount: number;
|
|
||||||
/** 자동 전환 간격 (초, 기본 5) */
|
|
||||||
interval?: number;
|
|
||||||
/** 터치 후 자동 재개까지 대기 시간 (초, 기본 3) */
|
|
||||||
resumeDelay?: number;
|
|
||||||
/** 페이지 인디케이터 표시 여부 */
|
|
||||||
showIndicator?: boolean;
|
|
||||||
/** 현재 인덱스에 해당하는 아이템 렌더링 */
|
|
||||||
renderItem: (index: number) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 메인 컴포넌트 =====
|
|
||||||
|
|
||||||
export function AutoSlideModeComponent({
|
|
||||||
itemCount,
|
|
||||||
interval = 5,
|
|
||||||
resumeDelay = 3,
|
|
||||||
showIndicator = true,
|
|
||||||
renderItem,
|
|
||||||
}: AutoSlideModeProps) {
|
|
||||||
const [currentIndex, setCurrentIndex] = useState(0);
|
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
|
||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
const resumeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
// 타이머 정리 함수
|
|
||||||
const clearTimers = useCallback(() => {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
if (resumeTimerRef.current) {
|
|
||||||
clearTimeout(resumeTimerRef.current);
|
|
||||||
resumeTimerRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 자동 슬라이드 시작
|
|
||||||
const startAutoSlide = useCallback(() => {
|
|
||||||
clearTimers();
|
|
||||||
if (itemCount <= 1) return;
|
|
||||||
|
|
||||||
intervalRef.current = setInterval(() => {
|
|
||||||
setCurrentIndex((prev) => (prev + 1) % itemCount);
|
|
||||||
}, interval * 1000);
|
|
||||||
}, [itemCount, interval, clearTimers]);
|
|
||||||
|
|
||||||
// 터치/클릭으로 일시 정지
|
|
||||||
const handlePause = useCallback(() => {
|
|
||||||
setIsPaused(true);
|
|
||||||
clearTimers();
|
|
||||||
|
|
||||||
// resumeDelay 후 자동 재개
|
|
||||||
resumeTimerRef.current = setTimeout(() => {
|
|
||||||
setIsPaused(false);
|
|
||||||
startAutoSlide();
|
|
||||||
}, resumeDelay * 1000);
|
|
||||||
}, [resumeDelay, clearTimers, startAutoSlide]);
|
|
||||||
|
|
||||||
// 마운트 시 자동 슬라이드 시작, unmount 시 정리
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isPaused) {
|
|
||||||
startAutoSlide();
|
|
||||||
}
|
|
||||||
return clearTimers;
|
|
||||||
}, [isPaused, startAutoSlide, clearTimers]);
|
|
||||||
|
|
||||||
if (itemCount === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<span className="text-xs text-muted-foreground">아이템 없음</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative h-full w-full"
|
|
||||||
onClick={handlePause}
|
|
||||||
onTouchStart={handlePause}
|
|
||||||
role="presentation"
|
|
||||||
>
|
|
||||||
{/* 콘텐츠 (슬라이드 애니메이션) */}
|
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="flex h-full transition-transform duration-500 ease-in-out"
|
|
||||||
style={{
|
|
||||||
width: `${itemCount * 100}%`,
|
|
||||||
transform: `translateX(-${currentIndex * (100 / itemCount)}%)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array.from({ length: itemCount }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-full"
|
|
||||||
style={{ width: `${100 / itemCount}%` }}
|
|
||||||
>
|
|
||||||
{renderItem(i)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 인디케이터 (콘텐츠 하단에 겹침) */}
|
|
||||||
{showIndicator && itemCount > 1 && (
|
|
||||||
<div className="absolute bottom-1 left-0 right-0 z-10 flex items-center justify-center gap-1.5">
|
|
||||||
{isPaused && (
|
|
||||||
<span className="mr-2 text-[10px] text-muted-foreground/70">
|
|
||||||
일시정지
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{Array.from({ length: itemCount }).map((_, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className={`h-1.5 rounded-full transition-all ${
|
|
||||||
i === currentIndex
|
|
||||||
? "w-4 bg-primary"
|
|
||||||
: "w-1.5 bg-muted-foreground/30"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 그리드 표시 모드
|
|
||||||
*
|
|
||||||
* CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영)
|
|
||||||
* 각 셀에 @container 적용하여 내부 아이템 반응형
|
|
||||||
*
|
|
||||||
* 반응형 자동 조정:
|
|
||||||
* - containerWidth에 따라 열 수를 자동 축소
|
|
||||||
* - 설정된 열 수가 최대값이고, 공간이 부족하면 줄어듦
|
|
||||||
* - 셀당 최소 너비(MIN_CELL_WIDTH) 기준으로 판단
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
|
||||||
import type { DashboardCell } from "../../types";
|
|
||||||
|
|
||||||
// ===== 상수 =====
|
|
||||||
|
|
||||||
/** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */
|
|
||||||
const MIN_CELL_WIDTH = 80;
|
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
|
|
||||||
export interface GridModeProps {
|
|
||||||
/** 셀 배치 정보 */
|
|
||||||
cells: DashboardCell[];
|
|
||||||
/** 설정된 열 수 (최대값) */
|
|
||||||
columns: number;
|
|
||||||
/** 설정된 행 수 */
|
|
||||||
rows: number;
|
|
||||||
/** 아이템 간 간격 (px) */
|
|
||||||
gap?: number;
|
|
||||||
/** 컨테이너 너비 (px, 반응형 자동 조정용) */
|
|
||||||
containerWidth?: number;
|
|
||||||
/** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */
|
|
||||||
renderItem: (itemId: string) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 반응형 열 수 계산 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컨테이너 너비에 맞는 실제 열 수를 계산
|
|
||||||
*
|
|
||||||
* 설정된 columns가 최대값이고, 공간이 부족하면 축소.
|
|
||||||
* gap도 고려하여 계산.
|
|
||||||
*
|
|
||||||
* 예: columns=3, containerWidth=400, gap=8, MIN_CELL_WIDTH=160
|
|
||||||
* 사용 가능 너비 = 400 - (3-1)*8 = 384
|
|
||||||
* 셀당 너비 = 384/3 = 128 < 160 -> 열 축소
|
|
||||||
* columns=2: 사용 가능 = 400 - (2-1)*8 = 392, 셀당 = 196 >= 160 -> OK
|
|
||||||
*/
|
|
||||||
function computeResponsiveColumns(
|
|
||||||
configColumns: number,
|
|
||||||
containerWidth: number,
|
|
||||||
gap: number
|
|
||||||
): number {
|
|
||||||
if (containerWidth <= 0) return configColumns;
|
|
||||||
|
|
||||||
for (let cols = configColumns; cols >= 1; cols--) {
|
|
||||||
const totalGap = (cols - 1) * gap;
|
|
||||||
const cellWidth = (containerWidth - totalGap) / cols;
|
|
||||||
if (cellWidth >= MIN_CELL_WIDTH) return cols;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 열 수가 줄어들 때 셀 배치를 자동 재배열
|
|
||||||
*
|
|
||||||
* 원본 gridColumn/gridRow를 actualColumns에 맞게 재매핑
|
|
||||||
* 셀이 원래 위치를 유지하려고 시도하되, 넘치면 아래로 이동
|
|
||||||
*/
|
|
||||||
function remapCells(
|
|
||||||
cells: DashboardCell[],
|
|
||||||
configColumns: number,
|
|
||||||
actualColumns: number,
|
|
||||||
configRows: number
|
|
||||||
): { remappedCells: DashboardCell[]; actualRows: number } {
|
|
||||||
// 열 수가 같으면 원본 그대로
|
|
||||||
if (actualColumns >= configColumns) {
|
|
||||||
return { remappedCells: cells, actualRows: configRows };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 셀을 원래 위치 순서대로 정렬 (행 우선)
|
|
||||||
const sorted = [...cells].sort((a, b) => {
|
|
||||||
const aRow = parseInt(a.gridRow.split(" / ")[0]) || 0;
|
|
||||||
const bRow = parseInt(b.gridRow.split(" / ")[0]) || 0;
|
|
||||||
if (aRow !== bRow) return aRow - bRow;
|
|
||||||
const aCol = parseInt(a.gridColumn.split(" / ")[0]) || 0;
|
|
||||||
const bCol = parseInt(b.gridColumn.split(" / ")[0]) || 0;
|
|
||||||
return aCol - bCol;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 순서대로 새 위치에 배치
|
|
||||||
let maxRow = 0;
|
|
||||||
const remapped = sorted.map((cell, index) => {
|
|
||||||
const newCol = (index % actualColumns) + 1;
|
|
||||||
const newRow = Math.floor(index / actualColumns) + 1;
|
|
||||||
maxRow = Math.max(maxRow, newRow);
|
|
||||||
return {
|
|
||||||
...cell,
|
|
||||||
gridColumn: `${newCol} / ${newCol + 1}`,
|
|
||||||
gridRow: `${newRow} / ${newRow + 1}`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return { remappedCells: remapped, actualRows: maxRow };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 메인 컴포넌트 =====
|
|
||||||
|
|
||||||
export function GridModeComponent({
|
|
||||||
cells,
|
|
||||||
columns,
|
|
||||||
rows,
|
|
||||||
gap = 8,
|
|
||||||
containerWidth,
|
|
||||||
renderItem,
|
|
||||||
}: GridModeProps) {
|
|
||||||
// 반응형 열 수 계산
|
|
||||||
const actualColumns = useMemo(
|
|
||||||
() =>
|
|
||||||
containerWidth
|
|
||||||
? computeResponsiveColumns(columns, containerWidth, gap)
|
|
||||||
: columns,
|
|
||||||
[columns, containerWidth, gap]
|
|
||||||
);
|
|
||||||
|
|
||||||
// 열 수가 줄었으면 셀 재배열
|
|
||||||
const { remappedCells, actualRows } = useMemo(
|
|
||||||
() => remapCells(cells, columns, actualColumns, rows),
|
|
||||||
[cells, columns, actualColumns, rows]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!remappedCells.length) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<span className="text-xs text-muted-foreground">셀 없음</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="h-full w-full"
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: `repeat(${actualColumns}, 1fr)`,
|
|
||||||
gridTemplateRows: `repeat(${actualRows}, 1fr)`,
|
|
||||||
gap: `${gap}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{remappedCells.map((cell) => (
|
|
||||||
<div
|
|
||||||
key={cell.id}
|
|
||||||
className="@container min-h-0 min-w-0 overflow-hidden rounded-md border border-border/50 bg-card"
|
|
||||||
style={{
|
|
||||||
gridColumn: cell.gridColumn,
|
|
||||||
gridRow: cell.gridRow,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cell.itemId ? (
|
|
||||||
renderItem(cell.itemId)
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<span className="text-[10px] text-muted-foreground/50">
|
|
||||||
빈 셀
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 스크롤 표시 모드
|
|
||||||
*
|
|
||||||
* 가로 스크롤 + CSS scroll-snap으로 아이템 단위 스냅
|
|
||||||
* 터치 스와이프 네이티브 지원
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
|
||||||
|
|
||||||
// ===== Props =====
|
|
||||||
|
|
||||||
export interface ScrollModeProps {
|
|
||||||
/** 총 아이템 수 */
|
|
||||||
itemCount: number;
|
|
||||||
/** 페이지 인디케이터 표시 여부 */
|
|
||||||
showIndicator?: boolean;
|
|
||||||
/** 현재 인덱스에 해당하는 아이템 렌더링 */
|
|
||||||
renderItem: (index: number) => React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 메인 컴포넌트 =====
|
|
||||||
|
|
||||||
export function ScrollModeComponent({
|
|
||||||
itemCount,
|
|
||||||
showIndicator = true,
|
|
||||||
renderItem,
|
|
||||||
}: ScrollModeProps) {
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
|
|
||||||
// 스크롤 위치로 현재 인덱스 계산
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el || !el.clientWidth) return;
|
|
||||||
const index = Math.round(el.scrollLeft / el.clientWidth);
|
|
||||||
setActiveIndex(Math.min(index, itemCount - 1));
|
|
||||||
}, [itemCount]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
el.addEventListener("scroll", handleScroll, { passive: true });
|
|
||||||
return () => el.removeEventListener("scroll", handleScroll);
|
|
||||||
}, [handleScroll]);
|
|
||||||
|
|
||||||
if (itemCount === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
|
||||||
<span className="text-xs text-muted-foreground">아이템 없음</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full w-full flex-col">
|
|
||||||
{/* 스크롤 영역 */}
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
className="flex min-h-0 flex-1 snap-x snap-mandatory overflow-x-auto scrollbar-none"
|
|
||||||
>
|
|
||||||
{Array.from({ length: itemCount }).map((_, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className="h-full w-full shrink-0 snap-center"
|
|
||||||
>
|
|
||||||
{renderItem(i)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 페이지 인디케이터 */}
|
|
||||||
{showIndicator && itemCount > 1 && (
|
|
||||||
<div className="flex items-center justify-center gap-1.5 py-1">
|
|
||||||
{Array.from({ length: itemCount }).map((_, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className={`h-1.5 rounded-full transition-all ${
|
|
||||||
i === activeIndex
|
|
||||||
? "w-4 bg-primary"
|
|
||||||
: "w-1.5 bg-muted-foreground/30"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,367 +0,0 @@
|
||||||
/**
|
|
||||||
* pop-dashboard 데이터 페처
|
|
||||||
*
|
|
||||||
* @INFRA-EXTRACT: useDataSource 완성 후 이 파일 전체를 훅으로 교체 예정
|
|
||||||
* 현재는 직접 API 호출. 공통 인프라 완성 후 useDataSource 훅으로 교체.
|
|
||||||
*
|
|
||||||
* 보안:
|
|
||||||
* - SQL 인젝션 방지: 사용자 입력값 이스케이프 처리
|
|
||||||
* - 멀티테넌시: autoFilter 자동 전달
|
|
||||||
* - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { apiClient } from "@/lib/api/client";
|
|
||||||
import { dashboardApi } from "@/lib/api/dashboard";
|
|
||||||
import { dataApi } from "@/lib/api/data";
|
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
|
||||||
import type { TableInfo } from "@/lib/api/tableManagement";
|
|
||||||
import type { DataSourceConfig, DataSourceFilter } from "../../types";
|
|
||||||
|
|
||||||
// ===== 타입 re-export =====
|
|
||||||
|
|
||||||
export type { TableInfo };
|
|
||||||
|
|
||||||
// ===== 반환 타입 =====
|
|
||||||
|
|
||||||
export interface AggregatedResult {
|
|
||||||
value: number;
|
|
||||||
rows?: Record<string, unknown>[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ColumnInfo {
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
udtName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== SQL 값 이스케이프 =====
|
|
||||||
|
|
||||||
/** SQL 문자열 값 이스케이프 (SQL 인젝션 방지) */
|
|
||||||
function escapeSQL(value: unknown): string {
|
|
||||||
if (value === null || value === undefined) return "NULL";
|
|
||||||
if (typeof value === "number") return String(value);
|
|
||||||
if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
|
|
||||||
// 문자열: 작은따옴표 이스케이프
|
|
||||||
const str = String(value).replace(/'/g, "''");
|
|
||||||
return `'${str}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 설정 완료 여부 검증 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DataSourceConfig의 필수값이 모두 채워졌는지 검증
|
|
||||||
* 설정 중간 상태(테이블 미선택, 컬럼 미선택 등)에서는
|
|
||||||
* SQL을 생성하지 않도록 사전 차단
|
|
||||||
*
|
|
||||||
* @returns null이면 유효, 문자열이면 미완료 사유
|
|
||||||
*/
|
|
||||||
function validateDataSourceConfig(config: DataSourceConfig): string | null {
|
|
||||||
// 테이블명 필수
|
|
||||||
if (!config.tableName || !config.tableName.trim()) {
|
|
||||||
return "테이블이 선택되지 않았습니다";
|
|
||||||
}
|
|
||||||
|
|
||||||
// 집계 함수가 설정되었으면 대상 컬럼도 필수
|
|
||||||
// (단, COUNT는 컬럼 없이도 COUNT(*)로 처리 가능)
|
|
||||||
if (config.aggregation) {
|
|
||||||
const aggType = config.aggregation.type?.toLowerCase();
|
|
||||||
const aggCol = config.aggregation.column?.trim();
|
|
||||||
if (aggType !== "count" && !aggCol) {
|
|
||||||
return `${config.aggregation.type.toUpperCase()} 집계에 대상 컬럼이 필요합니다`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 조인이 있으면 조인 조건 필수
|
|
||||||
if (config.joins?.length) {
|
|
||||||
for (const join of config.joins) {
|
|
||||||
if (!join.targetTable?.trim()) {
|
|
||||||
return "조인 대상 테이블이 선택되지 않았습니다";
|
|
||||||
}
|
|
||||||
if (!join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
|
|
||||||
return "조인 조건 컬럼이 설정되지 않았습니다";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 필터 조건 SQL 생성 =====
|
|
||||||
|
|
||||||
/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */
|
|
||||||
function buildWhereClause(filters: DataSourceFilter[]): string {
|
|
||||||
// 컬럼명이 빈 필터는 무시 (설정 중간 상태 방어)
|
|
||||||
const validFilters = filters.filter((f) => f.column?.trim());
|
|
||||||
if (!validFilters.length) return "";
|
|
||||||
|
|
||||||
const conditions = validFilters.map((f) => {
|
|
||||||
const col = sanitizeIdentifier(f.column);
|
|
||||||
|
|
||||||
switch (f.operator) {
|
|
||||||
case "between": {
|
|
||||||
const arr = Array.isArray(f.value) ? f.value : [f.value, f.value];
|
|
||||||
return `${col} BETWEEN ${escapeSQL(arr[0])} AND ${escapeSQL(arr[1])}`;
|
|
||||||
}
|
|
||||||
case "in": {
|
|
||||||
const arr = Array.isArray(f.value) ? f.value : [f.value];
|
|
||||||
const vals = arr.map(escapeSQL).join(", ");
|
|
||||||
return `${col} IN (${vals})`;
|
|
||||||
}
|
|
||||||
case "like":
|
|
||||||
return `${col} LIKE ${escapeSQL(f.value)}`;
|
|
||||||
default:
|
|
||||||
return `${col} ${f.operator} ${escapeSQL(f.value)}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return `WHERE ${conditions.join(" AND ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 식별자 검증 (테이블명, 컬럼명) =====
|
|
||||||
|
|
||||||
/** SQL 식별자(테이블명, 컬럼명)에서 위험한 문자 제거 */
|
|
||||||
function sanitizeIdentifier(name: string): string {
|
|
||||||
// 알파벳, 숫자, 언더스코어, 점(스키마.테이블)만 허용
|
|
||||||
return name.replace(/[^a-zA-Z0-9_.]/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 집계 SQL 빌더 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DataSourceConfig를 SELECT SQL로 변환
|
|
||||||
*
|
|
||||||
* @param config - 데이터 소스 설정
|
|
||||||
* @returns SQL 문자열
|
|
||||||
*/
|
|
||||||
export function buildAggregationSQL(config: DataSourceConfig): string {
|
|
||||||
const tableName = sanitizeIdentifier(config.tableName);
|
|
||||||
|
|
||||||
// SELECT 절
|
|
||||||
let selectClause: string;
|
|
||||||
if (config.aggregation) {
|
|
||||||
const aggType = config.aggregation.type.toUpperCase();
|
|
||||||
const aggCol = config.aggregation.column?.trim()
|
|
||||||
? sanitizeIdentifier(config.aggregation.column)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
// COUNT는 컬럼 없으면 COUNT(*), 나머지는 컬럼 필수
|
|
||||||
if (!aggCol) {
|
|
||||||
selectClause = aggType === "COUNT"
|
|
||||||
? "COUNT(*) as value"
|
|
||||||
: `${aggType}(${tableName}.*) as value`;
|
|
||||||
} else {
|
|
||||||
selectClause = `${aggType}(${aggCol}) as value`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// GROUP BY가 있으면 해당 컬럼도 SELECT에 포함
|
|
||||||
if (config.aggregation.groupBy?.length) {
|
|
||||||
const groupCols = config.aggregation.groupBy.map(sanitizeIdentifier).join(", ");
|
|
||||||
selectClause = `${groupCols}, ${selectClause}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
selectClause = "*";
|
|
||||||
}
|
|
||||||
|
|
||||||
// FROM 절 (조인 포함 - 조건이 완전한 조인만 적용)
|
|
||||||
let fromClause = tableName;
|
|
||||||
if (config.joins?.length) {
|
|
||||||
for (const join of config.joins) {
|
|
||||||
// 조인 설정이 불완전하면 건너뜀 (설정 중간 상태 방어)
|
|
||||||
if (!join.targetTable?.trim() || !join.on.sourceColumn?.trim() || !join.on.targetColumn?.trim()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const joinTable = sanitizeIdentifier(join.targetTable);
|
|
||||||
const joinType = join.joinType.toUpperCase();
|
|
||||||
const srcCol = sanitizeIdentifier(join.on.sourceColumn);
|
|
||||||
const tgtCol = sanitizeIdentifier(join.on.targetColumn);
|
|
||||||
fromClause += ` ${joinType} JOIN ${joinTable} ON ${tableName}.${srcCol} = ${joinTable}.${tgtCol}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WHERE 절
|
|
||||||
const whereClause = config.filters?.length
|
|
||||||
? buildWhereClause(config.filters)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
// GROUP BY 절
|
|
||||||
let groupByClause = "";
|
|
||||||
if (config.aggregation?.groupBy?.length) {
|
|
||||||
groupByClause = `GROUP BY ${config.aggregation.groupBy.map(sanitizeIdentifier).join(", ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ORDER BY 절
|
|
||||||
let orderByClause = "";
|
|
||||||
if (config.sort?.length) {
|
|
||||||
const sortCols = config.sort
|
|
||||||
.map((s) => `${sanitizeIdentifier(s.column)} ${s.direction.toUpperCase()}`)
|
|
||||||
.join(", ");
|
|
||||||
orderByClause = `ORDER BY ${sortCols}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// LIMIT 절
|
|
||||||
const limitClause = config.limit ? `LIMIT ${Math.max(1, Math.floor(config.limit))}` : "";
|
|
||||||
|
|
||||||
return [
|
|
||||||
`SELECT ${selectClause}`,
|
|
||||||
`FROM ${fromClause}`,
|
|
||||||
whereClause,
|
|
||||||
groupByClause,
|
|
||||||
orderByClause,
|
|
||||||
limitClause,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 메인 데이터 페처 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DataSourceConfig 기반으로 데이터를 조회하여 집계 결과 반환
|
|
||||||
*
|
|
||||||
* API 선택 전략:
|
|
||||||
* 1. 집계 있음 -> buildAggregationSQL -> dashboardApi.executeQuery()
|
|
||||||
* 2. 조인만 있음 (2개 테이블) -> dataApi.getJoinedData() (추후 지원)
|
|
||||||
* 3. 단순 조회 -> dataApi.getTableData()
|
|
||||||
*
|
|
||||||
* @INFRA-EXTRACT: useDataSource 완성 후 이 함수를 훅으로 교체
|
|
||||||
*/
|
|
||||||
export async function fetchAggregatedData(
|
|
||||||
config: DataSourceConfig
|
|
||||||
): Promise<AggregatedResult> {
|
|
||||||
try {
|
|
||||||
// 설정 완료 여부 검증 (미완료 시 SQL 전송 차단)
|
|
||||||
const validationError = validateDataSourceConfig(config);
|
|
||||||
if (validationError) {
|
|
||||||
return { value: 0, rows: [], error: validationError };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 집계 또는 조인이 있으면 SQL 직접 실행
|
|
||||||
if (config.aggregation || (config.joins && config.joins.length > 0)) {
|
|
||||||
const sql = buildAggregationSQL(config);
|
|
||||||
|
|
||||||
// API 호출: apiClient(axios) 우선, dashboardApi(fetch) 폴백
|
|
||||||
let queryResult: { columns: string[]; rows: any[] };
|
|
||||||
try {
|
|
||||||
// 1차: apiClient (axios 기반, 인증/세션 안정적)
|
|
||||||
const response = await apiClient.post("/dashboards/execute-query", { query: sql });
|
|
||||||
if (response.data?.success && response.data?.data) {
|
|
||||||
queryResult = response.data.data;
|
|
||||||
} else {
|
|
||||||
throw new Error(response.data?.message || "쿼리 실행 실패");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 2차: dashboardApi (fetch 기반, 폴백)
|
|
||||||
queryResult = await dashboardApi.executeQuery(sql);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queryResult.rows.length === 0) {
|
|
||||||
return { value: 0, rows: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL bigint/numeric는 JS에서 문자열로 반환됨
|
|
||||||
// Recharts PieChart 등은 숫자 타입이 필수이므로 변환 처리
|
|
||||||
const processedRows = queryResult.rows.map((row: Record<string, unknown>) => {
|
|
||||||
const converted: Record<string, unknown> = { ...row };
|
|
||||||
for (const key of Object.keys(converted)) {
|
|
||||||
const val = converted[key];
|
|
||||||
if (typeof val === "string" && val !== "" && !isNaN(Number(val))) {
|
|
||||||
converted[key] = Number(val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return converted;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 첫 번째 행의 value 컬럼 추출
|
|
||||||
const firstRow = processedRows[0];
|
|
||||||
const numericValue = parseFloat(String(firstRow.value ?? firstRow[queryResult.columns[0]] ?? 0));
|
|
||||||
|
|
||||||
return {
|
|
||||||
value: Number.isFinite(numericValue) ? numericValue : 0,
|
|
||||||
rows: processedRows,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 단순 조회
|
|
||||||
const tableResult = await dataApi.getTableData(config.tableName, {
|
|
||||||
page: 1,
|
|
||||||
size: config.limit ?? 100,
|
|
||||||
sortBy: config.sort?.[0]?.column,
|
|
||||||
sortOrder: config.sort?.[0]?.direction,
|
|
||||||
filters: config.filters?.reduce(
|
|
||||||
(acc, f) => {
|
|
||||||
acc[f.column] = f.value;
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, unknown>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 단순 조회 시에는 행 수를 value로 사용
|
|
||||||
return {
|
|
||||||
value: tableResult.total ?? tableResult.data.length,
|
|
||||||
rows: tableResult.data,
|
|
||||||
};
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : "데이터 조회 실패";
|
|
||||||
return { value: 0, error: message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 설정 패널용 헬퍼 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 테이블 목록 조회 (설정 패널 드롭다운용)
|
|
||||||
* dashboardApi.getTableSchema()로 특정 테이블의 스키마를 가져오되,
|
|
||||||
* 테이블 목록은 별도로 필요하므로 간단히 반환
|
|
||||||
*/
|
|
||||||
export async function fetchTableColumns(
|
|
||||||
tableName: string
|
|
||||||
): Promise<ColumnInfo[]> {
|
|
||||||
// 1차: tableManagementApi (apiClient/axios 기반, 인증 안정적)
|
|
||||||
try {
|
|
||||||
const response = await tableManagementApi.getTableSchema(tableName);
|
|
||||||
if (response.success && response.data) {
|
|
||||||
const cols = Array.isArray(response.data) ? response.data : [];
|
|
||||||
if (cols.length > 0) {
|
|
||||||
return cols.map((col: any) => ({
|
|
||||||
name: col.columnName || col.column_name || col.name,
|
|
||||||
type: col.dataType || col.data_type || col.type || "unknown",
|
|
||||||
udtName: col.dbType || col.udt_name || col.udtName || "unknown",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// tableManagementApi 실패 시 dashboardApi로 폴백
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2차: dashboardApi (fetch 기반, 폴백)
|
|
||||||
try {
|
|
||||||
const schema = await dashboardApi.getTableSchema(tableName);
|
|
||||||
return schema.columns.map((col) => ({
|
|
||||||
name: col.name,
|
|
||||||
type: col.type,
|
|
||||||
udtName: col.udtName,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 테이블 목록 조회 (설정 패널 Combobox용)
|
|
||||||
* tableManagementApi.getTableList() 래핑
|
|
||||||
*
|
|
||||||
* @INFRA-EXTRACT: useDataSource 완성 후 교체 예정
|
|
||||||
*/
|
|
||||||
export async function fetchTableList(): Promise<TableInfo[]> {
|
|
||||||
try {
|
|
||||||
const response = await tableManagementApi.getTableList();
|
|
||||||
if (response.success && response.data) {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
/**
|
|
||||||
* pop-dashboard 수식 파싱 및 평가 유틸리티
|
|
||||||
*
|
|
||||||
* 보안: eval()/new Function() 사용 금지. 직접 사칙연산 파서 구현.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { FormulaConfig, FormulaDisplayFormat } from "../../types";
|
|
||||||
|
|
||||||
// ===== 토큰 타입 =====
|
|
||||||
|
|
||||||
type TokenType = "number" | "variable" | "operator" | "lparen" | "rparen";
|
|
||||||
|
|
||||||
interface Token {
|
|
||||||
type: TokenType;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 토크나이저 =====
|
|
||||||
|
|
||||||
/** 수식 문자열을 토큰 배열로 분리 */
|
|
||||||
function tokenize(expression: string): Token[] {
|
|
||||||
const tokens: Token[] = [];
|
|
||||||
let i = 0;
|
|
||||||
const expr = expression.replace(/\s+/g, "");
|
|
||||||
|
|
||||||
while (i < expr.length) {
|
|
||||||
const ch = expr[i];
|
|
||||||
|
|
||||||
// 숫자 (정수, 소수)
|
|
||||||
if (/\d/.test(ch) || (ch === "." && i + 1 < expr.length && /\d/.test(expr[i + 1]))) {
|
|
||||||
let num = "";
|
|
||||||
while (i < expr.length && (/\d/.test(expr[i]) || expr[i] === ".")) {
|
|
||||||
num += expr[i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
tokens.push({ type: "number", value: num });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 변수 (A, B, C 등 알파벳)
|
|
||||||
if (/[A-Za-z]/.test(ch)) {
|
|
||||||
let varName = "";
|
|
||||||
while (i < expr.length && /[A-Za-z0-9_]/.test(expr[i])) {
|
|
||||||
varName += expr[i];
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
tokens.push({ type: "variable", value: varName });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 연산자
|
|
||||||
if ("+-*/".includes(ch)) {
|
|
||||||
tokens.push({ type: "operator", value: ch });
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 괄호
|
|
||||||
if (ch === "(") {
|
|
||||||
tokens.push({ type: "lparen", value: "(" });
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (ch === ")") {
|
|
||||||
tokens.push({ type: "rparen", value: ")" });
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 알 수 없는 문자는 건너뜀
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 재귀 하강 파서 =====
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사칙연산 수식을 안전하게 평가 (재귀 하강 파서)
|
|
||||||
*
|
|
||||||
* 문법:
|
|
||||||
* expr = term (('+' | '-') term)*
|
|
||||||
* term = factor (('*' | '/') factor)*
|
|
||||||
* factor = NUMBER | VARIABLE | '(' expr ')'
|
|
||||||
*
|
|
||||||
* @param expression - 수식 문자열 (예: "A / B * 100")
|
|
||||||
* @param values - 변수값 맵 (예: { A: 1234, B: 5678 })
|
|
||||||
* @returns 계산 결과 (0으로 나누기 시 0 반환)
|
|
||||||
*/
|
|
||||||
export function evaluateFormula(
|
|
||||||
expression: string,
|
|
||||||
values: Record<string, number>
|
|
||||||
): number {
|
|
||||||
const tokens = tokenize(expression);
|
|
||||||
let pos = 0;
|
|
||||||
|
|
||||||
function peek(): Token | undefined {
|
|
||||||
return tokens[pos];
|
|
||||||
}
|
|
||||||
|
|
||||||
function consume(): Token {
|
|
||||||
return tokens[pos++];
|
|
||||||
}
|
|
||||||
|
|
||||||
// factor = NUMBER | VARIABLE | '(' expr ')'
|
|
||||||
function parseFactor(): number {
|
|
||||||
const token = peek();
|
|
||||||
if (!token) return 0;
|
|
||||||
|
|
||||||
if (token.type === "number") {
|
|
||||||
consume();
|
|
||||||
return parseFloat(token.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token.type === "variable") {
|
|
||||||
consume();
|
|
||||||
return values[token.value] ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token.type === "lparen") {
|
|
||||||
consume(); // '(' 소비
|
|
||||||
const result = parseExpr();
|
|
||||||
if (peek()?.type === "rparen") {
|
|
||||||
consume(); // ')' 소비
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 예상치 못한 토큰
|
|
||||||
consume();
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// term = factor (('*' | '/') factor)*
|
|
||||||
function parseTerm(): number {
|
|
||||||
let result = parseFactor();
|
|
||||||
while (peek()?.type === "operator" && (peek()!.value === "*" || peek()!.value === "/")) {
|
|
||||||
const op = consume().value;
|
|
||||||
const right = parseFactor();
|
|
||||||
if (op === "*") {
|
|
||||||
result *= right;
|
|
||||||
} else {
|
|
||||||
// 0으로 나누기 방지
|
|
||||||
result = right === 0 ? 0 : result / right;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// expr = term (('+' | '-') term)*
|
|
||||||
function parseExpr(): number {
|
|
||||||
let result = parseTerm();
|
|
||||||
while (peek()?.type === "operator" && (peek()!.value === "+" || peek()!.value === "-")) {
|
|
||||||
const op = consume().value;
|
|
||||||
const right = parseTerm();
|
|
||||||
result = op === "+" ? result + right : result - right;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = parseExpr();
|
|
||||||
return Number.isFinite(result) ? result : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수식 결과를 displayFormat에 맞게 포맷팅
|
|
||||||
*
|
|
||||||
* @param config - 수식 설정
|
|
||||||
* @param values - 변수값 맵 (예: { A: 1234, B: 5678 })
|
|
||||||
* @returns 포맷된 문자열
|
|
||||||
*/
|
|
||||||
export function formatFormulaResult(
|
|
||||||
config: FormulaConfig,
|
|
||||||
values: Record<string, number>
|
|
||||||
): string {
|
|
||||||
const formatMap: Record<FormulaDisplayFormat, () => string> = {
|
|
||||||
value: () => {
|
|
||||||
const result = evaluateFormula(config.expression, values);
|
|
||||||
return formatNumber(result);
|
|
||||||
},
|
|
||||||
fraction: () => {
|
|
||||||
// "1,234 / 5,678" 형태
|
|
||||||
const ids = config.values.map((v) => v.id);
|
|
||||||
if (ids.length >= 2) {
|
|
||||||
return `${formatNumber(values[ids[0]] ?? 0)} / ${formatNumber(values[ids[1]] ?? 0)}`;
|
|
||||||
}
|
|
||||||
return formatNumber(evaluateFormula(config.expression, values));
|
|
||||||
},
|
|
||||||
percent: () => {
|
|
||||||
const result = evaluateFormula(config.expression, values);
|
|
||||||
return `${(result * 100).toFixed(1)}%`;
|
|
||||||
},
|
|
||||||
ratio: () => {
|
|
||||||
// "1,234 : 5,678" 형태
|
|
||||||
const ids = config.values.map((v) => v.id);
|
|
||||||
if (ids.length >= 2) {
|
|
||||||
return `${formatNumber(values[ids[0]] ?? 0)} : ${formatNumber(values[ids[1]] ?? 0)}`;
|
|
||||||
}
|
|
||||||
return formatNumber(evaluateFormula(config.expression, values));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return formatMap[config.displayFormat]();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 수식에 사용된 변수 ID가 모두 존재하는지 검증
|
|
||||||
*
|
|
||||||
* @param expression - 수식 문자열
|
|
||||||
* @param availableIds - 사용 가능한 변수 ID 배열
|
|
||||||
* @returns 유효 여부
|
|
||||||
*/
|
|
||||||
export function validateExpression(
|
|
||||||
expression: string,
|
|
||||||
availableIds: string[]
|
|
||||||
): boolean {
|
|
||||||
const tokens = tokenize(expression);
|
|
||||||
const usedVars = tokens
|
|
||||||
.filter((t) => t.type === "variable")
|
|
||||||
.map((t) => t.value);
|
|
||||||
|
|
||||||
return usedVars.every((v) => availableIds.includes(v));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 큰 숫자 축약 (Container Query 축소 시 사용)
|
|
||||||
*
|
|
||||||
* 1234 -> "1,234"
|
|
||||||
* 12345 -> "1.2만"
|
|
||||||
* 1234567 -> "123.5만"
|
|
||||||
* 123456789 -> "1.2억"
|
|
||||||
*/
|
|
||||||
export function abbreviateNumber(value: number): string {
|
|
||||||
const abs = Math.abs(value);
|
|
||||||
const sign = value < 0 ? "-" : "";
|
|
||||||
|
|
||||||
if (abs >= 100_000_000) {
|
|
||||||
return `${sign}${(abs / 100_000_000).toFixed(1)}억`;
|
|
||||||
}
|
|
||||||
if (abs >= 10_000) {
|
|
||||||
return `${sign}${(abs / 10_000).toFixed(1)}만`;
|
|
||||||
}
|
|
||||||
return `${sign}${formatNumber(abs)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 내부 헬퍼 =====
|
|
||||||
|
|
||||||
/** 숫자를 천 단위 콤마 포맷 */
|
|
||||||
function formatNumber(value: number): string {
|
|
||||||
if (Number.isInteger(value)) {
|
|
||||||
return value.toLocaleString("ko-KR");
|
|
||||||
}
|
|
||||||
// 소수점 이하 최대 2자리
|
|
||||||
return value.toLocaleString("ko-KR", {
|
|
||||||
minimumFractionDigits: 0,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -269,14 +269,10 @@ function DateTimePreview({ config }: { config?: PopTextConfig }) {
|
||||||
function DateTimeDisplay({ config }: { config?: PopTextConfig }) {
|
function DateTimeDisplay({ config }: { config?: PopTextConfig }) {
|
||||||
const [now, setNow] = useState(new Date());
|
const [now, setNow] = useState(new Date());
|
||||||
|
|
||||||
// isRealtime 기본값: true (설정 패널 UI와 일치)
|
|
||||||
const isRealtime = config?.isRealtime ?? true;
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRealtime) return;
|
|
||||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [isRealtime]);
|
}, []);
|
||||||
|
|
||||||
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
|
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
|
||||||
const dateFormat = config?.dateTimeConfig
|
const dateFormat = config?.dateTimeConfig
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,6 @@ export const FONT_SIZE_CLASSES: Record<FontSize, string> = {
|
||||||
xl: "text-[64px]",
|
xl: "text-[64px]",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export const FONT_WEIGHT_CLASSES: Record<FontWeight, string> = {
|
export const FONT_WEIGHT_CLASSES: Record<FontWeight, string> = {
|
||||||
normal: "font-normal",
|
normal: "font-normal",
|
||||||
medium: "font-medium",
|
medium: "font-medium",
|
||||||
|
|
@ -87,258 +86,3 @@ export const JUSTIFY_CLASSES: Record<string, string> = {
|
||||||
center: "justify-center",
|
center: "justify-center",
|
||||||
right: "justify-end",
|
right: "justify-end",
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// Phase 0 공통 타입 (모든 POP 컴포넌트 공용)
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// ----- 컬럼 바인딩: 컬럼별 읽기/쓰기 제어 -----
|
|
||||||
|
|
||||||
export type ColumnMode = "read" | "write" | "readwrite" | "hidden";
|
|
||||||
|
|
||||||
export interface ColumnBinding {
|
|
||||||
columnName: string;
|
|
||||||
sourceTable?: string;
|
|
||||||
mode: ColumnMode;
|
|
||||||
label?: string;
|
|
||||||
defaultValue?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 조인 설정: 테이블 간 관계 정의 -----
|
|
||||||
|
|
||||||
export type JoinType = "inner" | "left" | "right";
|
|
||||||
|
|
||||||
export interface JoinConfig {
|
|
||||||
targetTable: string;
|
|
||||||
joinType: JoinType;
|
|
||||||
on: {
|
|
||||||
sourceColumn: string;
|
|
||||||
targetColumn: string;
|
|
||||||
};
|
|
||||||
columns?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 데이터 소스: 테이블 조회/집계 통합 설정 -----
|
|
||||||
|
|
||||||
export type AggregationType = "count" | "sum" | "avg" | "min" | "max";
|
|
||||||
export type FilterOperator =
|
|
||||||
| "="
|
|
||||||
| "!="
|
|
||||||
| ">"
|
|
||||||
| ">="
|
|
||||||
| "<"
|
|
||||||
| "<="
|
|
||||||
| "like"
|
|
||||||
| "in"
|
|
||||||
| "between";
|
|
||||||
|
|
||||||
export interface DataSourceFilter {
|
|
||||||
column: string;
|
|
||||||
operator: FilterOperator;
|
|
||||||
value: unknown; // between이면 [from, to]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SortConfig {
|
|
||||||
column: string;
|
|
||||||
direction: "asc" | "desc";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DataSourceConfig {
|
|
||||||
tableName: string;
|
|
||||||
columns?: ColumnBinding[];
|
|
||||||
filters?: DataSourceFilter[];
|
|
||||||
sort?: SortConfig[];
|
|
||||||
aggregation?: {
|
|
||||||
type: AggregationType;
|
|
||||||
column: string;
|
|
||||||
groupBy?: string[];
|
|
||||||
};
|
|
||||||
joins?: JoinConfig[];
|
|
||||||
refreshInterval?: number; // 초 단위, 0이면 비활성
|
|
||||||
limit?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 액션 설정: 버튼/링크 클릭 시 동작 정의 -----
|
|
||||||
|
|
||||||
export interface PopActionConfig {
|
|
||||||
type:
|
|
||||||
| "navigate"
|
|
||||||
| "modal"
|
|
||||||
| "save"
|
|
||||||
| "delete"
|
|
||||||
| "api"
|
|
||||||
| "event"
|
|
||||||
| "refresh";
|
|
||||||
// navigate
|
|
||||||
targetScreenId?: string;
|
|
||||||
params?: Record<string, string>;
|
|
||||||
// modal
|
|
||||||
modalScreenId?: string;
|
|
||||||
modalTitle?: string;
|
|
||||||
// save/delete
|
|
||||||
targetTable?: string;
|
|
||||||
confirmMessage?: string;
|
|
||||||
// api
|
|
||||||
apiEndpoint?: string;
|
|
||||||
apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
|
|
||||||
// event
|
|
||||||
eventName?: string;
|
|
||||||
eventPayload?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// =============================================
|
|
||||||
// pop-dashboard 전용 타입
|
|
||||||
// =============================================
|
|
||||||
|
|
||||||
// ----- 표시 모드 / 서브타입 -----
|
|
||||||
|
|
||||||
export type DashboardDisplayMode =
|
|
||||||
| "arrows"
|
|
||||||
| "auto-slide"
|
|
||||||
| "scroll";
|
|
||||||
export type DashboardSubType = "kpi-card" | "chart" | "gauge" | "stat-card";
|
|
||||||
export type FormulaDisplayFormat = "value" | "fraction" | "percent" | "ratio";
|
|
||||||
export type ChartType = "bar" | "pie" | "line";
|
|
||||||
export type TrendPeriod = "daily" | "weekly" | "monthly";
|
|
||||||
|
|
||||||
// ----- 색상 구간 -----
|
|
||||||
|
|
||||||
export interface ColorRange {
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
color: string; // hex 또는 Tailwind 색상
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 수식(계산식) 설정 -----
|
|
||||||
|
|
||||||
export interface FormulaValue {
|
|
||||||
id: string; // "A", "B" 등
|
|
||||||
dataSource: DataSourceConfig;
|
|
||||||
label: string; // "생산량", "총재고량"
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FormulaConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
values: FormulaValue[];
|
|
||||||
expression: string; // "A / B", "A + B", "A / B * 100"
|
|
||||||
displayFormat: FormulaDisplayFormat;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 아이템 내 요소별 보이기/숨기기 -----
|
|
||||||
|
|
||||||
export interface ItemVisibility {
|
|
||||||
showLabel: boolean;
|
|
||||||
showValue: boolean;
|
|
||||||
showUnit: boolean;
|
|
||||||
showTrend: boolean;
|
|
||||||
showSubLabel: boolean;
|
|
||||||
showTarget: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 서브타입별 설정 -----
|
|
||||||
|
|
||||||
export interface KpiCardConfig {
|
|
||||||
unit?: string; // "EA", "톤", "원"
|
|
||||||
colorRanges?: ColorRange[];
|
|
||||||
showTrend?: boolean;
|
|
||||||
trendPeriod?: TrendPeriod;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChartItemConfig {
|
|
||||||
chartType: ChartType;
|
|
||||||
xAxisColumn?: string;
|
|
||||||
yAxisColumn?: string;
|
|
||||||
colors?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GaugeConfig {
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
target?: number; // 고정 목표값
|
|
||||||
targetDataSource?: DataSourceConfig; // 동적 목표값
|
|
||||||
colorRanges?: ColorRange[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatCategory {
|
|
||||||
label: string; // "대기", "진행", "완료"
|
|
||||||
filter: DataSourceFilter;
|
|
||||||
color?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatCardConfig {
|
|
||||||
categories: StatCategory[];
|
|
||||||
showLink?: boolean;
|
|
||||||
linkAction?: PopActionConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 그리드 모드 셀 (엑셀형 분할/병합) -----
|
|
||||||
|
|
||||||
export interface DashboardCell {
|
|
||||||
id: string;
|
|
||||||
gridColumn: string; // CSS Grid 값: "1 / 3"
|
|
||||||
gridRow: string; // CSS Grid 값: "1 / 2"
|
|
||||||
itemId: string | null; // null이면 빈 셀
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 대시보드 페이지(슬라이드) -----
|
|
||||||
|
|
||||||
/** 대시보드 한 페이지(슬라이드) - 독립적인 그리드 레이아웃 보유 */
|
|
||||||
export interface DashboardPage {
|
|
||||||
id: string;
|
|
||||||
label?: string; // 디자이너에서 표시할 라벨 (예: "페이지 1")
|
|
||||||
gridColumns: number; // 이 페이지의 열 수
|
|
||||||
gridRows: number; // 이 페이지의 행 수
|
|
||||||
gridCells: DashboardCell[]; // 이 페이지의 셀 배치 (각 셀에 itemId 지정)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 대시보드 아이템 스타일 설정 -----
|
|
||||||
|
|
||||||
export interface ItemStyleConfig {
|
|
||||||
/** 라벨 텍스트 정렬 (기본: center) */
|
|
||||||
labelAlign?: TextAlign;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 대시보드 아이템 -----
|
|
||||||
|
|
||||||
export interface DashboardItem {
|
|
||||||
id: string;
|
|
||||||
label: string; // pop-system 보이기/숨기기용
|
|
||||||
visible: boolean;
|
|
||||||
subType: DashboardSubType;
|
|
||||||
dataSource: DataSourceConfig;
|
|
||||||
|
|
||||||
// 요소별 보이기/숨기기
|
|
||||||
visibility: ItemVisibility;
|
|
||||||
|
|
||||||
// 계산식 (선택사항)
|
|
||||||
formula?: FormulaConfig;
|
|
||||||
|
|
||||||
// 서브타입별 설정 (subType에 따라 하나만 사용)
|
|
||||||
kpiConfig?: KpiCardConfig;
|
|
||||||
chartConfig?: ChartItemConfig;
|
|
||||||
gaugeConfig?: GaugeConfig;
|
|
||||||
statConfig?: StatCardConfig;
|
|
||||||
|
|
||||||
/** 스타일 설정 (정렬, 글자 크기 3그룹) */
|
|
||||||
itemStyle?: ItemStyleConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----- 대시보드 전체 설정 -----
|
|
||||||
|
|
||||||
export interface PopDashboardConfig {
|
|
||||||
items: DashboardItem[];
|
|
||||||
pages?: DashboardPage[]; // 페이지 배열 (각 페이지가 독립 그리드 레이아웃)
|
|
||||||
displayMode: DashboardDisplayMode; // 페이지 간 전환 방식
|
|
||||||
|
|
||||||
// 모드별 설정
|
|
||||||
autoSlideInterval?: number; // 초 (기본 5)
|
|
||||||
autoSlideResumeDelay?: number; // 터치 후 재개 대기 초 (기본 3)
|
|
||||||
|
|
||||||
// 공통 스타일
|
|
||||||
showIndicator?: boolean; // 페이지 인디케이터
|
|
||||||
gap?: number; // 아이템 간 간격 px
|
|
||||||
backgroundColor?: string;
|
|
||||||
|
|
||||||
// 데이터 소스 (아이템 공통)
|
|
||||||
dataSource?: DataSourceConfig;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue