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:
SeongHyun Kim 2026-02-10 11:04:18 +09:00
parent f825d65bfc
commit 4f3e9ec19e
20 changed files with 3222 additions and 4 deletions

View File

@ -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, 차트, 게이지, 통계 집계",
},
]; ];
// 드래그 가능한 컴포넌트 아이템 // 드래그 가능한 컴포넌트 아이템

View File

@ -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": "대시보드",
}; };
// ======================================== // ========================================

View File

@ -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 },
}; };
/** /**

View File

@ -13,6 +13,7 @@ export * from "./types";
// POP 컴포넌트 등록 // POP 컴포넌트 등록
import "./pop-text"; import "./pop-text";
import "./pop-dashboard";
// 향후 추가될 컴포넌트들: // 향후 추가될 컴포넌트들:
// import "./pop-field"; // import "./pop-field";

View File

@ -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

View File

@ -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>
);
}

View File

@ -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"],
});

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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 [];
}
}

View File

@ -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,
});
}

View File

@ -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

View File

@ -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;
}