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.
This commit is contained in:
@ -11,6 +11,7 @@ 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";
|
||||
@ -39,6 +40,7 @@ export interface TicketsPanelProps {
|
||||
}
|
||||
|
||||
export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
const { tr } = useI18n();
|
||||
const vm = useTickets(projectId);
|
||||
const { agents, nameOf, loaded: agentsLoaded } = useProjectAgents(projectId);
|
||||
|
||||
@ -159,10 +161,8 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
setSelectedRefs(new Set(refs.filter((ref) => failed.has(ref))));
|
||||
setBulkNotice(
|
||||
failed.size === 0
|
||||
? `Sprint mis à jour pour ${successCount} ticket${
|
||||
successCount > 1 ? "s" : ""
|
||||
}.`
|
||||
: `Sprint mis à jour pour ${successCount}/${result.items.length} tickets. Les échecs restent sélectionnés.`,
|
||||
? 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.`),
|
||||
);
|
||||
}
|
||||
|
||||
@ -177,7 +177,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Tickets"
|
||||
title={tr("Tickets", "Tickets")}
|
||||
actions={
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
@ -185,12 +185,12 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
variant="ghost"
|
||||
onClick={() => setShowCreate((v) => !v)}
|
||||
>
|
||||
{showCreate ? "Cancel" : "New"}
|
||||
{showCreate ? tr("Annuler", "Cancel") : tr("Nouveau", "New")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="manage sprints"
|
||||
aria-label={tr("Gérer les sprints", "Manage sprints")}
|
||||
onClick={() => setShowSprints(true)}
|
||||
>
|
||||
Sprints
|
||||
@ -201,7 +201,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
onClick={() => void vm.refresh()}
|
||||
loading={vm.busy}
|
||||
>
|
||||
Refresh
|
||||
{tr("Actualiser", "Refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@ -218,21 +218,20 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
{selectedVisibleRefs.length > 0 && (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="bulk ticket actions"
|
||||
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">
|
||||
{selectedVisibleRefs.length} sélectionné
|
||||
{selectedVisibleRefs.length > 1 ? "s" : ""}
|
||||
{tr(`${selectedVisibleRefs.length} sélectionné${selectedVisibleRefs.length > 1 ? "s" : ""}`, `${selectedVisibleRefs.length} selected`)}
|
||||
</span>
|
||||
<TicketViewportSelect
|
||||
aria-label="Changer le statut"
|
||||
aria-label={tr("Changer le statut", "Change status")}
|
||||
value={bulkStatus}
|
||||
options={[
|
||||
{ value: "", label: "Changer le statut" },
|
||||
{ value: "", label: tr("Changer le statut", "Change status") },
|
||||
...TICKET_STATUSES.map((status) => ({
|
||||
value: status,
|
||||
label: statusLabel(status),
|
||||
label: statusLabel(status, tr),
|
||||
})),
|
||||
]}
|
||||
onChange={(next) => void applyBulkStatus(next as TicketStatus | "")}
|
||||
@ -251,7 +250,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
disabled={vm.busy}
|
||||
onClick={() => setConfirmBulkDelete(true)}
|
||||
>
|
||||
Supprimer
|
||||
{tr("Supprimer", "Delete")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -259,7 +258,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
disabled={vm.busy}
|
||||
onClick={() => setSelectedRefs(new Set())}
|
||||
>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@ -279,18 +278,18 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
className="mb-3 flex flex-col gap-2 rounded-md border border-border bg-raised/50 p-2"
|
||||
>
|
||||
<Input
|
||||
aria-label="new ticket title"
|
||||
placeholder="Ticket title"
|
||||
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="new ticket priority"
|
||||
aria-label={tr("Priorité du nouveau ticket", "New ticket priority")}
|
||||
value={newPriority}
|
||||
options={TICKET_PRIORITIES.map((priority) => ({
|
||||
value: priority,
|
||||
label: priorityLabel(priority),
|
||||
label: priorityLabel(priority, tr),
|
||||
}))}
|
||||
onChange={(next) => setNewPriority(next as TicketPriority)}
|
||||
/>
|
||||
@ -299,23 +298,23 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
size="sm"
|
||||
disabled={!newTitle.trim() || vm.busy}
|
||||
>
|
||||
Create
|
||||
{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">Sprint</span>
|
||||
<span className="text-xs text-muted">{tr("Sprint", "Sprint")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
aria-label="choose sprint for new ticket"
|
||||
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 : "Sans sprint"}
|
||||
{newSprint ? newSprint.name : tr("Sans sprint", "No sprint")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@ -343,10 +342,10 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TicketViewportSelect
|
||||
aria-label="filter by assignee"
|
||||
aria-label={tr("Filtrer par agent assigné", "Filter by assignee")}
|
||||
value={vm.query.assignedAgentId ?? ""}
|
||||
options={[
|
||||
{ value: "", label: "All assignees" },
|
||||
{ value: "", label: tr("Tous les agents assignés", "All assignees") },
|
||||
...agents.map((agent) => ({
|
||||
value: agent.id,
|
||||
label: agent.name,
|
||||
@ -360,11 +359,11 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
}
|
||||
/>
|
||||
<TicketViewportSelect
|
||||
aria-label="filter by creator"
|
||||
aria-label={tr("Filtrer par créateur", "Filter by creator")}
|
||||
value={creatorFilterValue(vm.query.createdBy)}
|
||||
options={[
|
||||
{ value: "", label: "Tous créateurs" },
|
||||
{ value: "user", label: "Utilisateur" },
|
||||
{ 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,
|
||||
@ -386,10 +385,10 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
{vm.busy && vm.list === null ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>Loading tickets…</span>
|
||||
<span>{tr("Chargement des tickets…", "Loading tickets…")}</span>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-sm text-muted">No tickets.</p>
|
||||
<p className="text-sm text-muted">{tr("Aucun ticket.", "No tickets.")}</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{grouped.map((g) => (
|
||||
@ -408,7 +407,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
))}
|
||||
{noSprint.length > 0 && (
|
||||
<SprintSection
|
||||
heading="Sans sprint"
|
||||
heading={tr("Sans sprint", "No sprint")}
|
||||
count={noSprint.length}
|
||||
tickets={noSprint}
|
||||
sprints={vm.sprints}
|
||||
@ -435,7 +434,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
})
|
||||
}
|
||||
>
|
||||
Load more
|
||||
{tr("Charger plus", "Load more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@ -454,7 +453,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
open={showSprintPicker}
|
||||
projectId={projectId}
|
||||
selectedSprintId={newSprint?.id ?? null}
|
||||
title="Sprint du nouveau ticket"
|
||||
title={tr("Sprint du nouveau ticket", "Sprint for new ticket")}
|
||||
onSelect={(sprint) => setNewSprint(sprint)}
|
||||
onClose={() => setShowSprintPicker(false)}
|
||||
/>
|
||||
@ -463,9 +462,7 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
open={showBulkSprintPicker}
|
||||
projectId={projectId}
|
||||
selectedSprintId={null}
|
||||
title={`Sprint pour ${selectedVisibleRefs.length} ticket${
|
||||
selectedVisibleRefs.length > 1 ? "s" : ""
|
||||
}`}
|
||||
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)}
|
||||
/>
|
||||
@ -474,34 +471,33 @@ export function TicketsPanel({ projectId, onOpen }: TicketsPanelProps) {
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="confirmer la suppression groupée"
|
||||
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">
|
||||
Supprimer {selectedVisibleRefs.length} ticket
|
||||
{selectedVisibleRefs.length > 1 ? "s" : ""} ?
|
||||
{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">
|
||||
Cette action supprimera les tickets sélectionnés du projet.
|
||||
{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="cancel bulk delete"
|
||||
aria-label={tr("Annuler la suppression groupée", "Cancel bulk deletion")}
|
||||
onClick={() => setConfirmBulkDelete(false)}
|
||||
>
|
||||
Annuler
|
||||
{tr("Annuler", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
aria-label="confirm bulk delete"
|
||||
aria-label={tr("Confirmer la suppression groupée", "Confirm bulk deletion")}
|
||||
loading={vm.busy}
|
||||
onClick={() => void applyBulkDelete()}
|
||||
>
|
||||
Supprimer
|
||||
{tr("Supprimer", "Delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@ -537,8 +533,9 @@ function SprintSection({
|
||||
selectedRefs: Set<string>;
|
||||
onToggleSelected: (ref: string, selected: boolean) => void;
|
||||
}) {
|
||||
const { tr } = useI18n();
|
||||
return (
|
||||
<section aria-label={`sprint section ${heading}`}>
|
||||
<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">
|
||||
@ -551,7 +548,7 @@ function SprintSection({
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={`select ticket row ${t.ref}`}
|
||||
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"
|
||||
@ -571,7 +568,7 @@ function SprintSection({
|
||||
</span>
|
||||
)}
|
||||
<span className="mt-0.5 block truncate text-xs text-muted">
|
||||
Créé par : {ticketActorLabel(t.createdBy, nameOf)}
|
||||
{tr("Créé par", "Created by")}: {ticketActorLabel(t.createdBy, nameOf)}
|
||||
</span>
|
||||
</button>
|
||||
<span className="flex shrink-0 flex-col items-end gap-1">
|
||||
@ -581,10 +578,10 @@ function SprintSection({
|
||||
</div>
|
||||
<div className="mt-1 pl-6">
|
||||
<TicketViewportSelect
|
||||
aria-label={`sprint for ${t.ref}`}
|
||||
aria-label={tr(`Sprint de ${t.ref}`, `Sprint for ${t.ref}`)}
|
||||
value={t.sprintId ?? ""}
|
||||
options={[
|
||||
{ value: "", label: "— No sprint —" },
|
||||
{ value: "", label: tr("— Sans sprint —", "— No sprint —") },
|
||||
...sprints.map((sprint) => ({
|
||||
value: sprint.id,
|
||||
label: sprint.name,
|
||||
|
||||
Reference in New Issue
Block a user