159 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|