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