// Briefings management view — list + detail drawer. // // Routes (hash): // #/briefings → list view // #/briefings/:id → list view + drawer for that briefing on top // // Status state machine (from server/briefings/store.py): // draft → in_creative_review → in_business_review → approved // ↑ // any → rejected (with reason); rejected can be reopened to draft // any → archived (terminal) const { useState, useEffect, useMemo } = React; // ─── status pill colors ───────────────────────────────────────────────── const STATUS_META = { draft: { label: 'DRAFT', bg: '#f4f1ec', fg: '#7a7065', border: '#d6cfc2' }, in_creative_review: { label: 'IN CREATIVE REVIEW', bg: '#fdf4e3', fg: '#8a5a10', border: '#d6a85a' }, in_business_review: { label: 'IN BUSINESS REVIEW', bg: '#eef3fb', fg: '#345583', border: '#9bb6dc' }, approved: { label: 'APPROVED', bg: '#e8f3eb', fg: '#2c5d3a', border: '#83b893' }, rejected: { label: 'REJECTED', bg: '#fdecec', fg: '#a72020', border: '#e08a8a' }, archived: { label: 'ARCHIVED', bg: '#ececec', fg: '#666', border: '#c4c4c4' }, }; function StatusPill({ status, size = 'md' }) { const meta = STATUS_META[status] || STATUS_META.draft; const padding = size === 'sm' ? '2px 7px' : '4px 10px'; const fontSize = size === 'sm' ? 9 : 10; return ( {meta.label} ); } // ─── tiny helpers ─────────────────────────────────────────────────────── function relTime(iso) { if (!iso) return ''; const t = new Date(iso).getTime(); const diff = Date.now() - t; const mins = Math.floor(diff / 60000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); if (hrs < 24) return `${hrs}h ago`; const days = Math.floor(hrs / 24); if (days < 30) return `${days}d ago`; return new Date(iso).toLocaleDateString(); } function FilterChip({ label, count, active, onClick }) { return ( ); } // ─── BriefingsView (page) ─────────────────────────────────────────────── function BriefingsView() { const { briefings, briefingsById, briefingFilter, setBriefingFilter, route, navigate, createBriefing, } = useStore(); // Filter chips: "All" / per status. Counts come from the unfiltered list, // but in this in-memory backend the list is already filtered server-side. // For accurate counts, we'd need a separate "all" fetch. For now: just // show the active state without a count. const statuses = ['draft', 'in_creative_review', 'in_business_review', 'approved', 'rejected']; return (
{/* ── Editorial header ──────────────────────────────────────────── */}
Briefings desk
{String(briefings.length).padStart(3, '0')} on file
The brief desk.
Where conversations become creative direction. Filed, reviewed, and shipped.
{/* ── Filter row ────────────────────────────────────────────────── */}
setBriefingFilter({ ...briefingFilter, status: null })} /> setBriefingFilter({ ...briefingFilter, status: 'draft,in_creative_review,in_business_review', })} /> {statuses.map(s => ( setBriefingFilter({ ...briefingFilter, status: s })} /> ))}
{/* ── The index ─────────────────────────────────────────────────── */}
{briefings.length === 0 ? (
No briefings on file. Begin one above, or ask the agent in any thread.
) : (
{/* Column headers — kicker labels above the floating glass rows */}
Briefing Status Owner Updated
{briefings.map((b, i) => ( navigate(`/briefings/${b.id}`)} /> ))}
)}
); } function BriefingRow({ index, briefing, onClick }) { const [hover, setHover] = useState(false); return (
setHover(true)} onMouseLeave={() => setHover(false)} className="shimmer" style={{ display: 'grid', gridTemplateColumns: '36px 1fr 180px 140px 80px', padding: '20px 22px', gap: 16, alignItems: 'baseline', borderRadius: 'var(--radius)', background: 'var(--glass)', WebkitBackdropFilter: 'blur(24px) saturate(180%)', backdropFilter: 'blur(24px) saturate(180%)', border: `1px solid ${hover ? 'var(--glass-border-strong)' : 'var(--glass-border)'}`, boxShadow: hover ? 'var(--shadow-md), inset 0 1px 0 rgba(255,255,255,.85)' : 'var(--shadow-glass)', transform: hover ? 'translateY(-2px)' : 'none', cursor: 'pointer', transition: 'transform .35s var(--ease-fluid), box-shadow .35s var(--ease-fluid), border-color .35s var(--ease-fluid)', }} >
{String(index).padStart(2, '0')}
{briefing.title || 'Untitled brief'}
{briefing.subtitle && (
{briefing.subtitle}
)} {briefing.comment_count > 0 && (
{briefing.comment_count} {briefing.comment_count === 1 ? 'note' : 'notes'}
)}
{briefing.owner || 'unassigned'}
{relTime(briefing.updated_at)}
); } // ─── Drawer ───────────────────────────────────────────────────────────── // ─── BriefingDetailPage (full-page) ───────────────────────────────────── // // Replaces the old right-side drawer. Lives at /briefings/. When the // briefing has no marketing brief yet (just-created draft), the body // shows a full-page IntakePane that runs the CD agent against the // reserved thread_id. Once the brief lands, the body switches to the // standard review tabs. function BriefingDetailPage({ briefing }) { const { navigate, transitionBriefing, patchBriefing, commentOnBriefing, loadBriefing } = useStore(); const intakeMode = !briefing.brief && briefing.status === 'draft'; const [tab, setTab] = useState(intakeMode ? 'intake' : 'brief'); const [busy, setBusy] = useState(false); const [showRejectModal, setShowRejectModal] = useState(false); // When the brief actually lands during intake, jump the user to the // marketing brief tab so they see what was just produced. useEffect(() => { if (briefing.brief && tab === 'intake') setTab('brief'); }, [briefing.brief]); const handleTransition = async (to, reason = null) => { setBusy(true); try { await transitionBriefing(briefing.id, to, reason); } finally { setBusy(false); } }; const tabs = intakeMode ? [['intake', 'Intake (Creative Director)'], ['comments', `Comments (${(briefing.comments || []).length})`], ['activity', 'Activity']] : [ ['brief', 'Marketing brief'], ['cd_brief', 'Creative direction'], ['comments', `Comments (${(briefing.comments || []).length})`], ['activity', 'Activity'], ]; return (
{/* Top bar — back link + breadcrumb */}
{briefing.id}
{/* Header — title, status, owner, actions */}
{briefing.title}
Created {relTime(briefing.created_at)} · Updated {relTime(briefing.updated_at)}
Owner patchBriefing(briefing.id, { owner: v })} />
setShowRejectModal(true)} />
{/* Tabs */}
{tabs.map(([id, label]) => ( ))}
{/* Body — full bleed */}
{tab === 'intake' && ( loadBriefing && loadBriefing(briefing.id)} /> )} {tab === 'brief' && (
)} {tab === 'cd_brief' && (
)} {tab === 'comments' && (
commentOnBriefing(briefing.id, body)} />
)} {tab === 'activity' && (
)}
{/* Reject modal */} {showRejectModal && ( setShowRejectModal(false)} onConfirm={async (reason) => { setShowRejectModal(false); await handleTransition('rejected', reason); }} /> )}
); } // Wrapper that resolves the briefing record from the store before rendering // the page. Keeps the app's route dispatcher trivial. function BriefingDetailRoute() { const { route, briefingsById } = useStore(); const briefing = briefingsById[route.briefingId]; if (!briefing) { return (
Loading briefing…
); } return ; } function OwnerInput({ value, onSubmit }) { const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(value); React.useEffect(() => { setDraft(value); }, [value]); if (!editing) { return ( ); } return ( setDraft(e.target.value)} onBlur={() => { setEditing(false); if (draft !== value) onSubmit(draft); }} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); } if (e.key === 'Escape') { setDraft(value); setEditing(false); } }} style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid var(--ink)', background: 'var(--bg)', color: 'var(--ink)', fontSize: 12, fontFamily: 'inherit', outline: 'none', minWidth: 200, }} /> ); } function ActionButtons({ briefing, busy, onTransition, onReject }) { const buttons = []; switch (briefing.status) { case 'draft': buttons.push({ label: 'Submit for creative review', to: 'in_creative_review', primary: true }); buttons.push({ label: 'Archive', to: 'archived' }); break; case 'in_creative_review': buttons.push({ label: 'Approve creative · pass to business', to: 'in_business_review', primary: true }); buttons.push({ label: 'Reject', onClick: onReject, danger: true }); break; case 'in_business_review': buttons.push({ label: 'Approve business · final approval', to: 'approved', primary: true }); buttons.push({ label: 'Reject', onClick: onReject, danger: true }); break; case 'approved': buttons.push({ label: 'Archive', to: 'archived' }); break; case 'rejected': buttons.push({ label: 'Reopen as draft', to: 'draft' }); buttons.push({ label: 'Archive', to: 'archived' }); break; case 'archived': // Terminal — no actions break; } if (!buttons.length) return null; return (
{buttons.map((b, i) => ( ))}
); } function RejectModal({ onCancel, onConfirm }) { const [reason, setReason] = useState(''); return ( <>
Reject briefing
Add a short reason. The briefing can be reopened as draft.