o2o-infinith-frontend/src/features/report/components/KPIDashboard.tsx

159 lines
7.0 KiB
TypeScript

import { motion } from 'motion/react';
import { useParams, useNavigate } from 'react-router';
import { TrendingUp, ArrowUpRight, Download, Loader2, FileText, FileSpreadsheet, ChevronDown } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/shared/ui/dropdown-menu';
import { useExportPDF } from '@/features/report/hooks/useExportPDF';
import { useExportCSV } from '@/features/report/hooks/useExportCSV';
import type { KPIMetric, MarketingReport } from '@/features/report/types/report';
interface KPIDashboardProps {
metrics: KPIMetric[];
clinicName?: string;
/** 전체 리포트 — CSV 내보내기에 사용 */
report?: MarketingReport;
}
function isNegativeValue(value: string | number): boolean {
const lower = String(value).toLowerCase();
return lower === '0' || lower.includes('없음') || lower.includes('불가') || lower === 'n/a' || lower === '-' || lower.includes('측정 불가');
}
/** 가독성을 위해 큰 숫자 포맷팅: 150000 → 150K, 1500000 → 1.5M */
function formatKpiValue(value: string | number): string {
const str = String(value ?? '');
// 이미 포맷된 경우(K, M, %, ~, 월, 건 등 포함) 그대로 반환
if (/[KkMm%~월건회개명/]/.test(str)) return str;
// 순수 숫자로 파싱 시도
const num = parseInt(str.replace(/,/g, ''), 10);
if (isNaN(num)) return str;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(1)}M`;
if (num >= 10_000) return `${Math.round(num / 1_000)}K`;
if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`;
return str;
}
export default function KPIDashboard({ metrics, clinicName, report }: KPIDashboardProps) {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { exportPDF, isExporting } = useExportPDF();
const { exportReportCSV } = useExportCSV();
const baseFilename = `INFINITH_Marketing_Report_${clinicName || 'Report'}`;
return (
<SectionWrapper id="kpi-dashboard" title="KPI Dashboard" subtitle="핵심 성과 지표 목표">
{/* KPI Table */}
<motion.div
className="rounded-2xl overflow-hidden border border-slate-100 mb-10"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
{/* Header */}
<div className="grid grid-cols-4 bg-brand-navy text-white">
<div className="px-6 py-4 text-sm font-semibold">Metric</div>
<div className="px-6 py-4 text-sm font-semibold">Current</div>
<div className="px-6 py-4 text-sm font-semibold">3-Month Target</div>
<div className="px-6 py-4 text-sm font-semibold">12-Month Target</div>
</div>
{/* Data rows */}
{metrics.map((metric, i) => (
<motion.div
key={metric.metric}
className={`grid grid-cols-4 items-center ${i % 2 === 0 ? 'bg-white' : 'bg-slate-50/60'} border-b border-slate-100 last:border-b-0`}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: i * 0.03 }}
>
<div className="px-6 py-4 text-sm font-medium text-brand-navy">{metric.metric}</div>
<div
className={`px-6 py-4 text-sm font-semibold ${
isNegativeValue(metric.current) ? 'text-brand-rose' : 'text-brand-navy'
}`}
>
{formatKpiValue(metric.current)}
</div>
<div className="px-6 py-4 text-sm font-medium text-brand-purple-muted">{formatKpiValue(metric.target3Month)}</div>
<div className="px-6 py-4 text-sm font-medium text-brand-purple-muted">{formatKpiValue(metric.target12Month)}</div>
</motion.div>
))}
</motion.div>
{/* CTA Card */}
<motion.div
data-cta-card
className="rounded-2xl bg-gradient-to-r from-brand-grad-peach via-brand-grad-violet to-brand-grad-sky p-8 md:p-12 text-center relative overflow-hidden"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.3 }}
>
<div className="relative">
<div className="w-14 h-14 rounded-2xl bg-brand-purple-deep/10 flex items-center justify-center mx-auto mb-6">
<TrendingUp size={28} className="text-brand-purple-deep" />
</div>
<h3 className="font-serif text-2xl md:text-3xl font-bold text-brand-purple-deep mb-3">
Start Your Transformation
</h3>
<p className="text-brand-purple-deep/60 mb-8 max-w-xl mx-auto">
INFINITH . 90 .
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<button
type="button"
onClick={() => navigate(`/plan/${id || 'live'}#branding-guide`)}
className="inline-flex items-center gap-2 bg-gradient-to-r from-brand-purple to-brand-purple-deep text-white font-semibold px-8 py-4 rounded-full hover:shadow-xl transition-all"
>
<ArrowUpRight size={18} />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={isExporting}
className="inline-flex items-center gap-2 bg-white border border-slate-200 text-brand-purple-deep font-semibold px-8 py-4 rounded-full hover:bg-slate-50 shadow-sm hover:shadow-md transition-all disabled:opacity-60 disabled:cursor-not-allowed"
>
{isExporting ? (
<>
<Loader2 size={18} className="animate-spin" />
...
</>
) : (
<>
<Download size={18} />
<ChevronDown size={14} className="opacity-70" />
</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => exportPDF(baseFilename)}>
<FileText size={14} className="text-slate-500" />
<span>PDF </span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!report}
onSelect={() => report && exportReportCSV(baseFilename, report)}
>
<FileSpreadsheet size={14} className="text-slate-500" />
<span>CSV </span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</motion.div>
</SectionWrapper>
);
}