Files
IdeA/frontend/src/features/web/tickets/WebTicketsView.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

334 lines
12 KiB
TypeScript

/**
* "Tickets" tab of the web workspace (ticket #86).
*
* A mobile-first adaptation of the desktop `TicketsPanel`/`TicketDetail` pair:
* one vertical column that swaps between **list**, **create**, and **detail**
* modes in place — never a floating window / dock, per carnet #86. Reuses the
* transport-neutral `useTickets` view-model as-is; only the presentation (and
* the French labels — decision #78) is web-specific.
*/
import { useState } from "react";
import type { Sprint, TicketSummary } from "@/domain";
import type { TicketListSortField } from "@/ports";
import { TICKET_PRIORITIES, TICKET_STATUSES, useTickets } from "@/features/tickets";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { WebTicketCreate } from "./WebTicketCreate";
import { WebTicketDetail } from "./WebTicketDetail";
import { useWebProjectAgents } from "./useWebProjectAgents";
import { WebStatusBadge, WebPriorityBadge, webStatusLabel, webPriorityLabel } from "./webTicketLabels";
const selectClass = cn(
"h-8 rounded-md bg-raised px-2 text-xs text-content",
"border border-border outline-none transition-colors",
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
);
const SORT_FIELD_LABEL: Record<TicketListSortField, string> = {
number: "Numéro",
priority: "Priorité",
status: "Statut",
title: "Titre",
};
type Mode = { type: "list" } | { type: "create" } | { type: "detail"; ref: string };
export interface WebTicketsViewProps {
projectId: string;
/**
* When set, the list initially shows only this sprint's tickets (from the
* Sprints tab's "Voir tickets"), with a link back to the full list.
*/
focusSprintId?: string | null;
}
export function WebTicketsView({ projectId, focusSprintId = null }: WebTicketsViewProps) {
const vm = useTickets(projectId);
const webAgents = useWebProjectAgents(projectId);
const [mode, setMode] = useState<Mode>({ type: "list" });
const [sprintFocus, setSprintFocus] = useState<string | null>(focusSprintId);
if (mode.type === "detail") {
return (
<WebTicketDetail
key={mode.ref}
projectId={projectId}
ticketRef={mode.ref}
nameOf={webAgents.nameOf}
assignableAgents={webAgents.agents}
sprints={vm.sprints}
onBack={() => setMode({ type: "list" })}
onOpenRef={(ref) => setMode({ type: "detail", ref })}
/>
);
}
if (mode.type === "create") {
return (
<WebTicketCreate
sprints={vm.sprints}
busy={vm.busy}
error={vm.error}
onCancel={() => setMode({ type: "list" })}
onCreate={async (input, sprintId) => {
const created = await vm.create(input);
if (!created) return;
if (sprintId) await vm.assignSprint(created.ref, sprintId);
setMode({ type: "detail", ref: created.ref });
}}
/>
);
}
const items = vm.list?.items ?? [];
const hasFilters =
!!vm.query.text ||
(vm.query.statuses?.length ?? 0) > 0 ||
(vm.query.priorities?.length ?? 0) > 0 ||
!!vm.query.assignedAgentId ||
!!sprintFocus;
const sprintIds = new Set(vm.sprints.map((s) => s.id));
const visibleSprints = sprintFocus ? vm.sprints.filter((s) => s.id === sprintFocus) : vm.sprints;
const grouped = visibleSprints
.map((sprint) => ({ sprint, tickets: items.filter((t) => t.sprintId === sprint.id) }))
.filter((g) => g.tickets.length > 0);
const noSprint = sprintFocus
? []
: items.filter((t) => !t.sprintId || !sprintIds.has(t.sprintId));
function resetFilters() {
vm.setQuery({});
setSprintFocus(null);
}
return (
<Panel
className="flex flex-col"
title="Tickets"
actions={
<Button size="sm" onClick={() => setMode({ type: "create" })}>
+ Ticket
</Button>
}
>
{vm.error && (
<div className="mb-3 flex flex-wrap items-center justify-between gap-2 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
<span>{vm.error}</span>
<Button size="sm" variant="ghost" onClick={() => void vm.refresh()}>
Réessayer
</Button>
</div>
)}
{sprintFocus && (
<div className="mb-3 flex items-center justify-between gap-2 rounded-md border border-border bg-raised/50 px-3 py-2 text-xs text-muted">
<span>
Filtré sur le sprint « {vm.sprints.find((s) => s.id === sprintFocus)?.name ?? sprintFocus} »
</span>
<Button size="sm" variant="ghost" onClick={() => setSprintFocus(null)}>
Voir tous les tickets
</Button>
</div>
)}
{/* ── Search + compact filters ── */}
<div className="mb-3 flex flex-col gap-2">
<Input
aria-label="Rechercher des tickets"
placeholder="Recherche…"
value={vm.query.text ?? ""}
onChange={(e) => vm.setQuery({ ...vm.query, text: e.target.value || undefined })}
/>
<div className="flex flex-wrap items-center gap-2">
<select
aria-label="Filtrer par statut"
className={selectClass}
value={vm.query.statuses?.[0] ?? ""}
onChange={(e) =>
vm.setQuery({
...vm.query,
statuses: e.target.value ? [e.target.value as (typeof TICKET_STATUSES)[number]] : undefined,
})
}
>
<option value="">Tous les statuts</option>
{TICKET_STATUSES.map((s) => (
<option key={s} value={s}>
{webStatusLabel(s)}
</option>
))}
</select>
<select
aria-label="Filtrer par priorité"
className={selectClass}
value={vm.query.priorities?.[0] ?? ""}
onChange={(e) =>
vm.setQuery({
...vm.query,
priorities: e.target.value
? [e.target.value as (typeof TICKET_PRIORITIES)[number]]
: undefined,
})
}
>
<option value="">Toutes les priorités</option>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{webPriorityLabel(p)}
</option>
))}
</select>
<select
aria-label="Filtrer par agent assigné"
className={selectClass}
value={vm.query.assignedAgentId ?? ""}
onChange={(e) =>
vm.setQuery({ ...vm.query, assignedAgentId: e.target.value || undefined })
}
>
<option value="">Tous les agents</option>
{webAgents.agents.map((a) => (
<option key={a.id} value={a.id}>
{a.name}
</option>
))}
</select>
<select
aria-label="Trier les tickets"
className={selectClass}
value={vm.query.sort?.field ?? ""}
onChange={(e) => {
const field = e.target.value as TicketListSortField | "";
const { sort: _drop, ...rest } = vm.query;
vm.setQuery(field ? { ...rest, sort: { field, direction: "asc" } } : rest);
}}
>
<option value="">Tri par défaut</option>
{(Object.keys(SORT_FIELD_LABEL) as TicketListSortField[]).map((f) => (
<option key={f} value={f}>
{SORT_FIELD_LABEL[f]}
</option>
))}
</select>
</div>
</div>
{/* ── List, grouped by sprint ── */}
{vm.busy && vm.list === null ? (
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
<Spinner size={12} /> Chargement des tickets
</span>
) : items.length === 0 && !hasFilters ? (
<div className="flex flex-col items-start gap-2">
<p className="text-sm text-muted">Aucun ticket dans ce projet.</p>
<Button size="sm" onClick={() => setMode({ type: "create" })}>
Créer un ticket
</Button>
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-start gap-2">
<p className="text-sm text-muted">Aucun ticket ne correspond aux filtres.</p>
<Button size="sm" variant="ghost" onClick={resetFilters}>
Réinitialiser les filtres
</Button>
</div>
) : (
<div className="flex flex-col gap-4" data-testid="web-ticket-list">
{grouped.map((g) => (
<TicketGroup
key={g.sprint.id}
heading={g.sprint.name}
tickets={g.tickets}
sprints={vm.sprints}
nameOf={webAgents.nameOf}
onOpen={(ref) => setMode({ type: "detail", ref })}
/>
))}
{noSprint.length > 0 && (
<TicketGroup
heading="Sans sprint"
tickets={noSprint}
sprints={vm.sprints}
nameOf={webAgents.nameOf}
onOpen={(ref) => setMode({ type: "detail", ref })}
/>
)}
</div>
)}
{vm.list?.nextCursor && (
<div className="mt-3 flex justify-center">
<Button
size="sm"
variant="ghost"
loading={vm.busy}
onClick={() => vm.setQuery({ ...vm.query, limit: (vm.query.limit ?? 100) + 100 })}
>
Charger plus
</Button>
</div>
)}
</Panel>
);
}
function TicketGroup({
heading,
tickets,
sprints,
nameOf,
onOpen,
}: {
heading: string;
tickets: TicketSummary[];
sprints: Sprint[];
nameOf: (id: string) => string;
onOpen: (ref: string) => void;
}) {
const sprintNameOf = (id: string | null | undefined) =>
id ? sprints.find((s) => s.id === id)?.name : undefined;
return (
<section aria-label={`Tickets — ${heading}`}>
<h3 className="mb-1 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted">
{heading}
<span className="rounded-full bg-raised px-1.5 text-[10px] font-medium text-muted">
{tickets.length}
</span>
</h3>
<ul className="flex flex-col divide-y divide-border">
{tickets.map((t) => {
const sprintName = sprintNameOf(t.sprintId);
const assignees = t.assignedAgentIds.map(nameOf).join(", ");
return (
<li key={t.ref}>
<button
type="button"
onClick={() => onOpen(t.ref)}
aria-label={`${t.ref}, ${t.title}, statut ${webStatusLabel(t.status)}, priorité ${webPriorityLabel(t.priority)}`}
className="flex w-full min-w-0 flex-col items-start gap-0.5 rounded-md px-2 py-2 text-left transition-colors hover:bg-raised"
>
<span className="flex w-full min-w-0 items-baseline gap-2">
<code className="shrink-0 rounded bg-raised px-1.5 py-0.5 font-mono text-xs text-content">
{t.ref}
</code>
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
{t.title}
</span>
</span>
<span className="flex flex-wrap items-center gap-1.5 text-xs text-muted">
<WebStatusBadge status={t.status} />
<WebPriorityBadge priority={t.priority} />
{sprintName && <span>· {sprintName}</span>}
{assignees && <span>· {assignees}</span>}
</span>
</button>
</li>
);
})}
</ul>
</section>
);
}