/** * Sprint management overlay (ticket #11, F3). * * A full-screen dialog to create, rename, reorder (accessible up/down buttons, * no drag-and-drop required) and delete sprints, plus add/remove tickets to a * sprint. All behaviour comes from {@link useTickets} via the injected view-model * — this component is pure presentation over the same hook instance the panel * uses, so mutations reflect live. * * Deleting a sprint UNASSIGNS its tickets (they fall back to the backlog); it * never deletes tickets — the confirmation says so explicitly. */ import { useState } from "react"; import { Button, Input } from "@/shared"; import { TicketRef } from "./ticketMeta"; import { TicketPicker } from "./TicketPicker"; import type { TicketsViewModel } from "./useTickets"; export interface SprintManagerProps { projectId: string; vm: TicketsViewModel; onClose: () => void; } export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) { const items = vm.list?.items ?? []; const sprints = [...vm.sprints].sort((a, b) => a.order - b.order); const [newName, setNewName] = useState(""); // Local rename drafts keyed by sprint id; falls back to the stored name. const [renameDrafts, setRenameDrafts] = useState>({}); // Sprint pending delete confirmation, or null. 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(); // Name is optional in the UI; default to a positional name (the backend // requires a non-empty name). const name = newName.trim() || `Sprint ${sprints.length + 1}`; const created = await vm.createSprint(name); if (created) setNewName(""); } return (
{/* ── Header ── */}
Manage sprints
{vm.error && (

{vm.error}

)}
{/* ── Create ── */}
setNewName(e.target.value)} className="max-w-xs" />
{sprints.length === 0 ? (

No sprints yet.

) : (
    {sprints.map((sprint, index) => { const inSprint = items.filter((t) => t.sprintId === sprint.id); const addable = items.filter((t) => t.sprintId !== sprint.id); const draft = renameDrafts[sprint.id] ?? sprint.name; return (
  • {/* ── Row header: order, rename, reorder, delete ── */}
    #{sprint.order} setRenameDrafts((prev) => ({ ...prev, [sprint.id]: e.target.value, })) } className="max-w-xs" />
    {/* ── Tickets in this sprint ── */}
    {inSprint.length === 0 ? (

    No tickets in this sprint.

    ) : (
      {inSprint.map((t) => (
    • {t.title}
    • ))}
    )} {/* Add an existing ticket via the TicketPicker popup (#19): single-select, excluding tickets already in this sprint. */}
  • ); })}
)}
{/* ── Delete confirmation ── */} {confirmDelete && (

Supprimer le sprint « {confirmDelete.name} » ?

Les tickets de ce sprint ne seront pas supprimés : ils seront simplement désassignés et retourneront au backlog « Sans sprint ».

)} {/* ── Add-ticket picker (#19) — mounts at floatingWindowNested (60), above this sprint dialog. Excludes tickets already in the target sprint. ── */} 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)} />
); }