feat: 리포트·플랜 다운로드 버튼 → PDF/CSV 드롭다운 + CSV 포맷 Excel 친화적으로 개선

- KPIDashboard / PlanCTA: 단일 다운로드 버튼 → DropdownMenu(PDF로 저장 / CSV로 저장)
- useExportPlanCSV / useExportCSV: '=== Section ===' 헤더 → 빈 행 + 섹션 제목 행 구분
  (Excel/Numbers 에서 자연스럽게 보이도록)
- 캘린더 dayOfWeek 인덱스(0~6) → 한글 요일(월~일) 출력
main
Mina Choi 2026-05-20 11:50:23 +09:00
parent 87062f76eb
commit 7926636c09
4 changed files with 117 additions and 51 deletions

View File

@ -1,14 +1,30 @@
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { FileText, FileSpreadsheet, ChevronDown } from 'lucide-react';
import { RocketFilled, DownloadFilled } from '@/shared/icons/FilledIcons'; import { RocketFilled, DownloadFilled } from '@/shared/icons/FilledIcons';
import { Button } from '@/shared/ui/button'; import { Button } from '@/shared/ui/button';
import { PageContainer } from '@/shared/ui/page-container'; import { PageContainer } from '@/shared/ui/page-container';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/shared/ui/dropdown-menu';
import { useExportPDF } from '@/features/report/hooks/useExportPDF'; import { useExportPDF } from '@/features/report/hooks/useExportPDF';
import { useExportPlanCSV } from '@/features/plan/hooks/useExportPlanCSV';
import type { MarketingPlan } from '@/features/plan/types/plan';
export default function PlanCTA() { interface PlanCTAProps {
/** 전체 기획 — CSV 내보내기에 사용 */
plan?: MarketingPlan;
}
export default function PlanCTA({ plan }: PlanCTAProps = {}) {
const { exportPDF, isExporting } = useExportPDF(); const { exportPDF, isExporting } = useExportPDF();
const { exportPlanCSV } = useExportPlanCSV();
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const baseFilename = 'INFINITH_Marketing_Plan';
return ( return (
<motion.section <motion.section
@ -44,10 +60,11 @@ export default function PlanCTA() {
</Button> </Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => exportPDF('INFINITH_Marketing_Plan')}
disabled={isExporting} disabled={isExporting}
className="inline-flex items-center justify-center gap-2 rounded-full bg-white border-slate-200 px-6 py-3 h-auto text-sm font-medium text-brand-purple-deep shadow-sm hover:shadow-md hover:bg-white transition-shadow disabled:opacity-60" className="inline-flex items-center justify-center gap-2 rounded-full bg-white border-slate-200 px-6 py-3 h-auto text-sm font-medium text-brand-purple-deep shadow-sm hover:shadow-md hover:bg-white transition-shadow disabled:opacity-60"
> >
@ -60,7 +77,23 @@ export default function PlanCTA() {
<DownloadFilled size={16} /> <DownloadFilled size={16} />
)} )}
<ChevronDown size={14} className="opacity-70" />
</Button> </Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onSelect={() => exportPDF(baseFilename)}>
<FileText size={14} className="text-slate-500" />
<span>PDF </span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={!plan}
onSelect={() => plan && exportPlanCSV(baseFilename, plan)}
>
<FileSpreadsheet size={14} className="text-slate-500" />
<span>CSV </span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
</PageContainer> </PageContainer>

View File

@ -3,7 +3,7 @@ import type { MarketingPlan } from '@/features/plan/types/plan';
/** /**
* CSV . * CSV .
* `=== Section Title ===` . * + (Excel ).
* Excel/Numbers UTF-8 BOM . * Excel/Numbers UTF-8 BOM .
*/ */
@ -11,6 +11,12 @@ type Cell = string | number | boolean | null | undefined;
type Row = Cell[]; type Row = Cell[];
type Section = { title: string; rows: Row[] }; type Section = { title: string; rows: Row[] };
// 캘린더 dayOfWeek 인덱스 → 한글 요일 (월=0, 화=1, ..., 일=6)
const DAYS_KO = ['월', '화', '수', '목', '금', '토', '일'] as const;
function formatDayOfWeek(idx: number): string {
return DAYS_KO[idx] ?? String(idx);
}
function escapeCell(value: Cell): string { function escapeCell(value: Cell): string {
const s = String(value ?? ''); const s = String(value ?? '');
if (/[",\n\r]/.test(s)) { if (/[",\n\r]/.test(s)) {
@ -137,7 +143,7 @@ function buildPlanCsv(plan: MarketingPlan): string {
title: 'Content Pillars', title: 'Content Pillars',
rows: [ rows: [
['Title', 'Description', 'Related USP', 'Example Topics'], ['Title', 'Description', 'Related USP', 'Example Topics'],
...cs.pillars.map((p) => [p.title, p.description, p.relatedUSP, (p.exampleTopics ?? []).join(' | ')]), ...cs.pillars.map((p) => [p.title, p.description, p.relatedUsp, (p.exampleTopics ?? []).join(' | ')]),
], ],
}); });
} }
@ -177,7 +183,7 @@ function buildPlanCsv(plan: MarketingPlan): string {
w.entries.forEach((e, i) => { w.entries.forEach((e, i) => {
rows.push([ rows.push([
i === 0 ? w.label : '', i === 0 ? w.label : '',
e.dayOfWeek, formatDayOfWeek(e.dayOfWeek),
e.channel, e.channel,
e.contentType, e.contentType,
e.title, e.title,
@ -264,10 +270,11 @@ function buildPlanCsv(plan: MarketingPlan): string {
} }
// ─── 조립 ─── // ─── 조립 ───
// Excel에서 보기 좋도록: 섹션 사이에 빈 행 + 섹션 제목(한 셀) → 컬럼 헤더 → 데이터
const lines: string[] = []; const lines: string[] = [];
sections.forEach((sec, idx) => { sections.forEach((sec, idx) => {
if (idx > 0) lines.push(''); if (idx > 0) lines.push('');
lines.push(`=== ${sec.title} ===`); lines.push(escapeCell(sec.title));
sec.rows.forEach((row) => lines.push(row.map(escapeCell).join(','))); sec.rows.forEach((row) => lines.push(row.map(escapeCell).join(',')));
}); });

View File

@ -1,14 +1,22 @@
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { useParams, useNavigate } from 'react-router'; import { useParams, useNavigate } from 'react-router';
import { TrendingUp, ArrowUpRight, Download, Loader2 } from 'lucide-react'; import { TrendingUp, ArrowUpRight, Download, Loader2, FileText, FileSpreadsheet, ChevronDown } from 'lucide-react';
import { SectionWrapper } from './ui/SectionWrapper'; import { SectionWrapper } from './ui/SectionWrapper';
import { Button } from '@/shared/ui/button'; import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/shared/ui/dropdown-menu';
import { useExportPDF } from '@/features/report/hooks/useExportPDF'; import { useExportPDF } from '@/features/report/hooks/useExportPDF';
import type { KPIMetric } from '@/features/report/types/report'; import { useExportCSV } from '@/features/report/hooks/useExportCSV';
import type { KPIMetric, MarketingReport } from '@/features/report/types/report';
interface KPIDashboardProps { interface KPIDashboardProps {
metrics: KPIMetric[]; metrics: KPIMetric[];
clinicName?: string; clinicName?: string;
/** 전체 리포트 — CSV 내보내기에 사용 */
report?: MarketingReport;
} }
function isNegativeValue(value: string | number): boolean { function isNegativeValue(value: string | number): boolean {
@ -30,10 +38,12 @@ function formatKpiValue(value: string | number): string {
return str; return str;
} }
export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps) { export default function KPIDashboard({ metrics, clinicName, report }: KPIDashboardProps) {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { exportPDF, isExporting } = useExportPDF(); const { exportPDF, isExporting } = useExportPDF();
const { exportReportCSV } = useExportCSV();
const baseFilename = `INFINITH_Marketing_Report_${clinicName || 'Report'}`;
return ( return (
<SectionWrapper id="kpi-dashboard" title="KPI Dashboard" subtitle="핵심 성과 지표 목표"> <SectionWrapper id="kpi-dashboard" title="KPI Dashboard" subtitle="핵심 성과 지표 목표">
@ -97,20 +107,20 @@ export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps)
INFINITH . 90 . INFINITH . 90 .
</p> </p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-4"> <div className="flex flex-col sm:flex-row items-center justify-center gap-4">
<Button <button
type="button" type="button"
onClick={() => navigate(`/plan/${id || 'live'}#branding-guide`)} 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 h-auto rounded-full hover:from-brand-purple hover:to-brand-purple-deep hover:shadow-xl" 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} /> <ArrowUpRight size={18} />
</Button> </button>
<Button <DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button" type="button"
variant="outline"
onClick={() => exportPDF(`INFINITH_Marketing_Report_${clinicName || 'Report'}`)}
disabled={isExporting} disabled={isExporting}
className="inline-flex items-center gap-2 bg-white border-slate-200 text-brand-purple-deep font-semibold px-8 py-4 h-auto rounded-full hover:bg-slate-50 shadow-sm hover:shadow-md disabled:opacity-60 disabled:cursor-not-allowed" 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 ? ( {isExporting ? (
<> <>
@ -121,9 +131,25 @@ export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps)
<> <>
<Download size={18} /> <Download size={18} />
<ChevronDown size={14} className="opacity-70" />
</> </>
)} )}
</Button> </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>
</div> </div>
</motion.div> </motion.div>

View File

@ -3,7 +3,7 @@ import type { MarketingReport } from '@/features/report/types/report';
/** /**
* CSV . * CSV .
* `=== Section Title ===` . * + (Excel ).
* Excel/Numbers UTF-8 BOM . * Excel/Numbers UTF-8 BOM .
*/ */
@ -217,11 +217,11 @@ function buildReportCsv(report: MarketingReport): string {
} }
} }
// ─── 출력 조립: 섹션 사이 공백 행 한 줄 ─── // ─── 출력 조립: Excel에서 보기 좋도록 빈 행 + 섹션 제목(한 셀) + 데이터 ───
const lines: string[] = []; const lines: string[] = [];
sections.forEach((sec, idx) => { sections.forEach((sec, idx) => {
if (idx > 0) lines.push(''); if (idx > 0) lines.push('');
lines.push(`=== ${sec.title} ===`); lines.push(escapeCell(sec.title));
sec.rows.forEach((row) => lines.push(row.map(escapeCell).join(','))); sec.rows.forEach((row) => lines.push(row.map(escapeCell).join(',')));
}); });