386 lines
16 KiB
TypeScript
386 lines
16 KiB
TypeScript
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"
|
||
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
|
||
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' : ''}`}
|
||
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
|
||
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>
|
||
);
|
||
}
|