feat(frontend): tickets/sprints surface for the web workspace (#86 lot 2)

Adds a Live/Tickets/Sprints tab navigation to WebWorkspace after a project
is opened — a mobile-first adaptation, not a port of the desktop docks/
floating windows, per carnet #86. The web-server transport (17 ticket_*/
sprint_* commands) and the HttpTicketGateway were already wired (lot 1 +
pre-existing gateway code); this lot is UI only.

- Tickets tab: search/filters, list grouped by sprint, create screen,
  detail view with stacked accordion sections (Résumé/Statut et priorité/
  Carnet open by default; Agents assignés/Liens/Zone dangereuse collapsed),
  delete confirmation, optimistic-concurrency conflict banner.
- Sprints tab: create/rename/reorder (Monter/Descendre)/delete with
  confirmation, add tickets via a mobile full-screen ticket picker (never
  a small desktop modal), "Voir tickets" filters the Tickets tab to one
  sprint.
- Reuses the transport-neutral hooks as-is (useTickets, useTicketDetail,
  useTicketSearch) — only presentation and copy are web-specific, in
  French per decision #78 (new local label module, not a reuse of the
  English desktop ticketMeta labels).
- Workaround: `list_agents` isn't in the web-server allowlist, so agent
  names/assignment options are derived from `get_project_work_state`
  (already used by the Live tab) instead of the desktop's useProjectAgents.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-21 07:35:20 +02:00
parent 45def4844a
commit e69361feb7
11 changed files with 1942 additions and 1 deletions

View File

@ -0,0 +1,443 @@
/**
* 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 (
<section className="border-b border-border py-2 last:border-b-0">
<button
type="button"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
className="flex min-h-[32px] w-full items-center justify-between text-left text-xs font-semibold uppercase tracking-wide text-muted"
>
{title}
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
</button>
{open && <div className="mt-2 flex flex-col gap-2">{children}</div>}
</section>
);
}
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<TicketLinkKind>("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 (
<Panel className="flex flex-col" flush>
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-3">
<Button size="sm" variant="ghost" onClick={requestBack}>
Tickets
</Button>
<code className="rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content">{ticketRef}</code>
</div>
{vm.conflict && (
<p role="alert" className="border-b border-warning/40 bg-warning/10 px-4 py-2 text-sm text-warning">
Ce ticket a é modifié ailleurs et rechargé. Réappliquez votre modification.
</p>
)}
{vm.error && !vm.conflict && (
<p role="alert" className="border-b border-danger/40 bg-danger/10 px-4 py-2 text-sm text-danger">
{vm.error}
</p>
)}
{vm.busy && !t ? (
<div className="flex items-center gap-2 px-4 py-5 text-sm text-muted">
<Spinner size={14} />
<span>Chargement du ticket</span>
</div>
) : !t ? (
<p className="px-4 py-5 text-sm text-muted">Ticket introuvable.</p>
) : (
<div className="flex flex-col px-4 py-2">
<div className="flex flex-col gap-1 border-b border-border pb-3">
<span className="text-sm font-semibold text-content">{t.title}</span>
<span className="flex flex-wrap items-center gap-1.5 text-xs text-muted">
<WebStatusBadge status={t.status} />
<WebPriorityBadge priority={t.priority} />
{t.sprintId && <span>· {sprints.find((s) => s.id === t.sprintId)?.name ?? "Sprint"}</span>}
</span>
</div>
<Accordion title="Résumé" defaultOpen>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Titre</span>
<input
aria-label="Titre"
className={cn(selectClass, "w-full")}
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={vm.busy}
/>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted">Description</span>
<textarea
aria-label="Description"
className={cn(textareaClass, "min-h-24")}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={vm.busy}
/>
</label>
<div>
<Button
size="sm"
disabled={!dirtyFields || vm.busy}
loading={vm.busy && dirtyFields}
onClick={() => void vm.updateFields({ title, description })}
>
Enregistrer
</Button>
</div>
</Accordion>
<Accordion title="Statut et priorité" defaultOpen>
<label className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted">Statut</span>
<select
aria-label="Statut"
className={selectClass}
value={t.status}
disabled={vm.busy}
onChange={(e) => void vm.updateFields({ status: e.target.value as TicketStatus })}
>
{TICKET_STATUSES.map((s) => (
<option key={s} value={s}>
{webStatusLabel(s)}
</option>
))}
</select>
</label>
<label className="flex items-center justify-between gap-2 text-sm">
<span className="text-muted">Priorité</span>
<select
aria-label="Priorité"
className={selectClass}
value={t.priority}
disabled={vm.busy}
onChange={(e) => void vm.updateFields({ priority: e.target.value as TicketPriority })}
>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{webPriorityLabel(p)}
</option>
))}
</select>
</label>
{vm.busy && <Spinner size={12} />}
</Accordion>
<Accordion
title="Carnet"
defaultOpen
>
<textarea
aria-label="Carnet"
className={cn(textareaClass, "min-h-32 font-mono")}
value={carnet}
onChange={(e) => setCarnet(e.target.value)}
disabled={vm.busy}
/>
<div>
<Button
size="sm"
disabled={!dirtyCarnet || vm.busy}
loading={vm.busy && dirtyCarnet}
onClick={() => void vm.saveCarnet(carnet)}
>
Enregistrer le carnet
</Button>
</div>
</Accordion>
<Accordion title="Agents assignés" defaultOpen={false}>
{t.assignedAgentIds.length === 0 ? (
<p className="text-xs text-muted">Aucun agent assigné.</p>
) : (
<ul className="flex flex-wrap gap-2">
{t.assignedAgentIds.map((id) => (
<li key={id} className="flex items-center gap-1 rounded-full bg-raised px-2 py-0.5 text-xs text-content">
{nameOf(id)}
<button
type="button"
aria-label={`Désassigner ${nameOf(id)}`}
className="text-muted hover:text-danger disabled:opacity-50"
disabled={vm.busy}
onClick={() => void vm.assign(id, false)}
>
×
</button>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2">
<select
aria-label="Assigner un agent"
className={selectClass}
value={assignPick}
disabled={vm.busy || assignable.length === 0}
onChange={(e) => setAssignPick(e.target.value)}
>
<option value="">
{assignable.length === 0 ? "Aucun autre agent" : "Choisir un agent…"}
</option>
{assignable.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<Button
size="sm"
disabled={!assignPick || vm.busy}
onClick={async () => {
const ok = await vm.assign(assignPick, true);
if (ok) setAssignPick("");
}}
>
Assigner
</Button>
</div>
</Accordion>
<Accordion title="Liens" defaultOpen={false}>
{t.links.length === 0 ? (
<p className="text-xs text-muted">Aucun ticket lié.</p>
) : (
<ul className="flex flex-col gap-1">
{t.links.map((l) => (
<li key={`${l.kind}-${l.targetRef}`} className="flex items-center gap-2 text-sm">
<span className="text-xs text-muted">{webLinkKindLabel(l.kind)}</span>
<button
type="button"
className="rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content hover:text-primary"
onClick={() => onOpenRef(l.targetRef)}
>
{l.targetRef}
</button>
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => void vm.unlink(l.targetRef, l.kind)}
>
Retirer
</Button>
</li>
))}
</ul>
)}
<div className="flex flex-wrap items-center gap-2">
<select
aria-label="Type de lien"
className={selectClass}
value={linkKind}
disabled={vm.busy}
onChange={(e) => setLinkKind(e.target.value as TicketLinkKind)}
>
{TICKET_LINK_KINDS.map((k) => (
<option key={k} value={k}>
{webLinkKindLabel(k)}
</option>
))}
</select>
<Button size="sm" disabled={vm.busy} onClick={() => setShowLinkPicker(true)}>
+ Lier
</Button>
</div>
</Accordion>
<Accordion title="Zone dangereuse" defaultOpen={false}>
<p className="text-xs text-muted">
Le ticket {ticketRef} sera supprimé définitivement. Cette action ne supprime pas les
sprints ni les agents assignés.
</p>
<div>
<Button
size="sm"
variant="danger"
disabled={vm.busy}
onClick={() => setConfirmDelete(true)}
>
Supprimer
</Button>
</div>
</Accordion>
</div>
)}
{showLinkPicker && t && (
<WebTicketPickerSheet
projectId={projectId}
title="Lier un ticket"
selectionMode="single"
excludeRefs={[ticketRef, ...t.links.map((l) => l.targetRef)]}
onSelect={(result) => {
const picked = Array.isArray(result) ? result[0] : result;
if (picked) void vm.link(picked.ref, linkKind);
}}
onClose={() => setShowLinkPicker(false)}
/>
)}
{confirmDelete && (
<WebConfirmDialog
title="Supprimer ce ticket ?"
body={`Le ticket ${ticketRef} sera supprimé définitivement. Cette action ne supprime pas les sprints ni les agents assignés.`}
confirmLabel="Supprimer"
busy={vm.busy}
onCancel={() => setConfirmDelete(false)}
onConfirm={async () => {
const ok = await vm.remove();
if (ok) setConfirmDelete(false);
}}
/>
)}
{confirmBack && (
<div
role="alertdialog"
aria-modal="true"
aria-label="Modifications non enregistrées"
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4"
>
<div className="flex w-full max-w-sm flex-col gap-3 rounded-lg border border-border bg-raised p-4 shadow-xl">
<h3 className="text-sm font-semibold text-content">
Des modifications ne sont pas enregistrées.
</h3>
<div className="flex flex-wrap justify-end gap-2">
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => setConfirmBack(false)}>
Continuer l'édition
</Button>
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={onBack}>
Ignorer
</Button>
<Button size="sm" loading={vm.busy} disabled={vm.busy} onClick={() => void saveAndBack()}>
Enregistrer et quitter
</Button>
</div>
</div>
</div>
)}
</Panel>
);
}