/** * Tickets list panel (F2) — the project's ticket board in the sidebar. * * Filterable by status / priority / assignee + free-text search; each row shows * the `#ref`, title, status, priority and assigned agents. Clicking a row (or * its `#ref`) opens the detail via `onOpen`. Refreshes live on `Issue*` events * through {@link useTickets}. */ import { useEffect, useMemo, useState } from "react"; import type { Sprint, TicketPriority, TicketStatus, TicketSummary } from "@/domain"; import { Button, Input, Panel, Spinner } from "@/shared"; import { useI18n } from "@/i18n"; import { useTickets } from "./useTickets"; import { useProjectAgents } from "./useProjectAgents"; import { SprintManager } from "./SprintManager"; import { SprintPicker } from "./SprintPicker"; import { TicketFacetsBar } from "./TicketFacetsBar"; import { TicketViewportSelect } from "./TicketViewportSelect"; import { creatorFilterFromValue, creatorFilterValue, ticketActorLabel, } from "./ticketActor"; import { PriorityBadge, StatusBadge, TICKET_PRIORITIES, TICKET_STATUSES, TicketRef, priorityLabel, statusLabel, } from "./ticketMeta"; export interface TicketsPanelProps { projectId: string; /** Opens the detail overlay for the given `#ref` (F7). */ onOpen: (ref: string) => void; } export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) { const { tr } = useI18n(); const vm = useTickets(projectId); const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId); // Reconcile *restored* agent-scoped filters (ticket #29/#109) against the live // roster: once agents are known, stale ids are dropped and persisted storage // follows via `useTickets`. Guarded on `agentsLoaded` so a still-valid agent is // never cleared during the load window when the roster is momentarily empty. useEffect(() => { if (!agentsLoaded) return; const validAgentIds = new Set(agents.map((a) => a.id)); const staleAssignee = vm.query.assignedAgentId && !validAgentIds.has(vm.query.assignedAgentId); const staleCreator = vm.query.createdBy?.kind === "agent" && !validAgentIds.has(vm.query.createdBy.agentId); if (staleAssignee || staleCreator) { const { assignedAgentId: _dropAssignee, createdBy: _dropCreator, ...rest } = vm.query; vm.setQuery({ ...rest, ...(staleAssignee ? {} : { assignedAgentId: vm.query.assignedAgentId }), ...(staleCreator ? {} : { createdBy: vm.query.createdBy }), }); } }, [agentsLoaded, agents, vm.query, vm.setQuery]); const [showCreate, setShowCreate] = useState(false); const [showSprints, setShowSprints] = useState(false); const [newTitle, setNewTitle] = useState(""); const [newPriority, setNewPriority] = useState("medium"); // Sprint chosen for the new ticket (`null` ⇒ "Sans sprint"), and whether its // picker popup is open (#38). const [newSprint, setNewSprint] = useState(null); const [showSprintPicker, setShowSprintPicker] = useState(false); const [selectedRefs, setSelectedRefs] = useState>(() => new Set()); const [bulkStatus, setBulkStatus] = useState(""); const [showBulkSprintPicker, setShowBulkSprintPicker] = useState(false); const [bulkNotice, setBulkNotice] = useState(null); 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 // sprint fall into the bucket so none are ever hidden. const sprintIds = new Set(vm.sprints.map((s) => s.id)); const grouped = vm.sprints .map((sprint) => ({ sprint, tickets: items.filter((t) => t.sprintId === sprint.id), })) .filter((g) => g.tickets.length > 0); const noSprint = items.filter( (t) => !t.sprintId || !sprintIds.has(t.sprintId), ); async function submitCreate(e: React.FormEvent) { e.preventDefault(); if (!newTitle.trim()) return; const created = await vm.create({ title: newTitle.trim(), priority: newPriority, }); if (!created) return; // create failed; `vm.error` already carries the reason. // Two-step assignment (#38): the ticket exists either way. If the sprint // assignment fails, the ticket is kept and `vm.assignSprint` surfaces the // failure via `vm.error` — we never lose the created ticket. if (newSprint) { await vm.assignSprint(created.ref, newSprint.id); } setNewTitle(""); setNewPriority("medium"); setNewSprint(null); setShowCreate(false); onOpen(created.ref); } function toggleTicketSelection(ref: string, selected: boolean) { setBulkNotice(null); setSelectedRefs((prev) => { const next = new Set(prev); if (selected) next.add(ref); else next.delete(ref); return next; }); } async function applyBulkStatus(nextStatus: TicketStatus | "") { setBulkNotice(null); 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 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 ? tr(`Sprint mis à jour pour ${successCount} ticket${successCount > 1 ? "s" : ""}.`, `Sprint updated for ${successCount} ticket${successCount > 1 ? "s" : ""}.`) : tr(`Sprint mis à jour pour ${successCount}/${result.items.length} tickets. Les échecs restent sélectionnés.`, `Sprint updated for ${successCount}/${result.items.length} tickets. Failed items remain selected.`), ); } 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)); setSelectedRefs(new Set([...selectedVisibleRefs].filter((ref) => failed.has(ref)))); setConfirmBulkDelete(false); } return ( } > {vm.error && (

{vm.error}

)} {selectedVisibleRefs.length > 0 && (
{tr(`${selectedVisibleRefs.length} sélectionné${selectedVisibleRefs.length > 1 ? "s" : ""}`, `${selectedVisibleRefs.length} selected`)} ({ value: status, label: statusLabel(status, tr), })), ]} onChange={(next) => void applyBulkStatus(next as TicketStatus | "")} />
)} {bulkNotice && (

{bulkNotice}

)} {showCreate && (
setNewTitle(e.target.value)} />
({ value: priority, label: priorityLabel(priority, tr), }))} onChange={(next) => setNewPriority(next as TicketPriority)} />
{/* Sprint field (#38): opens the SprintPicker popup; shows the choice (or "Sans sprint"). */}
{tr("Sprint", "Sprint")}
)} {/* ── Filters ── */}
{/* Shared search + status/priority facets (#18). Assignee stays here — it needs the project agent roster and is out of the picker's scope. */} vm.setQuery({ ...vm.query, text: text || undefined }) } statuses={vm.query.statuses ?? []} priorities={vm.query.priorities ?? []} onToggleStatus={vm.toggleStatus} onTogglePriority={vm.togglePriority} onClearFacets={vm.clearFacets} sort={vm.query.sort} onSortChange={(sort) => { const { sort: _drop, ...rest } = vm.query; vm.setQuery(sort ? { ...rest, sort } : rest); }} />
({ value: agent.id, label: agent.name, })), ]} onChange={(next) => vm.setQuery({ ...vm.query, assignedAgentId: next || undefined, }) } /> ({ value: `agent:${agent.id}`, label: agent.name, })), ]} onChange={(next) => { const { createdBy: _drop, ...rest } = vm.query; const createdBy = creatorFilterFromValue(next); vm.setQuery({ ...rest, ...(createdBy ? { createdBy } : {}), }); }} />
{/* ── List, grouped by sprint (F2) ── */} {vm.busy && vm.list === null ? (
{tr("Chargement des tickets…", "Loading tickets…")}
) : items.length === 0 ? (

{tr("Aucun ticket.", "No tickets.")}

) : (
{grouped.map((g) => ( ))} {noSprint.length > 0 && ( )}
)} {vm.list?.nextCursor && (
)} {showSprints && ( setShowSprints(false)} /> )} {/* Sprint picker for the create form (#38) — chrome-level, nested z-index, focus-trapped. Selecting an entry sets the pending sprint and closes. */} setNewSprint(sprint)} onClose={() => setShowSprintPicker(false)} /> 1 ? "s" : ""}`, `Sprint for ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""}`)} onSelect={(sprint) => void applyBulkSprint(sprint)} onClose={() => setShowBulkSprintPicker(false)} /> {confirmBulkDelete && (

{tr(`Supprimer ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""} ?`, `Delete ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""}?`)}

{tr("Cette action supprimera les tickets sélectionnés du projet.", "This will delete the selected tickets from the project.")}

)}
); } /** * One sprint section (F2): a heading with the ticket count and the grouped * ticket rows. Each row carries a simple sprint selector wired to * `onAssignSprint` (assignment only — creation/reorder is ticket #11). */ function SprintSection({ heading, count, tickets, sprints, onOpen, nameOf, onAssignSprint, selectedRefs, onToggleSelected, }: { heading: string; count: number; tickets: TicketSummary[]; sprints: Sprint[]; onOpen: (ref: string) => void; nameOf: (id: string) => string; onAssignSprint: (ref: string, sprintId: string | null) => void; selectedRefs: Set; onToggleSelected: (ref: string, selected: boolean) => void; }) { const { tr } = useI18n(); return (

{heading} {count}

    {tickets.map((t) => (
  • 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" />
    ({ value: sprint.id, label: sprint.name, })), ]} onChange={(next) => onAssignSprint(t.ref, next || null) } />
  • ))}
); }