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 { cn } from "@/lib/utils";
|
||||
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";
|
||||
|
||||
// 컴포넌트 정의
|
||||
|
|
@ -27,6 +27,12 @@ const PALETTE_ITEMS: PaletteItem[] = [
|
|||
icon: FileText,
|
||||
description: "텍스트, 시간, 이미지 표시",
|
||||
},
|
||||
{
|
||||
type: "pop-dashboard",
|
||||
label: "대시보드",
|
||||
icon: BarChart3,
|
||||
description: "KPI, 차트, 게이지, 통계 집계",
|
||||
},
|
||||
];
|
||||
|
||||
// 드래그 가능한 컴포넌트 아이템
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@ interface PopRendererProps {
|
|||
|
||||
const COMPONENT_TYPE_LABELS: Record<PopComponentType, string> = {
|
||||
"pop-sample": "샘플",
|
||||
"pop-text": "텍스트",
|
||||
"pop-dashboard": "대시보드",
|
||||
};
|
||||
|
||||
// ========================================
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
/**
|
||||
* 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 }> = {
|
||||
"pop-sample": { colSpan: 2, rowSpan: 1 },
|
||||
"pop-text": { colSpan: 3, rowSpan: 1 },
|
||||
"pop-dashboard": { colSpan: 6, rowSpan: 3 },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export * from "./types";
|
|||
|
||||
// POP 컴포넌트 등록
|
||||
import "./pop-text";
|
||||
import "./pop-dashboard";
|
||||
|
||||
// 향후 추가될 컴포넌트들:
|
||||
// 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 }) {
|
||||
const [now, setNow] = useState(new Date());
|
||||
|
||||
// isRealtime 기본값: true (설정 패널 UI와 일치)
|
||||
const isRealtime = config?.isRealtime ?? true;
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.isRealtime) return;
|
||||
if (!isRealtime) return;
|
||||
const timer = setInterval(() => setNow(new Date()), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [config?.isRealtime]);
|
||||
}, [isRealtime]);
|
||||
|
||||
// 빌더 설정 또는 기존 dateFormat 사용 (하위 호환)
|
||||
const dateFormat = config?.dateTimeConfig
|
||||
|
|
|
|||
|
|
@ -86,3 +86,237 @@ export const JUSTIFY_CLASSES: Record<string, string> = {
|
|||
center: "justify-center",
|
||||
right: "justify-end",
|
||||
};
|
||||
|
||||
// =============================================
|
||||
// Phase 0 공통 타입 (모든 POP 컴포넌트 공용)
|
||||
// =============================================
|
||||
|
||||
// ----- 컬럼 바인딩: 컬럼별 읽기/쓰기 제어 -----
|
||||
|
||||
export type ColumnMode = "read" | "write" | "readwrite" | "hidden";
|
||||
|
||||
export interface ColumnBinding {
|
||||
columnName: string;
|
||||
sourceTable?: string;
|
||||
mode: ColumnMode;
|
||||
label?: string;
|
||||
defaultValue?: unknown;
|
||||
}
|
||||
|
||||
// ----- 조인 설정: 테이블 간 관계 정의 -----
|
||||
|
||||
export type JoinType = "inner" | "left" | "right";
|
||||
|
||||
export interface JoinConfig {
|
||||
targetTable: string;
|
||||
joinType: JoinType;
|
||||
on: {
|
||||
sourceColumn: string;
|
||||
targetColumn: string;
|
||||
};
|
||||
columns?: string[];
|
||||
}
|
||||
|
||||
// ----- 데이터 소스: 테이블 조회/집계 통합 설정 -----
|
||||
|
||||
export type AggregationType = "count" | "sum" | "avg" | "min" | "max";
|
||||
export type FilterOperator =
|
||||
| "="
|
||||
| "!="
|
||||
| ">"
|
||||
| ">="
|
||||
| "<"
|
||||
| "<="
|
||||
| "like"
|
||||
| "in"
|
||||
| "between";
|
||||
|
||||
export interface DataSourceFilter {
|
||||
column: string;
|
||||
operator: FilterOperator;
|
||||
value: unknown; // between이면 [from, to]
|
||||
}
|
||||
|
||||
export interface SortConfig {
|
||||
column: string;
|
||||
direction: "asc" | "desc";
|
||||
}
|
||||
|
||||
export interface DataSourceConfig {
|
||||
tableName: string;
|
||||
columns?: ColumnBinding[];
|
||||
filters?: DataSourceFilter[];
|
||||
sort?: SortConfig[];
|
||||
aggregation?: {
|
||||
type: AggregationType;
|
||||
column: string;
|
||||
groupBy?: string[];
|
||||
};
|
||||
joins?: JoinConfig[];
|
||||
refreshInterval?: number; // 초 단위, 0이면 비활성
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// ----- 액션 설정: 버튼/링크 클릭 시 동작 정의 -----
|
||||
|
||||
export interface PopActionConfig {
|
||||
type:
|
||||
| "navigate"
|
||||
| "modal"
|
||||
| "save"
|
||||
| "delete"
|
||||
| "api"
|
||||
| "event"
|
||||
| "refresh";
|
||||
// navigate
|
||||
targetScreenId?: string;
|
||||
params?: Record<string, string>;
|
||||
// modal
|
||||
modalScreenId?: string;
|
||||
modalTitle?: string;
|
||||
// save/delete
|
||||
targetTable?: string;
|
||||
confirmMessage?: string;
|
||||
// api
|
||||
apiEndpoint?: string;
|
||||
apiMethod?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
// event
|
||||
eventName?: string;
|
||||
eventPayload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// pop-dashboard 전용 타입
|
||||
// =============================================
|
||||
|
||||
// ----- 표시 모드 / 서브타입 -----
|
||||
|
||||
export type DashboardDisplayMode =
|
||||
| "arrows"
|
||||
| "auto-slide"
|
||||
| "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