o2o-infinith-demo/src/components/plan/WorkflowTracker.tsx

386 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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>
);
}