/** * 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. * * Ticket view (#37): the sprint tickets are read through this component's OWN * {@link useTicketSearch} instance — independent of the main Tickets panel * filter — so tickets stay visible once closed (no default `statuses` filter) * and can be filtered/sorted per status/priority via the shared * {@link TicketFacetsBar}. Rows show their {@link StatusBadge}/{@link * PriorityBadge}. Grouping by sprint is done client-side on the returned rows. */ import { useState } from "react"; import { Button, Input } from "@/shared"; import { PriorityBadge, StatusBadge, TicketRef } from "./ticketMeta"; import { TicketFacetsBar } from "./TicketFacetsBar"; import { TicketPicker } from "./TicketPicker"; import { useTicketSearch } from "./useTicketSearch"; import type { TicketsViewModel } from "./useTickets"; export interface SprintManagerProps { projectId: string; vm: TicketsViewModel; onClose: () => void; } export function SprintManager({ projectId, vm, onClose }: SprintManagerProps) { // Own ticket query, independent of the main panel filter (#37). No default // `statuses` → closed/done tickets remain visible; the facets bar below drives // per-status/priority filtering + sort of the whole sprint view. const search = useTicketSearch(projectId, { refreshOnEvents: true }); const items = search.rows; 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" />
{/* ── Sprint-ticket filters (#37) — independent of the main panel; filters/sorts every sprint's tickets by status/priority/text. ── */}
{search.error && (

{search.error}

)} {sprints.length === 0 ? (

No sprints yet.

) : (
    {sprints.map((sprint, index) => { const inSprint = 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 (with status/priority badges, #37; kept visible even once closed/done) ── */}
    {inSprint.length === 0 ? (

    No tickets in this sprint.

    ) : (
      {inSprint.map((t) => (
    • {t.title}
    • ))}
    )} {/* Add existing tickets via the TicketPicker popup (#19/#41): multi-select, excluding tickets already in this sprint. */}
  • ); })}
)} {/* More sprint tickets may exist beyond the first page (#37). */} {search.hasMore && (
)}
{/* ── 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/#41) — mounts at floatingWindowNested (60), above this sprint dialog. Multi-select so several tickets can be added in one pass; excludes tickets already in the target sprint. ── */} t.sprintId === pickerSprint?.id) .map((t) => t.ref)} onSelect={(result) => { const picked = Array.isArray(result) ? result : [result]; if (pickerSprint) { for (const p of picked) { void vm.assignSprint(p.ref, pickerSprint.id); } } }} onClose={() => setPickerSprint(null)} />
); }