feat: 리포트·플랜 다운로드 버튼 → PDF/CSV 드롭다운 + CSV 포맷 Excel 친화적으로 개선
- KPIDashboard / PlanCTA: 단일 다운로드 버튼 → DropdownMenu(PDF로 저장 / CSV로 저장) - useExportPlanCSV / useExportCSV: '=== Section ===' 헤더 → 빈 행 + 섹션 제목 행 구분 (Excel/Numbers 에서 자연스럽게 보이도록) - 캘린더 dayOfWeek 인덱스(0~6) → 한글 요일(월~일) 출력main
parent
87062f76eb
commit
7926636c09
|
|
@ -1,14 +1,30 @@
|
|||
import { motion } from 'motion/react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { FileText, FileSpreadsheet, ChevronDown } from 'lucide-react';
|
||||
import { RocketFilled, DownloadFilled } from '@/shared/icons/FilledIcons';
|
||||
import { Button } from '@/shared/ui/button';
|
||||
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 { 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 { exportPlanCSV } = useExportPlanCSV();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const baseFilename = 'INFINITH_Marketing_Plan';
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
|
|
@ -44,23 +60,40 @@ export default function PlanCTA() {
|
|||
콘텐츠 제작 시작
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => exportPDF('INFINITH_Marketing_Plan')}
|
||||
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"
|
||||
>
|
||||
{isExporting ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<DownloadFilled size={16} />
|
||||
)}
|
||||
플랜 다운로드
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
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"
|
||||
>
|
||||
{isExporting ? (
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<DownloadFilled size={16} />
|
||||
)}
|
||||
플랜 다운로드
|
||||
<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={!plan}
|
||||
onSelect={() => plan && exportPlanCSV(baseFilename, plan)}
|
||||
>
|
||||
<FileSpreadsheet size={14} className="text-slate-500" />
|
||||
<span>CSV로 저장</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</PageContainer>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { MarketingPlan } from '@/features/plan/types/plan';
|
|||
|
||||
/**
|
||||
* 마케팅 기획의 표 데이터 전체를 단일 CSV로 내보내는 훅.
|
||||
* 섹션마다 `=== Section Title ===` 헤더로 구분.
|
||||
* 섹션 사이는 빈 행 하나 + 섹션 제목 행으로 구분 (Excel에서 자연스럽게 보이도록).
|
||||
* Excel/Numbers에서 한글 안 깨지도록 UTF-8 BOM 포함.
|
||||
*/
|
||||
|
||||
|
|
@ -11,6 +11,12 @@ type Cell = string | number | boolean | null | undefined;
|
|||
type Row = Cell[];
|
||||
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 {
|
||||
const s = String(value ?? '');
|
||||
if (/[",\n\r]/.test(s)) {
|
||||
|
|
@ -137,7 +143,7 @@ function buildPlanCsv(plan: MarketingPlan): string {
|
|||
title: 'Content Pillars',
|
||||
rows: [
|
||||
['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) => {
|
||||
rows.push([
|
||||
i === 0 ? w.label : '',
|
||||
e.dayOfWeek,
|
||||
formatDayOfWeek(e.dayOfWeek),
|
||||
e.channel,
|
||||
e.contentType,
|
||||
e.title,
|
||||
|
|
@ -264,10 +270,11 @@ function buildPlanCsv(plan: MarketingPlan): string {
|
|||
}
|
||||
|
||||
// ─── 조립 ───
|
||||
// Excel에서 보기 좋도록: 섹션 사이에 빈 행 + 섹션 제목(한 셀) → 컬럼 헤더 → 데이터
|
||||
const lines: string[] = [];
|
||||
sections.forEach((sec, idx) => {
|
||||
if (idx > 0) lines.push('');
|
||||
lines.push(`=== ${sec.title} ===`);
|
||||
lines.push(escapeCell(sec.title));
|
||||
sec.rows.forEach((row) => lines.push(row.map(escapeCell).join(',')));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,22 @@
|
|||
import { motion } from 'motion/react';
|
||||
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 { Button } from '@/shared/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from '@/shared/ui/dropdown-menu';
|
||||
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 {
|
||||
metrics: KPIMetric[];
|
||||
clinicName?: string;
|
||||
/** 전체 리포트 — CSV 내보내기에 사용 */
|
||||
report?: MarketingReport;
|
||||
}
|
||||
|
||||
function isNegativeValue(value: string | number): boolean {
|
||||
|
|
@ -30,10 +38,12 @@ function formatKpiValue(value: string | number): string {
|
|||
return str;
|
||||
}
|
||||
|
||||
export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps) {
|
||||
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="핵심 성과 지표 목표">
|
||||
|
|
@ -97,33 +107,49 @@ export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps)
|
|||
INFINITH와 함께 데이터 기반 마케팅 전환을 시작하세요. 90일 안에 측정 가능한 성과를 만들어 드립니다.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
<Button
|
||||
<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 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} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => exportPDF(`INFINITH_Marketing_Report_${clinicName || 'Report'}`)}
|
||||
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"
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
내보내는 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download size={18} />
|
||||
리포트 다운로드
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</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>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { MarketingReport } from '@/features/report/types/report';
|
|||
|
||||
/**
|
||||
* 리포트의 표 데이터 전체를 단일 CSV로 내보내는 훅.
|
||||
* 섹션마다 `=== Section Title ===` 헤더로 구분.
|
||||
* 섹션 사이는 빈 행 하나 + 섹션 제목 행으로 구분 (Excel에서 자연스럽게 보이도록).
|
||||
* Excel/Numbers에서 한글이 깨지지 않도록 UTF-8 BOM 포함.
|
||||
*/
|
||||
|
||||
|
|
@ -217,11 +217,11 @@ function buildReportCsv(report: MarketingReport): string {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── 출력 조립: 섹션 사이 공백 행 한 줄 ───
|
||||
// ─── 출력 조립: Excel에서 보기 좋도록 빈 행 + 섹션 제목(한 셀) + 데이터 ───
|
||||
const lines: string[] = [];
|
||||
sections.forEach((sec, idx) => {
|
||||
if (idx > 0) lines.push('');
|
||||
lines.push(`=== ${sec.title} ===`);
|
||||
lines.push(escapeCell(sec.title));
|
||||
sec.rows.forEach((row) => lines.push(row.map(escapeCell).join(',')));
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue