feat: 마케팅 플랜 Phase 1~3 완성

- ContentCalendar: 드래그앤드롭(주차 내 요일 간 이동) + 엔트리 추가 버튼 + iCal Export
- BrandingGuide: 색상 팔레트 인라인 편집(스와치 클릭 → hex 팝오버) + DO/DON'T 2컬럼
- WorkflowTracker: 콘텐츠 제작 파이프라인(기획→AI초안→검토→승인→배포), 동영상/이미지+텍스트 분류
- RepurposingProposal: YouTube 인기 영상 리퍼포징 제안 아코디언 섹션
- AssetDetailModal: 에셋 카드 클릭 시 상세 모달
- 디자인 시스템 감사: Lucide 라인 아이콘 제거, 원색(pink/indigo/purple) 제거, 이모지 UI 제거
- "My Assets" → "나의 소재" 일관성 변경
- FilledIcons: DownloadFilled, RocketFilled 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
claude/bold-hawking
Haewon Kam 2026-04-07 16:44:21 +09:00
parent b84410341f
commit 2027ae9b64
19 changed files with 1547 additions and 207 deletions

View File

@ -281,6 +281,26 @@ export function LinkExternalFilled({ size = 20, className = '' }: IconProps) {
* HubSpot-style infinity loop with gradient shading.
* Horizontal aspect ratio, scaled to match text cap-height.
*/
export function DownloadFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<rect x="3" y="16" width="18" height="5" rx="1.5" fill="currentColor" opacity="0.25" />
<path d="M12 3v10M8 10l4 4 4-4" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export function RocketFilled({ size = 20, className = '' }: IconProps) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" className={className}>
<ellipse cx="12" cy="9" rx="5" ry="7" fill="currentColor" opacity="0.25" />
<path d="M12 2C9 2 6 5 6 9c0 3 1.5 5.5 3 7h6c1.5-1.5 3-4 3-7 0-4-3-7-6-7z" fill="currentColor" />
<path d="M9 16l-2 4h10l-2-4" fill="currentColor" opacity="0.4" />
<circle cx="12" cy="9" r="2" fill="white" opacity="0.7" />
</svg>
);
}
export function PrismFilled({ size = 20, className = '' }: IconProps) {
const w = Math.round(size * 1.6);
const h = size;

View File

@ -1,8 +1,13 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import { Youtube } from 'lucide-react';
import { YoutubeFilled } from '../icons/FilledIcons';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import type { AssetCollectionData, AssetSource, AssetStatus, AssetType } from '../../types/plan';
import AssetDetailModal from './AssetDetailModal';
import type { AssetCollectionData, AssetCard, YouTubeRepurposeItem, AssetSource, AssetStatus, AssetType } from '../../types/plan';
type ModalAsset =
| { kind: 'asset'; data: AssetCard }
| { kind: 'youtube'; data: YouTubeRepurposeItem };
interface AssetCollectionProps {
data: AssetCollectionData;
@ -23,13 +28,13 @@ const sourceBadgeColors: Record<AssetSource, string> = {
homepage: 'bg-slate-100 text-slate-700',
naver_place: 'bg-[#F3F0FF] text-[#4A3A7C]',
blog: 'bg-[#EFF0FF] text-[#3A3F7C]',
social: 'bg-pink-100 text-pink-700',
social: 'bg-[#FFF0F0] text-[#7C3A4B]',
youtube: 'bg-[#FFF0F0] text-[#7C3A4B]',
};
const typeBadgeColors: Record<AssetType, string> = {
photo: 'bg-indigo-50 text-indigo-700',
video: 'bg-purple-50 text-purple-700',
photo: 'bg-[#EFF0FF] text-[#3A3F7C]',
video: 'bg-[#F3F0FF] text-[#4A3A7C]',
text: 'bg-[#FFF6ED] text-[#7C5C3A]',
};
@ -47,6 +52,7 @@ function formatViews(views: number): string {
export default function AssetCollection({ data }: AssetCollectionProps) {
const [activeFilter, setActiveFilter] = useState<FilterKey>('all');
const [selectedAsset, setSelectedAsset] = useState<ModalAsset | null>(null);
const filteredAssets =
activeFilter === 'all'
@ -83,7 +89,8 @@ export default function AssetCollection({ data }: AssetCollectionProps) {
return (
<motion.div
key={asset.id}
className="rounded-2xl border border-slate-100 bg-white shadow-sm p-5"
className="rounded-2xl border border-slate-100 bg-white shadow-sm p-5 cursor-pointer hover:shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-[#D5CDF5] transition-all"
onClick={() => setSelectedAsset({ kind: 'asset', data: asset })}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
@ -122,7 +129,7 @@ export default function AssetCollection({ data }: AssetCollectionProps) {
{asset.repurposingSuggestions.map((suggestion, j) => (
<span
key={j}
className="rounded-full bg-purple-50 text-purple-700 text-xs px-2 py-1"
className="rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs px-2 py-1"
>
{suggestion}
</span>
@ -145,14 +152,15 @@ export default function AssetCollection({ data }: AssetCollectionProps) {
{data.youtubeRepurpose.map((video, i) => (
<motion.div
key={video.title}
className="min-w-[280px] rounded-2xl border border-slate-100 bg-white shadow-sm p-5 shrink-0"
className="min-w-[280px] rounded-2xl border border-slate-100 bg-white shadow-sm p-5 shrink-0 cursor-pointer hover:shadow-[3px_4px_12px_rgba(0,0,0,0.06)] hover:border-[#D5CDF5] transition-all"
onClick={() => setSelectedAsset({ kind: 'youtube', data: video })}
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
<div className="flex items-start gap-2 mb-3">
<Youtube size={18} className="text-[#D4889A] shrink-0 mt-1" />
<YoutubeFilled size={18} className="text-[#D4889A] shrink-0 mt-0.5" />
<h4 className="font-semibold text-sm text-[#0A1128]">{video.title}</h4>
</div>
<div className="flex items-center gap-2 mb-3">
@ -162,7 +170,7 @@ export default function AssetCollection({ data }: AssetCollectionProps) {
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
video.type === 'Short'
? 'bg-purple-50 text-purple-700'
? 'bg-[#F3F0FF] text-[#4A3A7C]'
: 'bg-[#EFF0FF] text-[#3A3F7C]'
}`}
>
@ -176,7 +184,7 @@ export default function AssetCollection({ data }: AssetCollectionProps) {
{video.repurposeAs.map((suggestion, j) => (
<span
key={j}
className="rounded-full bg-purple-50 text-purple-700 text-xs px-2 py-1"
className="rounded-full bg-[#F3F0FF] text-[#4A3A7C] text-xs px-2 py-1"
>
{suggestion}
</span>
@ -187,6 +195,10 @@ export default function AssetCollection({ data }: AssetCollectionProps) {
</div>
</div>
)}
<AssetDetailModal
asset={selectedAsset}
onClose={() => setSelectedAsset(null)}
/>
</SectionWrapper>
);
}

View File

@ -0,0 +1,217 @@
import { useEffect } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
YoutubeFilled,
GlobeFilled,
VideoFilled,
FileTextFilled,
ShareFilled,
RefreshFilled,
BoltFilled,
} from '../icons/FilledIcons';
import type { AssetCard, YouTubeRepurposeItem, AssetSource, AssetType, AssetStatus } from '../../types/plan';
type ModalAsset =
| { kind: 'asset'; data: AssetCard }
| { kind: 'youtube'; data: YouTubeRepurposeItem };
interface AssetDetailModalProps {
asset: ModalAsset | null;
onClose: () => void;
}
const sourceIcon: Record<AssetSource, typeof GlobeFilled> = {
homepage: GlobeFilled,
naver_place: GlobeFilled,
blog: FileTextFilled,
social: ShareFilled,
youtube: YoutubeFilled,
};
const typeLabels: Record<AssetType, string> = {
photo: '사진',
video: '영상',
text: '텍스트',
};
const statusConfig: Record<AssetStatus, { bg: string; text: string; border: string; label: string }> = {
collected: { bg: 'bg-[#F3F0FF]', text: 'text-[#4A3A7C]', border: 'border-[#D5CDF5]', label: '수집 완료' },
pending: { bg: 'bg-[#FFF6ED]', text: 'text-[#7C5C3A]', border: 'border-[#F5E0C5]', label: '수집 대기' },
needs_creation: { bg: 'bg-[#FFF0F0]', text: 'text-[#7C3A4B]', border: 'border-[#F5D5DC]', label: '제작 필요' },
};
function formatViews(views: number): string {
if (views >= 1_000_000) return `${(views / 1_000_000).toFixed(1)}M`;
if (views >= 1_000) return `${Math.round(views / 1_000)}K`;
return String(views);
}
export default function AssetDetailModal({ asset, onClose }: AssetDetailModalProps) {
// Close on Escape
useEffect(() => {
if (!asset) return;
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [asset, onClose]);
return (
<AnimatePresence>
{asset && (
<>
{/* Backdrop */}
<motion.div
className="fixed inset-0 z-50 bg-black/40 backdrop-blur-sm"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
/>
{/* Modal Panel */}
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center p-4 pointer-events-none"
initial={{ opacity: 0, scale: 0.95, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 8 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
>
<div
className="bg-white rounded-3xl shadow-2xl w-full max-w-lg pointer-events-auto overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header bar */}
<div className="flex items-center justify-between px-6 pt-5 pb-4 border-b border-slate-100">
<div className="flex items-center gap-3">
{asset.kind === 'asset' ? (
<>
{(() => {
const Icon = sourceIcon[asset.data.source];
const status = statusConfig[asset.data.status];
return (
<div className={`w-9 h-9 rounded-xl flex items-center justify-center ${status.bg} border ${status.border}`}>
<Icon size={16} className={status.text} />
</div>
);
})()}
<div>
<p className="font-bold text-[#0A1128] text-sm">{asset.data.title}</p>
<p className="text-xs text-slate-500">{asset.data.sourceLabel} · {typeLabels[asset.data.type]}</p>
</div>
</>
) : (
<>
<div className="w-9 h-9 rounded-xl bg-[#FFF0F0] border border-[#F5D5DC] flex items-center justify-center">
<YoutubeFilled size={16} className="text-[#D4889A]" />
</div>
<div>
<p className="font-bold text-[#0A1128] text-sm">{asset.data.title}</p>
<p className="text-xs text-slate-500">YouTube · {asset.data.type === 'Short' ? 'Shorts' : 'Long-form'} · {formatViews(asset.data.views)} views</p>
</div>
</>
)}
</div>
<button
onClick={onClose}
className="w-8 h-8 rounded-full bg-slate-100 hover:bg-slate-200 flex items-center justify-center transition-colors"
>
<svg className="w-4 h-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-5 max-h-[60vh] overflow-y-auto">
{asset.kind === 'asset' && (
<>
{/* Status + type badges */}
<div className="flex flex-wrap gap-2">
{(() => {
const s = statusConfig[asset.data.status];
return (
<span className={`text-xs font-semibold px-3 py-1 rounded-full border ${s.bg} ${s.text} ${s.border}`}>
{s.label}
</span>
);
})()}
<span className="text-xs font-medium px-3 py-1 rounded-full bg-slate-100 text-slate-600">
{typeLabels[asset.data.type]}
</span>
</div>
{/* Description */}
<div>
<p className="text-xs font-semibold text-slate-400 uppercase tracking-widest mb-1.5"></p>
<p className="text-sm text-slate-700 leading-relaxed">{asset.data.description}</p>
</div>
{/* Repurposing suggestions */}
{asset.data.repurposingSuggestions.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-2">
<RefreshFilled size={13} className="text-[#6C5CE7]" />
<p className="text-xs font-semibold text-slate-400 uppercase tracking-widest"> </p>
</div>
<div className="flex flex-wrap gap-2">
{asset.data.repurposingSuggestions.map((s, i) => (
<span key={i} className="text-xs px-3 py-1.5 rounded-full bg-[#F3F0FF] text-[#4A3A7C] border border-[#D5CDF5]">
{s}
</span>
))}
</div>
</div>
)}
</>
)}
{asset.kind === 'youtube' && (
<>
{/* Stats */}
<div className="flex gap-3">
<div className="flex-1 rounded-xl bg-slate-50 border border-slate-100 p-3 text-center">
<p className="text-xl font-bold text-[#0A1128]">{formatViews(asset.data.views)}</p>
<p className="text-xs text-slate-500 mt-0.5"></p>
</div>
<div className="flex-1 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] p-3 text-center">
<p className="text-xl font-bold text-[#4A3A7C]">{asset.data.type === 'Short' ? 'Shorts' : 'Long'}</p>
<p className="text-xs text-[#6C5CE7] mt-0.5"></p>
</div>
</div>
{/* Repurpose targets */}
{asset.data.repurposeAs.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-2">
<BoltFilled size={13} className="text-[#6C5CE7]" />
<p className="text-xs font-semibold text-slate-400 uppercase tracking-widest"> </p>
</div>
<div className="flex flex-wrap gap-2">
{asset.data.repurposeAs.map((s, i) => (
<span key={i} className="text-xs px-3 py-1.5 rounded-full bg-[#F3F0FF] text-[#4A3A7C] border border-[#D5CDF5]">
{s}
</span>
))}
</div>
</div>
)}
</>
)}
</div>
{/* Footer CTA */}
<div className="px-6 pb-5 pt-3 border-t border-slate-50">
<button
onClick={onClose}
className="w-full py-2.5 rounded-xl bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-sm font-semibold hover:opacity-90 transition-opacity"
>
</button>
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
);
}

View File

@ -1,19 +1,19 @@
import { useState, type ComponentType } from 'react';
import { useState, useRef, useEffect, type ComponentType } from 'react';
import { motion } from 'motion/react';
import { ArrowRight } from 'lucide-react';
import {
Youtube,
Instagram,
Facebook,
Globe,
Video,
MessageSquare,
CheckCircle2,
XCircle,
AlertCircle,
ArrowRight,
} from 'lucide-react';
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
VideoFilled,
MessageFilled,
CheckFilled,
CrossFilled,
WarningFilled,
} from '../icons/FilledIcons';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import type { BrandGuide } from '../../types/plan';
import type { BrandGuide, ColorSwatch } from '../../types/plan';
import type { BrandInconsistency } from '../../types/report';
interface BrandingGuideProps {
@ -30,16 +30,16 @@ const tabItems = [
type TabKey = (typeof tabItems)[number]['key'];
const channelIconMap: Record<string, ComponentType<{ size?: number; className?: string }>> = {
youtube: Youtube,
instagram: Instagram,
facebook: Facebook,
globe: Globe,
video: Video,
messagesquare: MessageSquare,
youtube: YoutubeFilled,
instagram: InstagramFilled,
facebook: FacebookFilled,
globe: GlobeFilled,
video: VideoFilled,
messagesquare: MessageFilled,
};
function getChannelIcon(icon: string) {
return channelIconMap[icon.toLowerCase()] ?? Globe;
return channelIconMap[icon.toLowerCase()] ?? GlobeFilled;
}
const statusColor: Record<string, string> = {
@ -54,8 +54,107 @@ const statusLabel: Record<string, string> = {
missing: 'Missing',
};
/* ─── Color Swatch Editor Popover ─── */
function ColorSwatchCard({ swatch, onUpdate }: { swatch: ColorSwatch; onUpdate: (newHex: string) => void }) {
const [open, setOpen] = useState(false);
const [hexInput, setHexInput] = useState(swatch.hex);
const popoverRef = useRef<HTMLDivElement>(null);
const colorInputRef = useRef<HTMLInputElement>(null);
// Close on outside click
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
// Sync hex input when swatch changes externally
useEffect(() => { setHexInput(swatch.hex); }, [swatch.hex]);
const applyHex = (value: string) => {
const normalized = value.startsWith('#') ? value : `#${value}`;
if (/^#[0-9A-Fa-f]{6}$/.test(normalized)) {
onUpdate(normalized.toUpperCase());
setHexInput(normalized.toUpperCase());
}
};
return (
<div ref={popoverRef} className="relative rounded-2xl border border-slate-100 overflow-visible shadow-[3px_4px_12px_rgba(0,0,0,0.06)]">
{/* Swatch — click to open */}
<button
className="w-full h-20 rounded-t-2xl cursor-pointer group relative overflow-hidden"
style={{ backgroundColor: swatch.hex }}
onClick={() => { setOpen((p) => !p); setHexInput(swatch.hex); }}
title="색상 편집"
>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center">
<svg className="w-5 h-5 text-white opacity-0 group-hover:opacity-80 transition-opacity drop-shadow" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536M9 11l6-6 3 3-6 6H9v-3z" />
</svg>
</div>
</button>
<div className="p-3">
<p className="font-mono text-sm text-slate-700">{swatch.hex}</p>
<p className="font-medium text-sm text-[#0A1128]">{swatch.name}</p>
<p className="text-xs text-slate-500">{swatch.usage}</p>
</div>
{/* Edit Popover */}
{open && (
<div className="absolute top-full left-0 mt-2 z-50 bg-white rounded-2xl shadow-[4px_6px_16px_rgba(0,0,0,0.12)] border border-slate-100 p-4 w-52">
{/* Native color wheel */}
<div className="flex items-center gap-3 mb-3">
<input
ref={colorInputRef}
type="color"
value={swatch.hex}
onChange={(e) => { onUpdate(e.target.value.toUpperCase()); setHexInput(e.target.value.toUpperCase()); }}
className="w-10 h-10 rounded-xl border border-slate-200 cursor-pointer p-0.5"
/>
<span className="text-xs text-slate-500"> </span>
</div>
{/* Hex input */}
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-slate-400">#</span>
<input
type="text"
value={hexInput.replace('#', '')}
onChange={(e) => setHexInput(`#${e.target.value}`)}
onBlur={(e) => applyHex(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') applyHex(hexInput); }}
maxLength={6}
placeholder="6C5CE7"
className="flex-1 font-mono text-sm border border-slate-200 rounded-lg px-2 py-1.5 text-[#0A1128] focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/30 focus:border-[#6C5CE7]"
/>
</div>
<button
onClick={() => setOpen(false)}
className="mt-3 w-full text-xs py-1.5 rounded-xl bg-[#F3F0FF] text-[#4A3A7C] font-medium hover:bg-[#EBE6FF] transition-colors"
>
</button>
</div>
)}
</div>
);
}
/* ─── Visual Identity Tab ─── */
function VisualIdentityTab({ data }: { data: BrandGuide }) {
const [colors, setColors] = useState<ColorSwatch[]>(data.colors);
const handleColorUpdate = (idx: number, newHex: string) => {
setColors((prev) => prev.map((c, i) => i === idx ? { ...c, hex: newHex } : c));
};
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
@ -67,18 +166,12 @@ function VisualIdentityTab({ data }: { data: BrandGuide }) {
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">Color Palette</h3>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{data.colors.map((swatch) => (
<div
key={swatch.hex}
className="rounded-2xl border border-slate-100 overflow-hidden"
>
<div className="h-20" style={{ backgroundColor: swatch.hex }} />
<div className="p-3">
<p className="font-mono text-sm text-slate-700">{swatch.hex}</p>
<p className="font-medium text-sm text-[#0A1128]">{swatch.name}</p>
<p className="text-xs text-slate-500">{swatch.usage}</p>
</div>
</div>
{colors.map((swatch: ColorSwatch, idx: number) => (
<ColorSwatchCard
key={swatch.hex + idx}
swatch={swatch}
onUpdate={(newHex) => handleColorUpdate(idx, newHex)}
/>
))}
</div>
</div>
@ -114,34 +207,59 @@ function VisualIdentityTab({ data }: { data: BrandGuide }) {
</div>
</div>
{/* Logo Rules */}
{/* Logo Rules — DO / DON'T split columns */}
<div>
<h3 className="font-serif font-bold text-2xl text-[#0A1128] mb-4">Logo Rules</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data.logoRules.map((rule) => (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* DO Column */}
<div>
<div className="flex items-center gap-2 mb-3">
<CheckFilled size={18} className="text-[#9B8AD4]" />
<span className="font-semibold text-[#4A3A7C] text-sm uppercase tracking-widest">DO</span>
</div>
<div className="space-y-3">
{data.logoRules.filter((r) => r.correct).map((rule) => (
<div
key={rule.rule}
className={`rounded-2xl p-5 ${
rule.correct
? 'border-2 border-[#D5CDF5] bg-[#F3F0FF]/30'
: 'border-2 border-[#F5D5DC] bg-[#FFF0F0]/30'
}`}
className="rounded-2xl p-4 border-2 border-[#D5CDF5] bg-[#F3F0FF]/40"
>
<div className="flex items-start gap-3">
{rule.correct ? (
<CheckCircle2 size={20} className="text-[#9B8AD4] shrink-0 mt-1" />
) : (
<XCircle size={20} className="text-[#D4889A] shrink-0 mt-1" />
)}
<CheckFilled size={16} className="text-[#9B8AD4] shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-[#0A1128]">{rule.rule}</p>
<p className="text-sm text-slate-600 mt-1">{rule.description}</p>
<p className="font-semibold text-[#0A1128] text-sm">{rule.rule}</p>
<p className="text-xs text-slate-600 mt-1">{rule.description}</p>
</div>
</div>
</div>
))}
</div>
</div>
{/* DON'T Column */}
<div>
<div className="flex items-center gap-2 mb-3">
<CrossFilled size={18} className="text-[#D4889A]" />
<span className="font-semibold text-[#7C3A4B] text-sm uppercase tracking-widest">DON&apos;T</span>
</div>
<div className="space-y-3">
{data.logoRules.filter((r) => !r.correct).map((rule) => (
<div
key={rule.rule}
className="rounded-2xl p-4 border-2 border-[#F5D5DC] bg-[#FFF0F0]/40"
>
<div className="flex items-start gap-3">
<CrossFilled size={16} className="text-[#D4889A] shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-[#0A1128] text-sm">{rule.rule}</p>
<p className="text-xs text-slate-600 mt-1">{rule.description}</p>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</motion.div>
);
}
@ -184,7 +302,7 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h4 className="font-semibold text-[#4A3A7C] mb-3 flex items-center gap-2">
<CheckCircle2 size={16} /> DO
<CheckFilled size={16} className="text-[#9B8AD4]" /> DO
</h4>
<div className="space-y-3">
{tone.doExamples.map((example, i) => (
@ -199,7 +317,7 @@ function ToneVoiceTab({ tone }: { tone: BrandGuide['toneOfVoice'] }) {
</div>
<div>
<h4 className="font-semibold text-[#7C3A4B] mb-3 flex items-center gap-2">
<XCircle size={16} /> DON&apos;T
<CrossFilled size={16} className="text-[#D4889A]" /> DON&apos;T
</h4>
<div className="space-y-3">
{tone.dontExamples.map((example, i) => (
@ -335,9 +453,9 @@ function BrandConsistencyTab({ inconsistencies }: { inconsistencies: BrandIncons
</span>
<span className="ml-3">
{v.isCorrect ? (
<CheckCircle2 size={15} className="text-[#9B8AD4]" />
<CheckFilled size={15} className="text-[#9B8AD4]" />
) : (
<XCircle size={15} className="text-[#D4889A]" />
<CrossFilled size={15} className="text-[#D4889A]" />
)}
</span>
</div>
@ -346,8 +464,8 @@ function BrandConsistencyTab({ inconsistencies }: { inconsistencies: BrandIncons
{/* Impact */}
<div className="rounded-xl bg-[#FFF6ED] border border-[#F5E0C5] p-4 mb-3">
<p className="text-xs font-semibold text-[#7C5C3A] uppercase tracking-wide mb-1">
<AlertCircle size={12} className="inline mr-1" />
<p className="text-xs font-semibold text-[#7C5C3A] uppercase tracking-wide mb-1 flex items-center gap-1">
<WarningFilled size={12} className="text-[#D4A872]" />
Impact
</p>
<p className="text-sm text-[#7C5C3A]">{item.impact}</p>
@ -355,8 +473,8 @@ function BrandConsistencyTab({ inconsistencies }: { inconsistencies: BrandIncons
{/* Recommendation */}
<div className="rounded-xl bg-[#F3F0FF] border border-[#D5CDF5] p-4">
<p className="text-xs font-semibold text-[#4A3A7C] uppercase tracking-wide mb-1">
<CheckCircle2 size={12} className="inline mr-1" />
<p className="text-xs font-semibold text-[#4A3A7C] uppercase tracking-wide mb-1 flex items-center gap-1">
<CheckFilled size={12} className="text-[#9B8AD4]" />
Recommendation
</p>
<p className="text-sm text-[#4A3A7C]">{item.recommendation}</p>

View File

@ -1,10 +1,18 @@
import { useState, useCallback } from 'react';
import React, { useState, useCallback } from 'react';
import { exportCalendarToICS } from '../../lib/calendarExport';
import { motion } from 'motion/react';
import {
VideoFilled,
FileTextFilled,
ShareFilled,
MegaphoneFilled,
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
MessageFilled,
CalendarFilled,
} from '../icons/FilledIcons';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import EditEntryModal from './EditEntryModal';
@ -37,21 +45,24 @@ const contentTypeIcons: Record<ContentCategory, typeof VideoFilled> = {
ad: MegaphoneFilled,
};
const channelEmojiMap: Record<string, string> = {
youtube: '▶',
instagram: '◎',
facebook: 'f',
blog: '✎',
globe: '◉',
star: '★',
map: '◇',
video: '▷',
// FilledIcon map for channel identification in calendar entries
const channelIconMap: Record<string, typeof VideoFilled> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
facebook: FacebookFilled,
blog: FileTextFilled,
globe: GlobeFilled,
video: TiktokFilled,
star: GlobeFilled,
map: GlobeFilled,
messagesquare: MessageFilled,
};
// Semantic status colors (no raw green/red per design system)
const statusDotColors: Record<string, string> = {
draft: 'bg-slate-300',
approved: 'bg-purple-400',
published: 'bg-green-400',
draft: 'bg-slate-400',
approved: 'bg-[#9B8AD4]',
published: 'bg-[#7A84D4]',
};
const dayHeaders = ['월', '화', '수', '목', '금', '토', '일'];
@ -59,30 +70,106 @@ const dayHeaders = ['월', '화', '수', '목', '금', '토', '일'];
export default function ContentCalendar({ data, planId, onEntryUpdate }: ContentCalendarProps) {
const [weeks, setWeeks] = useState(data.weeks);
const [editingEntry, setEditingEntry] = useState<CalendarEntry | null>(null);
const [creatingForWeek, setCreatingForWeek] = useState<number | null>(null);
const [filterType, setFilterType] = useState<ContentCategory | null>(null);
const [viewMode, setViewMode] = useState<'weekly' | 'monthly'>('weekly');
// DnD state: track which entry is being dragged and from which week
const [draggedEntry, setDraggedEntry] = useState<{ entry: CalendarEntry; weekNumber: number } | null>(null);
const [dropTargetDay, setDropTargetDay] = useState<{ weekNumber: number; dayIdx: number } | null>(null);
const handleEntryClick = useCallback((entry: CalendarEntry) => {
setCreatingForWeek(null);
setEditingEntry(entry);
}, []);
// Open modal in create mode for a given week
const handleAddEntry = useCallback((weekNumber: number) => {
setCreatingForWeek(weekNumber);
setEditingEntry({
id: '__new__',
dayOfWeek: 0,
channel: 'YouTube',
channelIcon: 'youtube',
contentType: 'video',
title: '',
status: 'draft',
});
}, []);
// Drag handlers — move an entry to a different day within the same week
const handleDragStart = useCallback((entry: CalendarEntry, weekNumber: number) => {
setDraggedEntry({ entry, weekNumber });
}, []);
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>, weekNumber: number, dayIdx: number) => {
e.preventDefault(); // required to allow drop
setDropTargetDay({ weekNumber, dayIdx });
}, []);
const handleDragLeave = useCallback(() => {
setDropTargetDay(null);
}, []);
const handleDrop = useCallback((weekNumber: number, dayIdx: number) => {
if (!draggedEntry || draggedEntry.weekNumber !== weekNumber) return;
setWeeks((prev) =>
prev.map((week) =>
week.weekNumber === weekNumber
? {
...week,
entries: week.entries.map((e) =>
e.id && e.id === draggedEntry.entry.id
? { ...e, dayOfWeek: dayIdx, isManualEdit: true }
: e
),
}
: week
)
);
setDraggedEntry(null);
setDropTargetDay(null);
}, [draggedEntry]);
const handleDragEnd = useCallback(() => {
setDraggedEntry(null);
setDropTargetDay(null);
}, []);
const handleSave = useCallback((updated: CalendarEntry) => {
if (updated.id === '__new__' && creatingForWeek !== null) {
// Create mode: append new entry with a generated id
const newEntry: CalendarEntry = {
...updated,
id: `entry-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
};
setWeeks((prev) =>
prev.map((week) =>
week.weekNumber === creatingForWeek
? { ...week, entries: [...week.entries, newEntry] }
: week
)
);
setCreatingForWeek(null);
} else {
// Edit mode: update existing entry by id
setWeeks((prev) =>
prev.map((week) => ({
...week,
entries: week.entries.map((e) =>
(e.id && e.id === updated.id) ? updated : e
e.id && e.id === updated.id ? updated : e
),
}))
);
if (onEntryUpdate && updated.id) {
onEntryUpdate(updated.id, updated);
}
}
setEditingEntry(null);
}, [onEntryUpdate]);
}, [onEntryUpdate, creatingForWeek]);
const handleClose = useCallback(() => {
setEditingEntry(null);
setCreatingForWeek(null);
}, []);
const toggleFilter = (type: ContentCategory) => {
@ -129,24 +216,26 @@ export default function ContentCalendar({ data, planId, onEntryUpdate }: Content
);
};
const renderEntry = (entry: CalendarEntry, entryIdx: number) => {
const renderEntry = (entry: CalendarEntry, entryIdx: number, weekNumber?: number) => {
const colors = contentTypeColors[entry.contentType];
const Icon = contentTypeIcons[entry.contentType];
const channelSymbol = channelEmojiMap[entry.channelIcon] || '·';
const statusDot = statusDotColors[entry.status || 'draft'];
const ContentIcon = contentTypeIcons[entry.contentType];
const ChannelIcon = channelIconMap[entry.channelIcon] ?? GlobeFilled;
const statusDot = statusDotColors[entry.status ?? 'draft'];
const isDragging = draggedEntry?.entry.id === entry.id;
return (
<div
key={entry.id || entryIdx}
className={`${colors.entry} border rounded-lg p-1.5 mb-1 last:mb-0 cursor-pointer hover:ring-2 hover:ring-purple-200 transition-all group relative`}
key={entry.id ?? entryIdx}
className={`${colors.entry} border rounded-lg p-1.5 mb-1 last:mb-0 cursor-grab active:cursor-grabbing hover:ring-2 hover:ring-purple-200 transition-all group relative ${isDragging ? 'opacity-40' : ''}`}
draggable={weekNumber !== undefined}
onDragStart={weekNumber !== undefined ? () => handleDragStart(entry, weekNumber) : undefined}
onDragEnd={weekNumber !== undefined ? handleDragEnd : undefined}
onClick={() => handleEntryClick(entry)}
>
<div className="flex items-center gap-1 mb-0.5">
<span className={`w-1.5 h-1.5 rounded-full ${statusDot} flex-shrink-0`} />
<span className={`text-[9px] font-bold ${colors.text} leading-none`}>
{channelSymbol}
</span>
<Icon size={10} className={colors.text} />
<ChannelIcon size={10} className={colors.text} />
<ContentIcon size={10} className={`${colors.text} opacity-60`} />
</div>
<p className="text-[11px] text-slate-700 leading-tight line-clamp-2">
{entry.title}
@ -167,16 +256,16 @@ export default function ContentCalendar({ data, planId, onEntryUpdate }: Content
subtitle="콘텐츠 캘린더 (월간)"
dark
>
{/* Toolbar: View Toggle + Filters */}
<div className="flex items-center justify-between mb-4 flex-wrap gap-2" data-no-print>
{/* View toggle */}
<div className="flex bg-slate-100 rounded-xl p-0.5">
{/* Toolbar: View Toggle + Status Legend */}
<div className="flex items-center justify-between mb-6 flex-wrap gap-2" data-no-print>
{/* View toggle — styled for dark section bg */}
<div className="flex bg-white/10 rounded-xl p-0.5">
<button
onClick={() => setViewMode('weekly')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
viewMode === 'weekly'
? 'bg-white text-[#0A1128] shadow-sm'
: 'text-slate-500 hover:text-slate-700'
? 'bg-white/20 text-white shadow-sm'
: 'text-white/50 hover:text-white/80'
}`}
>
@ -185,51 +274,72 @@ export default function ContentCalendar({ data, planId, onEntryUpdate }: Content
onClick={() => setViewMode('monthly')}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-all ${
viewMode === 'monthly'
? 'bg-white text-[#0A1128] shadow-sm'
: 'text-slate-500 hover:text-slate-700'
? 'bg-white/20 text-white shadow-sm'
: 'text-white/50 hover:text-white/80'
}`}
>
</button>
</div>
{/* Status legend */}
<div className="flex gap-3 text-[10px] text-slate-400">
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-slate-300" /></span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-purple-400" /></span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-green-400" /></span>
{/* iCal Export Button */}
<button
onClick={() => exportCalendarToICS(weeks)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-white/10 hover:bg-white/20 text-white/70 hover:text-white text-xs font-medium transition-all"
data-no-print
>
<CalendarFilled size={12} />
</button>
{/* Status legend with design-system colors */}
<div className="flex gap-3 text-[10px] text-white/50">
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-slate-400" />
</span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[#9B8AD4]" />
</span>
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[#7A84D4]" />
</span>
</div>
</div>
{/* Monthly Summary */}
<div className="flex flex-wrap gap-4 mb-8">
{data.monthlySummary.map((item) => {
{/* Monthly Summary — compact counter pills (click to filter) */}
<div className="flex flex-wrap gap-3 mb-8">
{data.monthlySummary.map((item, i) => {
const colors = contentTypeColors[item.type];
const Icon = contentTypeIcons[item.type];
const isActive = filterType === item.type;
return (
<motion.div
<motion.button
key={item.type}
className={`flex-1 min-w-[140px] rounded-2xl border p-4 cursor-pointer transition-all ${colors.bg} ${colors.border} ${colors.shadow} ${
isActive ? 'ring-2 ring-purple-400 ring-offset-1' : ''
className={`flex items-center gap-3 px-5 py-3 rounded-2xl border cursor-pointer transition-all ${colors.bg} ${colors.border} ${colors.shadow} ${
isActive ? 'ring-2 ring-white/40 ring-offset-2 ring-offset-transparent' : ''
} ${filterType && !isActive ? 'opacity-40' : ''}`}
onClick={() => toggleFilter(item.type)}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3 }}
onClick={() => toggleFilter(item.type)}
data-no-print={undefined}
transition={{ duration: 0.3, delay: i * 0.07 }}
>
<div className="flex items-center gap-2 mb-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: item.color }}
/>
<span className={`text-sm font-medium ${colors.text}`}>{item.label}</span>
<Icon size={16} className={`${colors.text} opacity-70`} />
<div className="flex flex-col items-start">
<span className={`text-2xl font-bold leading-none ${colors.text}`}>{item.count}</span>
<span className={`text-xs font-medium ${colors.text} opacity-70 mt-0.5`}>{item.label}</span>
</div>
<span className={`text-2xl font-bold ${colors.text}`}>{item.count}</span>
</motion.div>
</motion.button>
);
})}
{filterType && (
<button
onClick={() => setFilterType(null)}
className="text-xs text-white/40 hover:text-white/70 px-3 self-center transition-colors"
>
×
</button>
)}
</div>
{/* Calendar Content */}
@ -268,51 +378,42 @@ export default function ContentCalendar({ data, planId, onEntryUpdate }: Content
</div>
))}
{dayCells.map((entries, dayIdx) => (
{dayCells.map((entries, dayIdx) => {
const isDropTarget = dropTargetDay?.weekNumber === week.weekNumber && dropTargetDay?.dayIdx === dayIdx;
return (
<div
key={dayIdx}
className={`min-h-[80px] rounded-xl p-1.5 ${
className={`min-h-[80px] rounded-xl p-1.5 transition-all ${
entries.length > 0
? 'bg-slate-50/50 border border-slate-100'
: 'border border-dashed border-slate-200/60'
}`}
} ${isDropTarget ? 'ring-2 ring-[#6C5CE7]/40 bg-[#F3F0FF]/20 border-[#D5CDF5]' : ''}`}
onDragOver={(e) => handleDragOver(e, week.weekNumber, dayIdx)}
onDragLeave={handleDragLeave}
onDrop={() => handleDrop(week.weekNumber, dayIdx)}
>
{entries.map((entry, entryIdx) => renderEntry(entry, entryIdx))}
{entries.map((entry, entryIdx) => renderEntry(entry, entryIdx, week.weekNumber))}
</div>
))}
);
})}
</div>
{/* Add Entry Button */}
<button
onClick={() => handleAddEntry(week.weekNumber)}
className="mt-3 w-full flex items-center justify-center gap-1.5 py-2 rounded-xl border border-dashed border-slate-200 text-xs text-slate-400 hover:text-slate-600 hover:border-slate-300 hover:bg-slate-50/50 transition-all"
data-no-print
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 5v14M5 12h14" />
</svg>
</button>
</motion.div>
);
})
)}
{/* Color Legend (clickable filter) */}
<div className="flex flex-wrap gap-3 mt-4">
{(Object.keys(contentTypeColors) as ContentCategory[]).map((type) => {
const colors = contentTypeColors[type];
const isActive = filterType === type;
return (
<button
key={type}
onClick={() => toggleFilter(type)}
className={`${colors.bg} ${colors.text} border ${colors.border} rounded-full px-3 py-1 text-xs font-medium ${colors.shadow} transition-all ${
isActive ? 'ring-2 ring-purple-300' : ''
} ${filterType && !isActive ? 'opacity-40' : ''}`}
>
{contentTypeLabels[type]}
</button>
);
})}
{filterType && (
<button
onClick={() => setFilterType(null)}
className="text-xs text-slate-400 hover:text-slate-600 px-2 py-1 transition-colors"
>
</button>
)}
</div>
{/* Edit Modal */}
{editingEntry && (
<EditEntryModal

View File

@ -1,6 +1,7 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import { ArrowRight, Video, FileText, Share2, Megaphone } from 'lucide-react';
import { ArrowRight } from 'lucide-react';
import { VideoFilled } from '../icons/FilledIcons';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import type { ContentStrategyData } from '../../types/plan';
@ -19,15 +20,15 @@ type TabKey = (typeof tabItems)[number]['key'];
const channelColorMap: Record<string, string> = {
YouTube: 'bg-[#FFF0F0] text-[#7C3A4B]',
Instagram: 'bg-pink-100 text-pink-700',
Instagram: 'bg-[#FFF0F0] text-[#7C3A4B]',
Blog: 'bg-[#EFF0FF] text-[#3A3F7C]',
'블로그': 'bg-[#EFF0FF] text-[#3A3F7C]',
'네이버블로그': 'bg-[#F3F0FF] text-[#4A3A7C]',
'네이버': 'bg-[#F3F0FF] text-[#4A3A7C]',
Facebook: 'bg-indigo-100 text-indigo-700',
Facebook: 'bg-[#EFF0FF] text-[#3A3F7C]',
'홈페이지': 'bg-slate-100 text-slate-700',
Website: 'bg-slate-100 text-slate-700',
TikTok: 'bg-purple-100 text-purple-700',
TikTok: 'bg-[#F3F0FF] text-[#4A3A7C]',
'카카오': 'bg-[#FFF6ED] text-[#7C5C3A]',
};
@ -168,7 +169,7 @@ export default function ContentStrategy({ data }: ContentStrategyProps) {
<h4 className="font-semibold text-[#0A1128] mb-1">{step.name}</h4>
<p className="text-sm text-slate-600 mb-3">{step.description}</p>
<div className="flex flex-wrap gap-2">
<span className="rounded-full bg-purple-50 text-purple-700 px-2 py-1 text-xs">
<span className="rounded-full bg-[#F3F0FF] text-[#4A3A7C] px-2 py-1 text-xs">
{step.owner}
</span>
<span className="rounded-full bg-slate-100 text-slate-600 px-2 py-1 text-xs">
@ -197,7 +198,7 @@ export default function ContentStrategy({ data }: ContentStrategyProps) {
{/* Source Card */}
<div className="rounded-2xl bg-gradient-to-r from-[#4F1DA1] to-[#021341] p-6 text-white mb-6">
<div className="flex items-center gap-3">
<Video size={28} className="text-purple-300" />
<VideoFilled size={28} className="text-white/60" />
<h4 className="font-serif text-xl font-bold">{data.repurposingSource}</h4>
</div>
</div>

View File

@ -28,8 +28,8 @@ const DAYS = ['월', '화', '수', '목', '금', '토', '일'];
const STATUS_OPTIONS: { value: CalendarEntry['status']; label: string; color: string }[] = [
{ value: 'draft', label: '초안', color: 'bg-slate-200 text-slate-600' },
{ value: 'approved', label: '승인', color: 'bg-purple-100 text-purple-700' },
{ value: 'published', label: '게시됨', color: 'bg-green-100 text-green-700' },
{ value: 'approved', label: '승인', color: 'bg-[#F3F0FF] text-[#4A3A7C]' },
{ value: 'published', label: '게시됨', color: 'bg-[#EFF0FF] text-[#3A3F7C]' },
];
export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }: EditEntryModalProps) {
@ -118,7 +118,7 @@ export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }:
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none transition-all"
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-[#6C5CE7]/20 focus:border-[#6C5CE7] outline-none transition-all"
placeholder="콘텐츠 제목을 입력하세요"
/>
</div>
@ -130,7 +130,7 @@ export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }:
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none transition-all resize-none"
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-[#6C5CE7]/20 focus:border-[#6C5CE7] outline-none transition-all resize-none"
placeholder="AI가 생성한 제작 가이드 또는 직접 메모를 입력하세요"
/>
</div>
@ -142,7 +142,7 @@ export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }:
<select
value={channel}
onChange={(e) => handleChannelChange(e.target.value)}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none bg-white"
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-[#6C5CE7]/20 focus:border-[#6C5CE7] outline-none bg-white"
>
{CHANNELS.map((ch) => (
<option key={ch.id} value={ch.id}>{ch.id}</option>
@ -154,7 +154,7 @@ export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }:
<select
value={contentType}
onChange={(e) => setContentType(e.target.value as ContentCategory)}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none bg-white"
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-[#6C5CE7]/20 focus:border-[#6C5CE7] outline-none bg-white"
>
{CONTENT_TYPES.map((ct) => (
<option key={ct.value} value={ct.value}>{ct.label}</option>
@ -170,7 +170,7 @@ export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }:
<select
value={dayOfWeek}
onChange={(e) => setDayOfWeek(Number(e.target.value))}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-purple-200 focus:border-purple-400 outline-none bg-white"
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-[#6C5CE7]/20 focus:border-[#6C5CE7] outline-none bg-white"
>
{DAYS.map((d, i) => (
<option key={i} value={i}>{d}</option>
@ -186,7 +186,7 @@ export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }:
onClick={() => setStatus(s.value)}
className={`px-3 py-2 rounded-xl text-xs font-medium transition-all ${
status === s.value
? `${s.color} ring-2 ring-offset-1 ring-purple-300`
? `${s.color} ring-2 ring-offset-1 ring-[#9B8AD4]`
: 'bg-slate-50 text-slate-400 hover:bg-slate-100'
}`}
>
@ -203,7 +203,7 @@ export default function EditEntryModal({ entry, onSave, onClose, onRegenerate }:
{onRegenerate ? (
<button
onClick={() => onRegenerate(entry)}
className="flex items-center gap-1.5 px-3 py-2 text-sm text-purple-600 hover:bg-purple-50 rounded-xl transition-colors"
className="flex items-center gap-1.5 px-3 py-2 text-sm text-[#6C5CE7] hover:bg-[#F3F0FF] rounded-xl transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />

View File

@ -123,8 +123,8 @@ export default function MyAssetUpload() {
return (
<SectionWrapper
id="my-asset-upload"
title="My Assets"
subtitle="나의 에셋 업로드"
title="나의 소재"
subtitle="소재 업로드 및 관리"
>
{/* Drop Zone */}
<div

View File

@ -1,6 +1,6 @@
import { motion } from 'motion/react';
import { useNavigate, useParams } from 'react-router';
import { Rocket, Download, Loader2 } from 'lucide-react';
import { RocketFilled, DownloadFilled } from '../icons/FilledIcons';
import { useExportPDF } from '../../hooks/useExportPDF';
export default function PlanCTA() {
@ -23,7 +23,7 @@ export default function PlanCTA() {
>
<div className="flex justify-center mb-6">
<div className="w-14 h-14 rounded-full bg-white/80 backdrop-blur-sm border border-white/40 flex items-center justify-center">
<Rocket size={28} className="text-[#4F1DA1]" />
<RocketFilled size={28} className="text-[#4F1DA1]" />
</div>
</div>
@ -51,9 +51,12 @@ export default function PlanCTA() {
className="inline-flex items-center justify-center gap-2 rounded-full bg-white border border-slate-200 px-6 py-3 text-sm font-medium text-[#021341] shadow-sm hover:shadow-md transition-shadow disabled:opacity-60"
>
{isExporting ? (
<Loader2 size={16} className="animate-spin" />
<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>
) : (
<Download size={16} />
<DownloadFilled size={16} />
)}
</button>

View File

@ -1,5 +1,5 @@
import { motion } from 'motion/react';
import { Calendar, Globe } from 'lucide-react';
import { CalendarFilled, GlobeFilled } from './../../components/icons/FilledIcons';
function formatDate(raw: string): string {
try {
@ -30,17 +30,17 @@ export default function PlanHeader({
<section className="relative overflow-hidden bg-[radial-gradient(ellipse_at_top_left,#e0e7ff,transparent_50%),radial-gradient(ellipse_at_bottom_right,#fce7f3,transparent_50%),radial-gradient(ellipse_at_center,#f5f3ff,transparent_60%)] py-20 md:py-28 px-6">
{/* Animated blobs */}
<motion.div
className="absolute top-10 left-10 w-72 h-72 rounded-full bg-indigo-200/30 blur-3xl"
className="absolute top-10 left-10 w-72 h-72 rounded-full bg-[#6C5CE7]/10 blur-3xl"
animate={{ x: [0, 30, 0], y: [0, -20, 0] }}
transition={{ duration: 8, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-pink-200/30 blur-3xl"
className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-[#D5CDF5]/30 blur-3xl"
animate={{ x: [0, -20, 0], y: [0, 30, 0] }}
transition={{ duration: 10, repeat: Infinity, ease: 'easeInOut' }}
/>
<motion.div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full bg-purple-200/20 blur-3xl"
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full bg-[#6C5CE7]/8 blur-3xl"
animate={{ scale: [1, 1.1, 1] }}
transition={{ duration: 6, repeat: Infinity, ease: 'easeInOut' }}
/>
@ -93,11 +93,11 @@ export default function PlanHeader({
transition={{ duration: 0.5, delay: 0.4 }}
>
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
<Calendar size={14} className="text-slate-400" />
<CalendarFilled size={14} className="text-slate-400" />
{formatDate(date)}
</span>
<span className="inline-flex items-center gap-2 rounded-full bg-white/60 backdrop-blur-sm border border-white/40 px-3 py-1 text-sm font-medium text-slate-700">
<Globe size={14} className="text-slate-400" />
<GlobeFilled size={14} className="text-slate-400" />
{targetUrl}
</span>
</motion.div>
@ -115,7 +115,7 @@ export default function PlanHeader({
<span className="text-4xl font-bold text-white leading-none">
90
</span>
<span className="text-sm text-purple-200">Days</span>
<span className="text-sm text-white/60">Days</span>
</div>
</motion.div>
</div>

View File

@ -0,0 +1,165 @@
import { useState } from 'react';
import { motion } from 'motion/react';
import {
YoutubeFilled,
InstagramFilled,
FacebookFilled,
GlobeFilled,
TiktokFilled,
VideoFilled,
RefreshFilled,
BoltFilled,
} from '../icons/FilledIcons';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import type { RepurposingProposalItem } from '../../types/plan';
interface RepurposingProposalProps {
proposals: RepurposingProposalItem[];
}
// Maps channel keyword → FilledIcon
function ChannelIcon({ channel, size = 14 }: { channel: string; size?: number }) {
const lower = channel.toLowerCase();
const className = 'shrink-0';
if (lower.includes('youtube')) return <YoutubeFilled size={size} className={className} />;
if (lower.includes('instagram')) return <InstagramFilled size={size} className={className} />;
if (lower.includes('facebook')) return <FacebookFilled size={size} className={className} />;
if (lower.includes('tiktok')) return <TiktokFilled size={size} className={className} />;
if (lower.includes('naver') || lower.includes('blog')) return <GlobeFilled size={size} className={className} />;
return <VideoFilled size={size} className={className} />;
}
const effortConfig: Record<string, { label: string; bg: string; text: string; border: string }> = {
low: { label: '빠른 작업', bg: 'bg-[#F3F0FF]', text: 'text-[#4A3A7C]', border: 'border-[#D5CDF5]' },
medium: { label: '중간 작업', bg: 'bg-[#FFF6ED]', text: 'text-[#7C5C3A]', border: 'border-[#F5E0C5]' },
high: { label: '집중 작업', bg: 'bg-[#FFF0F0]', text: 'text-[#7C3A4B]', border: 'border-[#F5D5DC]' },
};
const priorityConfig: Record<string, { label: string; dot: string }> = {
high: { label: 'P0', dot: 'bg-[#D4889A]' },
medium: { label: 'P1', dot: 'bg-[#D4A872]' },
low: { label: 'P2', dot: 'bg-[#9B8AD4]' },
};
function formatViews(views: number): string {
if (views >= 1_000_000) return `${(views / 1_000_000).toFixed(1)}M`;
if (views >= 1_000) return `${Math.round(views / 1_000)}K`;
return String(views);
}
export default function RepurposingProposal({ proposals }: RepurposingProposalProps) {
const [expandedIdx, setExpandedIdx] = useState<number | null>(0);
return (
<SectionWrapper
id="repurposing-proposal"
title="Repurposing Proposal"
subtitle="콘텐츠 리퍼포징 제안"
>
<div className="space-y-4">
{proposals.map((item, idx) => {
const effort = effortConfig[item.estimatedEffort];
const priority = priorityConfig[item.priority];
const isExpanded = expandedIdx === idx;
return (
<motion.div
key={item.sourceVideo.title}
className="rounded-2xl border border-slate-100 bg-white shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.4, delay: idx * 0.08 }}
>
{/* Card Header — click to expand */}
<button
className="w-full flex items-center gap-4 px-6 py-4 text-left hover:bg-slate-50/50 transition-colors"
onClick={() => setExpandedIdx(isExpanded ? null : idx)}
>
{/* Source video info */}
<div className="w-10 h-10 rounded-xl bg-[#F3F0FF] flex items-center justify-center shrink-0">
<YoutubeFilled size={20} className="text-[#6C5CE7]" />
</div>
<div className="flex-1 min-w-0">
<p className="font-bold text-[#0A1128] text-sm truncate">{item.sourceVideo.title}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-xs text-slate-500">{formatViews(item.sourceVideo.views)} views</span>
<span className="w-1 h-1 rounded-full bg-slate-300" />
<span className="text-xs text-slate-500">{item.sourceVideo.type === 'Short' ? 'Shorts' : 'Long-form'}</span>
</div>
</div>
{/* Badges */}
<div className="flex items-center gap-2 shrink-0">
{/* Output count */}
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-[#F3F0FF] border border-[#D5CDF5]">
<RefreshFilled size={11} className="text-[#6C5CE7]" />
<span className="text-xs font-semibold text-[#4A3A7C]">{item.outputs.length} </span>
</div>
{/* Effort */}
<span className={`text-xs font-medium px-2.5 py-1 rounded-full border ${effort.bg} ${effort.text} ${effort.border} hidden sm:inline-flex`}>
{effort.label}
</span>
{/* Priority */}
<div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-slate-50 border border-slate-200">
<span className={`w-1.5 h-1.5 rounded-full ${priority.dot}`} />
<span className="text-xs font-bold text-slate-600">{priority.label}</span>
</div>
{/* Chevron */}
<svg
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${isExpanded ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
{/* Expanded: Repurpose outputs */}
{isExpanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-slate-100"
>
<div className="px-6 py-4">
<div className="flex items-center gap-2 mb-4">
<BoltFilled size={14} className="text-[#6C5CE7]" />
<p className="text-xs font-semibold text-slate-500 uppercase tracking-widest">REPURPOSE AS</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{item.outputs.map((output, oIdx) => (
<motion.div
key={output.format}
className="flex items-start gap-3 p-4 rounded-xl bg-slate-50 border border-slate-100"
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: oIdx * 0.06 }}
>
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#6C5CE7]/10 to-[#4F1DA1]/10 flex items-center justify-center shrink-0">
<ChannelIcon channel={output.channel} size={16} />
</div>
<div className="min-w-0">
<p className="font-semibold text-[#0A1128] text-sm">{output.format}</p>
<p className="text-xs text-[#6C5CE7] font-medium mb-1">{output.channel}</p>
<p className="text-xs text-slate-500 leading-relaxed">{output.description}</p>
</div>
</motion.div>
))}
</div>
</div>
</motion.div>
)}
</motion.div>
);
})}
</div>
</SectionWrapper>
);
}

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { motion } from 'motion/react';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import { MegaphoneFilled } from '../icons/FilledIcons';
import { supabase, triggerStrategyAdjustment } from '../../lib/supabase';
interface StrategyAdjustmentSectionProps {
@ -161,7 +162,9 @@ export default function StrategyAdjustmentSection({ clinicId, planId }: Strategy
{!hasData ? (
/* Empty state */
<div className="text-center py-12 bg-white rounded-2xl border border-dashed border-slate-200">
<div className="text-3xl mb-3">📊</div>
<div className="w-10 h-10 rounded-xl bg-[#F3F0FF] flex items-center justify-center mx-auto mb-3">
<MegaphoneFilled size={20} className="text-[#6C5CE7]" />
</div>
<p className="text-sm font-medium text-slate-600 mb-1"> </p>
<p className="text-xs text-slate-400">
2

View File

@ -0,0 +1,389 @@
import { useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
YoutubeFilled,
InstagramFilled,
GlobeFilled,
VideoFilled,
TiktokFilled,
BoltFilled,
CheckFilled,
CalendarFilled,
FileTextFilled,
ShareFilled,
} from '../icons/FilledIcons';
import { SectionWrapper } from '../report/ui/SectionWrapper';
import type { WorkflowData, WorkflowItem, WorkflowStage, WorkflowContentType } from '../../types/plan';
interface WorkflowTrackerProps {
data: WorkflowData;
}
const STAGES: { key: WorkflowStage; label: string; short: string }[] = [
{ key: 'planning', label: '기획 확정', short: '기획' },
{ key: 'ai-draft', label: 'AI 초안', short: 'AI 초안' },
{ key: 'review', label: '검토/수정', short: '검토' },
{ key: 'approved', label: '승인', short: '승인' },
{ key: 'scheduled', label: '배포 예약', short: '배포' },
];
const STAGE_COLORS: Record<WorkflowStage, { bg: string; text: string; border: string; dot: string }> = {
planning: { bg: 'bg-slate-100', text: 'text-slate-600', border: 'border-slate-200', dot: 'bg-slate-400' },
'ai-draft':{ bg: 'bg-[#EFF0FF]', text: 'text-[#3A3F7C]', border: 'border-[#C5CBF5]', dot: 'bg-[#7A84D4]' },
review: { bg: 'bg-[#FFF6ED]', text: 'text-[#7C5C3A]', border: 'border-[#F5E0C5]', dot: 'bg-[#D4A872]' },
approved: { bg: 'bg-[#F3F0FF]', text: 'text-[#4A3A7C]', border: 'border-[#D5CDF5]', dot: 'bg-[#9B8AD4]' },
scheduled: { bg: 'bg-[#F3F0FF]', text: 'text-[#4A3A7C]', border: 'border-[#D5CDF5]', dot: 'bg-[#6C5CE7]' },
};
const channelIconMap: Record<string, typeof YoutubeFilled> = {
youtube: YoutubeFilled,
instagram: InstagramFilled,
globe: GlobeFilled,
video: TiktokFilled,
share: ShareFilled,
};
function StageBar({ currentStage }: { currentStage: WorkflowStage }) {
const currentIdx = STAGES.findIndex((s) => s.key === currentStage);
return (
<div className="flex items-center gap-0 mb-4">
{STAGES.map((stage, idx) => {
const isPast = idx < currentIdx;
const isCurrent = idx === currentIdx;
return (
<div key={stage.key} className="flex items-center flex-1 min-w-0">
<div className={`flex flex-col items-center flex-1 min-w-0`}>
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0 transition-colors ${
isCurrent
? 'bg-[#6C5CE7] text-white shadow-[0_0_0_3px_rgba(108,92,231,0.2)]'
: isPast
? 'bg-[#9B8AD4] text-white'
: 'bg-slate-200 text-slate-400'
}`}
>
{isPast ? <CheckFilled size={10} /> : idx + 1}
</div>
<span className={`text-[9px] mt-1 font-medium whitespace-nowrap ${isCurrent ? 'text-[#6C5CE7]' : isPast ? 'text-[#9B8AD4]' : 'text-slate-400'}`}>
{stage.short}
</span>
</div>
{idx < STAGES.length - 1 && (
<div className={`h-0.5 flex-1 mx-1 rounded-full transition-colors ${idx < currentIdx ? 'bg-[#9B8AD4]' : 'bg-slate-200'}`} />
)}
</div>
);
})}
</div>
);
}
function WorkflowCard({ item, onStageChange, onNotesChange }: {
item: WorkflowItem;
onStageChange: (id: string, stage: WorkflowStage) => void;
onNotesChange: (id: string, notes: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const [editingNotes, setEditingNotes] = useState(false);
const [notesValue, setNotesValue] = useState(item.userNotes ?? '');
const stageColor = STAGE_COLORS[item.stage];
const ChannelIcon = channelIconMap[item.channelIcon] ?? GlobeFilled;
const currentStageIdx = STAGES.findIndex((s) => s.key === item.stage);
const nextStage = STAGES[currentStageIdx + 1];
const saveNotes = () => {
onNotesChange(item.id, notesValue);
setEditingNotes(false);
};
return (
<motion.div
layout
className="bg-white rounded-2xl border border-slate-100 shadow-[3px_4px_12px_rgba(0,0,0,0.06)] overflow-hidden"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.3 }}
>
{/* Card Header */}
<button
className="w-full flex items-center gap-3 px-5 py-4 text-left hover:bg-slate-50/50 transition-colors"
onClick={() => setExpanded((p) => !p)}
>
<div className={`w-8 h-8 rounded-xl flex items-center justify-center shrink-0 ${stageColor.bg} ${stageColor.border} border`}>
<ChannelIcon size={16} className={stageColor.text} />
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-[#0A1128] text-sm truncate">{item.title}</p>
<p className="text-xs text-slate-500 mt-0.5">{item.channel}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{/* Stage badge */}
<span className={`text-[10px] font-semibold px-2.5 py-1 rounded-full border ${stageColor.bg} ${stageColor.text} ${stageColor.border}`}>
{STAGES.find((s) => s.key === item.stage)?.label}
</span>
{item.scheduledDate && (
<div className="flex items-center gap-1 text-[10px] text-slate-500">
<CalendarFilled size={10} />
<span>{item.scheduledDate.slice(5)}</span>
</div>
)}
<svg
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${expanded ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</div>
</button>
{/* Expanded Body */}
<AnimatePresence>
{expanded && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-slate-100"
>
<div className="px-5 py-4 space-y-4">
{/* Stage Progress */}
<StageBar currentStage={item.stage} />
{/* AI Draft Content */}
{item.contentType === 'video' && item.videoDraft && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<BoltFilled size={13} className="text-[#6C5CE7]" />
<p className="text-xs font-semibold text-slate-500 uppercase tracking-widest">AI </p>
<span className="text-[10px] text-slate-400 font-mono">{item.videoDraft.duration}</span>
</div>
<pre className="text-xs text-slate-700 bg-slate-50 rounded-xl p-4 whitespace-pre-wrap leading-relaxed border border-slate-100 font-sans">
{item.videoDraft.script}
</pre>
<div className="flex items-center gap-2 mt-2">
<FileTextFilled size={13} className="text-[#6C5CE7]" />
<p className="text-xs font-semibold text-slate-500 uppercase tracking-widest"> </p>
</div>
<ul className="space-y-1.5">
{item.videoDraft.shootingGuide.map((guide, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-slate-600">
<span className="w-4 h-4 rounded-full bg-[#F3F0FF] text-[#6C5CE7] flex items-center justify-center text-[9px] font-bold shrink-0 mt-0.5">{i + 1}</span>
{guide}
</li>
))}
</ul>
</div>
)}
{item.contentType === 'image-text' && item.imageTextDraft && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<BoltFilled size={13} className="text-[#6C5CE7]" />
<p className="text-xs font-semibold text-slate-500 uppercase tracking-widest">
AI {item.imageTextDraft.type === 'cardnews' ? '카드뉴스 카피' : '블로그 초안'}
</p>
</div>
<div className="bg-slate-50 rounded-xl p-4 border border-slate-100">
<p className="font-bold text-sm text-[#0A1128] mb-3">{item.imageTextDraft.headline}</p>
<ul className="space-y-2">
{item.imageTextDraft.copy.map((line, i) => (
<li key={i} className="text-xs text-slate-600 leading-relaxed whitespace-pre-line">{line}</li>
))}
</ul>
</div>
{item.imageTextDraft.layoutHint && (
<p className="text-[10px] text-slate-400 italic">{item.imageTextDraft.layoutHint}</p>
)}
</div>
)}
{/* User Notes */}
<div>
<div className="flex items-center justify-between mb-1.5">
<p className="text-xs font-semibold text-slate-500"> / </p>
{!editingNotes && (
<button
onClick={(e) => { e.stopPropagation(); setEditingNotes(true); }}
className="text-[10px] text-[#6C5CE7] hover:text-[#4A3A7C] font-medium transition-colors"
>
{notesValue ? '편집' : '+ 추가'}
</button>
)}
</div>
{editingNotes ? (
<div className="space-y-2">
<textarea
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
placeholder="수정할 내용이나 추가 지시사항을 입력하세요..."
className="w-full text-xs text-slate-700 border border-slate-200 rounded-xl px-3 py-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-[#6C5CE7]/30 focus:border-[#6C5CE7] min-h-[80px] placeholder:text-slate-300"
autoFocus
/>
<div className="flex gap-2">
<button
onClick={saveNotes}
className="text-xs px-3 py-1.5 rounded-lg bg-[#6C5CE7] text-white font-medium hover:bg-[#5A4DD4] transition-colors"
>
</button>
<button
onClick={() => { setNotesValue(item.userNotes ?? ''); setEditingNotes(false); }}
className="text-xs px-3 py-1.5 rounded-lg bg-slate-100 text-slate-500 font-medium hover:bg-slate-200 transition-colors"
>
</button>
</div>
</div>
) : notesValue ? (
<p className="text-xs text-slate-600 bg-[#FFF6ED] border border-[#F5E0C5] rounded-xl px-3 py-2.5 leading-relaxed">
{notesValue}
</p>
) : (
<p className="text-xs text-slate-300 italic"></p>
)}
</div>
{/* Stage Advance Button */}
{nextStage && item.stage !== 'scheduled' && (
<button
onClick={(e) => { e.stopPropagation(); onStageChange(item.id, nextStage.key); }}
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-gradient-to-r from-[#4F1DA1] to-[#021341] text-white text-xs font-semibold hover:opacity-90 transition-opacity"
>
<span>{nextStage.label}() </span>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
)}
{item.stage === 'scheduled' && (
<div className="flex items-center justify-center gap-2 py-2.5 rounded-xl bg-[#F3F0FF] border border-[#D5CDF5]">
<CheckFilled size={14} className="text-[#6C5CE7]" />
<span className="text-xs font-semibold text-[#4A3A7C]"> </span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
}
export default function WorkflowTracker({ data }: WorkflowTrackerProps) {
const [items, setItems] = useState<WorkflowItem[]>(data.items);
const [activeTab, setActiveTab] = useState<WorkflowContentType>('video');
const [activeStageFilter, setActiveStageFilter] = useState<WorkflowStage | null>(null);
const handleStageChange = useCallback((id: string, stage: WorkflowStage) => {
setItems((prev) => prev.map((item) => item.id === id ? { ...item, stage } : item));
}, []);
const handleNotesChange = useCallback((id: string, notes: string) => {
setItems((prev) => prev.map((item) => item.id === id ? { ...item, userNotes: notes } : item));
}, []);
const filtered = items.filter((item) => {
if (item.contentType !== activeTab) return false;
if (activeStageFilter && item.stage !== activeStageFilter) return false;
return true;
});
// Stage counts for current tab
const stageCounts = STAGES.map((s) => ({
...s,
count: items.filter((i) => i.contentType === activeTab && i.stage === s.key).length,
}));
return (
<SectionWrapper
id="workflow-tracker"
title="Workflow Tracker"
subtitle="콘텐츠 제작 파이프라인"
dark
>
{/* Content Type Tabs */}
<div className="flex bg-white/10 rounded-xl p-0.5 mb-6 w-fit" data-no-print>
{(['video', 'image-text'] as WorkflowContentType[]).map((type) => (
<button
key={type}
onClick={() => { setActiveTab(type); setActiveStageFilter(null); }}
className={`flex items-center gap-2 px-4 py-1.5 text-xs font-medium rounded-lg transition-all ${
activeTab === type
? 'bg-white/20 text-white shadow-sm'
: 'text-white/50 hover:text-white/80'
}`}
>
{type === 'video'
? <VideoFilled size={12} />
: <FileTextFilled size={12} />
}
{type === 'video' ? '동영상 콘텐츠' : '이미지+텍스트'}
</button>
))}
</div>
{/* Stage Filter Pills */}
<div className="flex flex-wrap gap-2 mb-6">
{stageCounts.map((stage) => {
if (stage.count === 0) return null;
const isActive = activeStageFilter === stage.key;
const color = STAGE_COLORS[stage.key];
return (
<motion.button
key={stage.key}
onClick={() => setActiveStageFilter(isActive ? null : stage.key)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full border text-xs font-medium transition-all ${color.bg} ${color.text} ${color.border} ${
isActive ? 'ring-2 ring-white/40' : ''
} ${activeStageFilter && !isActive ? 'opacity-40' : ''}`}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2 }}
>
<span className={`w-1.5 h-1.5 rounded-full ${color.dot}`} />
{stage.label}
<span className="font-bold">{stage.count}</span>
</motion.button>
);
})}
{activeStageFilter && (
<button
onClick={() => setActiveStageFilter(null)}
className="text-xs text-white/40 hover:text-white/70 px-2 transition-colors"
>
×
</button>
)}
</div>
{/* Card List */}
<div className="space-y-3">
<AnimatePresence mode="popLayout">
{filtered.length === 0 ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-center py-10 text-white/30 text-sm"
>
</motion.div>
) : (
filtered.map((item: WorkflowItem) => (
<WorkflowCard
key={item.id}
item={item}
onStageChange={handleStageChange}
onNotesChange={handleNotesChange}
/>
))
)}
</AnimatePresence>
</div>
</SectionWrapper>
);
}

View File

@ -16,7 +16,7 @@ const PILLARS = [
const SOURCES: { key: AssetSourceType; title: string; description: string }[] = [
{ key: 'collected', title: '수집된 에셋', description: '홈페이지, 블로그, SNS에서 수집한 기존 에셋' },
{ key: 'my_assets', title: 'My Assets', description: '직접 업로드한 이미지, 영상, 텍스트 파일' },
{ key: 'my_assets', title: '나의 소재', description: '직접 업로드한 이미지, 영상, 텍스트 파일' },
{ key: 'ai_generated', title: 'AI 생성', description: 'AI가 새로 생성하는 이미지, 텍스트, 영상' },
];

View File

@ -269,4 +269,148 @@ export const mockPlan: MarketingPlan = {
{ title: '서울대 의학박사의 가슴재수술 성공전략', views: 1400, type: 'Long', repurposeAs: ['Shorts 추출', 'SEO 블로그', 'Carousel'] },
],
},
// ─── Section 6: Repurposing Proposals ───
repurposingProposals: [
{
sourceVideo: { title: '한번에 성공하는 성형', views: 574000, type: 'Short', repurposeAs: [] },
estimatedEffort: 'low',
priority: 'high',
outputs: [
{ format: 'Instagram Reel', channel: 'Instagram KR', description: '자막 추가 + 한국어 해시태그 최적화 후 즉시 크로스포스팅' },
{ format: 'TikTok', channel: 'TikTok', description: '트렌딩 사운드 교체 + 텍스트 오버레이 재구성' },
{ format: '광고 소재', channel: 'Facebook / Instagram', description: '가장 임팩트 있는 3초 후크 장면 + CTA 오버레이' },
],
},
{
sourceVideo: { title: '코성형! 내 얼굴에 가장 예쁜 코', views: 124000, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'high',
outputs: [
{ format: 'Shorts 5개 추출', channel: 'YouTube', description: '핵심 설명 구간 15-60초 클립 5개 자동 추출' },
{ format: 'Carousel 3개', channel: 'Instagram KR', description: '코성형 타입별 비교 정보 카드뉴스로 재구성' },
{ format: 'Blog Post', channel: 'Naver Blog', description: '영상 스크립트 → 2,000자 SEO 블로그 포스트 변환' },
{ format: 'Stories 시리즈', channel: 'Instagram', description: '촬영 비하인드 + Q&A 스니펫 5개' },
],
},
{
sourceVideo: { title: '아나운서 박은영, 가슴 할 결심', views: 127000, type: 'Long', repurposeAs: [] },
estimatedEffort: 'medium',
priority: 'medium',
outputs: [
{ format: 'Shorts 3개 추출', channel: 'YouTube / Instagram / TikTok', description: '스토리 하이라이트 구간 크로스포스팅' },
{ format: '스토리 시리즈', channel: 'Instagram KR', description: '상담 결정 과정 + 회복 타임라인 Stories' },
{ format: '광고 소재', channel: 'Facebook', description: '환자 신뢰도 강화 소셜 프루프 광고 소재' },
],
},
{
sourceVideo: { title: '코성형+지방이식 전후', views: 525000, type: 'Short', repurposeAs: [] },
estimatedEffort: 'low',
priority: 'high',
outputs: [
{ format: 'Instagram Reel', channel: 'Instagram KR', description: 'Before/After 포맷 최적화 + 동의서 확인 후 게시' },
{ format: 'TikTok', channel: 'TikTok', description: '트렌드 사운드 교체 + Stitch 유도 CTA 추가' },
{ format: 'Naver 블로그 삽입', channel: 'Naver Blog', description: '코+지방이식 복합 시술 블로그 포스트에 영상 임베드' },
],
},
],
workflow: {
items: [
{
id: 'wf-001',
title: '코성형 전후 비교 YouTube Shorts',
contentType: 'video',
channel: 'YouTube Shorts',
channelIcon: 'youtube',
stage: 'ai-draft',
videoDraft: {
script: `[인트로 — 0~3초]\n"코가 달라지면 인상이 달라져요."\n(전 사진 → 후 사진 전환)\n\n[본문 — 3~25초]\n"VIEW 성형외과의 코성형, 단순히 높이는 것이 아닙니다."\n"얼굴 전체 비율을 분석한 맞춤형 디자인,"\n"21년 무사고 기록이 말해줍니다."\n(수술 과정 그래픽 삽입)\n\n[CTA — 25~30초]\n"지금 상담 예약 — 링크 프로필 참고"`,
shootingGuide: [
'전/후 고화질 사진 세로 4:5 비율로 준비',
'자연광 또는 소프트박스 조명에서 정면 + 3/4 앵글 촬영',
'배경: 클리닉 로고 배경 또는 화이트 배경',
'의사 얼굴 없이 사진 위주 편집 (환자 동의서 필수)',
],
duration: '30초',
},
},
{
id: 'wf-002',
title: '가슴성형 궁금증 5가지 — Instagram Reel',
contentType: 'video',
channel: 'Instagram',
channelIcon: 'instagram',
stage: 'review',
userNotes: '마지막 슬라이드에 전화번호 대신 카카오톡 채널명으로 바꿔주세요',
videoDraft: {
script: `[후크 — 0~2초]\n"가슴성형 전에 꼭 알아야 할 5가지"\n\n[1] 보형물 종류: 라운드 vs 물방울\n[2] 절개 위치 선택법\n[3] 회복 기간 현실 (3일~2주)\n[4] 재수술률을 낮추는 병원 고르는 기준\n[5] VIEW 21년 무사고의 비결\n\n[CTA]\n"자세한 상담은 카카오채널 \'뷰성형외과의원\'"`,
shootingGuide: [
'텍스트 슬라이드 5장 제작 (배경: #7B2D8E 그라디언트)',
'각 슬라이드에 VIEW 로고 워터마크 우측 하단 배치',
'트랜지션: 빠른 슬라이드 컷 (0.2초)',
'배경음악: 경쾌한 팝 인스트루멘탈 (저작권 무료)',
],
duration: '45초',
},
},
{
id: 'wf-003',
title: '코성형 Q&A 카드뉴스 — Naver 블로그',
contentType: 'image-text',
channel: 'Naver Blog',
channelIcon: 'globe',
stage: 'planning',
imageTextDraft: {
type: 'cardnews',
headline: '코성형, 궁금한 게 너무 많죠? 전문의가 직접 답합니다',
copy: [
'[카드 1] 코성형 후 붓기는 얼마나 지속되나요?\n→ 초기 붓기 1~2주, 완전 회복 3~6개월. 일상 복귀는 보통 1주일.',
'[카드 2] 실리콘 vs 연골, 어떤 재료가 좋나요?\n→ 높이와 형태 교정에 따라 다름. VIEW는 개인 맞춤형 복합 재료 활용.',
'[카드 3] 코성형 후 운동은 언제부터?\n→ 가벼운 걷기: 1주 후 / 격렬한 운동: 최소 4주 후.',
'[카드 4] VIEW 코성형이 다른 이유\n→ 21년 무사고 · 전담 의료진 · 3D 시뮬레이션 상담 제공.',
],
layoutHint: '4장 카드 세로형, 보라+골드 브랜드 컬러, 마지막 카드에 CTA (상담 예약 버튼)',
},
},
{
id: 'wf-004',
title: '눈성형 회복기 솔직 후기 블로그 포스트',
contentType: 'image-text',
channel: 'Naver Blog',
channelIcon: 'globe',
stage: 'approved',
scheduledDate: '2026-04-14',
imageTextDraft: {
type: 'blog',
headline: '눈성형 2주 후 솔직 리뷰 — VIEW 성형외과 후기',
copy: [
'수술 당일: 2시간 소요, 국소마취. 통증 최소화 확인.',
'수술 다음날: 붓기 있지만 일상생활 가능 수준.',
'1주일 후: 자연스러운 라인 확인. 실밥 제거.',
'2주일 후: 친구들도 "뭔가 달라졌는데?" 반응.',
'VIEW의 장점: 의사 선생님이 수술 전 충분히 상담해주셔서 기대치 조정이 잘 됐습니다.',
],
layoutHint: '1200px 썸네일 + 본문 2000자 이상, 키워드: 눈성형 후기, 강남 눈성형, VIEW 성형외과',
},
},
{
id: 'wf-005',
title: '21년 무사고 스토리 TikTok',
contentType: 'video',
channel: 'TikTok',
channelIcon: 'video',
stage: 'scheduled',
scheduledDate: '2026-04-10',
videoDraft: {
script: `"21년 동안 단 한 건의 의료사고도 없었습니다."\n(숫자 카운터 애니메이션: 0 → 21)\n"이게 VIEW의 자랑입니다."\n#강남성형외과 #무사고 #VIEW성형외과`,
shootingGuide: [
'텍스트 애니메이션 위주 편집 (After Effects 또는 CapCut)',
'배경: 클리닉 내부 실사 영상 블러 처리',
'폰트: VIEW 브랜드 서체 일치',
],
duration: '15초',
},
},
],
},
};

View File

@ -3,6 +3,7 @@ import { useLocation } from 'react-router';
import type { MarketingPlan, ChannelStrategyCard, CalendarData, ContentStrategyData } from '../types/plan';
import { fetchReportById, fetchActiveContentPlan, supabase } from '../lib/supabase';
import { transformReportToPlan } from '../lib/transformPlan';
import { mockPlan } from '../data/mockPlan';
interface UseMarketingPlanResult {
data: MarketingPlan | null;
@ -87,6 +88,13 @@ export function useMarketingPlan(id: string | undefined): UseMarketingPlanResult
async function loadPlan() {
try {
// ─── Dev / Demo: return mock data immediately ───
if (id === 'demo') {
setData(mockPlan);
setIsLoading(false);
return;
}
// ─── Source 1: Try content_plans table (AI-generated strategy) ───
// First, resolve clinicId from navigation state or analysis_runs
let clinicId = state?.clinicId || null;

101
src/lib/calendarExport.ts Normal file
View File

@ -0,0 +1,101 @@
import type { CalendarWeek, CalendarEntry } from '../types/plan';
/**
* Returns the Monday date of a given ISO week number in a year.
* Week 1 = the week containing the first Thursday of the year (ISO 8601).
*/
function isoWeekToDate(year: number, week: number, dayOffset: number): Date {
// Jan 4 is always in week 1
const jan4 = new Date(year, 0, 4);
const dayOfWeek = jan4.getDay() || 7; // convert Sun=0 to 7
const monday = new Date(jan4);
monday.setDate(jan4.getDate() - (dayOfWeek - 1) + (week - 1) * 7);
monday.setDate(monday.getDate() + dayOffset);
return monday;
}
function formatICSDate(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return (
`${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
`T${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
);
}
function escapeICS(str: string): string {
return str
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\n/g, '\\n');
}
function buildVEvent(
entry: CalendarEntry,
weekNumber: number,
year: number,
uid: string,
): string {
const startDate = isoWeekToDate(year, weekNumber, entry.dayOfWeek);
// All-day event: DTSTART is DATE only, DTEND is next day
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 1);
const formatDate = (d: Date) => {
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}`;
};
const lines = [
'BEGIN:VEVENT',
`UID:${uid}`,
`DTSTAMP:${formatICSDate(new Date())}Z`,
`DTSTART;VALUE=DATE:${formatDate(startDate)}`,
`DTEND;VALUE=DATE:${formatDate(endDate)}`,
`SUMMARY:${escapeICS(`[${entry.channel}] ${entry.title}`)}`,
entry.description ? `DESCRIPTION:${escapeICS(entry.description)}` : null,
`CATEGORIES:${escapeICS(entry.contentType.toUpperCase())}`,
`STATUS:${entry.status === 'published' ? 'CONFIRMED' : entry.status === 'approved' ? 'TENTATIVE' : 'NEEDS-ACTION'}`,
'END:VEVENT',
].filter(Boolean) as string[];
return lines.join('\r\n');
}
export function exportCalendarToICS(
weeks: CalendarWeek[],
calendarName = 'INFINITH 콘텐츠 캘린더',
): void {
const year = new Date().getFullYear();
const vEvents = weeks.flatMap((week) =>
week.entries.map((entry, idx) =>
buildVEvent(
entry,
week.weekNumber,
year,
`infinith-${week.weekNumber}-${entry.id ?? idx}@infinith.ai`,
),
),
);
const icsContent = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//INFINITH//Marketing Content Calendar//KO',
`X-WR-CALNAME:${escapeICS(calendarName)}`,
'X-WR-TIMEZONE:Asia/Seoul',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
...vEvents,
'END:VCALENDAR',
].join('\r\n');
const blob = new Blob([icsContent], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'infinith-content-calendar.ics';
a.click();
URL.revokeObjectURL(url);
}

View File

@ -9,8 +9,10 @@ import ChannelStrategy from '../components/plan/ChannelStrategy';
import ContentStrategy from '../components/plan/ContentStrategy';
import ContentCalendar from '../components/plan/ContentCalendar';
import AssetCollection from '../components/plan/AssetCollection';
import RepurposingProposal from '../components/plan/RepurposingProposal';
import MyAssetUpload from '../components/plan/MyAssetUpload';
import StrategyAdjustmentSection from '../components/plan/StrategyAdjustmentSection';
import WorkflowTracker from '../components/plan/WorkflowTracker';
import PlanCTA from '../components/plan/PlanCTA';
const PLAN_SECTIONS = [
@ -19,7 +21,9 @@ const PLAN_SECTIONS = [
{ id: 'content-strategy', label: '콘텐츠 전략' },
{ id: 'content-calendar', label: '콘텐츠 캘린더' },
{ id: 'asset-collection', label: '에셋 수집' },
{ id: 'my-asset-upload', label: 'My Assets' },
{ id: 'repurposing-proposal', label: '리퍼포징 제안' },
{ id: 'workflow-tracker', label: '제작 파이프라인' },
{ id: 'my-asset-upload', label: '나의 소재' },
{ id: 'strategy-adjustment', label: '전략 조정' },
];
@ -75,6 +79,14 @@ export default function MarketingPlanPage() {
<AssetCollection data={data.assetCollection} />
{data.repurposingProposals && data.repurposingProposals.length > 0 && (
<RepurposingProposal proposals={data.repurposingProposals} />
)}
{data.workflow && (
<WorkflowTracker data={data.workflow} />
)}
<div data-no-print>
<MyAssetUpload />
</div>

View File

@ -167,6 +167,50 @@ export interface AssetCollectionData {
youtubeRepurpose: YouTubeRepurposeItem[];
}
// ─── Section 6: Repurposing Proposal ───
export interface RepurposingProposalItem {
sourceVideo: YouTubeRepurposeItem;
outputs: RepurposingOutput[];
estimatedEffort: 'low' | 'medium' | 'high';
priority: 'high' | 'medium' | 'low';
}
// ─── Section 7: Workflow Tracker ───
export type WorkflowStage = 'planning' | 'ai-draft' | 'review' | 'approved' | 'scheduled';
export type WorkflowContentType = 'video' | 'image-text';
export interface WorkflowVideoDraft {
script: string;
shootingGuide: string[];
duration: string; // e.g. '60초', '15분'
}
export interface WorkflowImageTextDraft {
type: 'cardnews' | 'blog';
headline: string;
copy: string[];
layoutHint?: string;
}
export interface WorkflowItem {
id: string;
title: string;
contentType: WorkflowContentType;
channel: string;
channelIcon: string;
stage: WorkflowStage;
userNotes?: string; // 사람이 입력한 수정 사항
videoDraft?: WorkflowVideoDraft;
imageTextDraft?: WorkflowImageTextDraft;
scheduledDate?: string;
}
export interface WorkflowData {
items: WorkflowItem[];
}
// ─── Root Plan Type ───
export interface MarketingPlan {
@ -181,4 +225,6 @@ export interface MarketingPlan {
contentStrategy: ContentStrategyData;
calendar: CalendarData;
assetCollection: AssetCollectionData;
repurposingProposals?: RepurposingProposalItem[];
workflow?: WorkflowData;
}