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