Files
IdeA/frontend/src/features/tickets/TicketsPanel.tsx
Blomios 1a1429f10e feat(domain,application,infrastructure,backend,web-server,app-tauri,frontend): lot utilisateur #325 #326 — icônes projet avec watermark CLI custom et localisation FR/EN (QA verte)
Lot en un seul commit: les deux features partagent les mêmes contrats
(domain/settings.rs, DTO, commandes Tauri/HTTP, web-server) et les mêmes
surfaces UI — la séparation par fichiers était impossible sans chirurgie
de hunks risquant des états intermédiaires non compilables (convention
lot des batches #310-#313 et #315).

#325 — icône configurable par projet, watermark CLI custom:
- domaine: ProjectIconSet (devicon, simple-icons, lucide, idea),
  ProjectIconRef validé (nom ASCII 1..=64, -_. internes), champ icon
  optionnel sur ProjectSettings; sérialisation camelCase et try_from
  strict
- application: UpdateProjectSettings.execute accepte un patch
  theme_color OU icon (absent = inchangé, null = défaut produit), tests
  in-dependants avec store mémoire
- backend/app-tauri/web-server: ProjectIconRefDto, UpdateProjectSettingsRequestDto
  avec icon optionnel (désérialisation additive), commandes
  update_project_settings alignées Tauri/HTTP, tests DTO et HTTP
  (défaut null, set, clear, nom invalide rejeté)
- frontend: catalogue projectIcons (recherche EN), ProjectIconPicker en
  grille multi-lignes dans Paramètres projet, affichage de l'icône dans
  ProjectTabs et watermark grand format à faible opacité en fond de CLI
  custom; ports ProjectSettings.icon, Partial<ProjectSettings>,
  adapters tauri/mock/http et tests alignés

#326 — localisation FR/EN et traduction des textes IdeA:
- domaine: IdeaLanguage (fr | en), champ language optionnel sur
  IdeaUserSettings (absence = défaut produit, série additive validée)
- backend/web-server: UserSettingsDto.language, update_user_settings
  accepte language, tests HTTP alignés; fixtures structured_launch_d3
  mises à jour
- frontend: i18n/ (catalogue FR/EN, I18nProvider, scan anti-régression
  des sources visibles), GeneralSettings avec sélecteur de langue dans
  les paramètres, setupI18n dans vitest; migration de l'ensemble des
  textes hardcodés vers le catalogue sur toutes les surfaces (agents,
  layout, projets, tickets, sprints, web, plugins, permissions, git,
  templates, skills, mémoire, model servers, embedder, first-run,
  devices, scheduled tasks, workstate, dialogs), FR conservé par défaut;
  recherche d'icônes #325 en anglais conformément au ticket

QA verte sur feature/batch-325-326-project-icons-localisation. Aucun push.
2026-09-11 21:53:56 +02:00

601 lines
22 KiB
TypeScript

/**
* 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<TicketPriority>("medium");
// Sprint chosen for the new ticket (`null` ⇒ "Sans sprint"), and whether its
// picker popup is open (#38).
const [newSprint, setNewSprint] = useState<Sprint | null>(null);
const [showSprintPicker, setShowSprintPicker] = useState(false);
const [selectedRefs, setSelectedRefs] = useState<Set<string>>(() => new Set());
const [bulkStatus, setBulkStatus] = useState<TicketStatus | "">("");
const [showBulkSprintPicker, setShowBulkSprintPicker] = useState(false);
const [bulkNotice, setBulkNotice] = useState<string | null>(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 (
<Panel
title={tr("Tickets", "Tickets")}
actions={
<div className="flex items-center gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => setShowCreate((v) => !v)}
>
{showCreate ? tr("Annuler", "Cancel") : tr("Nouveau", "New")}
</Button>
<Button
size="sm"
variant="ghost"
aria-label={tr("Gérer les sprints", "Manage sprints")}
onClick={() => setShowSprints(true)}
>
Sprints
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => void vm.refresh()}
loading={vm.busy}
>
{tr("Actualiser", "Refresh")}
</Button>
</div>
}
>
{vm.error && (
<p
role="alert"
className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{selectedVisibleRefs.length > 0 && (
<div
role="toolbar"
aria-label={tr("Actions groupées sur les tickets", "Bulk ticket actions")}
className="mb-3 flex flex-wrap items-center gap-2 rounded-md border border-border bg-raised/70 px-2 py-2"
>
<span className="text-xs font-medium text-content">
{tr(`${selectedVisibleRefs.length} sélectionné${selectedVisibleRefs.length > 1 ? "s" : ""}`, `${selectedVisibleRefs.length} selected`)}
</span>
<TicketViewportSelect
aria-label={tr("Changer le statut", "Change status")}
value={bulkStatus}
options={[
{ value: "", label: tr("Changer le statut", "Change status") },
...TICKET_STATUSES.map((status) => ({
value: status,
label: statusLabel(status, tr),
})),
]}
onChange={(next) => void applyBulkStatus(next as TicketStatus | "")}
/>
<Button
size="sm"
variant="secondary"
disabled={vm.busy}
onClick={() => setShowBulkSprintPicker(true)}
>
Sprint
</Button>
<Button
size="sm"
variant="danger"
disabled={vm.busy}
onClick={() => setConfirmBulkDelete(true)}
>
{tr("Supprimer", "Delete")}
</Button>
<Button
size="sm"
variant="ghost"
disabled={vm.busy}
onClick={() => setSelectedRefs(new Set())}
>
{tr("Annuler", "Cancel")}
</Button>
</div>
)}
{bulkNotice && (
<p
role="status"
className="mb-3 rounded-md border border-border bg-raised/60 px-3 py-2 text-sm text-content"
>
{bulkNotice}
</p>
)}
{showCreate && (
<form
onSubmit={submitCreate}
className="mb-3 flex flex-col gap-2 rounded-md border border-border bg-raised/50 p-2"
>
<Input
aria-label={tr("Titre du nouveau ticket", "New ticket title")}
placeholder={tr("Titre du ticket", "Ticket title")}
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
/>
<div className="flex items-center gap-2">
<TicketViewportSelect
aria-label={tr("Priorité du nouveau ticket", "New ticket priority")}
value={newPriority}
options={TICKET_PRIORITIES.map((priority) => ({
value: priority,
label: priorityLabel(priority, tr),
}))}
onChange={(next) => setNewPriority(next as TicketPriority)}
/>
<Button
type="submit"
size="sm"
disabled={!newTitle.trim() || vm.busy}
>
{tr("Créer", "Create")}
</Button>
</div>
{/* Sprint field (#38): opens the SprintPicker popup; shows the choice
(or "Sans sprint"). */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted">{tr("Sprint", "Sprint")}</span>
<Button
type="button"
size="sm"
variant="ghost"
aria-label={tr("Choisir un sprint pour le nouveau ticket", "Choose sprint for new ticket")}
disabled={vm.busy}
onClick={() => setShowSprintPicker(true)}
className="border border-border"
>
{newSprint ? newSprint.name : tr("Sans sprint", "No sprint")}
</Button>
</div>
</form>
)}
{/* ── Filters ── */}
<div className="mb-3 flex flex-col gap-2">
{/* Shared search + status/priority facets (#18). Assignee stays here —
it needs the project agent roster and is out of the picker's scope. */}
<TicketFacetsBar
text={vm.query.text ?? ""}
onTextChange={(text) =>
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);
}}
/>
<div className="flex flex-wrap items-center gap-2">
<TicketViewportSelect
aria-label={tr("Filtrer par agent assigné", "Filter by assignee")}
value={vm.query.assignedAgentId ?? ""}
options={[
{ value: "", label: tr("Tous les agents assignés", "All assignees") },
...agents.map((agent) => ({
value: agent.id,
label: agent.name,
})),
]}
onChange={(next) =>
vm.setQuery({
...vm.query,
assignedAgentId: next || undefined,
})
}
/>
<TicketViewportSelect
aria-label={tr("Filtrer par créateur", "Filter by creator")}
value={creatorFilterValue(vm.query.createdBy)}
options={[
{ value: "", label: tr("Tous les créateurs", "All creators") },
{ value: "user", label: tr("Utilisateur", "User") },
...agents.map((agent) => ({
value: `agent:${agent.id}`,
label: agent.name,
})),
]}
onChange={(next) => {
const { createdBy: _drop, ...rest } = vm.query;
const createdBy = creatorFilterFromValue(next);
vm.setQuery({
...rest,
...(createdBy ? { createdBy } : {}),
});
}}
/>
</div>
</div>
{/* ── List, grouped by sprint (F2) ── */}
{vm.busy && vm.list === null ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>{tr("Chargement des tickets…", "Loading tickets…")}</span>
</div>
) : items.length === 0 ? (
<p className="text-sm text-muted">{tr("Aucun ticket.", "No tickets.")}</p>
) : (
<div className="flex flex-col gap-4">
{grouped.map((g) => (
<SprintSection
key={g.sprint.id}
heading={g.sprint.name}
count={g.tickets.length}
tickets={g.tickets}
sprints={vm.sprints}
onOpen={onOpen}
nameOf={nameOf}
onAssignSprint={vm.assignSprint}
selectedRefs={selectedRefs}
onToggleSelected={toggleTicketSelection}
/>
))}
{noSprint.length > 0 && (
<SprintSection
heading={tr("Sans sprint", "No sprint")}
count={noSprint.length}
tickets={noSprint}
sprints={vm.sprints}
onOpen={onOpen}
nameOf={nameOf}
onAssignSprint={vm.assignSprint}
selectedRefs={selectedRefs}
onToggleSelected={toggleTicketSelection}
/>
)}
</div>
)}
{vm.list?.nextCursor && (
<div className="mt-3 flex justify-center">
<Button
size="sm"
variant="ghost"
loading={vm.busy}
onClick={() =>
vm.setQuery({
...vm.query,
limit: (vm.query.limit ?? 100) + 100,
})
}
>
{tr("Charger plus", "Load more")}
</Button>
</div>
)}
{showSprints && (
<SprintManager
projectId={projectId}
vm={vm}
onClose={() => 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. */}
<SprintPicker
open={showSprintPicker}
projectId={projectId}
selectedSprintId={newSprint?.id ?? null}
title={tr("Sprint du nouveau ticket", "Sprint for new ticket")}
onSelect={(sprint) => setNewSprint(sprint)}
onClose={() => setShowSprintPicker(false)}
/>
<SprintPicker
open={showBulkSprintPicker}
projectId={projectId}
selectedSprintId={null}
title={tr(`Sprint pour ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""}`, `Sprint for ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""}`)}
onSelect={(sprint) => void applyBulkSprint(sprint)}
onClose={() => setShowBulkSprintPicker(false)}
/>
{confirmBulkDelete && (
<div
role="dialog"
aria-modal="true"
aria-label={tr("Confirmer la suppression groupée", "Confirm bulk deletion")}
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-panel p-4 shadow-lg">
<p className="text-sm font-semibold text-content">
{tr(`Supprimer ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""} ?`, `Delete ${selectedVisibleRefs.length} ticket${selectedVisibleRefs.length > 1 ? "s" : ""}?`)}
</p>
<p className="mt-1 text-sm text-muted">
{tr("Cette action supprimera les tickets sélectionnés du projet.", "This will delete the selected tickets from the project.")}
</p>
<div className="mt-4 flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
aria-label={tr("Annuler la suppression groupée", "Cancel bulk deletion")}
onClick={() => setConfirmBulkDelete(false)}
>
{tr("Annuler", "Cancel")}
</Button>
<Button
size="sm"
variant="danger"
aria-label={tr("Confirmer la suppression groupée", "Confirm bulk deletion")}
loading={vm.busy}
onClick={() => void applyBulkDelete()}
>
{tr("Supprimer", "Delete")}
</Button>
</div>
</div>
</div>
)}
</Panel>
);
}
/**
* 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<string>;
onToggleSelected: (ref: string, selected: boolean) => void;
}) {
const { tr } = useI18n();
return (
<section aria-label={`${tr("Section du sprint", "Sprint section")} ${heading}`}>
<h3 className="mb-1 flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted">
{heading}
<span className="rounded-full bg-raised px-1.5 text-[10px] font-medium text-muted">
{count}
</span>
</h3>
<ul className="flex flex-col divide-y divide-border">
{tickets.map((t) => (
<li key={t.ref} className="py-2 first:pt-0 last:pb-0">
<div className="flex items-start gap-2">
<input
type="checkbox"
aria-label={tr(`Sélectionner le ticket ${t.ref}`, `Select ticket ${t.ref}`)}
checked={selectedRefs.has(t.ref)}
onChange={(e) => 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"
/>
<TicketRef ticketRef={t.ref} onOpen={onOpen} className="mt-0.5" />
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={() => onOpen(t.ref)}
>
<span className="block truncate text-sm font-medium text-content hover:underline">
{t.title}
</span>
{t.assignedAgentIds.length > 0 && (
<span className="mt-0.5 block truncate text-xs text-muted">
{t.assignedAgentIds.map(nameOf).join(", ")}
</span>
)}
<span className="mt-0.5 block truncate text-xs text-muted">
{tr("Créé par", "Created by")}: {ticketActorLabel(t.createdBy, nameOf)}
</span>
</button>
<span className="flex shrink-0 flex-col items-end gap-1">
<StatusBadge status={t.status} />
<PriorityBadge priority={t.priority} />
</span>
</div>
<div className="mt-1 pl-6">
<TicketViewportSelect
aria-label={tr(`Sprint de ${t.ref}`, `Sprint for ${t.ref}`)}
value={t.sprintId ?? ""}
options={[
{ value: "", label: tr("— Sans sprint —", "— No sprint —") },
...sprints.map((sprint) => ({
value: sprint.id,
label: sprint.name,
})),
]}
onChange={(next) =>
onAssignSprint(t.ref, next || null)
}
/>
</div>
</li>
))}
</ul>
</section>
);
}