From 7926636c09a87c8358b0d8d77bac377c2c3bcc14 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 20 May 2026 11:50:23 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=C2=B7?= =?UTF-8?q?=ED=94=8C=EB=9E=9C=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=E2=86=92=20PDF/CSV=20=EB=93=9C=EB=A1=AD?= =?UTF-8?q?=EB=8B=A4=EC=9A=B4=20+=20CSV=20=ED=8F=AC=EB=A7=B7=20Excel=20?= =?UTF-8?q?=EC=B9=9C=ED=99=94=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KPIDashboard / PlanCTA: 단일 다운로드 버튼 → DropdownMenu(PDF로 저장 / CSV로 저장) - useExportPlanCSV / useExportCSV: '=== Section ===' 헤더 → 빈 행 + 섹션 제목 행 구분 (Excel/Numbers 에서 자연스럽게 보이도록) - 캘린더 dayOfWeek 인덱스(0~6) → 한글 요일(월~일) 출력 --- src/features/plan/components/PlanCTA.tsx | 69 +++++++++++----- src/features/plan/hooks/useExportPlanCSV.ts | 15 +++- .../report/components/KPIDashboard.tsx | 78 ++++++++++++------- src/features/report/hooks/useExportCSV.ts | 6 +- 4 files changed, 117 insertions(+), 51 deletions(-) diff --git a/src/features/plan/components/PlanCTA.tsx b/src/features/plan/components/PlanCTA.tsx index 3f18fa4..920e3fb 100644 --- a/src/features/plan/components/PlanCTA.tsx +++ b/src/features/plan/components/PlanCTA.tsx @@ -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 ( - + + + + + + exportPDF(baseFilename)}> + + PDF로 저장 + + plan && exportPlanCSV(baseFilename, plan)} + > + + CSV로 저장 + + + diff --git a/src/features/plan/hooks/useExportPlanCSV.ts b/src/features/plan/hooks/useExportPlanCSV.ts index 849fe68..c5dab9e 100644 --- a/src/features/plan/hooks/useExportPlanCSV.ts +++ b/src/features/plan/hooks/useExportPlanCSV.ts @@ -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(','))); }); diff --git a/src/features/report/components/KPIDashboard.tsx b/src/features/report/components/KPIDashboard.tsx index e598e44..41c236a 100644 --- a/src/features/report/components/KPIDashboard.tsx +++ b/src/features/report/components/KPIDashboard.tsx @@ -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 ( @@ -97,33 +107,49 @@ export default function KPIDashboard({ metrics, clinicName }: KPIDashboardProps) INFINITH와 함께 데이터 기반 마케팅 전환을 시작하세요. 90일 안에 측정 가능한 성과를 만들어 드립니다.

- - + + + + + + + exportPDF(baseFilename)}> + + PDF로 저장 + + report && exportReportCSV(baseFilename, report)} + > + + CSV로 저장 + + +
diff --git a/src/features/report/hooks/useExportCSV.ts b/src/features/report/hooks/useExportCSV.ts index aea8843..3100853 100644 --- a/src/features/report/hooks/useExportCSV.ts +++ b/src/features/report/hooks/useExportCSV.ts @@ -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(','))); });