/** * Ticket detail/edit view for the web workspace (ticket #86) — replaces the * list in the column (no floating window), with an explicit back button. * Sections are stacked accordions: `Résumé`, `Statut et priorité` and `Carnet` * open by default; `Agents assignés`, `Liens` and `Zone dangereuse` collapsed, * per carnet #86. * * Reuses `useTicketDetail` as-is (transport-neutral); only presentation and * copy are web-specific, including the conflict message, which this surface * renders in French independently of the hook's own (English, desktop-shared) * `error` string — the desktop `TicketDetail` does the same thing (its own * hardcoded copy keyed off `vm.conflict`, not `vm.error`). */ import { useEffect, useRef, useState } from "react"; import type { Sprint, TicketLinkKind, TicketPriority, TicketStatus } from "@/domain"; import { TICKET_LINK_KINDS, TICKET_PRIORITIES, TICKET_STATUSES, useTicketDetail } from "@/features/tickets"; import { Button, Panel, Spinner, cn } from "@/shared"; import type { WebProjectAgent } from "./useWebProjectAgents"; import { WebConfirmDialog } from "./WebConfirmDialog"; import { WebTicketPickerSheet } from "./WebTicketPickerSheet"; import { WebPriorityBadge, WebStatusBadge, webLinkKindLabel, webPriorityLabel, webStatusLabel } from "./webTicketLabels"; 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 WebTicketDetailProps { projectId: string; ticketRef: string; nameOf: (agentId: string) => string; assignableAgents: WebProjectAgent[]; sprints: Sprint[]; onBack: () => void; onOpenRef: (ref: string) => void; } function Accordion({ title, defaultOpen, children, }: { title: string; defaultOpen: boolean; children: React.ReactNode; }) { const [open, setOpen] = useState(defaultOpen); return (
{open &&
{children}
}
); } export function WebTicketDetail({ projectId, ticketRef, nameOf, assignableAgents, sprints, onBack, onOpenRef, }: WebTicketDetailProps) { const vm = useTicketDetail(projectId, ticketRef); const t = vm.ticket; const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); const [carnet, setCarnet] = useState(""); const [linkKind, setLinkKind] = useState("relatesTo"); const [assignPick, setAssignPick] = useState(""); const [confirmDelete, setConfirmDelete] = useState(false); const [confirmBack, setConfirmBack] = useState(false); const [showLinkPicker, setShowLinkPicker] = useState(false); useEffect(() => { if (vm.deleted) onBack(); }, [vm.deleted, onBack]); // Re-seed the draft only on a genuine (re)load — mirrors the desktop // `TicketDetail` (#9): an ordinary optimistic mutation (status/priority) // must never clobber an in-progress title/description/carnet edit. const reloadCount = vm.reloadCount; const seededReload = useRef(-1); const baseline = useRef<{ title: string; description: string; carnet: string } | null>(null); useEffect(() => { if (!t) return; if (seededReload.current === reloadCount) return; seededReload.current = reloadCount; const nextCarnet = t.carnet ?? ""; const base = baseline.current; const forced = base === null || vm.conflict; if (forced) { setTitle(t.title); setDescription(t.description); setCarnet(nextCarnet); } else { setTitle((cur) => (cur === base.title ? t.title : cur)); setDescription((cur) => (cur === base.description ? t.description : cur)); setCarnet((cur) => (cur === base.carnet ? nextCarnet : cur)); } baseline.current = { title: t.title, description: t.description, carnet: nextCarnet }; }, [t, reloadCount, vm.conflict]); const dirtyFields = !!t && (title !== t.title || description !== t.description); const dirtyCarnet = !!t && carnet !== (t.carnet ?? ""); const dirty = dirtyFields || dirtyCarnet; const assignable = assignableAgents.filter((a) => !t?.assignedAgentIds.includes(a.id)); function requestBack() { if (dirty) setConfirmBack(true); else onBack(); } async function saveAndBack() { let ok = true; if (dirtyFields) ok = (await vm.updateFields({ title, description })) && ok; if (ok && dirtyCarnet) ok = (await vm.saveCarnet(carnet)) && ok; setConfirmBack(false); if (ok) onBack(); } return (
{ticketRef}
{vm.conflict && (

Ce ticket a été modifié ailleurs et rechargé. Réappliquez votre modification.

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

{vm.error}

)} {vm.busy && !t ? (
Chargement du ticket…
) : !t ? (

Ticket introuvable.

) : (
{t.title} {t.sprintId && · {sprints.find((s) => s.id === t.sprintId)?.name ?? "Sprint"}}