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
parent
b84410341f
commit
2027ae9b64
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,32 +207,57 @@ 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
|
||||
key={rule.rule}
|
||||
className={`rounded-2xl p-5 ${
|
||||
rule.correct
|
||||
? 'border-2 border-[#D5CDF5] bg-[#F3F0FF]/30'
|
||||
: 'border-2 border-[#F5D5DC] bg-[#FFF0F0]/30'
|
||||
}`}
|
||||
>
|
||||
<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" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-semibold text-[#0A1128]">{rule.rule}</p>
|
||||
<p className="text-sm text-slate-600 mt-1">{rule.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<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-4 border-2 border-[#D5CDF5] bg-[#F3F0FF]/40"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckFilled size={16} className="text-[#9B8AD4] 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>
|
||||
|
||||
{/* 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'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'T
|
||||
<CrossFilled size={16} className="text-[#D4889A]" /> DON'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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback((updated: CalendarEntry) => {
|
||||
// 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,
|
||||
entries: week.entries.map((e) =>
|
||||
(e.id && e.id === updated.id) ? updated : e
|
||||
),
|
||||
}))
|
||||
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
|
||||
)
|
||||
);
|
||||
if (onEntryUpdate && updated.id) {
|
||||
onEntryUpdate(updated.id, updated);
|
||||
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
|
||||
),
|
||||
}))
|
||||
);
|
||||
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) => (
|
||||
<div
|
||||
key={dayIdx}
|
||||
className={`min-h-[80px] rounded-xl p-1.5 ${
|
||||
entries.length > 0
|
||||
? 'bg-slate-50/50 border border-slate-100'
|
||||
: 'border border-dashed border-slate-200/60'
|
||||
}`}
|
||||
>
|
||||
{entries.map((entry, entryIdx) => renderEntry(entry, entryIdx))}
|
||||
</div>
|
||||
))}
|
||||
{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 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, 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -123,8 +123,8 @@ export default function MyAssetUpload() {
|
|||
return (
|
||||
<SectionWrapper
|
||||
id="my-asset-upload"
|
||||
title="My Assets"
|
||||
subtitle="나의 에셋 업로드"
|
||||
title="나의 소재"
|
||||
subtitle="소재 업로드 및 관리"
|
||||
>
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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회 이상 실행하면 성과 비교 및 전략 조정이 가능합니다
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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가 새로 생성하는 이미지, 텍스트, 영상' },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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초',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue