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>
334 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|