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(',')));
});