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

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