295 lines
8.3 KiB
TypeScript
295 lines
8.3 KiB
TypeScript
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 };
|
||
}
|