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:
333
frontend/src/features/web/tickets/WebTicketsView.tsx
Normal file
333
frontend/src/features/web/tickets/WebTicketsView.tsx
Normal file
@ -0,0 +1,333 @@
|
||||
/**
|
||||
* "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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user