feat(pop-dashboard): Phase 0 공통 타입 + Phase 1 대시보드 컴포넌트 구현
Phase 0: 공통 인프라 타입 정의 - ColumnBinding, JoinConfig, DataSourceConfig, PopActionConfig 등 - FilterOperator, AggregationType, SortConfig 타입 Phase 1: pop-dashboard 컴포넌트 - 4개 서브타입: KpiCard, ChartItem, GaugeItem, StatCard - 4개 표시모드: ArrowsMode, AutoSlideMode, GridMode, ScrollMode - 설정패널(PopDashboardConfig), 미리보기(PopDashboardPreview) - 계산식 엔진(formula.ts), 데이터 조회(dataFetcher.ts) - 팔레트/렌더러/타입 시스템 연동 fix(pop-text): DateTimeDisplay isRealtime 기본값 true로 수정 EOF
This commit is contained in:
parent
f825d65bfc
commit
4f3e9ec19e
|
|
@ -3,7 +3,7 @@
|
||||||
import { useDrag } from "react-dnd";
|
import { useDrag } from "react-dnd";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { PopComponentType } from "../types/pop-layout";
|
import { PopComponentType } from "../types/pop-layout";
|
||||||
import { Square, FileText } from "lucide-react";
|
import { Square, FileText, BarChart3 } from "lucide-react";
|
||||||
import { DND_ITEM_TYPES } from "../constants";
|
import { DND_ITEM_TYPES } from "../constants";
|
||||||
|
|
||||||
// 컴포넌트 정의
|
// 컴포넌트 정의
|
||||||
|
|
@ -27,6 +27,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
||||||
icon: FileText,
|
icon: FileText,
|
||||||
description: "텍스트, 시간, 이미지 표시",
|
description: "텍스트, 시간, 이미지 표시",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "pop-dashboard",
|
||||||
|
label: "대시보드",
|
||||||
|
icon: BarChart3,
|
||||||
|
description: "KPI, 차트, 게이지, 통계 집계",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 드래그 가능한 컴포넌트 아이템
|
// 드래그 가능한 컴포넌트 아이템
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ interface PopRendererProps {
|
||||||
|
|
||||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||||
"pop-sample": "샘플",
|
"pop-sample": "샘플",
|
||||||
|
"pop-text": "텍스트",
|
||||||
|
"pop-dashboard": "대시보드",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
/**
|
/**
|
||||||
* POP 컴포넌트 타입
|
* POP 컴포넌트 타입
|
||||||
*/
|
*/
|
||||||
export type PopComponentType = "pop-sample" | "pop-text"; // 테스트용 샘플 박스, 텍스트 컴포넌트
|
export type PopComponentType = "pop-sample" | "pop-text" | "pop-dashboard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 흐름 정의
|
* 데이터 흐름 정의
|
||||||
|
|
@ -342,6 +342,7 @@ export const isV5Layout = (layout: any): layout is PopLayoutDataV5 => {
|
||||||
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
export const DEFAULT_COMPONENT_GRID_SIZE: Record<PopComponentType, { colSpan: number; rowSpan: number }> = {
|
||||||
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
||||||
"pop-text": { colSpan: 3, rowSpan: 1 },
|
"pop-text": { colSpan: 3, rowSpan: 1 },
|
||||||
|
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export * from "./types";
|
||||||
|
|
||||||
// POP 컴포넌트 등록
|
// POP 컴포넌트 등록
|
||||||
import "./pop-text";
|
import "./pop-text";
|
||||||
|
import "./pop-dashboard";
|
||||||
|
|
||||||
// 향후 추가될 컴포넌트들:
|
// 향후 추가될 컴포넌트들:
|
||||||
// import "./pop-field";
|
// import "./pop-field";
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,314 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pop-dashboard 메인 컴포넌트 (뷰어용)
|
||||||
|
*
|
||||||
|
* 멀티 아이템 컨테이너: 여러 집계 아이템을 묶어서 다양한 표시 모드로 렌더링
|
||||||
|
*
|
||||||
|
* @INFRA-EXTRACT 대상:
|
||||||
|
* - fetchAggregatedData 호출부 -> useDataSource로 교체 예정
|
||||||
|
* - filter_changed 이벤트 수신 -> usePopEvent로 교체 예정
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import type {
|
||||||
|
PopDashboardConfig,
|
||||||
|
DashboardItem,
|
||||||
|
} 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";
|
||||||
|
|
||||||
|
// ===== 내부 타입 =====
|
||||||
|
|
||||||
|
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,
|
||||||
|
}: {
|
||||||
|
config?: PopDashboardConfig;
|
||||||
|
}) {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 빈 설정
|
||||||
|
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 visibleItems = 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();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 데이터 로딩 함수
|
||||||
|
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);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [JSON.stringify(visibleItems.map((i) => i.id))]);
|
||||||
|
|
||||||
|
// 초기 로딩 + 주기적 새로고침
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAllData();
|
||||||
|
|
||||||
|
// refreshInterval 적용 (첫 번째 아이템 기준)
|
||||||
|
const refreshSec = visibleItems[0]?.dataSource.refreshInterval;
|
||||||
|
if (refreshSec && refreshSec > 0) {
|
||||||
|
refreshTimerRef.current = setInterval(fetchAllData, refreshSec * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (refreshTimerRef.current) {
|
||||||
|
clearInterval(refreshTimerRef.current);
|
||||||
|
refreshTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fetchAllData, visibleItems]);
|
||||||
|
|
||||||
|
// 단일 아이템 렌더링
|
||||||
|
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":
|
||||||
|
return (
|
||||||
|
<ChartItemComponent
|
||||||
|
item={item}
|
||||||
|
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) {
|
||||||
|
// 각 카테고리 행에서 건수 추출 (간단 구현: 행 수 기준)
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 표시 모드별 렌더링
|
||||||
|
const displayMode = config.displayMode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-full w-full"
|
||||||
|
style={
|
||||||
|
config.backgroundColor
|
||||||
|
? { backgroundColor: config.backgroundColor }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{displayMode === "arrows" && (
|
||||||
|
<ArrowsModeComponent
|
||||||
|
itemCount={visibleItems.length}
|
||||||
|
showIndicator={config.showIndicator}
|
||||||
|
renderItem={(index) => renderSingleItem(visibleItems[index])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayMode === "auto-slide" && (
|
||||||
|
<AutoSlideModeComponent
|
||||||
|
itemCount={visibleItems.length}
|
||||||
|
interval={config.autoSlideInterval}
|
||||||
|
resumeDelay={config.autoSlideResumeDelay}
|
||||||
|
showIndicator={config.showIndicator}
|
||||||
|
renderItem={(index) => renderSingleItem(visibleItems[index])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayMode === "grid" && (
|
||||||
|
<GridModeComponent
|
||||||
|
cells={config.gridCells ?? []}
|
||||||
|
columns={config.gridColumns ?? 2}
|
||||||
|
rows={config.gridRows ?? 2}
|
||||||
|
gap={config.gap}
|
||||||
|
renderItem={(itemId) => {
|
||||||
|
const item = visibleItems.find((i) => i.id === itemId);
|
||||||
|
if (!item) return null;
|
||||||
|
return renderSingleItem(item);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayMode === "scroll" && (
|
||||||
|
<ScrollModeComponent
|
||||||
|
itemCount={visibleItems.length}
|
||||||
|
showIndicator={config.showIndicator}
|
||||||
|
renderItem={(index) => renderSingleItem(visibleItems[index])}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,156 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pop-dashboard 디자이너 미리보기 컴포넌트
|
||||||
|
*
|
||||||
|
* 실제 데이터 없이 더미 레이아웃으로 미리보기 표시
|
||||||
|
* 디자이너가 설정 변경 시 즉시 미리보기 확인 가능
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { BarChart3, PieChart, Gauge, LayoutList } from "lucide-react";
|
||||||
|
import type { PopDashboardConfig, DashboardSubType } from "../types";
|
||||||
|
|
||||||
|
// ===== 서브타입별 아이콘 매핑 =====
|
||||||
|
|
||||||
|
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": "자동 슬라이드",
|
||||||
|
grid: "그리드",
|
||||||
|
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;
|
||||||
|
}) {
|
||||||
|
if (!config || !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 mode = config.displayMode;
|
||||||
|
|
||||||
|
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[mode] ?? mode}
|
||||||
|
</span>
|
||||||
|
<span className="text-[8px] text-muted-foreground">
|
||||||
|
{visibleItems.length}개
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모드별 미리보기 */}
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
{mode === "grid" ? (
|
||||||
|
// 그리드: 셀 구조 시각화
|
||||||
|
<div
|
||||||
|
className="h-full w-full gap-1"
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: `repeat(${config.gridColumns ?? 2}, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(${config.gridRows ?? 2}, 1fr)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{config.gridCells?.length
|
||||||
|
? config.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,34 @@
|
||||||
|
"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: [],
|
||||||
|
displayMode: "arrows",
|
||||||
|
autoSlideInterval: 5,
|
||||||
|
autoSlideResumeDelay: 3,
|
||||||
|
showIndicator: true,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
touchOptimized: true,
|
||||||
|
supportedDevices: ["mobile", "tablet"],
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 차트 서브타입 컴포넌트
|
||||||
|
*
|
||||||
|
* Recharts 기반 막대/원형/라인 차트
|
||||||
|
* 컨테이너 크기가 너무 작으면 "차트 표시 불가" 메시지
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
BarChart,
|
||||||
|
Bar,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import type { DashboardItem } from "../../types";
|
||||||
|
|
||||||
|
// ===== 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 } = 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";
|
||||||
|
|
||||||
|
// 컨테이너가 너무 작으면 메시지 표시
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="@container flex h-full w-full flex-col p-1">
|
||||||
|
{/* 라벨 */}
|
||||||
|
{visibility.showLabel && (
|
||||||
|
<p className="mb-1 truncate text-[10px] text-muted-foreground @[200px]:text-xs">
|
||||||
|
{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>[]}>
|
||||||
|
<XAxis
|
||||||
|
dataKey={xKey}
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
hide={containerWidth < 200}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 10 }} hide={containerWidth < 200} />
|
||||||
|
<Tooltip />
|
||||||
|
<Bar dataKey={yKey} fill={colors[0]} radius={[2, 2, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
) : chartType === "line" ? (
|
||||||
|
<LineChart data={rows as Record<string, string | number>[]}>
|
||||||
|
<XAxis
|
||||||
|
dataKey={xKey}
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
hide={containerWidth < 200}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 10 }} hide={containerWidth < 200} />
|
||||||
|
<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="80%"
|
||||||
|
label={containerWidth > 250}
|
||||||
|
>
|
||||||
|
{rows.map((_, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={colors[index % colors.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
</PieChart>
|
||||||
|
)}
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 게이지 서브타입 컴포넌트
|
||||||
|
*
|
||||||
|
* SVG 기반 반원형 게이지 (외부 라이브러리 불필요)
|
||||||
|
* min/max/target/current 표시, 달성률 구간별 색상
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import type { DashboardItem } from "../../types";
|
||||||
|
import { abbreviateNumber } from "../utils/formula";
|
||||||
|
|
||||||
|
// ===== 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 } = item;
|
||||||
|
const current = data ?? 0;
|
||||||
|
const min = gaugeConfig?.min ?? 0;
|
||||||
|
const max = gaugeConfig?.max ?? 100;
|
||||||
|
const target = targetValue ?? gaugeConfig?.target ?? max;
|
||||||
|
|
||||||
|
// 달성률 계산 (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="mb-1 truncate text-[10px] text-muted-foreground @[150px]:text-xs">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 게이지 SVG */}
|
||||||
|
<div className="relative w-full max-w-[200px]">
|
||||||
|
<svg viewBox="0 0 200 110" className="w-full">
|
||||||
|
{/* 배경 반원 (회색) */}
|
||||||
|
<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 text-2xl font-bold"
|
||||||
|
fontSize="24"
|
||||||
|
>
|
||||||
|
{abbreviateNumber(current)}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 퍼센트 */}
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy + 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
className="fill-muted-foreground text-xs"
|
||||||
|
fontSize="12"
|
||||||
|
>
|
||||||
|
{percentage.toFixed(1)}%
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 목표값 */}
|
||||||
|
{visibility.showTarget && (
|
||||||
|
<p className="hidden text-[10px] text-muted-foreground @[150px]:block @[200px]:text-xs">
|
||||||
|
목표: {abbreviateNumber(target)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI 카드 서브타입 컴포넌트
|
||||||
|
*
|
||||||
|
* 큰 숫자 + 단위 + 증감 표시
|
||||||
|
* CSS Container Query로 반응형 내부 콘텐츠
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import type { DashboardItem } 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 } = item;
|
||||||
|
const displayValue = data ?? 0;
|
||||||
|
const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="@container flex h-full w-full flex-col justify-center p-2">
|
||||||
|
{/* 라벨 */}
|
||||||
|
{visibility.showLabel && (
|
||||||
|
<p className="truncate text-[10px] text-muted-foreground @[150px]:text-xs @[250px]:text-sm">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 메인 값 */}
|
||||||
|
{visibility.showValue && (
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span
|
||||||
|
className="text-lg font-bold @[120px]:text-xl @[200px]:text-3xl @[350px]:text-4xl @[500px]:text-5xl"
|
||||||
|
style={valueColor ? { color: valueColor } : undefined}
|
||||||
|
>
|
||||||
|
{formulaDisplay ?? abbreviateNumber(displayValue)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 단위 */}
|
||||||
|
{visibility.showUnit && kpiConfig?.unit && (
|
||||||
|
<span className="hidden text-xs text-muted-foreground @[120px]:inline @[200px]:text-sm">
|
||||||
|
{kpiConfig.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 증감율 */}
|
||||||
|
{visibility.showTrend && trendValue != null && (
|
||||||
|
<div className="hidden @[200px]:block">
|
||||||
|
<TrendIndicator value={trendValue} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 보조 라벨 (수식 표시 등) */}
|
||||||
|
{visibility.showSubLabel && formulaDisplay && (
|
||||||
|
<p className="hidden truncate text-xs text-muted-foreground @[350px]:block">
|
||||||
|
{item.formula?.values.map((v) => v.label).join(" / ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통계 카드 서브타입 컴포넌트
|
||||||
|
*
|
||||||
|
* 상태별 건수 표시 (대기/진행/완료 등)
|
||||||
|
* 각 카테고리별 색상 및 링크 지원
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import type { DashboardItem } 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 } = item;
|
||||||
|
const categories = statConfig?.categories ?? [];
|
||||||
|
const total = Object.values(categoryData).reduce((sum, v) => sum + v, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="@container flex h-full w-full flex-col p-2">
|
||||||
|
{/* 라벨 */}
|
||||||
|
{visibility.showLabel && (
|
||||||
|
<p className="mb-1 truncate text-[10px] text-muted-foreground @[150px]:text-xs @[250px]:text-sm">
|
||||||
|
{item.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 총합 */}
|
||||||
|
{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 hidden text-[10px] text-muted-foreground @[250px]:block">
|
||||||
|
{visibility.showUnit && item.kpiConfig?.unit
|
||||||
|
? `단위: ${item.kpiConfig.unit}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
"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="flex h-full w-full flex-col">
|
||||||
|
{/* 콘텐츠 + 화살표 */}
|
||||||
|
<div className="relative flex min-h-0 flex-1 items-center">
|
||||||
|
{/* 왼쪽 화살표 */}
|
||||||
|
{itemCount > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goToPrev}
|
||||||
|
className="absolute left-0 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-background/80 shadow-sm transition-colors hover:bg-accent active:scale-95"
|
||||||
|
aria-label="이전"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 아이템 */}
|
||||||
|
<div className="h-full w-full px-12">
|
||||||
|
{renderItem(currentIndex)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽 화살표 */}
|
||||||
|
{itemCount > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goToNext}
|
||||||
|
className="absolute right-0 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-background/80 shadow-sm transition-colors hover:bg-accent active:scale-95"
|
||||||
|
aria-label="다음"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지 인디케이터 */}
|
||||||
|
{showIndicator && itemCount > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-1.5 py-1">
|
||||||
|
{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="flex h-full w-full flex-col"
|
||||||
|
onClick={handlePause}
|
||||||
|
onTouchStart={handlePause}
|
||||||
|
role="presentation"
|
||||||
|
>
|
||||||
|
{/* 콘텐츠 (슬라이드 애니메이션) */}
|
||||||
|
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 transition-transform duration-500 ease-in-out"
|
||||||
|
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex h-full"
|
||||||
|
style={{ width: `${itemCount * 100}%` }}
|
||||||
|
>
|
||||||
|
{Array.from({ length: itemCount }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-full"
|
||||||
|
style={{ width: `${100 / itemCount}%` }}
|
||||||
|
>
|
||||||
|
{renderItem(i)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 인디케이터 + 일시정지 표시 */}
|
||||||
|
{showIndicator && itemCount > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-1.5 py-1">
|
||||||
|
{isPaused && (
|
||||||
|
<span className="mr-2 text-[10px] text-muted-foreground">일시정지</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,75 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 표시 모드
|
||||||
|
*
|
||||||
|
* CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영)
|
||||||
|
* 각 셀에 @container 적용하여 내부 아이템 반응형
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import type { DashboardCell } from "../../types";
|
||||||
|
|
||||||
|
// ===== Props =====
|
||||||
|
|
||||||
|
export interface GridModeProps {
|
||||||
|
/** 셀 배치 정보 */
|
||||||
|
cells: DashboardCell[];
|
||||||
|
/** 열 수 */
|
||||||
|
columns: number;
|
||||||
|
/** 행 수 */
|
||||||
|
rows: number;
|
||||||
|
/** 아이템 간 간격 (px) */
|
||||||
|
gap?: number;
|
||||||
|
/** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */
|
||||||
|
renderItem: (itemId: string) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 메인 컴포넌트 =====
|
||||||
|
|
||||||
|
export function GridModeComponent({
|
||||||
|
cells,
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
gap = 8,
|
||||||
|
renderItem,
|
||||||
|
}: GridModeProps) {
|
||||||
|
if (!cells.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(${columns}, 1fr)`,
|
||||||
|
gridTemplateRows: `repeat(${rows}, 1fr)`,
|
||||||
|
gap: `${gap}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cells.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,235 @@
|
||||||
|
/**
|
||||||
|
* pop-dashboard 데이터 페처
|
||||||
|
*
|
||||||
|
* @INFRA-EXTRACT: useDataSource 완성 후 이 파일 전체를 훅으로 교체 예정
|
||||||
|
* 현재는 직접 API 호출. 공통 인프라 완성 후 useDataSource 훅으로 교체.
|
||||||
|
*
|
||||||
|
* 보안:
|
||||||
|
* - SQL 인젝션 방지: 사용자 입력값 이스케이프 처리
|
||||||
|
* - 멀티테넌시: autoFilter 자동 전달
|
||||||
|
* - fetch 직접 사용 금지: 반드시 dashboardApi/dataApi 사용
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { dashboardApi } from "@/lib/api/dashboard";
|
||||||
|
import { dataApi } from "@/lib/api/data";
|
||||||
|
import type { DataSourceConfig, DataSourceFilter } from "../../types";
|
||||||
|
|
||||||
|
// ===== 반환 타입 =====
|
||||||
|
|
||||||
|
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}'`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 필터 조건 SQL 생성 =====
|
||||||
|
|
||||||
|
/** DataSourceFilter 배열을 WHERE 절 조건문으로 변환 */
|
||||||
|
function buildWhereClause(filters: DataSourceFilter[]): string {
|
||||||
|
if (!filters.length) return "";
|
||||||
|
|
||||||
|
const conditions = filters.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 = sanitizeIdentifier(config.aggregation.column);
|
||||||
|
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) {
|
||||||
|
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 직접 실행
|
||||||
|
if (config.aggregation || (config.joins && config.joins.length > 0)) {
|
||||||
|
const sql = buildAggregationSQL(config);
|
||||||
|
const result = await dashboardApi.executeQuery(sql);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return { value: 0, rows: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첫 번째 행의 value 컬럼 추출
|
||||||
|
const firstRow = result.rows[0];
|
||||||
|
const numericValue = parseFloat(firstRow.value ?? firstRow[result.columns[0]] ?? 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: Number.isFinite(numericValue) ? numericValue : 0,
|
||||||
|
rows: result.rows,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 단순 조회
|
||||||
|
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[]> {
|
||||||
|
try {
|
||||||
|
const schema = await dashboardApi.getTableSchema(tableName);
|
||||||
|
return schema.columns.map((col) => ({
|
||||||
|
name: col.name,
|
||||||
|
type: col.type,
|
||||||
|
udtName: col.udtName,
|
||||||
|
}));
|
||||||
|
} 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,11 +269,14 @@ function DateTimePreview({ config }: { config?: PopTextConfig }) {
|
||||||
function DateTimeDisplay({ config }: { config?: PopTextConfig }) {
|
function DateTimeDisplay({ config }: { config?: PopTextConfig }) {
|
||||||
const [now, setNow] = useState(new Date());
|
const [now, setNow] = useState(new Date());
|
||||||
|
|
||||||
|
// isRealtime 기본값: true (설정 패널 UI와 일치)
|
||||||
|
const isRealtime = config?.isRealtime ?? true;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config?.isRealtime) return;
|
if (!isRealtime) return;
|
||||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, [config?.isRealtime]);
|
}, [isRealtime]);
|
||||||
|
|
||||||
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
|
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
|
||||||
const dateFormat = config?.dateTimeConfig
|
const dateFormat = config?.dateTimeConfig
|
||||||
|
|
|
||||||
|
|
@ -86,3 +86,237 @@ export const JUSTIFY_CLASSES: Record<string, string> = {
|
||||||
center: "justify-center",
|
center: "justify-center",
|
||||||
right: "justify-end",
|
right: "justify-end",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// Phase 0 공통 타입 (모든 POP 컴포넌트 공용)
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// ----- 컬럼 바인딩: 컬럼별 읽기/쓰기 제어 -----
|
||||||
|
|
||||||
|
export type ColumnMode = "read" | "write" | "readwrite" | "hidden";
|
||||||
|
|
||||||
|
export interface ColumnBinding {
|
||||||
|
columnName: string;
|
||||||
|
sourceTable?: string;
|
||||||
|
mode: ColumnMode;
|
||||||
|
label?: string;
|
||||||
|
defaultValue?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 조인 설정: 테이블 간 관계 정의 -----
|
||||||
|
|
||||||
|
export type JoinType = "inner" | "left" | "right";
|
||||||
|
|
||||||
|
export interface JoinConfig {
|
||||||
|
targetTable: string;
|
||||||
|
joinType: JoinType;
|
||||||
|
on: {
|
||||||
|
sourceColumn: string;
|
||||||
|
targetColumn: string;
|
||||||
|
};
|
||||||
|
columns?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 데이터 소스: 테이블 조회/집계 통합 설정 -----
|
||||||
|
|
||||||
|
export type AggregationType = "count" | "sum" | "avg" | "min" | "max";
|
||||||
|
export type FilterOperator =
|
||||||
|
| "="
|
||||||
|
| "!="
|
||||||
|
| ">"
|
||||||
|
| ">="
|
||||||
|
| "<"
|
||||||
|
| "<="
|
||||||
|
| "like"
|
||||||
|
| "in"
|
||||||
|
| "between";
|
||||||
|
|
||||||
|
export interface DataSourceFilter {
|
||||||
|
column: string;
|
||||||
|
operator: FilterOperator;
|
||||||
|
value: unknown; // between이면 [from, to]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SortConfig {
|
||||||
|
column: string;
|
||||||
|
direction: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataSourceConfig {
|
||||||
|
tableName: string;
|
||||||
|
columns?: ColumnBinding[];
|
||||||
|
filters?: DataSourceFilter[];
|
||||||
|
sort?: SortConfig[];
|
||||||
|
aggregation?: {
|
||||||
|
type: AggregationType;
|
||||||
|
column: string;
|
||||||
|
groupBy?: string[];
|
||||||
|
};
|
||||||
|
joins?: JoinConfig[];
|
||||||
|
refreshInterval?: number; // 초 단위, 0이면 비활성
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 액션 설정: 버튼/링크 클릭 시 동작 정의 -----
|
||||||
|
|
||||||
|
export interface PopActionConfig {
|
||||||
|
type:
|
||||||
|
| "navigate"
|
||||||
|
| "modal"
|
||||||
|
| "save"
|
||||||
|
| "delete"
|
||||||
|
| "api"
|
||||||
|
| "event"
|
||||||
|
| "refresh";
|
||||||
|
// navigate
|
||||||
|
targetScreenId?: string;
|
||||||
|
params?: Record<string, string>;
|
||||||
|
// modal
|
||||||
|
modalScreenId?: string;
|
||||||
|
modalTitle?: string;
|
||||||
|
// save/delete
|
||||||
|
targetTable?: string;
|
||||||
|
confirmMessage?: string;
|
||||||
|
// api
|
||||||
|
apiEndpoint?: string;
|
||||||
|
apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
|
||||||
|
// event
|
||||||
|
eventName?: string;
|
||||||
|
eventPayload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================
|
||||||
|
// pop-dashboard 전용 타입
|
||||||
|
// =============================================
|
||||||
|
|
||||||
|
// ----- 표시 모드 / 서브타입 -----
|
||||||
|
|
||||||
|
export type DashboardDisplayMode =
|
||||||
|
| "arrows"
|
||||||
|
| "auto-slide"
|
||||||
|
| "grid"
|
||||||
|
| "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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- 대시보드 전체 설정 -----
|
||||||
|
|
||||||
|
export interface PopDashboardConfig {
|
||||||
|
items: DashboardItem[];
|
||||||
|
displayMode: DashboardDisplayMode;
|
||||||
|
|
||||||
|
// 모드별 설정
|
||||||
|
autoSlideInterval?: number; // 초 (기본 5)
|
||||||
|
autoSlideResumeDelay?: number; // 터치 후 재개 대기 초 (기본 3)
|
||||||
|
gridCells?: DashboardCell[]; // grid 모드 셀 배치
|
||||||
|
gridColumns?: number; // grid 모드 열 수 (기본 2)
|
||||||
|
gridRows?: number; // grid 모드 행 수 (기본 2)
|
||||||
|
|
||||||
|
// 공통 스타일
|
||||||
|
showIndicator?: boolean; // 페이지 인디케이터
|
||||||
|
gap?: number; // 아이템 간 간격 px
|
||||||
|
backgroundColor?: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue