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:
@ -36,4 +36,7 @@ export {
|
||||
statusLabel,
|
||||
priorityLabel,
|
||||
linkKindLabel,
|
||||
TICKET_STATUSES,
|
||||
TICKET_PRIORITIES,
|
||||
TICKET_LINK_KINDS,
|
||||
} from "./ticketMeta";
|
||||
|
||||
@ -29,6 +29,8 @@ import { Button, Panel, Spinner, cn } from "@/shared";
|
||||
import { WebAgentCell } from "./WebAgentCell";
|
||||
import { useLiveReconnect } from "./useLiveReconnect";
|
||||
import { useLiveConnectionState } from "./useLiveConnectionState";
|
||||
import { WebTicketsView } from "./tickets/WebTicketsView";
|
||||
import { WebSprintsView } from "./tickets/WebSprintsView";
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
@ -118,12 +120,109 @@ export function WebWorkspace() {
|
||||
)}
|
||||
|
||||
{openId && (
|
||||
<LiveProjectPanel projectId={openId} root={openRoot} />
|
||||
<ProjectTabs projectId={openId} root={openRoot} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ProjectTab = "live" | "tickets" | "sprints";
|
||||
|
||||
const PROJECT_TABS: { id: ProjectTab; label: string }[] = [
|
||||
{ id: "live", label: "Live" },
|
||||
{ id: "tickets", label: "Tickets" },
|
||||
{ id: "sprints", label: "Sprints" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Project navigation for the web workspace (ticket #86): `Live` / `Tickets` /
|
||||
* `Sprints`, a compact tablist above the project surfaces — never the desktop
|
||||
* dock/layout-grid/floating-window shell (carnet #86). Each tab keeps its own
|
||||
* data (own hook instance), so switching away and back re-fetches fresh rather
|
||||
* than caching stale state across tabs.
|
||||
*/
|
||||
function ProjectTabs({ projectId, root }: { projectId: string; root: string | null }) {
|
||||
const [tab, setTab] = useState<ProjectTab>("live");
|
||||
// Set by the Sprints tab's "Voir tickets"; consumed once by the Tickets tab.
|
||||
const [focusSprintId, setFocusSprintId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-3">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Navigation du projet"
|
||||
className="flex gap-1 overflow-x-auto border-b border-border"
|
||||
>
|
||||
{PROJECT_TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`web-project-tab-${t.id}`}
|
||||
aria-selected={tab === t.id}
|
||||
aria-controls={`web-project-tabpanel-${t.id}`}
|
||||
tabIndex={tab === t.id ? 0 : -1}
|
||||
onClick={() => setTab(t.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
||||
e.preventDefault();
|
||||
const i = PROJECT_TABS.findIndex((x) => x.id === tab);
|
||||
const next =
|
||||
e.key === "ArrowRight"
|
||||
? (i + 1) % PROJECT_TABS.length
|
||||
: (i - 1 + PROJECT_TABS.length) % PROJECT_TABS.length;
|
||||
setTab(PROJECT_TABS[next].id);
|
||||
}}
|
||||
className={cn(
|
||||
"min-h-[32px] shrink-0 rounded-t-md border-b-2 px-3 py-1.5 text-sm font-medium transition-colors",
|
||||
tab === t.id
|
||||
? "border-primary text-content"
|
||||
: "border-transparent text-muted hover:text-content",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="web-project-tabpanel-live"
|
||||
aria-labelledby="web-project-tab-live"
|
||||
hidden={tab !== "live"}
|
||||
>
|
||||
{tab === "live" && <LiveProjectPanel projectId={projectId} root={root} />}
|
||||
</div>
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="web-project-tabpanel-tickets"
|
||||
aria-labelledby="web-project-tab-tickets"
|
||||
hidden={tab !== "tickets"}
|
||||
>
|
||||
{tab === "tickets" && (
|
||||
<WebTicketsView projectId={projectId} focusSprintId={focusSprintId} />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
role="tabpanel"
|
||||
id="web-project-tabpanel-sprints"
|
||||
aria-labelledby="web-project-tab-sprints"
|
||||
hidden={tab !== "sprints"}
|
||||
>
|
||||
{tab === "sprints" && (
|
||||
<WebSprintsView
|
||||
projectId={projectId}
|
||||
onViewSprintTickets={(sprintId) => {
|
||||
setFocusSprintId(sprintId);
|
||||
setTab("tickets");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Global reconnection banner (F6). The F3 terminal path writes a "déconnecté"
|
||||
* notice into xterm, but live-only surfaces (no terminal open) had no visible
|
||||
|
||||
78
frontend/src/features/web/tickets/WebConfirmDialog.tsx
Normal file
78
frontend/src/features/web/tickets/WebConfirmDialog.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Destructive-action confirmation for the web tickets/sprints surface (ticket
|
||||
* #86). Local to this feature, like `AppExitConfirmDialog` and
|
||||
* `features/devices/ConfirmDialog` are local to theirs — a shared dialog
|
||||
* primitive is a design-system decision outside this lot.
|
||||
*
|
||||
* `role="alertdialog"`, focus on `Annuler` by default, `Échap` cancels, no
|
||||
* dismiss-on-outside-click (a destructive confirmation should not be dismissed
|
||||
* ambiguously), per carnet #86's accessibility section.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { Button, zIndex } from "@/shared";
|
||||
|
||||
export interface WebConfirmDialogProps {
|
||||
title: string;
|
||||
body: string;
|
||||
confirmLabel: string;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function WebConfirmDialog({
|
||||
title,
|
||||
body,
|
||||
confirmLabel,
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: WebConfirmDialogProps) {
|
||||
const cancelRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cancelRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onCancel();
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onCancel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center bg-black/50 p-4"
|
||||
style={{ zIndex: zIndex.toast }}
|
||||
>
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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">{title}</h3>
|
||||
<p className="text-sm text-muted">{body}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button ref={cancelRef} size="sm" variant="ghost" disabled={busy} onClick={onCancel}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
loading={busy}
|
||||
disabled={busy}
|
||||
onClick={() => void onConfirm()}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
233
frontend/src/features/web/tickets/WebSprintsView.tsx
Normal file
233
frontend/src/features/web/tickets/WebSprintsView.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
/**
|
||||
* "Sprints" tab of the web workspace (ticket #86): create, rename, reorder
|
||||
* (Monter/Descendre — no drag-and-drop required) and delete sprints, plus
|
||||
* add/remove tickets via the mobile-adapted ticket picker. A dedicated surface
|
||||
* — not an overlay above the ticket list like the desktop `SprintManager` —
|
||||
* reachable directly from the project tab bar.
|
||||
*
|
||||
* Reuses `useTickets` (sprint CRUD) and `useTicketSearch` (to resolve which
|
||||
* tickets belong to a sprint client-side, exactly like the desktop
|
||||
* `SprintManager` — `ticket_list` has no server-side `sprintId` filter).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTickets, useTicketSearch } from "@/features/tickets";
|
||||
import { Button, Input, Panel, Spinner } from "@/shared";
|
||||
import { WebConfirmDialog } from "./WebConfirmDialog";
|
||||
import { WebTicketPickerSheet } from "./WebTicketPickerSheet";
|
||||
|
||||
export interface WebSprintsViewProps {
|
||||
projectId: string;
|
||||
/** Switches to the Tickets tab, optionally pre-filtered to one sprint. */
|
||||
onViewSprintTickets: (sprintId: string) => void;
|
||||
}
|
||||
|
||||
export function WebSprintsView({ projectId, onViewSprintTickets }: WebSprintsViewProps) {
|
||||
const vm = useTickets(projectId);
|
||||
// Independent of the Tickets tab's own query (mirrors desktop SprintManager):
|
||||
// used only to resolve which refs already belong to a sprint, for the
|
||||
// "ajouter des tickets" picker's exclude set.
|
||||
const search = useTicketSearch(projectId, { refreshOnEvents: true });
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [renamingId, setRenamingId] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string } | null>(null);
|
||||
const [pickerSprint, setPickerSprint] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const sprints = [...vm.sprints].sort((a, b) => a.order - b.order);
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
const created = await vm.createSprint(name);
|
||||
if (created) {
|
||||
setNewName("");
|
||||
setShowCreate(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Sprints"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setShowCreate((v) => !v)}>
|
||||
+ Sprint
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{vm.error && (
|
||||
<p role="alert" className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||
{vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<form onSubmit={handleCreate} className="mb-3 flex flex-col gap-2 rounded-md border border-border bg-raised/50 p-2">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Nom du sprint</span>
|
||||
<Input
|
||||
aria-label="Nom du sprint"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setShowCreate(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={!newName.trim() || vm.busy}>
|
||||
Créer
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{vm.busy && vm.sprints.length === 0 ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-sm text-muted">
|
||||
<Spinner size={12} /> Chargement des sprints…
|
||||
</span>
|
||||
) : sprints.length === 0 ? (
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<p className="text-sm text-muted">Aucun sprint.</p>
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||
Créer un sprint
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{sprints.map((sprint, index) => {
|
||||
const renaming = renamingId === sprint.id;
|
||||
return (
|
||||
<li key={sprint.id} className="rounded-md border border-border p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-muted">#{sprint.order}</span>
|
||||
{!renaming && (
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-content">
|
||||
{sprint.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renaming ? (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<Input
|
||||
aria-label={`Renommer le sprint ${sprint.name}`}
|
||||
value={renameDraft}
|
||||
onChange={(e) => setRenameDraft(e.target.value)}
|
||||
disabled={vm.busy}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" disabled={vm.busy} onClick={() => setRenamingId(null)}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={vm.busy || !renameDraft.trim() || renameDraft.trim() === sprint.name}
|
||||
onClick={async () => {
|
||||
const ok = await vm.renameSprint(sprint.id, renameDraft.trim());
|
||||
if (ok) setRenamingId(null);
|
||||
}}
|
||||
>
|
||||
Enregistrer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-1 text-xs text-muted">{sprint.ticketCount} ticket{sprint.ticketCount > 1 ? "s" : ""}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => onViewSprintTickets(sprint.id)}>
|
||||
Voir tickets
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setPickerSprint({ id: sprint.id, name: sprint.name })}
|
||||
>
|
||||
Ajouter tickets
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Renommer ${sprint.name}`}
|
||||
disabled={vm.busy}
|
||||
onClick={() => {
|
||||
setRenamingId(sprint.id);
|
||||
setRenameDraft(sprint.name);
|
||||
}}
|
||||
>
|
||||
Renommer
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Monter ${sprint.name}`}
|
||||
disabled={vm.busy || index === 0}
|
||||
onClick={() => void vm.moveSprint(sprint.id, "up")}
|
||||
>
|
||||
Monter
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Descendre ${sprint.name}`}
|
||||
disabled={vm.busy || index === sprints.length - 1}
|
||||
onClick={() => void vm.moveSprint(sprint.id, "down")}
|
||||
>
|
||||
Descendre
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label={`Supprimer ${sprint.name}`}
|
||||
disabled={vm.busy}
|
||||
className="text-danger hover:text-danger"
|
||||
onClick={() => setConfirmDelete({ id: sprint.id, name: sprint.name })}
|
||||
>
|
||||
Supprimer
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<WebConfirmDialog
|
||||
title={`Supprimer le sprint « ${confirmDelete.name} » ?`}
|
||||
body="Les tickets de ce sprint seront conservés et passeront en « Sans sprint »."
|
||||
confirmLabel="Supprimer"
|
||||
busy={vm.busy}
|
||||
onCancel={() => setConfirmDelete(null)}
|
||||
onConfirm={async () => {
|
||||
await vm.deleteSprint(confirmDelete.id);
|
||||
setConfirmDelete(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pickerSprint && (
|
||||
<WebTicketPickerSheet
|
||||
projectId={projectId}
|
||||
title={`Ajouter des tickets au sprint « ${pickerSprint.name} »`}
|
||||
confirmLabel="Ajouter au sprint"
|
||||
selectionMode="multi"
|
||||
excludeRefs={search.rows.filter((t) => t.sprintId === pickerSprint.id).map((t) => t.ref)}
|
||||
onSelect={(result) => {
|
||||
const picked = Array.isArray(result) ? result : [result];
|
||||
for (const p of picked) void vm.assignSprint(p.ref, pickerSprint.id);
|
||||
}}
|
||||
onClose={() => setPickerSprint(null)}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
141
frontend/src/features/web/tickets/WebTicketCreate.tsx
Normal file
141
frontend/src/features/web/tickets/WebTicketCreate.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Ticket creation screen for the web workspace (ticket #86) — replaces the list
|
||||
* in the column (no inline permanent form, no desktop popup), per carnet #86.
|
||||
*
|
||||
* Short form: Titre (required) / Priorité / Sprint / Description (optional).
|
||||
* On success the caller opens the created ticket's detail so the user can
|
||||
* complete carnet/assignments/links right away.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { CreateTicketInput } from "@/ports";
|
||||
import type { Sprint, TicketPriority } from "@/domain";
|
||||
import { Button, Input, Panel, cn } from "@/shared";
|
||||
import { TICKET_PRIORITIES } from "@/features/tickets";
|
||||
import { webPriorityLabel } 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 WebTicketCreateProps {
|
||||
sprints: Sprint[];
|
||||
busy: boolean;
|
||||
error: string | null;
|
||||
onCancel: () => void;
|
||||
onCreate: (input: CreateTicketInput, sprintId: string | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export function WebTicketCreate({ sprints, busy, error, onCancel, onCreate }: WebTicketCreateProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [priority, setPriority] = useState<TicketPriority>("medium");
|
||||
const [sprintId, setSprintId] = useState<string>("");
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return;
|
||||
await onCreate(
|
||||
{
|
||||
title: trimmed,
|
||||
priority,
|
||||
...(description.trim() ? { description: description.trim() } : {}),
|
||||
},
|
||||
sprintId || null,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Nouveau ticket"
|
||||
actions={
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}>
|
||||
Annuler
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<p role="alert" className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Titre</span>
|
||||
<Input
|
||||
aria-label="Titre"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
disabled={busy}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Priorité</span>
|
||||
<select
|
||||
aria-label="Priorité"
|
||||
className={selectClass}
|
||||
value={priority}
|
||||
disabled={busy}
|
||||
onChange={(e) => setPriority(e.target.value as TicketPriority)}
|
||||
>
|
||||
{TICKET_PRIORITIES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{webPriorityLabel(p)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs font-medium text-muted">Sprint</span>
|
||||
<select
|
||||
aria-label="Sprint"
|
||||
className={selectClass}
|
||||
value={sprintId}
|
||||
disabled={busy}
|
||||
onChange={(e) => setSprintId(e.target.value)}
|
||||
>
|
||||
<option value="">Sans sprint</option>
|
||||
{sprints.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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-20")}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button type="button" variant="ghost" size="sm" disabled={busy} onClick={onCancel}>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button type="submit" size="sm" disabled={!title.trim() || busy} loading={busy}>
|
||||
Créer
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
443
frontend/src/features/web/tickets/WebTicketDetail.tsx
Normal file
443
frontend/src/features/web/tickets/WebTicketDetail.tsx
Normal file
@ -0,0 +1,443 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
207
frontend/src/features/web/tickets/WebTicketPickerSheet.tsx
Normal file
207
frontend/src/features/web/tickets/WebTicketPickerSheet.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Mobile-adapted ticket picker for the web workspace (ticket #86): a full-screen
|
||||
* panel — never a small centered desktop modal — with a fixed header, search at
|
||||
* the top, a scrollable result list, and actions pinned at the bottom. Reused
|
||||
* for both "ajouter des tickets à un sprint" (multi-select) and "lier un
|
||||
* ticket" (single-select), same as the desktop `TicketPicker` reuses across
|
||||
* those two call sites.
|
||||
*
|
||||
* Business logic (search/pagination) comes straight from the transport-neutral
|
||||
* `useTicketSearch` hook; only the chrome is web-specific.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { TicketRef } from "@/domain";
|
||||
import { useTicketSearch, type TicketPickerResult } from "@/features/tickets";
|
||||
import { Button, Input, Spinner, cn, zIndex } from "@/shared";
|
||||
import { WebPriorityBadge, WebStatusBadge } from "./webTicketLabels";
|
||||
|
||||
export type WebTicketPickerSelectionMode = "single" | "multi";
|
||||
|
||||
export interface WebTicketPickerSheetProps {
|
||||
projectId: string;
|
||||
title: string;
|
||||
/** Multi-select only; the count is appended automatically. */
|
||||
confirmLabel?: string;
|
||||
selectionMode: WebTicketPickerSelectionMode;
|
||||
excludeRefs?: TicketRef[];
|
||||
onSelect: (result: TicketPickerResult | TicketPickerResult[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function WebTicketPickerSheet({
|
||||
projectId,
|
||||
title,
|
||||
confirmLabel = "Ajouter",
|
||||
selectionMode,
|
||||
excludeRefs,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: WebTicketPickerSheetProps) {
|
||||
const vm = useTicketSearch(projectId, { excludeRefs });
|
||||
const isMulti = selectionMode === "multi";
|
||||
const [selected, setSelected] = useState<Set<TicketRef>>(() => new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [resolveError, setResolveError] = useState<string | null>(null);
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
closeRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
function describeErr(e: unknown): string {
|
||||
return e && typeof e === "object" && "message" in e
|
||||
? String((e as { message: unknown }).message)
|
||||
: String(e);
|
||||
}
|
||||
|
||||
function toggle(ref: TicketRef) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(ref)) next.delete(ref);
|
||||
else next.add(ref);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function pickSingle(ref: TicketRef) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setResolveError(null);
|
||||
try {
|
||||
onSelect(await vm.resolve(ref));
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setResolveError(describeErr(e));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmMulti() {
|
||||
if (busy || selected.size === 0) return;
|
||||
setBusy(true);
|
||||
setResolveError(null);
|
||||
try {
|
||||
const results = await Promise.all(Array.from(selected).map((ref) => vm.resolve(ref)));
|
||||
onSelect(results);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setResolveError(describeErr(e));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex flex-col bg-canvas"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
style={{ zIndex: zIndex.floatingWindowNested }}
|
||||
>
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3 pt-[max(0.75rem,env(safe-area-inset-top))]">
|
||||
<span className="min-w-0 truncate text-sm font-medium text-content">{title}</span>
|
||||
<Button ref={closeRef} size="sm" variant="ghost" onClick={onClose}>
|
||||
Fermer
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="shrink-0 border-b border-border px-4 py-3">
|
||||
<Input
|
||||
aria-label="Rechercher des tickets"
|
||||
placeholder="Recherche…"
|
||||
value={vm.text}
|
||||
onChange={(e) => vm.setText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(vm.error || resolveError) && (
|
||||
<p role="alert" className="shrink-0 border-b border-danger/40 bg-danger/10 px-4 py-2 text-sm text-danger">
|
||||
{resolveError ?? vm.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto px-4 py-3">
|
||||
{vm.busy && vm.rows.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>Chargement des tickets…</span>
|
||||
</div>
|
||||
) : vm.rows.length === 0 ? (
|
||||
<p className="text-sm text-muted">Aucun ticket ne correspond.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-border">
|
||||
{vm.rows.map((t) => {
|
||||
const checked = selected.has(t.ref);
|
||||
return (
|
||||
<li key={t.ref} className="py-1.5 first:pt-0 last:pb-0">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t.ref}, ${t.title}`}
|
||||
{...(isMulti ? { role: "checkbox", "aria-checked": checked } : {})}
|
||||
disabled={busy}
|
||||
onClick={() => (isMulti ? toggle(t.ref) : void pickSingle(t.ref))}
|
||||
className={cn(
|
||||
"flex min-h-[40px] w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors",
|
||||
"hover:bg-raised focus:bg-raised focus:outline-none disabled:opacity-60",
|
||||
isMulti && checked && "bg-raised",
|
||||
)}
|
||||
>
|
||||
{isMulti && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded border text-[10px] font-bold",
|
||||
checked ? "border-primary bg-primary text-on-primary" : "border-border text-transparent",
|
||||
)}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
<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 text-content">{t.title}</span>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<WebStatusBadge status={t.status} />
|
||||
<WebPriorityBadge priority={t.priority} />
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
{vm.hasMore && (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button size="sm" variant="ghost" loading={vm.busy} onClick={() => vm.loadMore()}>
|
||||
Charger plus
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMulti && (
|
||||
<footer className="flex shrink-0 items-center justify-between gap-2 border-t border-border px-4 py-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
|
||||
<span className="text-xs text-muted" aria-live="polite">
|
||||
{selected.size} sélectionné{selected.size > 1 ? "s" : ""}
|
||||
</span>
|
||||
<Button size="sm" loading={busy} disabled={selected.size === 0 || busy} onClick={() => void confirmMulti()}>
|
||||
{confirmLabel} {selected.size > 0 ? `(${selected.size})` : ""}
|
||||
</Button>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
263
frontend/src/features/web/tickets/WebTicketsSprints.test.tsx
Normal file
263
frontend/src/features/web/tickets/WebTicketsSprints.test.tsx
Normal file
@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Ticket #86, lot 2 — the web workspace's Tickets/Sprints tabs.
|
||||
*
|
||||
* Pins the carnet #86 UX contract exercised through the real `WebWorkspace`
|
||||
* (project list → tab navigation), driven entirely by the same
|
||||
* `MockTicketGateway`/`MockSystemGateway` pair the desktop ticket tests use, so
|
||||
* behaviour (event-driven refresh, optimistic concurrency, sprint semantics)
|
||||
* stays identical — only the presentation is web-specific.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { createMockGateways, MockTicketGateway } from "@/adapters/mock";
|
||||
import type { Gateways } from "@/ports";
|
||||
import type { Ticket } from "@/domain";
|
||||
import { WebWorkspace } from "../WebWorkspace";
|
||||
|
||||
function ticket(over: Partial<Ticket> = {}): Ticket {
|
||||
return {
|
||||
id: "t-1",
|
||||
ref: "#1",
|
||||
number: 1,
|
||||
title: "Ajouter tickets/sprints au web",
|
||||
description: "",
|
||||
status: "open",
|
||||
priority: "medium",
|
||||
sprintId: null,
|
||||
links: [],
|
||||
assignedAgentIds: [],
|
||||
createdBy: { kind: "user" },
|
||||
updatedBy: { kind: "user" },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
version: 1,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const gateways = createMockGateways();
|
||||
const project = await gateways.project.createProject("IdeA", "/srv/idea");
|
||||
return { gateways, projectId: project.id };
|
||||
}
|
||||
|
||||
function renderWorkspace(gateways: Gateways) {
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<WebWorkspace />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
async function openProjectAndTab(gateways: Gateways, tabName: string) {
|
||||
renderWorkspace(gateways);
|
||||
fireEvent.click(await screen.findByText("IdeA"));
|
||||
await screen.findByRole("tablist", { name: "Navigation du projet" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: tabName }));
|
||||
}
|
||||
|
||||
describe("WebWorkspace — project tabs (ticket #86)", () => {
|
||||
it("defaults to the Live tab and lets the user switch to Tickets/Sprints", async () => {
|
||||
const { gateways } = await setup();
|
||||
renderWorkspace(gateways);
|
||||
fireEvent.click(await screen.findByText("IdeA"));
|
||||
|
||||
const tablist = await screen.findByRole("tablist", { name: "Navigation du projet" });
|
||||
const tabs = within(tablist).getAllByRole("tab");
|
||||
expect(tabs.map((t) => t.textContent)).toEqual(["Live", "Tickets", "Sprints"]);
|
||||
expect(screen.getByRole("tab", { name: "Live" }).getAttribute("aria-selected")).toBe("true");
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Tickets" }));
|
||||
expect(await screen.findByRole("heading", { name: "Tickets" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Sprints" }));
|
||||
expect(await screen.findByRole("heading", { name: "Sprints" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the ticket list grouped by sprint, in French", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint courant" });
|
||||
tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ajouter tickets/sprints au web", sprintId: "s1" }));
|
||||
tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Langue uniforme Settings", status: "closed", priority: "low" }));
|
||||
|
||||
await openProjectAndTab(gateways, "Tickets");
|
||||
|
||||
const list = await screen.findByTestId("web-ticket-list");
|
||||
expect(within(list).getByText("Sprint courant")).toBeTruthy();
|
||||
expect(within(list).getByText("Sans sprint")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByRole("button", {
|
||||
name: "#2, Langue uniforme Settings, statut Fermé, priorité Faible",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the empty state with a create shortcut when the project has no tickets", async () => {
|
||||
const { gateways } = await setup();
|
||||
await openProjectAndTab(gateways, "Tickets");
|
||||
|
||||
expect(await screen.findByText("Aucun ticket dans ce projet.")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Créer un ticket" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creates a ticket and opens its detail", async () => {
|
||||
const { gateways } = await setup();
|
||||
await openProjectAndTab(gateways, "Tickets");
|
||||
await screen.findByText("Aucun ticket dans ce projet.");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Ticket" }));
|
||||
expect(await screen.findByRole("heading", { name: "Nouveau ticket" })).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Titre"), { target: { value: "Un nouveau ticket" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Créer" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Un nouveau ticket")).toBeTruthy());
|
||||
// Landed on the detail screen (back button visible), not the list.
|
||||
expect(screen.getByRole("button", { name: "← Tickets" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("changes a ticket's status immediately from the detail view", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
tk._seedTicket(projectId, ticket());
|
||||
|
||||
await openProjectAndTab(gateways, "Tickets");
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
|
||||
}),
|
||||
);
|
||||
|
||||
const statusSelect = await screen.findByLabelText("Statut");
|
||||
fireEvent.change(statusSelect, { target: { value: "closed" } });
|
||||
|
||||
await waitFor(async () => {
|
||||
const updated = await tk.read(projectId, "#1");
|
||||
expect(updated.status).toBe("closed");
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes a ticket after confirmation and returns to the list", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
tk._seedTicket(projectId, ticket());
|
||||
|
||||
await openProjectAndTab(gateways, "Tickets");
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "#1, Ajouter tickets/sprints au web, statut Ouvert, priorité Moyenne",
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Zone dangereuse" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Supprimer" }));
|
||||
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Supprimer ce ticket ?" });
|
||||
expect(within(dialog).getByText(/sera supprimé définitivement/)).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Supprimer" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Aucun ticket dans ce projet.")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebWorkspace — Sprints tab (ticket #86)", () => {
|
||||
it("shows the empty state with a create shortcut", async () => {
|
||||
const { gateways } = await setup();
|
||||
await openProjectAndTab(gateways, "Sprints");
|
||||
expect(await screen.findByText("Aucun sprint.")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Créer un sprint" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("creates a sprint (disabled until a name is entered)", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
await openProjectAndTab(gateways, "Sprints");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Sprint" }));
|
||||
const createButton = screen.getByRole("button", { name: "Créer" });
|
||||
expect(createButton).toHaveProperty("disabled", true);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Nom du sprint"), {
|
||||
target: { value: "Backlog client web" },
|
||||
});
|
||||
expect(createButton).toHaveProperty("disabled", false);
|
||||
fireEvent.click(createButton);
|
||||
|
||||
await waitFor(async () => {
|
||||
const sprints = await tk.listSprints(projectId);
|
||||
expect(sprints.map((s) => s.name)).toContain("Backlog client web");
|
||||
});
|
||||
expect(await screen.findByText("Backlog client web")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renames, reorders and deletes a sprint with confirmation", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint A" });
|
||||
tk._seedSprint(projectId, { id: "s2", order: 2, name: "Sprint B" });
|
||||
|
||||
await openProjectAndTab(gateways, "Sprints");
|
||||
await screen.findByText("Sprint A");
|
||||
|
||||
// Rename.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Renommer Sprint A" }));
|
||||
fireEvent.change(screen.getByLabelText("Renommer le sprint Sprint A"), {
|
||||
target: { value: "Sprint A renommé" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Enregistrer" }));
|
||||
await waitFor(() => expect(screen.getByText("Sprint A renommé")).toBeTruthy());
|
||||
|
||||
// Reorder: move Sprint B up.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Monter Sprint B" }));
|
||||
await waitFor(async () => {
|
||||
const sprints = await tk.listSprints(projectId);
|
||||
expect(sprints.find((s) => s.id === "s2")?.order).toBe(1);
|
||||
});
|
||||
|
||||
// Delete with confirmation.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Supprimer Sprint B" }));
|
||||
const dialog = await screen.findByRole("alertdialog", {
|
||||
name: "Supprimer le sprint « Sprint B » ?",
|
||||
});
|
||||
expect(within(dialog).getByText(/passeront en « Sans sprint »/)).toBeTruthy();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Supprimer" }));
|
||||
|
||||
await waitFor(async () => {
|
||||
const sprints = await tk.listSprints(projectId);
|
||||
expect(sprints.map((s) => s.id)).not.toContain("s2");
|
||||
});
|
||||
});
|
||||
|
||||
it("adds tickets to a sprint via the mobile picker", async () => {
|
||||
const { gateways, projectId } = await setup();
|
||||
const tk = gateways.ticket as MockTicketGateway;
|
||||
tk._seedSprint(projectId, { id: "s1", order: 1, name: "Sprint A" });
|
||||
tk._seedTicket(projectId, ticket({ ref: "#1", number: 1, title: "Ticket un" }));
|
||||
tk._seedTicket(projectId, ticket({ ref: "#2", number: 2, title: "Ticket deux" }));
|
||||
|
||||
await openProjectAndTab(gateways, "Sprints");
|
||||
await screen.findByText("Sprint A");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Ajouter tickets" }));
|
||||
const sheet = await screen.findByRole("dialog", {
|
||||
name: "Ajouter des tickets au sprint « Sprint A »",
|
||||
});
|
||||
fireEvent.click(within(sheet).getByRole("checkbox", { name: "#1, Ticket un" }));
|
||||
fireEvent.click(
|
||||
within(sheet).getByRole("button", { name: /Ajouter au sprint/ }),
|
||||
);
|
||||
|
||||
await waitFor(async () => {
|
||||
const t = await tk.read(projectId, "#1");
|
||||
expect(t.sprintId).toBe("s1");
|
||||
});
|
||||
});
|
||||
});
|
||||
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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/features/web/tickets/useWebProjectAgents.ts
Normal file
41
frontend/src/features/web/tickets/useWebProjectAgents.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Minimal `{id, name}` agent roster for the web tickets surface (ticket #86).
|
||||
*
|
||||
* NOT a reuse of `@/features/tickets/useProjectAgents`: that hook calls
|
||||
* `AgentGateway.listAgents` (`list_agents`), which is not in the web-server's
|
||||
* `/api/invoke` allowlist (desktop/Tauri-only surface — confirmed absent from
|
||||
* `crates/web-server/src/lib.rs`'s command dispatch). Adding it there is a
|
||||
* backend change outside this frontend-only lot.
|
||||
*
|
||||
* Workaround: derive the roster from `get_project_work_state`, which IS
|
||||
* allowlisted for web (the Live tab already depends on it) and already carries
|
||||
* `agentId` + `name` for every manifest agent. This only surfaces agents that
|
||||
* have a work-state row; on this codebase that is every declared project
|
||||
* agent, matching what `listAgents` would return.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
|
||||
|
||||
export interface WebProjectAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface WebProjectAgents {
|
||||
agents: WebProjectAgent[];
|
||||
nameOf: (agentId: string) => string;
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
export function useWebProjectAgents(projectId: string): WebProjectAgents {
|
||||
const vm = useProjectWorkState(projectId);
|
||||
const agents = useMemo(
|
||||
() => (vm.state?.agents ?? []).map((a) => ({ id: a.agentId, name: a.name })),
|
||||
[vm.state],
|
||||
);
|
||||
const byId = useMemo(() => new Map(agents.map((a) => [a.id, a.name])), [agents]);
|
||||
const nameOf = (agentId: string): string => byId.get(agentId) ?? `${agentId.slice(0, 8)}…`;
|
||||
return { agents, nameOf, loaded: vm.state !== null };
|
||||
}
|
||||
100
frontend/src/features/web/tickets/webTicketLabels.tsx
Normal file
100
frontend/src/features/web/tickets/webTicketLabels.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
/**
|
||||
* French presentation helpers for the web tickets/sprints surface (ticket #86).
|
||||
*
|
||||
* Deliberately **not** a reuse of `@/features/tickets/ticketMeta`: that module's
|
||||
* `statusLabel`/`priorityLabel`/badges render English text for the desktop
|
||||
* surface. Per carnet #86 (French UI by default, decision #78) the web surface
|
||||
* needs its own French labels — a separate, tiny module rather than adding an
|
||||
* i18n layer to the shared desktop component. The pure type/constant arrays
|
||||
* (`TICKET_STATUSES`, `TICKET_PRIORITIES`, `TICKET_LINK_KINDS`) ARE reused from
|
||||
* `ticketMeta` since they carry no English text.
|
||||
*/
|
||||
|
||||
import type { TicketLinkKind, TicketPriority, TicketStatus } from "@/domain";
|
||||
import { cn } from "@/shared";
|
||||
|
||||
export function webStatusLabel(status: TicketStatus): string {
|
||||
switch (status) {
|
||||
case "open":
|
||||
return "Ouvert";
|
||||
case "inProgress":
|
||||
return "En cours";
|
||||
case "QA":
|
||||
return "QA";
|
||||
case "closed":
|
||||
return "Fermé";
|
||||
}
|
||||
}
|
||||
|
||||
export function webPriorityLabel(priority: TicketPriority): string {
|
||||
switch (priority) {
|
||||
case "low":
|
||||
return "Faible";
|
||||
case "medium":
|
||||
return "Moyenne";
|
||||
case "high":
|
||||
return "Haute";
|
||||
case "critical":
|
||||
return "Critique";
|
||||
}
|
||||
}
|
||||
|
||||
export function webLinkKindLabel(kind: TicketLinkKind): string {
|
||||
switch (kind) {
|
||||
case "relatesTo":
|
||||
return "en relation avec";
|
||||
case "blocks":
|
||||
return "bloque";
|
||||
case "blockedBy":
|
||||
return "bloqué par";
|
||||
case "duplicates":
|
||||
return "duplique";
|
||||
case "dependsOn":
|
||||
return "dépend de";
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(status: TicketStatus): string {
|
||||
switch (status) {
|
||||
case "open":
|
||||
return "bg-raised text-muted";
|
||||
case "inProgress":
|
||||
return "bg-warning/15 text-warning";
|
||||
case "QA":
|
||||
return "bg-primary/15 text-primary";
|
||||
case "closed":
|
||||
return "bg-success/15 text-success";
|
||||
}
|
||||
}
|
||||
|
||||
function priorityClass(priority: TicketPriority): string {
|
||||
switch (priority) {
|
||||
case "low":
|
||||
return "bg-raised text-muted";
|
||||
case "medium":
|
||||
return "bg-primary/10 text-primary";
|
||||
case "high":
|
||||
return "bg-warning/15 text-warning";
|
||||
case "critical":
|
||||
return "bg-danger/15 text-danger";
|
||||
}
|
||||
}
|
||||
|
||||
function badgeBase(className?: string): string {
|
||||
return cn(
|
||||
"inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium",
|
||||
className,
|
||||
);
|
||||
}
|
||||
|
||||
export function WebStatusBadge({ status }: { status: TicketStatus }) {
|
||||
return <span className={badgeBase(statusClass(status))}>{webStatusLabel(status)}</span>;
|
||||
}
|
||||
|
||||
export function WebPriorityBadge({ priority }: { priority: TicketPriority }) {
|
||||
return (
|
||||
<span className={badgeBase(priorityClass(priority))}>
|
||||
{webPriorityLabel(priority)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user