fix(pop-dashboard): 집계 함수 설정 유효성 검증 강화 - 문자열 컬럼에 SUM 적용 방지
팀원 pull 후 대시보드 500 에러 발생 원인 해결: - subType별 허용 집계 함수 제한 (stat-card는 COUNT만) - SUM/AVG 선택 시 숫자 타입 컬럼만 표시 - 비호환 집계 함수 선택 시 컬럼 자동 초기화 - subType 변경 시 비호환 aggregation 자동 전환 (STEP 7.5) - chart 모드 groupBy 미설정 경고 추가 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1d93b65060
commit
8c08e7f8e9
|
|
@ -60,6 +60,7 @@ import type {
|
||||||
JoinConfig,
|
JoinConfig,
|
||||||
JoinType,
|
JoinType,
|
||||||
ItemStyleConfig,
|
ItemStyleConfig,
|
||||||
|
AggregationType,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import {
|
import {
|
||||||
TEXT_ALIGN_LABELS,
|
TEXT_ALIGN_LABELS,
|
||||||
|
|
@ -132,6 +133,50 @@ const JOIN_TYPE_LABELS: Record<JoinType, string> = {
|
||||||
right: "RIGHT JOIN",
|
right: "RIGHT JOIN",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== 집계 함수 유효성 검증 유틸 =====
|
||||||
|
|
||||||
|
// 아이템 타입별 사용 가능한 집계 함수
|
||||||
|
const SUBTYPE_AGGREGATION_MAP: Record<DashboardSubType, AggregationType[]> = {
|
||||||
|
"kpi-card": ["count", "sum", "avg", "min", "max"],
|
||||||
|
chart: ["count", "sum", "avg", "min", "max"],
|
||||||
|
gauge: ["count", "sum", "avg", "min", "max"],
|
||||||
|
"stat-card": ["count"],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 집계 함수 라벨
|
||||||
|
const AGGREGATION_LABELS: Record<AggregationType, string> = {
|
||||||
|
count: "건수 (COUNT)",
|
||||||
|
sum: "합계 (SUM)",
|
||||||
|
avg: "평균 (AVG)",
|
||||||
|
min: "최소 (MIN)",
|
||||||
|
max: "최대 (MAX)",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 숫자 전용 집계 함수 (숫자 컬럼에만 사용 가능)
|
||||||
|
const NUMERIC_ONLY_AGGREGATIONS: AggregationType[] = ["sum", "avg"];
|
||||||
|
|
||||||
|
// PostgreSQL 숫자 타입 판별용 패턴
|
||||||
|
const NUMERIC_TYPE_PATTERNS = [
|
||||||
|
"int", "integer", "bigint", "smallint",
|
||||||
|
"numeric", "decimal", "real", "double",
|
||||||
|
"float", "serial", "bigserial", "smallserial",
|
||||||
|
"money", "number",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 컬럼이 숫자 타입인지 판별 */
|
||||||
|
function isNumericColumn(col: ColumnInfo): boolean {
|
||||||
|
const t = (col.type || "").toLowerCase();
|
||||||
|
const u = (col.udtName || "").toLowerCase();
|
||||||
|
return NUMERIC_TYPE_PATTERNS.some(
|
||||||
|
(pattern) => t.includes(pattern) || u.includes(pattern)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 집계 함수가 숫자 전용(sum/avg)인지 판별 */
|
||||||
|
function isNumericOnlyAggregation(aggType: string | undefined): boolean {
|
||||||
|
return !!aggType && NUMERIC_ONLY_AGGREGATIONS.includes(aggType as AggregationType);
|
||||||
|
}
|
||||||
|
|
||||||
const FILTER_OPERATOR_LABELS: Record<FilterOperator, string> = {
|
const FILTER_OPERATOR_LABELS: Record<FilterOperator, string> = {
|
||||||
"=": "같음 (=)",
|
"=": "같음 (=)",
|
||||||
"!=": "다름 (!=)",
|
"!=": "다름 (!=)",
|
||||||
|
|
@ -149,9 +194,11 @@ const FILTER_OPERATOR_LABELS: Record<FilterOperator, string> = {
|
||||||
function DataSourceEditor({
|
function DataSourceEditor({
|
||||||
dataSource,
|
dataSource,
|
||||||
onChange,
|
onChange,
|
||||||
|
subType,
|
||||||
}: {
|
}: {
|
||||||
dataSource: DataSourceConfig;
|
dataSource: DataSourceConfig;
|
||||||
onChange: (ds: DataSourceConfig) => void;
|
onChange: (ds: DataSourceConfig) => void;
|
||||||
|
subType?: DashboardSubType;
|
||||||
}) {
|
}) {
|
||||||
// 테이블 목록 (Combobox용)
|
// 테이블 목록 (Combobox용)
|
||||||
const [tables, setTables] = useState<TableInfo[]>([]);
|
const [tables, setTables] = useState<TableInfo[]>([]);
|
||||||
|
|
@ -268,7 +315,15 @@ function DataSourceEditor({
|
||||||
<Label className="text-xs">집계 함수</Label>
|
<Label className="text-xs">집계 함수</Label>
|
||||||
<Select
|
<Select
|
||||||
value={dataSource.aggregation?.type ?? ""}
|
value={dataSource.aggregation?.type ?? ""}
|
||||||
onValueChange={(val) =>
|
onValueChange={(val) => {
|
||||||
|
// STEP 4: 숫자 전용 집계로 변경 시, 현재 컬럼이 숫자가 아니면 초기화
|
||||||
|
let currentColumn = dataSource.aggregation?.column ?? "";
|
||||||
|
if (val && isNumericOnlyAggregation(val) && currentColumn) {
|
||||||
|
const selectedCol = columns.find((c) => c.name === currentColumn);
|
||||||
|
if (selectedCol && !isNumericColumn(selectedCol)) {
|
||||||
|
currentColumn = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
onChange({
|
onChange({
|
||||||
...dataSource,
|
...dataSource,
|
||||||
aggregation: val
|
aggregation: val
|
||||||
|
|
@ -276,21 +331,25 @@ function DataSourceEditor({
|
||||||
type: val as NonNullable<
|
type: val as NonNullable<
|
||||||
DataSourceConfig["aggregation"]
|
DataSourceConfig["aggregation"]
|
||||||
>["type"],
|
>["type"],
|
||||||
column: dataSource.aggregation?.column ?? "",
|
column: currentColumn,
|
||||||
|
groupBy: dataSource.aggregation?.groupBy,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
})
|
});
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue placeholder="없음" />
|
<SelectValue placeholder="없음" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="count">건수 (COUNT)</SelectItem>
|
{(subType
|
||||||
<SelectItem value="sum">합계 (SUM)</SelectItem>
|
? SUBTYPE_AGGREGATION_MAP[subType]
|
||||||
<SelectItem value="avg">평균 (AVG)</SelectItem>
|
: (Object.keys(AGGREGATION_LABELS) as AggregationType[])
|
||||||
<SelectItem value="min">최소 (MIN)</SelectItem>
|
).map((aggType) => (
|
||||||
<SelectItem value="max">최대 (MAX)</SelectItem>
|
<SelectItem key={aggType} value={aggType}>
|
||||||
|
{AGGREGATION_LABELS[aggType]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -331,10 +390,15 @@ function DataSourceEditor({
|
||||||
/>
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty className="py-2 text-center text-xs">
|
<CommandEmpty className="py-2 text-center text-xs">
|
||||||
컬럼을 찾을 수 없습니다.
|
{isNumericOnlyAggregation(dataSource.aggregation?.type)
|
||||||
|
? "숫자 타입 컬럼이 없습니다."
|
||||||
|
: "컬럼을 찾을 수 없습니다."}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{columns.map((col) => (
|
{(isNumericOnlyAggregation(dataSource.aggregation?.type)
|
||||||
|
? columns.filter(isNumericColumn)
|
||||||
|
: columns
|
||||||
|
).map((col) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={col.name}
|
key={col.name}
|
||||||
value={`${col.name} ${col.type}`}
|
value={`${col.name} ${col.type}`}
|
||||||
|
|
@ -445,6 +509,11 @@ function DataSourceEditor({
|
||||||
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||||
차트에서 X축 카테고리로 사용됩니다
|
차트에서 X축 카테고리로 사용됩니다
|
||||||
</p>
|
</p>
|
||||||
|
{subType === "chart" && !dataSource.aggregation?.groupBy?.length && (
|
||||||
|
<p className="mt-0.5 text-[10px] text-destructive">
|
||||||
|
차트 모드에서는 그룹핑(X축)을 설정해야 의미 있는 차트가 표시됩니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -1144,9 +1213,30 @@ function ItemEditor({
|
||||||
<Label className="text-xs">타입</Label>
|
<Label className="text-xs">타입</Label>
|
||||||
<Select
|
<Select
|
||||||
value={item.subType}
|
value={item.subType}
|
||||||
onValueChange={(val) =>
|
onValueChange={(val) => {
|
||||||
onUpdate({ ...item, subType: val as DashboardSubType })
|
const newSubType = val as DashboardSubType;
|
||||||
|
const allowedAggs = SUBTYPE_AGGREGATION_MAP[newSubType];
|
||||||
|
const currentAggType = item.dataSource.aggregation?.type;
|
||||||
|
|
||||||
|
// STEP 7.5: subType 변경 시, 현재 집계 함수가 새 타입에서 허용되지 않으면 자동 전환
|
||||||
|
let newDataSource = item.dataSource;
|
||||||
|
if (currentAggType && !allowedAggs.includes(currentAggType)) {
|
||||||
|
const fallbackAgg = allowedAggs[0]; // 허용되는 첫 번째 집계 함수로 전환
|
||||||
|
newDataSource = {
|
||||||
|
...item.dataSource,
|
||||||
|
aggregation: item.dataSource.aggregation
|
||||||
|
? {
|
||||||
|
...item.dataSource.aggregation,
|
||||||
|
type: fallbackAgg,
|
||||||
|
// count로 전환되면 컬럼 의미 없으므로 초기화
|
||||||
|
column: fallbackAgg === "count" ? "" : item.dataSource.aggregation.column,
|
||||||
}
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdate({ ...item, subType: newSubType, dataSource: newDataSource });
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="h-8 text-xs">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
|
|
@ -1212,6 +1302,7 @@ function ItemEditor({
|
||||||
<DataSourceEditor
|
<DataSourceEditor
|
||||||
dataSource={item.dataSource}
|
dataSource={item.dataSource}
|
||||||
onChange={(ds) => onUpdate({ ...item, dataSource: ds })}
|
onChange={(ds) => onUpdate({ ...item, dataSource: ds })}
|
||||||
|
subType={item.subType}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
634
popdocs/PLAN.md
634
popdocs/PLAN.md
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 현재 상태 (2026-02-11)
|
## 현재 상태 (2026-02-12)
|
||||||
|
|
||||||
**Phase 0 공통 인프라 (usePopEvent + useDataSource) 구현 완료, ksh-v2-work 병합 + 원격 push 완료. Phase 2 pop-button 설계 진행 중.**
|
**대시보드 집계 함수 설정 유효성 검증 강화 (v2 구현 완료, 브라우저 확인 대기)**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -143,347 +143,420 @@ v4 관련 파일은 모두 삭제되었습니다.
|
||||||
> 새 세션에서 이 섹션만 읽으면 코딩을 시작할 수 있어야 합니다.
|
> 새 세션에서 이 섹션만 읽으면 코딩을 시작할 수 있어야 합니다.
|
||||||
> 완료되면 다음 기능의 계획으로 **교체**합니다.
|
> 완료되면 다음 기능의 계획으로 **교체**합니다.
|
||||||
|
|
||||||
### 대상: Phase 0 공통 인프라 (usePopEvent + useDataSource 훅)
|
### 대상: 대시보드 집계 함수 설정 유효성 검증 강화 (v2 - 시뮬레이션 검증 완료)
|
||||||
|
|
||||||
#### 배경 (2026-02-11)
|
#### 배경 (2026-02-12)
|
||||||
|
|
||||||
모든 데이터 연동 POP 컴포넌트(pop-button, pop-table, pop-search 등)가 공유하는 2개 핵심 훅을 구현한다.
|
팀원이 브랜치를 pull 받은 뒤 대시보드에서 500 에러가 발생.
|
||||||
|
원인: `batch_mappings` 테이블의 `to_table_name`(문자열) 컬럼에 `SUM` 집계를 설정한 대시보드 아이템이 존재.
|
||||||
|
PostgreSQL이 `SELECT SUM(to_table_name)` 실행 시 `function sum(character varying) does not exist` 에러 반환.
|
||||||
|
|
||||||
- **usePopEvent**: 같은 화면(screenId) 안에서 컴포넌트 간 이벤트 통신 (publish/subscribe)
|
**근본 원인**: 설정 UI(`PopDashboardConfig.tsx`)에서 아이템 타입(subType)에 관계없이 동일한 5개 집계 함수를 모두 보여주고, 컬럼 선택에서도 타입 구분 없이 모든 컬럼이 표시됨.
|
||||||
- **useDataSource**: DB 테이블 CRUD 통합 (조회/생성/수정/삭제)
|
|
||||||
|
|
||||||
**핵심 원칙**: 새로 만드는 것이 아니라, 기존 코드를 공통화하는 작업이다.
|
#### 시뮬레이션 결과 (2026-02-12)
|
||||||
|
|
||||||
- `useDataSource`는 대시보드의 `dataFetcher.ts` 조회 로직 + 기존 `dataApi` CRUD를 훅으로 래핑
|
8개 시나리오로 가상 코딩 → 데이터 흐름 추적 수행. 원래 7단계 계획에서 **1건의 심각한 결함 발견**:
|
||||||
- `usePopEvent`는 신규 구현 (Map 기반 이벤트 버스)
|
|
||||||
- 대시보드는 **이번에 교체하지 않는다** (훅 안정화 후 별도 교체)
|
|
||||||
|
|
||||||
#### 결정사항 (2026-02-11)
|
> **chart(sum) → stat-card로 subType 변경 시**, Select UI에서 "sum"이 목록에 없어도
|
||||||
|
> 내부 value는 "sum"으로 유지되어 SQL 생성 시 그대로 적용됨.
|
||||||
|
|
||||||
| 항목 | 결정 | 이유 |
|
이를 해결하기 위해 **STEP 7.5 추가**: ItemEditor의 subType 변경 핸들러에서 비호환 aggregation 자동 전환.
|
||||||
|------|------|------|
|
|
||||||
| usePopEvent 범위 | 같은 screenId 안에서만 통신 | 화면 간 의존성 방지 |
|
|
||||||
| usePopEvent 모달 | 같은 screenId면 모달 안 컴포넌트도 통신 가능 | 모달은 별도 화면이 아님 |
|
|
||||||
| useDataSource 조회 분기 | 집계/조인이면 SQL 빌더 + executeQuery, 단순이면 dataApi.getTableData | 대시보드 dataFetcher.ts와 동일 전략 |
|
|
||||||
| useDataSource CRUD | dataApi.createRecord/updateRecord/deleteRecord 래핑 | 백엔드 API 이미 완성됨 |
|
|
||||||
| 대시보드 교체 시점 | 이번에 하지 않음, 훅 안정화 후 별도 작업 | 안정성 우선 |
|
|
||||||
| SQL 빌더 위치 | dataFetcher.ts에서 추출하여 별도 유틸로 분리 | 훅과 대시보드 모두 사용 |
|
|
||||||
| 이벤트 버스 저장소 | 전역 Map (screenId -> EventEmitter) | React 외부에서도 접근 가능, GC 관리 용이 |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### 구현 순서 (의존성 기반)
|
#### 수정 대상
|
||||||
|
|
||||||
| 순서 | 파일 | 작업 | 의존성 | 상태 |
|
| 파일 | 경로 | 변경 유형 |
|
||||||
|------|------|------|--------|------|
|
|------|------|-----------|
|
||||||
| 1 | `hooks/pop/usePopEvent.ts` | 이벤트 버스 훅 (신규) | 없음 | **완료** |
|
| PopDashboardConfig.tsx | `frontend/lib/registry/pop-components/pop-dashboard/PopDashboardConfig.tsx` | 수정 (신규 파일 없음) |
|
||||||
| 2 | `hooks/pop/popSqlBuilder.ts` | SQL 빌더 유틸 분리 (dataFetcher.ts에서 추출) | 없음 | **완료** |
|
|
||||||
| 3 | `hooks/pop/useDataSource.ts` | 데이터 CRUD 훅 (신규) | 2 | **완료** |
|
|
||||||
| 4 | `hooks/pop/index.ts` | 배럴 파일 (re-export) | 1, 3 | **완료** |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### STEP 1: `usePopEvent.ts` (신규 생성)
|
#### 구현 순서 (의존성 기반, 8단계)
|
||||||
|
|
||||||
**파일**: `frontend/hooks/pop/usePopEvent.ts`
|
| 순서 | 작업 | 수정 위치 (원본 라인) | 의존성 | 상태 |
|
||||||
|
|------|------|---------------------|--------|------|
|
||||||
|
| 1 | import에 `AggregationType` 추가 + 유틸 상수/함수 6개 추가 | 라인 48~63, 라인 133 뒤 | 없음 | 완료 |
|
||||||
|
| 2 | `DataSourceEditor` props에 `subType` 추가 | 라인 149~155 | 없음 | 완료 |
|
||||||
|
| 3 | 집계 함수 Select를 동적 생성으로 교체 | 라인 288~294 | 1, 2 | 완료 |
|
||||||
|
| 4 | 집계 함수 변경 시 컬럼 자동 초기화 + groupBy 보존 | 라인 271~283 | 1 | 완료 |
|
||||||
|
| 5 | 대상 컬럼 Combobox 숫자 필터링 + 안내 메시지 | 라인 333~366 | 1 | 완료 |
|
||||||
|
| 6 | chart 모드 groupBy 미설정 경고 | 라인 445~447 | 2 | 완료 |
|
||||||
|
| 7 | `DataSourceEditor` 호출부에 `subType` 전달 (라인 1212) | 라인 1212~1215 | 2 | 완료 |
|
||||||
|
| **7.5** | **ItemEditor subType 변경 시 비호환 aggregation 자동 전환** | **라인 1147~1148** | **1** | **완료** |
|
||||||
|
|
||||||
**역할**: 같은 화면(screenId) 안에서 컴포넌트 간 이벤트 통신
|
---
|
||||||
|
|
||||||
**핵심 구조**:
|
#### STEP 1: import 추가 + 유틸 상수/함수
|
||||||
|
|
||||||
```
|
**1-A. import 추가** (라인 48~63)
|
||||||
전역 저장소 (React 외부)
|
|
||||||
screenBuses: Map<string, Map<string, Set<callback>>>
|
|
||||||
│
|
|
||||||
└── screenId: "S001"
|
|
||||||
├── "supplier-selected" → [콜백A, 콜백B]
|
|
||||||
├── "data-saved" → [콜백C]
|
|
||||||
└── sharedData: Map<string, unknown>
|
|
||||||
|
|
||||||
sharedDataStore: Map<string, Map<string, unknown>>
|
현재:
|
||||||
│
|
|
||||||
└── screenId: "S001"
|
|
||||||
├── "selectedSupplier" → { id: "SUP-001", name: "삼성" }
|
|
||||||
└── "inputQuantity" → 50
|
|
||||||
```
|
|
||||||
|
|
||||||
**외부 API (훅이 반환하는 것)**:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function usePopEvent(screenId: string) {
|
import type {
|
||||||
return {
|
PopDashboardConfig,
|
||||||
publish, // (eventName, payload) => void
|
DashboardItem,
|
||||||
subscribe, // (eventName, callback) => unsubscribe 함수
|
DashboardSubType,
|
||||||
getSharedData, // (key) => unknown
|
DashboardDisplayMode,
|
||||||
setSharedData, // (key, value) => void
|
DataSourceConfig,
|
||||||
};
|
DataSourceFilter,
|
||||||
}
|
FilterOperator,
|
||||||
|
FormulaConfig,
|
||||||
|
ItemVisibility,
|
||||||
|
DashboardCell,
|
||||||
|
DashboardPage,
|
||||||
|
JoinConfig,
|
||||||
|
JoinType,
|
||||||
|
ItemStyleConfig,
|
||||||
|
} from "../types";
|
||||||
```
|
```
|
||||||
|
|
||||||
**상세 구현 명세**:
|
변경: `ItemStyleConfig,` 뒤에 `AggregationType,` 추가:
|
||||||
|
|
||||||
1. **전역 Map 저장소** (모듈 스코프, React 외부)
|
|
||||||
- `screenBuses: Map<string, Map<string, Set<Function>>>` - 이벤트 리스너
|
|
||||||
- `sharedDataStore: Map<string, Map<string, unknown>>` - 공유 데이터
|
|
||||||
|
|
||||||
2. **`publish(eventName, payload)`**
|
|
||||||
- 해당 screenId의 eventName에 등록된 모든 콜백을 순회하며 payload 전달
|
|
||||||
- 등록된 리스너가 없으면 아무 일도 안 함 (에러 아님)
|
|
||||||
|
|
||||||
3. **`subscribe(eventName, callback)`**
|
|
||||||
- 해당 screenId의 eventName에 콜백 등록
|
|
||||||
- **반환값**: unsubscribe 함수
|
|
||||||
- **useEffect 내부에서 호출되어야 함** (cleanup으로 unsubscribe)
|
|
||||||
|
|
||||||
4. **`getSharedData(key)`** / **`setSharedData(key, value)`**
|
|
||||||
- screenId별 격리된 key-value 저장소
|
|
||||||
- publish/subscribe는 "이벤트"(일회성), sharedData는 "상태"(지속)
|
|
||||||
- 용도: 버튼이 저장할 때 다른 컴포넌트들의 현재 값을 수집
|
|
||||||
|
|
||||||
5. **`cleanupScreen(screenId)`** (내부 유틸)
|
|
||||||
- 화면 언마운트 시 해당 screenId의 모든 리스너 + sharedData 정리
|
|
||||||
- 메모리 누수 방지
|
|
||||||
|
|
||||||
**사용 예시**:
|
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// 거래처 선택 버튼
|
import type {
|
||||||
const { publish, setSharedData } = usePopEvent("S001");
|
PopDashboardConfig,
|
||||||
|
DashboardItem,
|
||||||
|
DashboardSubType,
|
||||||
|
DashboardDisplayMode,
|
||||||
|
DataSourceConfig,
|
||||||
|
DataSourceFilter,
|
||||||
|
FilterOperator,
|
||||||
|
FormulaConfig,
|
||||||
|
ItemVisibility,
|
||||||
|
DashboardCell,
|
||||||
|
DashboardPage,
|
||||||
|
JoinConfig,
|
||||||
|
JoinType,
|
||||||
|
ItemStyleConfig,
|
||||||
|
AggregationType,
|
||||||
|
} from "../types";
|
||||||
|
```
|
||||||
|
|
||||||
const onSelect = (supplier) => {
|
**1-B. 유틸 상수/함수 추가** (라인 133 `};` 직후, 라인 135 `const FILTER_OPERATOR_LABELS` 직전)
|
||||||
setSharedData("selectedSupplier", supplier); // 상태 저장
|
|
||||||
publish("supplier-selected", { supplierId: supplier.id }); // 이벤트 발행
|
```typescript
|
||||||
|
// ===== 집계 함수 유효성 검증 유틸 =====
|
||||||
|
|
||||||
|
// 아이템 타입별 사용 가능한 집계 함수
|
||||||
|
const SUBTYPE_AGGREGATION_MAP: Record<DashboardSubType, AggregationType[]> = {
|
||||||
|
"kpi-card": ["count", "sum", "avg", "min", "max"],
|
||||||
|
chart: ["count", "sum", "avg", "min", "max"],
|
||||||
|
gauge: ["count", "sum", "avg", "min", "max"],
|
||||||
|
"stat-card": ["count"],
|
||||||
};
|
};
|
||||||
|
|
||||||
// 발주 테이블 (다른 컴포넌트)
|
// 집계 함수 라벨
|
||||||
const { subscribe } = usePopEvent("S001");
|
const AGGREGATION_LABELS: Record<AggregationType, string> = {
|
||||||
|
count: "건수 (COUNT)",
|
||||||
useEffect(() => {
|
sum: "합계 (SUM)",
|
||||||
const unsub = subscribe("supplier-selected", (payload) => {
|
avg: "평균 (AVG)",
|
||||||
refetch({ filters: { supplier_id: payload.supplierId } });
|
min: "최소 (MIN)",
|
||||||
});
|
max: "최대 (MAX)",
|
||||||
return unsub; // cleanup
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 저장 버튼 (다른 컴포넌트)
|
|
||||||
const { getSharedData } = usePopEvent("S001");
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
const supplier = getSharedData("selectedSupplier"); // 다른 컴포넌트가 저장한 값 수집
|
|
||||||
const quantity = getSharedData("inputQuantity");
|
|
||||||
save({ supplier_id: supplier.id, quantity });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 숫자 전용 집계 함수 (숫자 컬럼에만 사용 가능)
|
||||||
|
const NUMERIC_ONLY_AGGREGATIONS: AggregationType[] = ["sum", "avg"];
|
||||||
|
|
||||||
|
// PostgreSQL 숫자 타입 판별용 패턴
|
||||||
|
const NUMERIC_TYPE_PATTERNS = [
|
||||||
|
"int", "integer", "bigint", "smallint",
|
||||||
|
"numeric", "decimal", "real", "double",
|
||||||
|
"float", "serial", "bigserial", "smallserial",
|
||||||
|
"money", "number",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 컬럼이 숫자 타입인지 판별 */
|
||||||
|
function isNumericColumn(col: ColumnInfo): boolean {
|
||||||
|
const t = (col.type || "").toLowerCase();
|
||||||
|
const u = (col.udtName || "").toLowerCase();
|
||||||
|
return NUMERIC_TYPE_PATTERNS.some(
|
||||||
|
(pattern) => t.includes(pattern) || u.includes(pattern)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 집계 함수가 숫자 전용(sum/avg)인지 판별 */
|
||||||
|
function isNumericOnlyAggregation(aggType: string | undefined): boolean {
|
||||||
|
return !!aggType && NUMERIC_ONLY_AGGREGATIONS.includes(aggType as AggregationType);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### STEP 2: `popSqlBuilder.ts` (신규 생성 - dataFetcher.ts에서 추출)
|
#### STEP 2: `DataSourceEditor` props에 `subType` 추가
|
||||||
|
|
||||||
**파일**: `frontend/hooks/pop/popSqlBuilder.ts`
|
라인 149~155 교체:
|
||||||
|
|
||||||
**역할**: DataSourceConfig를 SQL 문자열로 변환하는 순수 유틸리티
|
```typescript
|
||||||
|
// 현재
|
||||||
|
function DataSourceEditor({
|
||||||
|
dataSource,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
dataSource: DataSourceConfig;
|
||||||
|
onChange: (ds: DataSourceConfig) => void;
|
||||||
|
}) {
|
||||||
|
|
||||||
**기존 dataFetcher.ts에서 그대로 추출할 함수 5개** (로직 변경 없음):
|
// 변경
|
||||||
|
function DataSourceEditor({
|
||||||
|
dataSource,
|
||||||
|
onChange,
|
||||||
|
subType,
|
||||||
|
}: {
|
||||||
|
dataSource: DataSourceConfig;
|
||||||
|
onChange: (ds: DataSourceConfig) => void;
|
||||||
|
subType?: DashboardSubType;
|
||||||
|
}) {
|
||||||
|
```
|
||||||
|
|
||||||
| 함수 | 원본 위치 (dataFetcher.ts) | 역할 |
|
`subType`은 optional. FormulaEditor 내부 호출(라인 969)에서는 미전달 → undefined → 5개 전부 표시.
|
||||||
|------|--------------------------|------|
|
|
||||||
| `escapeSQL(value)` | 라인 41~48 | SQL 값 이스케이프 |
|
|
||||||
| `sanitizeIdentifier(name)` | 라인 124~127 | 테이블/컬럼명 위험 문자 제거 |
|
|
||||||
| `validateDataSourceConfig(config)` | 라인 59~88 | 설정 완료 여부 검증 |
|
|
||||||
| `buildWhereClause(filters)` | 라인 93~118 | 필터 -> WHERE 절 변환 |
|
|
||||||
| `buildAggregationSQL(config)` | 라인 137~215 | DataSourceConfig -> SELECT SQL 변환 |
|
|
||||||
|
|
||||||
**export 대상**: `validateDataSourceConfig`, `buildAggregationSQL` (나머지는 내부 함수)
|
|
||||||
|
|
||||||
**주의**: dataFetcher.ts는 **수정하지 않는다**. 대시보드가 계속 사용 중이므로, 복사만 한다. 대시보드 교체 시점에 dataFetcher.ts에서 이 파일을 import하도록 변경할 예정.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### STEP 3: `useDataSource.ts` (신규 생성)
|
#### STEP 3: 집계 함수 Select 동적 생성
|
||||||
|
|
||||||
**파일**: `frontend/hooks/pop/useDataSource.ts`
|
라인 288~294 교체:
|
||||||
|
|
||||||
**역할**: DataSourceConfig 기반 DB 테이블 CRUD 통합 훅
|
```tsx
|
||||||
|
// 현재
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="count">건수 (COUNT)</SelectItem>
|
||||||
|
<SelectItem value="sum">합계 (SUM)</SelectItem>
|
||||||
|
<SelectItem value="avg">평균 (AVG)</SelectItem>
|
||||||
|
<SelectItem value="min">최소 (MIN)</SelectItem>
|
||||||
|
<SelectItem value="max">최대 (MAX)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
|
||||||
**내부 의존성**:
|
// 변경
|
||||||
- `popSqlBuilder.ts` - SQL 빌더 (STEP 2)
|
<SelectContent>
|
||||||
- `@/lib/api/data` - dataApi (기존, 조회/생성/수정/삭제)
|
{(subType
|
||||||
- `@/lib/api/dashboard` - dashboardApi (기존, SQL 직접 실행)
|
? SUBTYPE_AGGREGATION_MAP[subType]
|
||||||
- `@/lib/api/client` - apiClient (기존, axios 기반)
|
: (Object.keys(AGGREGATION_LABELS) as AggregationType[])
|
||||||
|
).map((aggType) => (
|
||||||
|
<SelectItem key={aggType} value={aggType}>
|
||||||
|
{AGGREGATION_LABELS[aggType]}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
```
|
||||||
|
|
||||||
**외부 API (훅이 반환하는 것)**:
|
---
|
||||||
|
|
||||||
|
#### STEP 4: 집계 함수 변경 시 컬럼 자동 초기화 + groupBy 보존
|
||||||
|
|
||||||
|
라인 271~283 교체 (onValueChange 핸들러 전체):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
function useDataSource(config: DataSourceConfig) {
|
// 현재
|
||||||
return {
|
onValueChange={(val) =>
|
||||||
// 상태
|
onChange({
|
||||||
data: { rows: [], value: 0, total: 0 },
|
...dataSource,
|
||||||
loading: boolean,
|
aggregation: val
|
||||||
error: string | null,
|
? {
|
||||||
|
type: val as NonNullable<
|
||||||
// 조회
|
DataSourceConfig["aggregation"]
|
||||||
refetch: (overrideFilters?) => Promise<void>,
|
>["type"],
|
||||||
|
column: dataSource.aggregation?.column ?? "",
|
||||||
// 쓰기
|
|
||||||
save: (record) => Promise<MutationResult>,
|
|
||||||
update: (id, record) => Promise<MutationResult>,
|
|
||||||
remove: (id) => Promise<MutationResult>,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**MutationResult 타입** (신규):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface MutationResult {
|
|
||||||
success: boolean;
|
|
||||||
data?: any;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**조회 분기 로직 (핵심)**:
|
|
||||||
|
|
||||||
```
|
|
||||||
config에 aggregation 또는 joins가 있는가?
|
|
||||||
├── YES → buildAggregationSQL(config) → apiClient.post("/dashboards/execute-query")
|
|
||||||
│ (대시보드와 동일한 경로, SQL 직접 실행)
|
|
||||||
│ 실패 시 → dashboardApi.executeQuery() 폴백
|
|
||||||
│
|
|
||||||
└── NO → dataApi.getTableData(tableName, { page, size, filters, sortBy, sortOrder })
|
|
||||||
(단순 테이블 조회)
|
|
||||||
```
|
|
||||||
|
|
||||||
**CRUD 메서드 구현**:
|
|
||||||
|
|
||||||
| 메서드 | 내부 호출 | 비고 |
|
|
||||||
|--------|----------|------|
|
|
||||||
| `save(record)` | `dataApi.createRecord(config.tableName, record)` | company_code 자동 추가는 백엔드가 처리 |
|
|
||||||
| `update(id, record)` | `dataApi.updateRecord(config.tableName, id, record)` | |
|
|
||||||
| `remove(id)` | `dataApi.deleteRecord(config.tableName, id)` | 복합키 객체도 지원 |
|
|
||||||
|
|
||||||
**자동 새로고침**:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
useEffect(() => {
|
|
||||||
if (config.tableName) refetch();
|
|
||||||
|
|
||||||
if (config.refreshInterval && config.refreshInterval > 0) {
|
|
||||||
const sec = Math.max(5, config.refreshInterval); // 최소 5초
|
|
||||||
const timer = setInterval(refetch, sec * 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}
|
}
|
||||||
}, [config.tableName, config.refreshInterval]);
|
: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경
|
||||||
|
onValueChange={(val) => {
|
||||||
|
// 숫자 전용 집계로 변경 시, 기존 컬럼이 숫자가 아니면 초기화
|
||||||
|
let currentColumn = dataSource.aggregation?.column ?? "";
|
||||||
|
if (val && isNumericOnlyAggregation(val) && currentColumn) {
|
||||||
|
const selectedCol = columns.find((c) => c.name === currentColumn);
|
||||||
|
if (selectedCol && !isNumericColumn(selectedCol)) {
|
||||||
|
currentColumn = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onChange({
|
||||||
|
...dataSource,
|
||||||
|
aggregation: val
|
||||||
|
? {
|
||||||
|
type: val as NonNullable<
|
||||||
|
DataSourceConfig["aggregation"]
|
||||||
|
>["type"],
|
||||||
|
column: currentColumn,
|
||||||
|
groupBy: dataSource.aggregation?.groupBy,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}}
|
||||||
```
|
```
|
||||||
|
|
||||||
**refetch 오버라이드 필터**:
|
기존 대비 변경점 2가지:
|
||||||
|
1. 숫자 전용 집계 + 문자열 컬럼 → `currentColumn = ""`
|
||||||
|
2. `groupBy` 보존 (기존 코드에서 누락되던 버그 수정)
|
||||||
|
|
||||||
```typescript
|
---
|
||||||
// 기본 조회
|
|
||||||
refetch();
|
|
||||||
|
|
||||||
// 필터 추가하여 조회 (usePopEvent와 연동 시)
|
#### STEP 5: 대상 컬럼 Combobox 필터링
|
||||||
refetch({ filters: { supplier_id: "SUP-001" } });
|
|
||||||
|
**5-A. CommandEmpty 메시지 교체** (라인 333~335):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 현재
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">
|
||||||
|
컬럼을 찾을 수 없습니다.
|
||||||
|
</CommandEmpty>
|
||||||
|
|
||||||
|
// 변경
|
||||||
|
<CommandEmpty className="py-2 text-center text-xs">
|
||||||
|
{isNumericOnlyAggregation(dataSource.aggregation?.type)
|
||||||
|
? "숫자 타입 컬럼이 없습니다."
|
||||||
|
: "컬럼을 찾을 수 없습니다."}
|
||||||
|
</CommandEmpty>
|
||||||
```
|
```
|
||||||
|
|
||||||
내부적으로 `overrideFilters`가 있으면 `config.filters`에 병합하여 조회한다.
|
**5-B. columns.map을 필터링 후 map으로 교체** (라인 337):
|
||||||
|
|
||||||
**사용 예시**:
|
```tsx
|
||||||
|
// 현재
|
||||||
|
{columns.map((col) => (
|
||||||
|
|
||||||
```typescript
|
// 변경
|
||||||
// 대시보드 스타일 (집계)
|
{(isNumericOnlyAggregation(dataSource.aggregation?.type)
|
||||||
const { data, loading } = useDataSource({
|
? columns.filter(isNumericColumn)
|
||||||
tableName: "sales_order",
|
: columns
|
||||||
aggregation: { type: "sum", column: "amount", groupBy: ["category"] },
|
).map((col) => (
|
||||||
refreshInterval: 30,
|
```
|
||||||
});
|
|
||||||
// data.rows → [{ category: "A", value: 1500 }, ...]
|
|
||||||
// data.value → 첫 번째 행의 value
|
|
||||||
|
|
||||||
// 테이블 스타일 (목록)
|
나머지 CommandItem 내부는 변경 없음.
|
||||||
const { data, refetch } = useDataSource({
|
|
||||||
tableName: "purchase_order",
|
|
||||||
sort: [{ column: "created_at", direction: "desc" }],
|
|
||||||
limit: 20,
|
|
||||||
});
|
|
||||||
// data.rows → [{ id: 1, item_name: "볼트", ... }, ...]
|
|
||||||
// data.total → 전체 행 수
|
|
||||||
|
|
||||||
// 버튼 스타일 (저장만)
|
---
|
||||||
const { save, remove, loading } = useDataSource({
|
|
||||||
tableName: "inbound_record",
|
#### STEP 6: chart 모드 groupBy 경고
|
||||||
});
|
|
||||||
const result = await save({ supplier_id: "SUP-001", quantity: 50 });
|
라인 445~447 뒤에 추가 (기존 `<p>` 태그는 유지):
|
||||||
// result.success → true/false
|
|
||||||
|
```tsx
|
||||||
|
// 기존 유지
|
||||||
|
<p className="mt-0.5 text-[10px] text-muted-foreground">
|
||||||
|
차트에서 X축 카테고리로 사용됩니다
|
||||||
|
</p>
|
||||||
|
|
||||||
|
// 아래에 추가
|
||||||
|
{subType === "chart" && !dataSource.aggregation?.groupBy?.length && (
|
||||||
|
<p className="mt-0.5 text-[10px] text-destructive">
|
||||||
|
차트 모드에서는 그룹핑(X축)을 설정해야 의미 있는 차트가 표시됩니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### STEP 4: `index.ts` (신규 생성 - 배럴 파일)
|
#### STEP 7: `DataSourceEditor` 호출부에 `subType` 전달
|
||||||
|
|
||||||
**파일**: `frontend/hooks/pop/index.ts`
|
**라인 1212~1215** (ItemEditor 내부, 단일 집계 모드):
|
||||||
|
|
||||||
```typescript
|
```tsx
|
||||||
export { usePopEvent, cleanupScreen } from "./usePopEvent";
|
// 현재
|
||||||
export { useDataSource } from "./useDataSource";
|
<DataSourceEditor
|
||||||
export type { MutationResult } from "./useDataSource";
|
dataSource={item.dataSource}
|
||||||
export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
onChange={(ds) => onUpdate({ ...item, dataSource: ds })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// 변경
|
||||||
|
<DataSourceEditor
|
||||||
|
dataSource={item.dataSource}
|
||||||
|
onChange={(ds) => onUpdate({ ...item, dataSource: ds })}
|
||||||
|
subType={item.subType}
|
||||||
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
외부에서 사용할 때: `import { usePopEvent, useDataSource } from "@/hooks/pop";`
|
**라인 969** (FormulaEditor 내부): **수정하지 않음** (subType=undefined → 모든 집계 표시).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### 사전 충돌 검사 결과 (2026-02-11)
|
#### STEP 7.5: ItemEditor subType 변경 시 비호환 aggregation 자동 전환
|
||||||
|
|
||||||
|
> **시뮬레이션에서 발견**: chart(aggregation=sum) → stat-card로 subType 변경 시,
|
||||||
|
> Select 목록에 "sum"이 없지만 내부 value는 "sum" 유지 → SQL 생성 시 SUM 실행됨.
|
||||||
|
> radix Select는 목록에 없는 value를 자동으로 초기화하지 않음.
|
||||||
|
|
||||||
|
**라인 1147~1148** (ItemEditor 내부, subType Select onValueChange):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 현재
|
||||||
|
onValueChange={(val) =>
|
||||||
|
onUpdate({ ...item, subType: val as DashboardSubType })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경
|
||||||
|
onValueChange={(val) => {
|
||||||
|
const newSubType = val as DashboardSubType;
|
||||||
|
const allowedAggs = SUBTYPE_AGGREGATION_MAP[newSubType];
|
||||||
|
const currentAggType = item.dataSource.aggregation?.type;
|
||||||
|
// 새 subType에서 현재 집계 함수가 허용 안 되면 첫 번째 허용 함수로 전환
|
||||||
|
let newDataSource = item.dataSource;
|
||||||
|
if (currentAggType && !allowedAggs.includes(currentAggType)) {
|
||||||
|
newDataSource = {
|
||||||
|
...item.dataSource,
|
||||||
|
aggregation: item.dataSource.aggregation
|
||||||
|
? {
|
||||||
|
...item.dataSource.aggregation,
|
||||||
|
type: allowedAggs[0],
|
||||||
|
column: allowedAggs[0] === "count" ? "" : item.dataSource.aggregation.column,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
onUpdate({ ...item, subType: newSubType, dataSource: newDataSource });
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
핵심: `allowedAggs[0]`이 "count"면 column도 빈 문자열로 (count는 column 불필요).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 사전 충돌 검사 결과 (2026-02-12)
|
||||||
|
|
||||||
| 이름 | 유형 | 검색 범위 | 검색 결과 | 판정 |
|
| 이름 | 유형 | 검색 범위 | 검색 결과 | 판정 |
|
||||||
|------|------|-----------|-----------|------|
|
|------|------|-----------|-----------|------|
|
||||||
| `usePopEvent` | 훅 이름 | frontend 전체 | **주석 2건** (PopDashboardComponent.tsx 라인 10, `@INFRA-EXTRACT` 교체 예정 주석) | **충돌 없음** (실제 코드 아님) |
|
| `SUBTYPE_AGGREGATION_MAP` | 상수 | frontend 전체 | 0건 | **충돌 없음** |
|
||||||
| `useDataSource` | 훅 이름 | frontend 전체 | **주석 4건** (dataFetcher.ts 라인 4,227,355 + PopDashboardComponent.tsx 라인 9, 모두 `@INFRA-EXTRACT` 주석) | **충돌 없음** (실제 코드 아님) |
|
| `AGGREGATION_LABELS` | 상수 | frontend 전체 | 0건 | **충돌 없음** |
|
||||||
| `PopEventBus` | 클래스명 | frontend 전체 | **1건** (PopDashboardComponent.tsx 라인 10, `@INFRA-EXTRACT` 주석 내) | **충돌 없음** (주석) |
|
| `NUMERIC_ONLY_AGGREGATIONS` | 상수 | frontend 전체 | 0건 | **충돌 없음** |
|
||||||
| `MutationResult` | 타입명 | frontend 전체 | **0건** | **충돌 없음** |
|
| `NUMERIC_TYPE_PATTERNS` | 상수 | frontend 전체 | 0건 | **충돌 없음** |
|
||||||
| `popSqlBuilder` | 파일명 | frontend 전체 | **0건** | **충돌 없음** |
|
| `isNumericColumn` | 함수 | frontend 전체 | 0건 | **충돌 없음** |
|
||||||
| `cleanupScreen` | 함수명 | frontend 전체 | **0건** | **충돌 없음** |
|
| `isNumericOnlyAggregation` | 함수 | frontend 전체 | 0건 | **충돌 없음** |
|
||||||
| `screenBuses` | 변수명 | frontend 전체 | **0건** | **충돌 없음** |
|
| `AggregationType` | type import | PopDashboardConfig.tsx | 0건 (현재 미import) | **충돌 없음** (추가 필요) |
|
||||||
| `sharedDataStore` | 변수명 | frontend 전체 | **0건** | **충돌 없음** |
|
|
||||||
| `buildAggregationSQL` | 함수명 | frontend 전체 | **2건** (dataFetcher.ts 정의 + PopDashboardComponent에서 import) | **충돌 주의**: 동일 이름을 popSqlBuilder.ts에서 재정의. 대시보드는 여전히 dataFetcher.ts 것을 사용하므로 런타임 충돌 없음. 향후 대시보드 교체 시 import 경로만 변경. |
|
---
|
||||||
| `validateDataSourceConfig` | 함수명 | frontend 전체 | **1건** (dataFetcher.ts 정의) | 위와 동일 |
|
|
||||||
| `escapeSQL` | 함수명 | frontend 전체 | **1건** (dataFetcher.ts 내부 함수) | **충돌 없음** (export 안 됨) |
|
|
||||||
| `sanitizeIdentifier` | 함수명 | frontend 전체 | **1건** (dataFetcher.ts 내부 함수) | **충돌 없음** (export 안 됨) |
|
|
||||||
| `AggregatedResult` | 타입명 | frontend 전체 | **1건** (dataFetcher.ts 정의) | **충돌 주의**: useDataSource 내부에서 동일 타입 사용. types.ts 정의를 공유하므로 별도 재정의 불필요. |
|
|
||||||
|
|
||||||
#### 정의-사용 매핑
|
#### 정의-사용 매핑
|
||||||
|
|
||||||
| 정의 | 정의 위치 | 사용 위치 |
|
| 정의 | 정의 위치 | 사용 위치 |
|
||||||
|------|-----------|-----------|
|
|------|-----------|-----------|
|
||||||
| `usePopEvent` | `hooks/pop/usePopEvent.ts` (신규) | pop-button (Phase 2), pop-table (Phase 3), pop-search (Phase 4) 등 |
|
| `SUBTYPE_AGGREGATION_MAP` | STEP 1 (상수) | STEP 3, STEP 7.5 |
|
||||||
| `useDataSource` | `hooks/pop/useDataSource.ts` (신규) | pop-button (Phase 2), pop-table (Phase 3), pop-dashboard (향후 교체) 등 |
|
| `AGGREGATION_LABELS` | STEP 1 (상수) | STEP 3 |
|
||||||
| `MutationResult` | `hooks/pop/useDataSource.ts` (신규) | useDataSource 반환 타입으로 사용 |
|
| `NUMERIC_ONLY_AGGREGATIONS` | STEP 1 (상수) | `isNumericOnlyAggregation` 내부 |
|
||||||
| `buildAggregationSQL` | `hooks/pop/popSqlBuilder.ts` (신규) | useDataSource 내부에서 호출 |
|
| `NUMERIC_TYPE_PATTERNS` | STEP 1 (상수) | `isNumericColumn` 내부 |
|
||||||
| `validateDataSourceConfig` | `hooks/pop/popSqlBuilder.ts` (신규) | useDataSource 내부에서 호출 |
|
| `isNumericColumn` | STEP 1 (함수) | STEP 4, STEP 5 |
|
||||||
| `cleanupScreen` | `hooks/pop/usePopEvent.ts` (신규) | 화면 언마운트 시 호출 (PopRenderer 또는 뷰어에서) |
|
| `isNumericOnlyAggregation` | STEP 1 (함수) | STEP 4, STEP 5 |
|
||||||
| `DataSourceConfig` | `lib/registry/pop-components/types.ts` (기존) | useDataSource 파라미터 타입 |
|
| `subType` (prop) | STEP 2 (DataSourceEditor) | STEP 3, STEP 6 |
|
||||||
| `DataSourceFilter` | `lib/registry/pop-components/types.ts` (기존) | popSqlBuilder 내부 |
|
| `AggregationType` | `types.ts` L123 (기존) | STEP 1 import 추가 |
|
||||||
| `dataApi` | `lib/api/data.ts` (기존) | useDataSource 내부에서 CRUD 호출 |
|
| `ColumnInfo` | `dataFetcher.ts` (기존, L71에서 import) | STEP 1 `isNumericColumn` 파라미터 |
|
||||||
| `dashboardApi` | `lib/api/dashboard.ts` (기존) | useDataSource 내부에서 SQL 실행 폴백 |
|
| `DashboardSubType` | `types.ts` (기존, L48에서 이미 import) | STEP 2 prop 타입, STEP 7.5 |
|
||||||
| `apiClient` | `lib/api/client.ts` (기존) | useDataSource 내부에서 SQL 실행 1차 |
|
|
||||||
|
|
||||||
**누락 검사**: 모든 신규 정의에 사용처 있음. 모든 사용처에 정의 존재.
|
**누락 검사**: 모든 신규 정의에 사용처 있음. 모든 사용처에 정의 존재. 누락 없음.
|
||||||
단, `cleanupScreen`의 호출 시점은 Phase 2 이후 뷰어 통합 시 결정 (이번에는 export만 해둠).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### 함정 경고
|
#### 함정 경고
|
||||||
|
|
||||||
| 번호 | 위험 | 설명 | 해결 방안 |
|
| # | 심각도 | 위험 | 설명 | 해결 방안 |
|
||||||
|------|------|------|-----------|
|
|---|--------|------|------|-----------|
|
||||||
| W1 | **subscribe를 useEffect 밖에서 호출하면 메모리 누수** | subscribe는 콜백을 등록하므로, 컴포넌트 언마운트 시 해제해야 함 | subscribe의 반환값(unsubscribe)을 useEffect cleanup에서 호출. JSDoc에 사용 패턴 명시. |
|
| W1 | **높음** | **import 누락** | `AggregationType`을 import에 추가 안 하면 컴파일 에러 | STEP 1-A에서 반드시 추가 |
|
||||||
| W2 | **DataSourceConfig의 import 경로** | `DataSourceConfig`는 `lib/registry/pop-components/types.ts`에 정의됨. hooks 디렉토리에서 import 시 경로가 김 | `@/lib/registry/pop-components/types`로 import. 별도 re-export 하지 않음 (타입 중복 방지). |
|
| W2 | **높음** | **subType 변경 시 비호환 aggregation 잔류** | chart(sum) → stat-card 시 Select 목록에 없는 값이 내부에 남음 | STEP 7.5에서 해결 (시뮬레이션 발견) |
|
||||||
| W3 | **buildAggregationSQL 동일 이름 2곳** | dataFetcher.ts와 popSqlBuilder.ts에 같은 이름의 함수가 존재 | 의도적 복사. 대시보드는 dataFetcher.ts, 새 컴포넌트는 popSqlBuilder.ts 사용. 향후 대시보드 교체 시 dataFetcher.ts를 popSqlBuilder.ts import로 변경. |
|
| W3 | **중간** | **groupBy 소실 (기존 버그)** | 기존 코드에서 집계 함수 변경 시 groupBy가 누락됨 | STEP 4에서 `groupBy` 명시적 보존 |
|
||||||
| W4 | **apiClient.post와 dashboardApi.executeQuery 이중 경로** | 대시보드 dataFetcher.ts에서 apiClient 우선 + dashboardApi 폴백 패턴을 그대로 복사함 | 동일 패턴 유지 (안정성 검증 완료). 향후 하나로 통합 가능. |
|
| W4 | **낮음** | **FormulaEditor 호출 미수정** | FormulaEditor 내부 DataSourceEditor에는 subType 미전달 | 의도적 결정 (수식 모드는 모든 집계 허용). 절대 수정하지 말 것 (연쇄 변경 발생) |
|
||||||
| W5 | **refetch overrideFilters와 config.filters 병합 순서** | overrideFilters가 config.filters를 완전 대체하는지, 추가하는지 모호 | **추가(append) 방식**: config.filters + overrideFilters를 합침. overrideFilters에 같은 column이 있으면 덮어씀. 이 동작을 JSDoc에 명시. |
|
| W5 | **낮음** | **columns 비동기 로드** | 테이블 방금 선택 → columns=[] → STEP 4 초기화 안 됨 | 허용. 컬럼 로드 후 재변경 시 정상 동작 |
|
||||||
| W6 | **SSR 환경에서 전역 Map** | Next.js SSR에서 전역 Map이 서버/클라이언트 간 공유될 수 있음 | `typeof window !== "undefined"` 가드. 이벤트 버스는 클라이언트 전용. |
|
| W6 | **정보** | **MIN/MAX 문자열 허용** | PostgreSQL MIN/MAX는 문자열에도 작동 (사전순) | 의도적. `NUMERIC_ONLY`에 포함 안 함 |
|
||||||
| W7 | **hooks/pop/ 디렉토리 신규** | `frontend/hooks/pop/` 디렉토리가 존재하지 않음 | STEP 1에서 파일 생성 시 디렉토리 자동 생성됨. 수동으로 mkdir 불필요. |
|
| W7 | **정보** | **STEP 7.5에서 column 초기화 조건** | `allowedAggs[0] === "count"`이면 column="" (count는 * 사용) | stat-card → count 전환 시 올바른 동작 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -491,33 +564,36 @@ export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
||||||
|
|
||||||
##### 코드 레벨
|
##### 코드 레벨
|
||||||
|
|
||||||
- [ ] `usePopEvent` - publish/subscribe 기본 동작 (같은 screenId)
|
- [x] `AggregationType` import 추가 확인
|
||||||
- [ ] `usePopEvent` - 다른 screenId 간 격리 확인
|
- [x] stat-card: 집계 함수 Select에 "건수 (COUNT)"만 표시
|
||||||
- [ ] `usePopEvent` - subscribe cleanup (메모리 누수 없음)
|
- [x] kpi-card/chart/gauge: 5개 집계 함수 전부 표시
|
||||||
- [ ] `usePopEvent` - sharedData set/get
|
- [x] SUM 선택 시 컬럼 목록에 숫자 타입만 표시
|
||||||
- [ ] `useDataSource` - 단순 조회 (aggregation 없음 → dataApi.getTableData)
|
- [x] COUNT 선택 시 컬럼 목록에 모든 타입 표시
|
||||||
- [ ] `useDataSource` - 집계 조회 (aggregation 있음 → SQL 빌더 → executeQuery)
|
- [x] COUNT→SUM 변경 시 문자열 컬럼 자동 초기화
|
||||||
- [ ] `useDataSource` - save/update/remove
|
- [x] SUM→COUNT 변경 시 숫자 컬럼 유지
|
||||||
- [ ] `useDataSource` - loading/error 상태 관리
|
- [x] 집계 함수 변경 시 groupBy 보존
|
||||||
- [ ] `useDataSource` - refreshInterval 자동 새로고침
|
- [x] chart→stat-card subType 변경 시 sum→count 자동 전환 (STEP 7.5)
|
||||||
- [ ] `popSqlBuilder` - buildAggregationSQL이 dataFetcher.ts와 동일 결과 생성
|
- [x] chart 모드에서 groupBy 미설정 시 빨간 경고 표시
|
||||||
- [ ] TypeScript 컴파일 에러 0건
|
- [x] FormulaEditor 내부에서 5개 전부 표시 (subType 미전달 → 기본 전체 목록)
|
||||||
- [ ] 린트 에러 0건
|
- [x] TypeScript 컴파일 에러 0건
|
||||||
- [ ] 기존 대시보드 동작에 영향 없음 (dataFetcher.ts 미수정)
|
- [x] 린트 에러 0건
|
||||||
|
- [ ] 기존 저장된 대시보드 데이터 조회 동작에 영향 없음 (브라우저 확인 필요)
|
||||||
|
|
||||||
##### 구조 레벨
|
##### 구조 레벨
|
||||||
|
|
||||||
- [ ] `frontend/hooks/pop/` 디렉토리 생성됨
|
- [x] 수정 파일 1개: `PopDashboardConfig.tsx`
|
||||||
- [ ] index.ts 배럴 파일에서 모든 public API export 됨
|
- [x] 신규 파일 0개
|
||||||
- [ ] `@/hooks/pop`으로 import 가능
|
- [x] 기존 파일 삭제 0개
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### 이전 완료된 계획 (보관)
|
#### 이전 완료된 계획 (보관)
|
||||||
|
|
||||||
|
**Phase 0 공통 인프라 (2026-02-11, 완료)**:
|
||||||
|
usePopEvent + useDataSource + popSqlBuilder 구현.
|
||||||
|
|
||||||
**대시보드 스타일 정리 (2026-02-11, 완료)**:
|
**대시보드 스타일 정리 (2026-02-11, 완료)**:
|
||||||
글자 크기 커스텀 제거, 라벨 정렬만 유지, stale closure 수정, .next 캐시 해결.
|
글자 크기 커스텀 제거, 라벨 정렬만 유지, stale closure 수정, .next 캐시 해결.
|
||||||
상세: `popdocs/sessions/2026-02-11.md`
|
|
||||||
|
|
||||||
**브라우저 확인 체크리스트 (대기)**:
|
**브라우저 확인 체크리스트 (대기)**:
|
||||||
- [ ] 라벨 정렬, 페이지 미리보기, 차트 디자인, 게이지/통계카드 동작 확인
|
- [ ] 라벨 정렬, 페이지 미리보기, 차트 디자인, 게이지/통계카드 동작 확인
|
||||||
|
|
@ -548,4 +624,4 @@ export { buildAggregationSQL, validateDataSourceConfig } from "./popSqlBuilder";
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*최종 업데이트: 2026-02-11 (Phase 0 공통 인프라 완료, Phase 2 pop-button 설계 시작)*
|
*최종 업데이트: 2026-02-12 (대시보드 집계 함수 유효성 검증 v2 - 시뮬레이션 검증 완료, STEP 7.5 추가)*
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# 현재 상태
|
# 현재 상태
|
||||||
|
|
||||||
> **마지막 업데이트**: 2026-02-11
|
> **마지막 업데이트**: 2026-02-12
|
||||||
> **담당**: POP 화면 디자이너
|
> **담당**: POP 화면 디자이너
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -72,23 +72,28 @@
|
||||||
|
|
||||||
## 다음 작업 (우선순위)
|
## 다음 작업 (우선순위)
|
||||||
|
|
||||||
### 현재: Phase 2 pop-button 컴포넌트 구현
|
### 현재: 대시보드 집계 함수 설정 유효성 검증 강화 (v2 시뮬레이션 검증 완료)
|
||||||
|
|
||||||
> Phase 0 공통 인프라 (usePopEvent + useDataSource) 완료
|
> 팀원 pull 후 대시보드 500 에러 (문자열 컬럼에 SUM 적용)
|
||||||
> pop-button 컴포넌트 설계 및 구현 진행 중
|
> 8개 시나리오 시뮬레이션 완료, STEP 7.5 추가 (subType 변경 시 비호환 aggregation 자동 전환)
|
||||||
|
|
||||||
### 완료된 Phase 0 (2026-02-11)
|
| 순서 | 작업 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | import + 유틸 상수/함수 6개 | 대기 |
|
||||||
|
| 2 | DataSourceEditor props에 subType 추가 | 대기 |
|
||||||
|
| 3 | 집계 함수 Select 동적 생성 (subType별) | 대기 |
|
||||||
|
| 4 | 집계 함수 변경 시 컬럼 초기화 + groupBy 보존 | 대기 |
|
||||||
|
| 5 | 대상 컬럼 Combobox 숫자 필터링 | 대기 |
|
||||||
|
| 6 | chart 모드 groupBy 경고 | 대기 |
|
||||||
|
| 7 | DataSourceEditor 호출부 subType 전달 | 대기 |
|
||||||
|
| **7.5** | **subType 변경 시 비호환 aggregation 전환** | **대기** |
|
||||||
|
|
||||||
| 순서 | 파일 | 작업 | 상태 |
|
수정 파일: `PopDashboardConfig.tsx` 1개만. 상세: `popdocs/PLAN.md` "현재 구현 계획"
|
||||||
|------|------|------|------|
|
|
||||||
| 1 | `hooks/pop/usePopEvent.ts` | 이벤트 버스 훅 | **완료** |
|
|
||||||
| 2 | `hooks/pop/popSqlBuilder.ts` | SQL 빌더 유틸 | **완료** |
|
|
||||||
| 3 | `hooks/pop/useDataSource.ts` | 데이터 CRUD 훅 | **완료** |
|
|
||||||
| 4 | `hooks/pop/index.ts` | 배럴 파일 | **완료** |
|
|
||||||
|
|
||||||
### 대기
|
### 대기
|
||||||
|
|
||||||
- 브라우저 확인: 라벨 정렬, 페이지 미리보기, 차트 디자인, 게이지/통계카드
|
- 브라우저 확인: 라벨 정렬, 페이지 미리보기, 차트 디자인, 게이지/통계카드
|
||||||
|
- Phase 2: pop-button 컴포넌트 구현
|
||||||
- Phase 2: pop-icon 검토/개선
|
- Phase 2: pop-icon 검토/개선
|
||||||
|
|
||||||
### 후속
|
### 후속
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue