feat(tickets): sélection de ticket via popup dans la gestion de sprint (#19)

Lot D du sprint UI rework : branche la popup réutilisable TicketPicker (#18)
dans la sélection de ticket de la création/édition de sprint.

- SprintManager : sélection de ticket déléguée à la popup TicketPicker
- TicketsPanel : ajustements liés à l'intégration

Tests : tsc --noEmit clean, suite complète 528/528, suite tickets 38/38
(dont 2 nouveaux tests #19).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 21:01:06 +02:00
parent a118069f80
commit 5a0d9f0db4
3 changed files with 78 additions and 32 deletions

View File

@ -13,22 +13,18 @@
import { useState } from "react";
import { Button, Input, cn } from "@/shared";
import { Button, Input } from "@/shared";
import { TicketRef } from "./ticketMeta";
import { TicketPicker } from "./TicketPicker";
import type { TicketsViewModel } from "./useTickets";
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",
);
export interface SprintManagerProps {
projectId: string;
vm: TicketsViewModel;
onClose: () => void;
}
export function SprintManager({ vm, onClose }: SprintManagerProps) {
export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) {
const items = vm.list?.items ?? [];
const sprints = [...vm.sprints].sort((a, b) => a.order - b.order);
@ -39,6 +35,11 @@ export function SprintManager({ vm, onClose }: SprintManagerProps) {
const [confirmDelete, setConfirmDelete] = useState<
{ id: string; name: string } | null
>(null);
// The sprint whose "add ticket" TicketPicker is open, or null (#19). Held as
// {id,name} so the picker keeps rendering while the underlying list refreshes.
const [pickerSprint, setPickerSprint] = useState<
{ id: string; name: string } | null
>(null);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
@ -208,28 +209,23 @@ export function SprintManager({ vm, onClose }: SprintManagerProps) {
</ul>
)}
{/* Add an existing ticket (backlog or another sprint). */}
<select
aria-label={`add ticket to sprint ${sprint.name}`}
className={cn(selectClass, "mt-1 max-w-xs")}
value=""
disabled={vm.busy || addable.length === 0}
onChange={(e) => {
const ref = e.target.value;
if (ref) void vm.assignSprint(ref, sprint.id);
}}
>
<option value="">
{/* Add an existing ticket via the TicketPicker popup (#19):
single-select, excluding tickets already in this sprint. */}
<div className="mt-1">
<Button
size="sm"
variant="ghost"
aria-label={`add ticket to sprint ${sprint.name}`}
disabled={vm.busy || addable.length === 0}
onClick={() =>
setPickerSprint({ id: sprint.id, name: sprint.name })
}
>
{addable.length === 0
? "No tickets to add"
: "Add a ticket"}
</option>
{addable.map((t) => (
<option key={t.ref} value={t.ref}>
{t.ref} {t.title}
</option>
))}
</select>
: "Ajouter un ticket"}
</Button>
</div>
</div>
</li>
);
@ -281,6 +277,29 @@ export function SprintManager({ vm, onClose }: SprintManagerProps) {
</div>
</div>
)}
{/* ── Add-ticket picker (#19) — mounts at floatingWindowNested (60), above
this sprint dialog. Excludes tickets already in the target sprint. ── */}
<TicketPicker
open={pickerSprint !== null}
projectId={projectId}
title={
pickerSprint
? `Ajouter un ticket au sprint « ${pickerSprint.name} »`
: "Ajouter un ticket"
}
selectionMode="single"
excludeRefs={items
.filter((t) => t.sprintId === pickerSprint?.id)
.map((t) => t.ref)}
onSelect={(result) => {
const picked = Array.isArray(result) ? result[0] : result;
if (picked && pickerSprint) {
void vm.assignSprint(picked.ref, pickerSprint.id);
}
}}
onClose={() => setPickerSprint(null)}
/>
</div>
);
}