o2o-infinith-frontend/src/features/plan/hooks/useExportPlanCSV.ts

295 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

import { useCallback } from 'react';
import type { MarketingPlan } from '@/features/plan/types/plan';
/**
* 마케팅 기획의 표 데이터 전체를 단일 CSV로 내보내는 훅.
* 섹션마다 `=== Section Title ===` 헤더로 구분.
* Excel/Numbers에서 한글 안 깨지도록 UTF-8 BOM 포함.
*/
type Cell = string | number | boolean | null | undefined;
type Row = Cell[];
type Section = { title: string; rows: Row[] };
function escapeCell(value: Cell): string {
const s = String(value ?? '');
if (/[",\n\r]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}
function buildPlanCsv(plan: MarketingPlan): string {
const sections: Section[] = [];
// ─── 메타 ───
sections.push({
title: 'Plan Metadata',
rows: [
['Field', 'Value'],
['Clinic Name', plan.clinicName],
['Clinic Name (EN)', plan.clinicNameEn],
['Target URL', plan.targetUrl],
['Created At', plan.createdAt],
['Plan ID', plan.id],
['Source Report ID', plan.reportId],
],
});
// ─── Brand Guide ───
const bg = plan.brandGuide;
if (bg?.colors?.length) {
sections.push({
title: 'Brand Guide: Colors',
rows: [
['Name', 'Hex', 'Usage'],
...bg.colors.map((c) => [c.name, c.hex, c.usage]),
],
});
}
if (bg?.fonts?.length) {
sections.push({
title: 'Brand Guide: Fonts',
rows: [
['Family', 'Weight', 'Usage', 'Sample Text'],
...bg.fonts.map((f) => [f.family, f.weight, f.usage, f.sampleText]),
],
});
}
if (bg?.logoRules?.length) {
sections.push({
title: 'Brand Guide: Logo Rules',
rows: [
['Rule', 'Description', 'Correct'],
...bg.logoRules.map((r) => [r.rule, r.description, r.correct ? 'Y' : 'N']),
],
});
}
if (bg?.toneOfVoice) {
const t = bg.toneOfVoice;
sections.push({
title: 'Brand Guide: Tone of Voice',
rows: [
['Field', 'Value'],
['Personality', (t.personality ?? []).join(' / ')],
['Communication Style', t.communicationStyle],
['Do Examples', (t.doExamples ?? []).join(' | ')],
['Dont Examples', (t.dontExamples ?? []).join(' | ')],
],
});
}
if (bg?.channelBranding?.length) {
sections.push({
title: 'Brand Guide: Channel Branding',
rows: [
['Channel', 'Profile Photo', 'Banner Spec', 'Bio Template', 'Current Status'],
...bg.channelBranding.map((c) => [
c.channel,
c.profilePhoto,
c.bannerSpec,
c.bioTemplate,
c.currentStatus,
]),
],
});
}
if (bg?.brandInconsistencies?.length) {
const rows: Row[] = [['Field', 'Channel', 'Value', 'Correct', 'Impact', 'Recommendation']];
bg.brandInconsistencies.forEach((b) => {
b.values.forEach((v, i) => {
rows.push([
i === 0 ? b.field : '',
v.channel,
v.value,
v.isCorrect ? 'Y' : 'N',
i === 0 ? b.impact : '',
i === 0 ? b.recommendation : '',
]);
});
});
sections.push({ title: 'Brand Guide: Brand Inconsistencies', rows });
}
// ─── Channel Strategies ───
if (plan.channelStrategies?.length) {
sections.push({
title: 'Channel Strategies',
rows: [
['Channel', 'Current Status', 'Target Goal', 'Content Types', 'Posting Frequency', 'Tone', 'Priority', 'Journey Stage'],
...plan.channelStrategies.map((s) => [
s.channelName,
s.currentStatus,
s.targetGoal,
(s.contentTypes ?? []).join(' | '),
s.postingFrequency,
s.tone,
s.priority,
s.customerJourneyStage ?? '',
]),
],
});
}
// ─── Content Strategy ───
const cs = plan.contentStrategy;
if (cs?.pillars?.length) {
sections.push({
title: 'Content Pillars',
rows: [
['Title', 'Description', 'Related USP', 'Example Topics'],
...cs.pillars.map((p) => [p.title, p.description, p.relatedUSP, (p.exampleTopics ?? []).join(' | ')]),
],
});
}
if (cs?.typeMatrix?.length) {
sections.push({
title: 'Content Type Matrix',
rows: [
['Format', 'Channels', 'Frequency', 'Purpose'],
...cs.typeMatrix.map((t) => [t.format, (t.channels ?? []).join(' | '), t.frequency, t.purpose]),
],
});
}
if (cs?.workflow?.length) {
sections.push({
title: 'Content Workflow',
rows: [
['Step', 'Name', 'Description', 'Owner', 'Duration'],
...cs.workflow.map((w) => [w.step, w.name, w.description, w.owner, w.duration]),
],
});
}
if (cs?.repurposingOutputs?.length) {
sections.push({
title: `Repurposing Outputs (source: ${cs.repurposingSource ?? ''})`,
rows: [
['Format', 'Channel', 'Description'],
...cs.repurposingOutputs.map((r) => [r.format, r.channel, r.description]),
],
});
}
// ─── Calendar (주차 → 일별 항목으로 풀어 펼침) ───
const cal = plan.calendar;
if (cal?.weeks?.length) {
const rows: Row[] = [['Week', 'Day of Week', 'Channel', 'Content Type', 'Title', 'Description', 'Pillar', 'Status']];
cal.weeks.forEach((w) => {
w.entries.forEach((e, i) => {
rows.push([
i === 0 ? w.label : '',
e.dayOfWeek,
e.channel,
e.contentType,
e.title,
e.description ?? '',
e.pillar ?? '',
e.status ?? '',
]);
});
});
sections.push({ title: 'Content Calendar', rows });
}
if (cal?.monthlySummary?.length) {
sections.push({
title: 'Monthly Content Summary',
rows: [
['Type', 'Label', 'Count'],
...cal.monthlySummary.map((s) => [s.type, s.label, s.count]),
],
});
}
// ─── Asset Collection ───
const ac = plan.assetCollection;
if (ac?.assets?.length) {
sections.push({
title: 'Asset Collection',
rows: [
['Source', 'Type', 'Title', 'Description', 'Status', 'Repurposing Suggestions'],
...ac.assets.map((a) => [
a.sourceLabel,
a.type,
a.title,
a.description,
a.status,
(a.repurposingSuggestions ?? []).join(' | '),
]),
],
});
}
if (ac?.youtubeRepurpose?.length) {
sections.push({
title: 'YouTube Repurpose Sources',
rows: [
['Title', 'Views', 'Type', 'Repurpose As'],
...ac.youtubeRepurpose.map((y) => [y.title, y.views, y.type, (y.repurposeAs ?? []).join(' | ')]),
],
});
}
// ─── Repurposing Proposals ───
if (plan.repurposingProposals?.length) {
sections.push({
title: 'Repurposing Proposals',
rows: [
['Source Video', 'Source Views', 'Source Type', 'Output Count', 'Priority', 'Estimated Effort'],
...plan.repurposingProposals.map((p) => [
p.sourceVideo.title,
p.sourceVideo.views,
p.sourceVideo.type,
p.outputs.length,
p.priority,
p.estimatedEffort,
]),
],
});
}
// ─── Workflow Items ───
if (plan.workflow?.items?.length) {
sections.push({
title: 'Workflow Items',
rows: [
['Title', 'Content Type', 'Channel', 'Stage', 'Scheduled Date', 'User Notes'],
...plan.workflow.items.map((w) => [
w.title,
w.contentType,
w.channel,
w.stage,
w.scheduledDate ?? '',
w.userNotes ?? '',
]),
],
});
}
// ─── 조립 ───
const lines: string[] = [];
sections.forEach((sec, idx) => {
if (idx > 0) lines.push('');
lines.push(`=== ${sec.title} ===`);
sec.rows.forEach((row) => lines.push(row.map(escapeCell).join(',')));
});
return lines.join('\r\n');
}
export function useExportPlanCSV() {
const exportPlanCSV = useCallback((filename: string, plan: MarketingPlan) => {
const csv = '' + buildPlanCsv(plan); // UTF-8 BOM
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${filename}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, []);
return { exportPlanCSV };
}