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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user