1 line
16 KiB
TypeScript
1 line
16 KiB
TypeScript
'use client'; import { AnimatePresence, motion } from 'framer-motion'; import { useEffect, useMemo, useState } from 'react'; import { buildExecutionChecklist, type BlockedTreeNode } from '../../lib/kanban'; import { buildEditableIssueDraft, buildIssueUpdatePayload, validateEditableIssueDraft, type EditableIssueDraft, type EditableIssueFieldErrors, } from '../../lib/issue-editor'; import type { UpdateMutationPayload } from '../../lib/mutations'; import type { BeadIssue } from '../../lib/types'; import { Chip } from '../shared/chip'; interface KanbanDetailProps { issue: BeadIssue | null; issues?: BeadIssue[]; framed?: boolean; blockedTree?: { total: number; nodes: BlockedTreeNode[] }; outgoingBlocks?: { id: string; title: string; status: string }[]; onSelectBlockedIssue?: (issueId: string) => void; projectRoot?: string; editable?: boolean; onIssueUpdated?: (issueId: string) => Promise<void> | void; } const LEVEL_INDENT: Record<number, string> = { 1: 'ml-0', 2: 'ml-3', 3: 'ml-6', }; async function postIssueUpdate(body: UpdateMutationPayload): Promise<void> { const response = await fetch('/api/beads/update', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); const payload = (await response.json()) as { ok: boolean; error?: { message?: string } }; if (!response.ok || !payload.ok) { throw new Error(payload.error?.message ?? 'Update failed'); } } export function KanbanDetail({ issue, issues = [], framed = true, blockedTree, outgoingBlocks = [], onSelectBlockedIssue, projectRoot, editable = true, onIssueUpdated, }: KanbanDetailProps) { const frameClass = framed ? 'rounded-2xl border border-border-soft bg-gradient-to-b from-surface/80 to-surface/95 p-4 shadow-panel' : 'p-1'; const sectionClass = 'rounded-xl border border-border-soft/70 bg-gradient-to-b from-surface/45 to-surface/60 px-2.5 py-2'; const [editMode, setEditMode] = useState(false); const [draft, setDraft] = useState<EditableIssueDraft | null>(null); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState<string | null>(null); const [fieldErrors, setFieldErrors] = useState<EditableIssueFieldErrors>({}); const [optimisticIssue, setOptimisticIssue] = useState<BeadIssue | null>(null); useEffect(() => { setEditMode(false); setSaving(false); setSaveError(null); setFieldErrors({}); setDraft(issue ? buildEditableIssueDraft(issue) : null); setOptimisticIssue(issue); }, [issue]); const effectiveIssue = optimisticIssue ?? issue; const executionChecklist = useMemo( () => (effectiveIssue ? buildExecutionChecklist(effectiveIssue, issues) : []), [effectiveIssue, issues], ); const projectName = (effectiveIssue as BeadIssue & { project?: { name?: string } } | null)?.project?.name ?? null; const formattedSummary = effectiveIssue?.description?.replace(/\s-\s/g, '\n- ') ?? ''; const beginEdit = () => { if (!effectiveIssue) { return; } setEditMode(true); setDraft(buildEditableIssueDraft(effectiveIssue)); setFieldErrors({}); setSaveError(null); }; const cancelEdit = () => { if (effectiveIssue) { setDraft(buildEditableIssueDraft(effectiveIssue)); } setEditMode(false); setFieldErrors({}); setSaveError(null); }; const handleSave = async () => { if (!effectiveIssue || !draft || !projectRoot || !editable) { return; } const validation = validateEditableIssueDraft(draft); if (!validation.ok) { setFieldErrors(validation.errors); return; } const payload = buildIssueUpdatePayload(effectiveIssue, draft, projectRoot); if (!payload) { setEditMode(false); return; } setSaving(true); setSaveError(null); setFieldErrors({}); try { await postIssueUpdate(payload); setOptimisticIssue((current) => { if (!current) { return current; } return { ...current, title: payload.title ?? current.title, description: payload.description ?? current.description, status: payload.status ?? current.status, priority: payload.priority ?? current.priority, issue_type: payload.issueType ?? current.issue_type, assignee: payload.assignee ?? current.assignee, labels: payload.labels ?? current.labels, }; }); await onIssueUpdated?.(effectiveIssue.id); setEditMode(false); } catch (error) { setSaveError(error instanceof Error ? error.message : 'Save failed'); } finally { setSaving(false); } }; return ( <AnimatePresence mode="wait" initial={false}> {effectiveIssue ? ( <motion.aside key={effectiveIssue.id} initial={{ opacity: 0, x: 24 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 24 }} transition={{ duration: 0.2, ease: 'easeOut' }} className={frameClass} > <div className="flex flex-col gap-2 border-b border-border-soft/60 pb-3"> <div className="min-w-0 flex-1"> <div className="system-data text-xs text-text-muted break-all">{effectiveIssue.id}</div> <h2 className="ui-text mt-1 break-words text-lg font-semibold leading-7 text-text-strong sm:text-xl">{effectiveIssue.title}</h2> </div> <div className="flex flex-wrap items-center gap-2 self-start"> <Chip tone="status">{effectiveIssue.status}</Chip> <Chip tone="priority">P{effectiveIssue.priority}</Chip> {effectiveIssue.assignee ? <Chip>@{effectiveIssue.assignee}</Chip> : null} {projectName ? <Chip tone="status">project {projectName}</Chip> : null} {projectRoot && editable ? ( <button type="button" className="whitespace-nowrap rounded-lg border border-border-soft bg-gradient-to-b from-surface-muted/60 to-surface-muted/80 px-2 py-1 text-xs text-text-body hover:from-surface-muted/75 hover:to-surface-muted/90 shadow-[0_1px_2px_rgba(0,0,0,0.1)]" onClick={editMode ? cancelEdit : beginEdit} > {editMode ? 'Cancel edit' : 'Edit'} </button> ) : null} </div> </div> {editMode && draft ? ( <section className={`mt-3 ${sectionClass}`}> <div className="mb-2 flex items-center justify-between gap-2"> <p className="ui-text text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Edit fields</p> {saving ? <span className="ui-text text-xs text-sky-100">Saving...</span> : null} </div> <div className="grid gap-2"> <label className="ui-text text-xs text-text-muted"> Title <input className="ui-field ui-text mt-1 w-full rounded-md px-2 py-1 text-sm" value={draft.title} onChange={(event) => setDraft((current) => (current ? { ...current, title: event.target.value } : current))} /> </label> {fieldErrors.title ? <p className="ui-text text-xs text-rose-200">{fieldErrors.title}</p> : null} <label className="ui-text text-xs text-text-muted"> Description <textarea className="ui-field ui-text mt-1 min-h-20 w-full rounded-md px-2 py-1 text-sm" value={draft.description} onChange={(event) => setDraft((current) => (current ? { ...current, description: event.target.value } : current))} /> </label> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2"> <label className="ui-text text-xs text-text-muted"> Status <select className="ui-field ui-select system-data mt-1 w-full rounded-md px-2 py-1 text-sm" value={draft.status} onChange={(event) => setDraft((current) => (current ? { ...current, status: event.target.value as EditableIssueDraft['status'] } : current)) } > <option className="ui-option" value="open">open</option> <option className="ui-option" value="in_progress">in_progress</option> <option className="ui-option" value="blocked">blocked</option> <option className="ui-option" value="deferred">deferred</option> <option className="ui-option" value="closed">closed</option> </select> </label> <label className="ui-text text-xs text-text-muted"> Priority <select className="ui-field ui-select system-data mt-1 w-full rounded-md px-2 py-1 text-sm" value={String(draft.priority)} onChange={(event) => setDraft((current) => (current ? { ...current, priority: Number(event.target.value) } : current))} > {[0, 1, 2, 3, 4].map((priority) => ( <option className="ui-option" key={priority} value={priority}> {priority} </option> ))} </select> </label> </div> <label className="ui-text text-xs text-text-muted"> Type <input className="ui-field system-data mt-1 w-full rounded-md px-2 py-1 text-sm" value={draft.issueType} onChange={(event) => setDraft((current) => (current ? { ...current, issueType: event.target.value } : current))} /> </label> {fieldErrors.status ? <p className="ui-text text-xs text-rose-200">{fieldErrors.status}</p> : null} {fieldErrors.priority ? <p className="ui-text text-xs text-rose-200">{fieldErrors.priority}</p> : null} {fieldErrors.issueType ? <p className="ui-text text-xs text-rose-200">{fieldErrors.issueType}</p> : null} <label className="ui-text text-xs text-text-muted"> Assignee <input className="ui-field system-data mt-1 w-full rounded-md px-2 py-1 text-sm" value={draft.assignee} onChange={(event) => setDraft((current) => (current ? { ...current, assignee: event.target.value } : current))} /> </label> <label className="ui-text text-xs text-text-muted"> Owner (read-only from beads) <input className="ui-field system-data mt-1 w-full rounded-md px-2 py-1 text-sm opacity-70" value={draft.owner} disabled /> </label> <label className="ui-text text-xs text-text-muted"> Labels (comma separated) <input className="ui-field system-data mt-1 w-full rounded-md px-2 py-1 text-sm" value={draft.labelsInput} onChange={(event) => setDraft((current) => (current ? { ...current, labelsInput: event.target.value } : current))} /> </label> {fieldErrors.labelsInput ? <p className="ui-text text-xs text-rose-200">{fieldErrors.labelsInput}</p> : null} </div> {saveError ? <p className="ui-text mt-2 text-xs text-rose-200">{saveError}</p> : null} <div className="mt-3 flex items-center justify-end gap-2"> <button type="button" className="ui-text rounded-md border border-border-soft bg-surface-muted/70 px-2 py-1 text-xs text-text-body hover:bg-surface-raised" onClick={cancelEdit} > Cancel </button> <button type="button" disabled={saving} className="ui-text rounded-md border border-sky-300/50 bg-sky-500/15 px-2 py-1 text-xs text-sky-100 hover:bg-sky-500/25 disabled:opacity-60" onClick={() => void handleSave()} > {saving ? 'Saving...' : 'Save changes'} </button> </div> </section> ) : null} <div className="mt-3 space-y-3"> <section className={sectionClass}> <p className="ui-text text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Summary</p> {effectiveIssue.description ? ( <p className="ui-text mt-1.5 whitespace-pre-line text-sm leading-6 text-text-body break-words">{formattedSummary}</p> ) : ( <p className="ui-text mt-1.5 text-sm text-text-muted">No description provided.</p> )} </section> <section className={sectionClass}> <div className="flex items-center justify-between gap-2"> <p className="ui-text text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Execution checklist</p> <span className="system-data rounded-md border border-border-soft/70 bg-surface-muted/60 px-1.5 py-0.5 text-[10px] text-text-body"> {executionChecklist.filter((item) => item.passed).length}/{executionChecklist.length} </span> </div> <div className="mt-1.5 space-y-1"> {executionChecklist.map((item) => ( <div key={`${effectiveIssue.id}-${item.key}`} className="flex items-center justify-between rounded-md border border-border-soft/60 bg-surface-muted/40 px-2 py-1 text-xs" > <span className="ui-text text-text-body">{item.label}</span> <span className={`system-data ${item.passed ? 'text-emerald-200' : 'text-rose-200'}`}> {item.passed ? 'pass' : 'fail'} </span> </div> ))} </div> </section> </div> {blockedTree && blockedTree.total > 0 ? ( <div className={`mt-3 rounded-xl border border-rose-500/20 bg-rose-500/5 px-2.5 py-2`}> <div className="flex items-center justify-between gap-2"> <p className="ui-text text-[11px] font-semibold uppercase tracking-[0.12em] text-rose-200">Blocked by</p> <span className="system-data rounded-md border border-rose-500/30 bg-rose-500/10 px-1.5 py-0.5 text-[10px] text-rose-100"> {blockedTree.total} </span> </div> <div className="mt-1.5 space-y-1"> {blockedTree.nodes.map((node) => ( <button key={node.id} type="button" onClick={() => onSelectBlockedIssue?.(node.id)} className={`block w-full rounded-md border border-rose-500/20 bg-rose-500/5 px-2 py-1 text-left hover:border-rose-500/40 hover:bg-rose-500/10 ${LEVEL_INDENT[node.level] ?? 'ml-6' }`} > <div className="system-data text-[10px] text-rose-300/70">{node.id}</div> <div className="ui-text line-clamp-1 text-xs text-rose-100">{node.title}</div> </button> ))} {blockedTree.total > blockedTree.nodes.length ? ( <p className="system-data text-[10px] text-rose-300/50">+{blockedTree.total - blockedTree.nodes.length} more blockers</p> ) : null} </div> </div> ) : null} {/* Outgoing Blocks (Unlocks) */} {outgoingBlocks.length > 0 ? ( <div className={`mt-3 rounded-xl border border-emerald-500/20 bg-emerald-500/5 px-2.5 py-2`}> <div className="flex items-center justify-between gap-2"> <p className="ui-text text-[11px] font-semibold uppercase tracking-[0.12em] text-emerald-200">Unlocks</p> <span className="system-data rounded-md border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] text-emerald-100"> {outgoingBlocks.length} </span> </div> <div className="mt-1.5 space-y-1"> {outgoingBlocks.map((block) => ( <button key={block.id} type="button" onClick={() => onSelectBlockedIssue?.(block.id)} className="block w-full rounded-md border border-emerald-500/20 bg-emerald-500/5 px-2 py-1 text-left hover:border-emerald-500/40 hover:bg-emerald-500/10" > <div className="system-data text-[10px] text-emerald-300/70">{block.id}</div> <div className="ui-text line-clamp-1 text-xs text-emerald-100">{block.title}</div> </button> ))} </div> </div> ) : null} {/* Collapsible Metadata Section */} <details className="group mt-3"> <summary className="ui-text flex cursor-pointer items-center gap-2 rounded-lg border border-white/5 bg-white/[0.02] p-2 text-[10px] font-semibold uppercase tracking-widest text-text-muted transition-colors hover:bg-white/5"> <span>Task metadata</span> <span className="ml-auto transition-transform group-open:rotate-180">▼</span> </summary> <div className="mt-2 space-y-3 pl-1"> <section className={sectionClass}> <p className="ui-text text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Properties</p> <div className="mt-1.5 flex flex-wrap gap-1.5"> <Chip>{effectiveIssue.issue_type}</Chip> <Chip>{effectiveIssue.dependencies.length} dependencies</Chip> </div> </section> <section className={sectionClass}> <div className="flex items-center justify-between gap-2"> <p className="ui-text text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Timeline</p> </div> <dl className="mt-1.5 grid gap-1.5 text-sm text-text-body"> <div> <dt className="ui-text inline font-semibold text-text-strong">Created:</dt>{' '} <dd className="system-data inline break-all">{effectiveIssue.created_at || '-'}</dd> </div> <div> <dt className="ui-text inline font-semibold text-text-strong">Updated:</dt>{' '} <dd className="system-data inline break-all">{effectiveIssue.updated_at || '-'}</dd> </div> <div> <dt className="ui-text inline font-semibold text-text-strong">Closed:</dt>{' '} <dd className="system-data inline">{effectiveIssue.closed_at || '-'}</dd> </div> </dl> </section> {effectiveIssue.labels.length > 0 ? ( <section className={sectionClass}> <p className="ui-text text-[11px] font-semibold uppercase tracking-[0.12em] text-text-muted">Labels</p> <div className="mt-1.5 flex flex-wrap gap-1.5"> {effectiveIssue.labels.map((label) => ( <Chip key={`${effectiveIssue.id}-${label}`}>#{label}</Chip> ))} </div> </section> ) : null} </div> </details> </motion.aside> ) : ( <motion.aside key="empty-detail" initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 12 }} transition={{ duration: 0.18, ease: 'easeOut' }} className={framed ? 'rounded-2xl border border-border-soft bg-surface/80 p-4' : 'p-1'} > <strong className="ui-text text-text-strong">Details</strong> <p className="ui-text mt-1 text-sm text-text-muted">Select a card to inspect full issue details.</p> </motion.aside> )} </AnimatePresence> ); }
|