ERP-node/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx

178 lines
5.5 KiB
TypeScript

"use client";
/**
* KPI 카드 서브타입 컴포넌트
*
* 큰 숫자 + 단위 + 증감 표시
* ResizeObserver로 컨테이너 크기를 감지하여 글자 크기를 비례적으로 조절
* itemStyle.valueFontSize 로 배율 조절 가능
*/
import React, { useRef, useEffect, useState } from "react";
import type { DashboardItem, FontSize } from "../../types";
import { TEXT_ALIGN_CLASSES } from "../../types";
import { abbreviateNumber } from "../utils/formula";
// ===== Props =====
export interface KpiCardProps {
item: DashboardItem;
data: number | null;
/** 이전 기간 대비 증감 퍼센트 (선택) */
trendValue?: number | null;
/** 수식 결과 표시 문자열 (formula가 있을 때) */
formulaDisplay?: string | null;
}
// ===== valueFontSize → 크기 배율 =====
const FONT_SIZE_SCALE: Record<FontSize, number> = {
xs: 0.65,
sm: 0.80,
base: 1.0,
lg: 1.25,
xl: 1.55,
};
// ===== 색상 구간 판정 =====
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, itemStyle } = item;
const displayValue = data ?? 0;
const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges);
// valueFontSize 배율 (기본: base = 1.0)
const valueFontSize: FontSize = itemStyle?.valueFontSize ?? "base";
const scale = FONT_SIZE_SCALE[valueFontSize];
// 라벨 정렬
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
// 단위: kpiConfig.unit 우선, fallback으로 item.unit (레거시)
const unit = kpiConfig?.unit ?? item.unit;
// ===== 컨테이너 크기 감지 =====
const containerRef = useRef<HTMLDivElement>(null);
const [containerSize, setContainerSize] = useState({ w: 200, h: 100 });
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setContainerSize({ w: width, h: height });
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// ===== 비례 폰트 크기 계산 =====
// 숫자: 컨테이너 높이의 50% (라벨·단위가 없으면 60%)
// 라벨: 컨테이너 높이의 13%
// 단위: 숫자 크기의 40%
// 추세: 고정 최소값 (너무 작으면 읽기 어려움)
const hasLabel = visibility.showLabel && !!item.label;
const hasTrend = visibility.showTrend && trendValue != null;
const hasSubLabel = visibility.showSubLabel && !!formulaDisplay;
// 수직 공간을 차지하는 요소 수에 따라 숫자 비율 조정
const occupiedLines = (hasLabel ? 1 : 0) + (hasTrend ? 1 : 0) + (hasSubLabel ? 1 : 0);
const valueRatio = occupiedLines === 0 ? 0.62 : occupiedLines === 1 ? 0.52 : 0.42;
// 너비 기준으로도 제한 (너무 넓은 셀에서 지나치게 커지지 않도록)
const baseFromHeight = containerSize.h * valueRatio;
const baseFromWidth = containerSize.w * 0.35;
const baseSize = Math.min(baseFromHeight, baseFromWidth);
const valueFontPx = Math.max(16, Math.round(baseSize * scale));
const labelFontPx = Math.max(11, Math.round(containerSize.h * 0.13 * scale));
const unitFontPx = Math.max(12, Math.round(valueFontPx * 0.40));
const trendFontPx = Math.max(10, Math.round(containerSize.h * 0.09 * scale));
return (
<div
ref={containerRef}
className="flex h-full w-full flex-col items-center justify-center p-3"
>
{/* 라벨 */}
{hasLabel && (
<p
className={`w-full text-muted-foreground ${labelAlignClass}`}
style={{ fontSize: `${labelFontPx}px`, lineHeight: 1.2 }}
>
{item.label}
</p>
)}
{/* 메인 값 */}
{visibility.showValue && (
<div className="flex items-baseline gap-[0.15em]">
<span
className="font-bold leading-none"
style={{
fontSize: `${valueFontPx}px`,
...(valueColor ? { color: valueColor } : {}),
}}
>
{formulaDisplay ?? abbreviateNumber(displayValue)}
</span>
{/* 단위 */}
{visibility.showUnit && unit && (
<span
className="text-muted-foreground font-medium"
style={{ fontSize: `${unitFontPx}px` }}
>
{unit}
</span>
)}
</div>
)}
{/* 증감율 */}
{hasTrend && trendValue != null && (
<span
className="inline-flex items-center gap-0.5 font-medium"
style={{
fontSize: `${trendFontPx}px`,
color: trendValue > 0 ? "var(--color-emerald-600)" : trendValue === 0 ? "var(--muted-foreground)" : "var(--color-rose-600)",
}}
>
<span>{trendValue > 0 ? "↑" : trendValue === 0 ? "→" : "↓"}</span>
<span>{Math.abs(trendValue).toFixed(1)}%</span>
</span>
)}
{/* 보조 라벨 (수식 표시 등) */}
{hasSubLabel && (
<p
className="text-muted-foreground"
style={{ fontSize: `${Math.max(10, Math.round(labelFontPx * 0.85))}px` }}
>
{item.formula?.values.map((v) => v.label).join(" / ")}
</p>
)}
</div>
);
}