/** * 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, useState } from "react"; import type { Sprint, TicketPriority, TicketSummary } from "@/domain"; import { Button, Input, Panel, Spinner } from "@/shared"; 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 { PriorityBadge, StatusBadge, TICKET_PRIORITIES, TicketRef, priorityLabel, } 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 vm = useTickets(projectId); const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId); // Reconcile a *restored* assignee filter (ticket #29) against the live roster: // once the agents are known, an assignee that no longer exists is dropped // (which also clears it from persisted storage via `useTickets`). Guarded on // `agentsLoaded` so a still-valid assignee is never cleared during the load // window when the roster is momentarily empty. useEffect(() => { if (!agentsLoaded) return; const id = vm.query.assignedAgentId; if (id && !agents.some((a) => a.id === id)) { const { assignedAgentId: _drop, ...rest } = vm.query; vm.setQuery(rest); } }, [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 items = vm.list?.items ?? []; // 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); } return ( } > {vm.error && (

{vm.error}

)} {showCreate && (
setNewTitle(e.target.value)} />
({ value: priority, label: priorityLabel(priority), }))} onChange={(next) => setNewPriority(next as TicketPriority)} />
{/* Sprint field (#38): opens the SprintPicker popup; shows the choice (or "Sans 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, }) } />
{/* ── List, grouped by sprint (F2) ── */} {vm.busy && vm.list === null ? (
Loading tickets…
) : items.length === 0 ? (

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)} />
); } /** * 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, }: { heading: string; count: number; tickets: TicketSummary[]; sprints: Sprint[]; onOpen: (ref: string) => void; nameOf: (id: string) => string; onAssignSprint: (ref: string, sprintId: string | null) => void; }) { return (

{heading} {count}

    {tickets.map((t) => (
  • ({ value: sprint.id, label: sprint.name, })), ]} onChange={(next) => onAssignSprint(t.ref, next || null) } />
  • ))}
); }