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>
444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
/**
|
||
* 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 été 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>
|
||
);
|
||
}
|