bulk operations fixes

- Frontend: implémente bulkDelete, bulkArchive, bulkRestore
- Backend: adapts ticket DTOs pour bulk operations
- Infrastructure: MCP server pour gestion bulk tickets
- Tests: coverage frontend + backend
This commit is contained in:
2026-07-28 20:38:07 +02:00
parent 73c6081ab8
commit bd4f76ae74
19 changed files with 597 additions and 35 deletions

View File

@ -7,9 +7,9 @@
* through {@link useTickets}.
*/
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type { Sprint, TicketPriority, TicketSummary } from "@/domain";
import type { Sprint, TicketPriority, TicketStatus, TicketSummary } from "@/domain";
import { Button, Input, Panel, Spinner } from "@/shared";
import { useTickets } from "./useTickets";
import { useProjectAgents } from "./useProjectAgents";
@ -21,8 +21,10 @@ import {
PriorityBadge,
StatusBadge,
TICKET_PRIORITIES,
TICKET_STATUSES,
TicketRef,
priorityLabel,
statusLabel,
} from "./ticketMeta";
export interface TicketsPanelProps {
@ -56,8 +58,23 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
// picker popup is open (#38).
const [newSprint, setNewSprint] = useState<Sprint | null>(null);
const [showSprintPicker, setShowSprintPicker] = useState(false);
const [selectedRefs, setSelectedRefs] = useState<Set<string>>(() => new Set());
const [bulkStatus, setBulkStatus] = useState<TicketStatus | "">("");
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
const items = vm.list?.items ?? [];
const visibleRefs = useMemo(() => new Set(items.map((t) => t.ref)), [items]);
const selectedVisibleRefs = useMemo(
() => [...selectedRefs].filter((ref) => visibleRefs.has(ref)),
[selectedRefs, visibleRefs],
);
useEffect(() => {
setSelectedRefs((prev) => {
const next = new Set([...prev].filter((ref) => visibleRefs.has(ref)));
return next.size === prev.size ? prev : next;
});
}, [visibleRefs]);
// Group tickets by sprint (ticket #10): one ordered section per sprint that
// holds tickets, then a "Sans sprint" bucket. Tickets referencing an unknown
@ -94,6 +111,33 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onOpen(created.ref);
}
function toggleTicketSelection(ref: string, selected: boolean) {
setSelectedRefs((prev) => {
const next = new Set(prev);
if (selected) next.add(ref);
else next.delete(ref);
return next;
});
}
async function applyBulkStatus(nextStatus: TicketStatus | "") {
setBulkStatus(nextStatus);
if (!nextStatus || selectedVisibleRefs.length === 0) return;
const result = await vm.bulkUpdateStatus(selectedVisibleRefs, nextStatus);
if (!result) return;
const failed = new Set(result.items.filter((i) => !i.ok).map((i) => i.ref));
setSelectedRefs(new Set([...selectedVisibleRefs].filter((ref) => failed.has(ref))));
setBulkStatus("");
}
async function applyBulkDelete() {
const result = await vm.bulkDelete(selectedVisibleRefs);
if (!result) return;
const failed = new Set(result.items.filter((i) => !i.ok).map((i) => i.ref));
setSelectedRefs(new Set([...selectedVisibleRefs].filter((ref) => failed.has(ref))));
setConfirmBulkDelete(false);
}
return (
<Panel
title="Tickets"
@ -134,6 +178,47 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
</p>
)}
{selectedVisibleRefs.length > 0 && (
<div
role="toolbar"
aria-label="bulk ticket actions"
className="mb-3 flex flex-wrap items-center gap-2 rounded-md border border-border bg-raised/70 px-2 py-2"
>
<span className="text-xs font-medium text-content">
{selectedVisibleRefs.length} sélectionné
{selectedVisibleRefs.length > 1 ? "s" : ""}
</span>
<TicketViewportSelect
aria-label="Changer le statut"
value={bulkStatus}
options={[
{ value: "", label: "Changer le statut" },
...TICKET_STATUSES.map((status) => ({
value: status,
label: statusLabel(status),
})),
]}
onChange={(next) => void applyBulkStatus(next as TicketStatus | "")}
/>
<Button
size="sm"
variant="danger"
disabled={vm.busy}
onClick={() => setConfirmBulkDelete(true)}
>
Supprimer
</Button>
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => setSelectedRefs(new Set())}
>
Annuler
</Button>
</div>
)}
{showCreate && (
<form
onSubmit={submitCreate}
@ -243,6 +328,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onOpen={onOpen}
nameOf={nameOf}
onAssignSprint={vm.assignSprint}
selectedRefs={selectedRefs}
onToggleSelected={toggleTicketSelection}
/>
))}
{noSprint.length > 0 && (
@ -254,6 +341,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onOpen={onOpen}
nameOf={nameOf}
onAssignSprint={vm.assignSprint}
selectedRefs={selectedRefs}
onToggleSelected={toggleTicketSelection}
/>
)}
</div>
@ -295,6 +384,44 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onSelect={(sprint) => setNewSprint(sprint)}
onClose={() => setShowSprintPicker(false)}
/>
{confirmBulkDelete && (
<div
role="dialog"
aria-modal="true"
aria-label="confirmer la suppression groupée"
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-panel p-4 shadow-lg">
<p className="text-sm font-semibold text-content">
Supprimer {selectedVisibleRefs.length} ticket
{selectedVisibleRefs.length > 1 ? "s" : ""}&nbsp;?
</p>
<p className="mt-1 text-sm text-muted">
Cette action supprimera les tickets sélectionnés du projet.
</p>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
aria-label="cancel bulk delete"
onClick={() => setConfirmBulkDelete(false)}
>
Annuler
</Button>
<Button
size="sm"
variant="danger"
aria-label="confirm bulk delete"
loading={vm.busy}
onClick={() => void applyBulkDelete()}
>
Supprimer
</Button>
</div>
</div>
</div>
)}
</Panel>
);
}
@ -312,6 +439,8 @@ function SprintSection({
onOpen,
nameOf,
onAssignSprint,
selectedRefs,
onToggleSelected,
}: {
heading: string;
count: number;
@ -320,6 +449,8 @@ function SprintSection({
onOpen: (ref: string) => void;
nameOf: (id: string) => string;
onAssignSprint: (ref: string, sprintId: string | null) => void;
selectedRefs: Set<string>;
onToggleSelected: (ref: string, selected: boolean) => void;
}) {
return (
<section aria-label={`sprint section ${heading}`}>
@ -333,6 +464,13 @@ function SprintSection({
{tickets.map((t) => (
<li key={t.ref} className="py-2 first:pt-0 last:pb-0">
<div className="flex items-start gap-2">
<input
type="checkbox"
aria-label={`select ticket row ${t.ref}`}
checked={selectedRefs.has(t.ref)}
onChange={(e) => onToggleSelected(t.ref, e.currentTarget.checked)}
className="mt-1 h-4 w-4 shrink-0 rounded border-border bg-panel text-primary focus-visible:outline-none"
/>
<TicketRef ticketRef={t.ref} onOpen={onOpen} className="mt-0.5" />
<button
type="button"