← back to Norma
components/drafts/SessionCard.tsx
304 lines
'use client';
import { useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronDown, ChevronRight, CheckCircle2, Trash2, Clock } from 'lucide-react';
import { useOrg } from '@/components/OrgProvider';
import type { LaneKey } from '@/lib/brand-types';
import DraftCard from './DraftCard';
import RalphyboyWidget from './RalphyboyWidget';
import type { DraftData } from './DraftEditor';
/* ─── Types ──────────────────────────────────────────────────────────────── */
interface Session {
id: string;
title?: string;
session_date: string;
status: string;
planning_checklist?: Record<string, boolean>;
planning_notes?: string;
created_at: string;
drafts: DraftData[];
}
interface SessionCardProps {
session: Session;
defaultExpanded?: boolean;
onDeleted: (id: string) => void;
onStatusChange: (id: string, status: string) => void;
}
/* ─── Helpers ────────────────────────────────────────────────────────────── */
function formatSessionDate(dateStr: string | null | undefined): string {
if (!dateStr) return 'No date';
try {
// pg driver may return DATE as full ISO string "2026-02-27T00:00:00.000Z"
// or as plain "2026-02-27" — handle both
const raw = String(dateStr);
const d = raw.includes('T') ? new Date(raw) : new Date(raw + 'T12:00:00Z');
if (isNaN(d.getTime())) return 'No date';
return d.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
} catch {
return 'No date';
}
}
const SESSION_STATUS_STYLE: Record<string, { cls: string; label: string }> = {
draft: { cls: 'badge-warning', label: 'Draft' },
reviewed: { cls: 'badge-success', label: 'Reviewed' },
sent: { cls: 'badge-success', label: 'Sent' },
};
/* ─── Lane ordering (A → B → C) ─────────────────────────────────────────── */
const LANE_ORDER: Array<'A' | 'B' | 'C'> = ['A', 'B', 'C'];
/* ─── SessionCard ────────────────────────────────────────────────────────── */
export default function SessionCard({
session,
defaultExpanded = false,
onDeleted,
onStatusChange,
}: SessionCardProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const [deleting, setDeleting] = useState(false);
const [drafts, setDrafts] = useState<DraftData[]>(session.drafts ?? []);
const { org } = useOrg();
const lanes = org?.lanes ?? {};
const statusMeta =
SESSION_STATUS_STYLE[session.status] ?? SESSION_STATUS_STYLE.draft;
const title =
session.title ??
`Morning Batch — ${formatSessionDate(session.session_date)}`;
/* Sort drafts A → B → C; fill gaps with placeholders */
const draftsByLane = Object.fromEntries(
drafts.map((d) => [d.lane, d])
) as Record<string, DraftData>;
function handleDraftUpdated(laneKey: string, updated: Partial<DraftData>) {
setDrafts((prev) =>
prev.map((d) => (d.lane === laneKey ? { ...d, ...updated } : d))
);
}
async function handleMarkReviewed() {
try {
const res = await fetch(`/api/sessions/${session.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'reviewed' }),
});
if (!res.ok) throw new Error('Update failed');
onStatusChange(session.id, 'reviewed');
} catch {
alert('Failed to mark as reviewed.');
}
}
async function handleDelete() {
if (
!confirm(
'Delete this morning batch and all its drafts? This cannot be undone.'
)
)
return;
setDeleting(true);
try {
const res = await fetch(`/api/sessions/${session.id}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error('Delete failed');
onDeleted(session.id);
} catch {
alert('Failed to delete session.');
setDeleting(false);
}
}
return (
<article
className="rounded-xl overflow-hidden"
style={{
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
}}
aria-label={`Session: ${title}`}
>
{/* ── Session Header ────────────────────────────────────────────────── */}
<div
className="flex items-center justify-between gap-3 px-4 py-3 cursor-pointer"
style={{
borderBottom: expanded ? '1px solid var(--color-border)' : 'none',
}}
onClick={() => setExpanded((v) => !v)}
role="button"
tabIndex={0}
aria-expanded={expanded}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setExpanded((v) => !v);
}
}}
>
<div className="flex items-center gap-2.5 min-w-0">
{expanded ? (
<ChevronDown
size={15}
style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}
aria-hidden="true"
/>
) : (
<ChevronRight
size={15}
style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}
aria-hidden="true"
/>
)}
<div className="min-w-0">
<p
className="text-sm font-semibold truncate"
style={{ color: 'var(--color-text)' }}
>
{title}
</p>
<p
className="text-xs mt-0.5 num"
style={{ color: 'var(--color-text-muted)' }}
>
<Clock
size={11}
className="inline mr-1"
aria-hidden="true"
/>
{formatSessionDate(session.session_date)}
</p>
</div>
</div>
<div
className="flex items-center gap-2 shrink-0"
onClick={(e) => e.stopPropagation()}
role="group"
aria-label="Session actions"
>
<span
className={`badge ${statusMeta.cls}`}
style={{ letterSpacing: '0.05em', fontSize: '0.7rem' }}
>
{statusMeta.label}
</span>
{session.status !== 'reviewed' && (
<button
type="button"
onClick={handleMarkReviewed}
className="btn btn-secondary btn-sm"
title="Mark session as reviewed"
>
<CheckCircle2 size={12} aria-hidden="true" />
<span className="hidden sm:inline">Reviewed</span>
</button>
)}
<button
type="button"
onClick={handleDelete}
disabled={deleting}
className="btn btn-danger btn-sm"
title="Delete this session"
aria-label="Delete session"
>
{deleting ? (
<span
className="spinner"
style={{ width: 12, height: 12, borderWidth: 1.5 }}
/>
) : (
<Trash2 size={12} aria-hidden="true" />
)}
</button>
</div>
</div>
{/* ── Collapsible Body ──────────────────────────────────────────────── */}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
key="body"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
style={{ overflow: 'hidden' }}
>
<div className="px-4 py-3 flex flex-col gap-3">
{/* Ralphyboy planning checklist */}
<RalphyboyWidget
sessionId={session.id}
initialChecklist={session.planning_checklist}
initialNotes={session.planning_notes}
/>
{/* Drafts for each lane */}
<div className="flex flex-col gap-2">
<p
className="text-xs font-semibold uppercase tracking-wider"
style={{ color: 'var(--color-text-muted)' }}
>
Email Drafts
</p>
{LANE_ORDER.map((laneKey) => {
const d = draftsByLane[laneKey];
const laneMeta = lanes[laneKey] ?? { label: laneKey, color: '#888', description: '' };
if (!d) {
return (
<div
key={laneKey}
className="rounded-lg px-3 py-2.5"
style={{
border: '1px dashed var(--color-border)',
borderRadius: 'var(--radius-md)',
}}
>
<div className="flex items-center gap-2">
<span
className={`badge lane-${laneKey.toLowerCase()}`}
>
{laneKey}
</span>
<span
className="text-sm"
style={{ color: 'var(--color-text-muted)' }}
>
{laneMeta.label} draft not generated yet
</span>
</div>
</div>
);
}
return (
<DraftCard
key={d.id}
draft={d}
onDraftUpdated={(updated) =>
handleDraftUpdated(laneKey, updated)
}
/>
);
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</article>
);
}