/** * Ticket detail / edit overlay (F3, F4, F5, F7). * * A full-screen dialog mirroring the ticket: editable title/description, status * and priority, the ticket-scoped Markdown **carnet** (replaceable save, F4), * links to other tickets (F5) and agent assignments limited to known agents * (F5). Optimistic-concurrency conflicts surface a clear banner and reload * (F3). `#ref`s are clickable throughout (F7), and a one-click "delegation * context" prefill lets the user hand an agent "work on #N" (F7). */ import { useEffect, useState } from "react"; import type { TicketLinkKind } from "@/domain"; import { Button, Input, Spinner, cn } from "@/shared"; import { useTicketDetail } from "./useTicketDetail"; import { useProjectAgents } from "./useProjectAgents"; import { PriorityBadge, StatusBadge, TICKET_LINK_KINDS, TICKET_PRIORITIES, TICKET_STATUSES, TicketRef, linkKindLabel, linkifyTicketRefs, priorityLabel, statusLabel, } from "./ticketMeta"; const selectClass = cn( "h-9 rounded-md bg-raised px-3 text-sm text-content", "border border-border outline-none transition-colors", "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", ); const textareaClass = cn( "w-full rounded-md bg-raised p-3 text-sm text-content", "border border-border outline-none transition-colors", "focus:border-primary disabled:cursor-not-allowed disabled:opacity-50", ); export interface TicketDetailProps { projectId: string; ticketRef: string; onClose: () => void; /** Navigate to another ticket (from a link or an inline `#ref`, F7). */ onOpenRef: (ref: string) => void; } async function copyToClipboard(text: string): Promise { if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } return false; } function Section({ title, hint, children, }: { title: string; hint?: string; children: React.ReactNode; }) { return (

{title}

{hint &&

{hint}

}
{children}
); } export function TicketDetail({ projectId, ticketRef, onClose, onOpenRef, }: TicketDetailProps) { const vm = useTicketDetail(projectId, ticketRef); const { agents, nameOf } = useProjectAgents(projectId); const t = vm.ticket; // Local draft state, re-seeded whenever the loaded ticket version changes so a // conflict-reload or an external edit refreshes the fields. const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [carnet, setCarnet] = useState(""); const [linkTarget, setLinkTarget] = useState(""); const [linkKind, setLinkKind] = useState("relatesTo"); const [assignPick, setAssignPick] = useState(""); const [copied, setCopied] = useState(false); const version = t?.version; useEffect(() => { if (!t) return; setTitle(t.title); setDescription(t.description); setCarnet(t.carnet ?? ""); }, [t, version]); const dirtyFields = !!t && (title !== t.title || description !== t.description); const dirtyCarnet = !!t && carnet !== (t.carnet ?? ""); const assignable = agents.filter((a) => !t?.assignedAgentIds.includes(a.id)); async function copyDelegation() { if (!t) return; const ok = await copyToClipboard( `Travaille sur le ticket ${t.ref} : ${t.title}`, ); if (ok) { setCopied(true); window.setTimeout(() => setCopied(false), 1200); } } return (
{/* ── Header ── */}
{t && } {t && } {t?.title ?? ticketRef}
{vm.conflict && (

This ticket was modified elsewhere and reloaded. Re-apply your change.

)} {vm.error && !vm.conflict && (

{vm.error}

)} {vm.busy && !t ? (
Loading ticket…
) : !t ? (
Ticket not found.
) : (
{/* ── Fields (F3) ── */}
setTitle(e.target.value)} disabled={vm.busy} />