feat(tickets): filtres multi-critères par cases à cocher (backend + frontend)

Passe les filtres de la liste des tickets en sélection multiple (#12).

Backend Rust :
- IssueListFilter : statuses/priorities en Vec, filter_matches en OR intra-champ
  et AND inter-champs
- TicketListRequestDto en tableaux + from_request (parse/déduplication)
- MCP idea_ticket_list aligné sur le nouveau contrat

Frontend :
- TicketListQuery.statuses/priorities
- UI de cases à cocher, toggle/clear des filtres

Tests verts : issue_store (7), app-tauri --lib (59), mcp_server (24),
issue_usecases (6), sprint_usecases (6), frontend vitest (505), npm run build (exit 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 06:41:01 +02:00
parent 413db25f08
commit feeca3462c
11 changed files with 410 additions and 81 deletions

View File

@ -9,12 +9,7 @@
import { useState } from "react";
import type {
Sprint,
TicketPriority,
TicketStatus,
TicketSummary,
} from "@/domain";
import type { Sprint, TicketPriority, TicketSummary } from "@/domain";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { useTickets } from "./useTickets";
import { useProjectAgents } from "./useProjectAgents";
@ -165,45 +160,53 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
vm.setQuery({ ...vm.query, text: e.target.value || undefined })
}
/>
{/* Multi-select facets (ticket #12): OR within a facet, AND across; no
box ticked ⇒ that facet is unconstrained. */}
<fieldset
aria-label="filter by status"
className="flex flex-wrap items-center gap-x-3 gap-y-1"
>
<legend className="mr-1 text-xs font-medium text-muted">Statut</legend>
{TICKET_STATUSES.map((s) => (
<label
key={s}
className="flex cursor-pointer items-center gap-1.5 text-xs"
>
<input
type="checkbox"
aria-label={`filter status ${statusLabel(s)}`}
className="accent-primary"
checked={(vm.query.statuses ?? []).includes(s)}
onChange={() => vm.toggleStatus(s)}
/>
<StatusBadge status={s} />
</label>
))}
</fieldset>
<fieldset
aria-label="filter by priority"
className="flex flex-wrap items-center gap-x-3 gap-y-1"
>
<legend className="mr-1 text-xs font-medium text-muted">
Priorité
</legend>
{TICKET_PRIORITIES.map((p) => (
<label
key={p}
className="flex cursor-pointer items-center gap-1.5 text-xs"
>
<input
type="checkbox"
aria-label={`filter priority ${priorityLabel(p)}`}
className="accent-primary"
checked={(vm.query.priorities ?? []).includes(p)}
onChange={() => vm.togglePriority(p)}
/>
<PriorityBadge priority={p} />
</label>
))}
</fieldset>
<div className="flex flex-wrap items-center gap-2">
<select
aria-label="filter by status"
className={selectClass}
value={vm.query.status ?? ""}
onChange={(e) =>
vm.setQuery({
...vm.query,
status: (e.target.value || undefined) as TicketStatus | undefined,
})
}
>
<option value="">All statuses</option>
{TICKET_STATUSES.map((s) => (
<option key={s} value={s}>
{statusLabel(s)}
</option>
))}
</select>
<select
aria-label="filter by priority"
className={selectClass}
value={vm.query.priority ?? ""}
onChange={(e) =>
vm.setQuery({
...vm.query,
priority: (e.target.value || undefined) as
| TicketPriority
| undefined,
})
}
>
<option value="">All priorities</option>
{TICKET_PRIORITIES.map((p) => (
<option key={p} value={p}>
{priorityLabel(p)}
</option>
))}
</select>
<select
aria-label="filter by assignee"
className={selectClass}
@ -222,6 +225,17 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
</option>
))}
</select>
{((vm.query.statuses?.length ?? 0) > 0 ||
(vm.query.priorities?.length ?? 0) > 0) && (
<Button
size="sm"
variant="ghost"
aria-label="clear filters"
onClick={() => vm.clearFacets()}
>
Effacer
</Button>
)}
</div>
</div>