feat(tickets): assignation de sprint en masse via multi-sélection

Permet de sélectionner plusieurs tickets dans TicketsPanel et de leur
assigner un sprint en une seule action, au lieu d'un ticket à la fois.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 15:58:00 +02:00
parent 6bd5e64dd1
commit f5007fcb11
3 changed files with 176 additions and 0 deletions

View File

@ -74,6 +74,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
const [showSprintPicker, setShowSprintPicker] = useState(false);
const [selectedRefs, setSelectedRefs] = useState<Set<string>>(() => new Set());
const [bulkStatus, setBulkStatus] = useState<TicketStatus | "">("");
const [showBulkSprintPicker, setShowBulkSprintPicker] = useState(false);
const [bulkNotice, setBulkNotice] = useState<string | null>(null);
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
const items = vm.list?.items ?? [];
@ -126,6 +128,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
}
function toggleTicketSelection(ref: string, selected: boolean) {
setBulkNotice(null);
setSelectedRefs((prev) => {
const next = new Set(prev);
if (selected) next.add(ref);
@ -135,6 +138,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
}
async function applyBulkStatus(nextStatus: TicketStatus | "") {
setBulkNotice(null);
setBulkStatus(nextStatus);
if (!nextStatus || selectedVisibleRefs.length === 0) return;
const result = await vm.bulkUpdateStatus(selectedVisibleRefs, nextStatus);
@ -144,7 +148,26 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
setBulkStatus("");
}
async function applyBulkSprint(sprint: Sprint | null) {
setBulkNotice(null);
const refs = selectedVisibleRefs;
if (refs.length === 0) return;
const result = await vm.bulkAssignSprint(refs, sprint?.id ?? null);
if (!result) return;
const failed = new Set(result.items.filter((i) => !i.ok).map((i) => i.ref));
const successCount = result.items.length - failed.size;
setSelectedRefs(new Set(refs.filter((ref) => failed.has(ref))));
setBulkNotice(
failed.size === 0
? `Sprint mis à jour pour ${successCount} ticket${
successCount > 1 ? "s" : ""
}.`
: `Sprint mis à jour pour ${successCount}/${result.items.length} tickets. Les échecs restent sélectionnés.`,
);
}
async function applyBulkDelete() {
setBulkNotice(null);
const result = await vm.bulkDelete(selectedVisibleRefs);
if (!result) return;
const failed = new Set(result.items.filter((i) => !i.ok).map((i) => i.ref));
@ -214,6 +237,14 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
]}
onChange={(next) => void applyBulkStatus(next as TicketStatus | "")}
/>
<Button
size="sm"
variant="secondary"
disabled={vm.busy}
onClick={() => setShowBulkSprintPicker(true)}
>
Sprint
</Button>
<Button
size="sm"
variant="danger"
@ -233,6 +264,15 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
</div>
)}
{bulkNotice && (
<p
role="status"
className="mb-3 rounded-md border border-border bg-raised/60 px-3 py-2 text-sm text-content"
>
{bulkNotice}
</p>
)}
{showCreate && (
<form
onSubmit={submitCreate}
@ -419,6 +459,17 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
onClose={() => setShowSprintPicker(false)}
/>
<SprintPicker
open={showBulkSprintPicker}
projectId={projectId}
selectedSprintId={null}
title={`Sprint pour ${selectedVisibleRefs.length} ticket${
selectedVisibleRefs.length > 1 ? "s" : ""
}`}
onSelect={(sprint) => void applyBulkSprint(sprint)}
onClose={() => setShowBulkSprintPicker(false)}
/>
{confirmBulkDelete && (
<div
role="dialog"