Files
IdeaSDK/frontend/src/features/web/tickets/WebTicketDetail.tsx
Blomios e69361feb7 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>
2026-07-21 07:35:20 +02:00

444 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
);
}