/** * 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 { useState } from "react"; import type { Sprint, TicketPriority, TicketStatus, TicketSummary, } from "@/domain"; import { Button, Input, Panel, Spinner, cn } from "@/shared"; import { useTickets } from "./useTickets"; import { useProjectAgents } from "./useProjectAgents"; import { PriorityBadge, StatusBadge, TICKET_PRIORITIES, TICKET_STATUSES, TicketRef, priorityLabel, statusLabel, } from "./ticketMeta"; 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 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 } = useProjectAgents(projectId); const [showCreate, setShowCreate] = useState(false); const [newTitle, setNewTitle] = useState(""); const [newPriority, setNewPriority] = useState("medium"); 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) { setNewTitle(""); setNewPriority("medium"); setShowCreate(false); onOpen(created.ref); } } return ( } > {vm.error && (

{vm.error}

)} {showCreate && (
setNewTitle(e.target.value)} />
)} {/* ── Filters ── */}
vm.setQuery({ ...vm.query, text: e.target.value || 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 && (
)}
); } /** * 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}

); }