TQ-Slator/assets/components/Status.tsx
Stephan Morand 9025c64c0b Socle complet de TQ-Slator : éditeur, API, CLI, administration
Système de gestion de traductions pensé en CMS headless : un back-office
pour ceux qui traduisent, une API pour ce qui consomme.

Architecture
- Symfony 7.4 / API Platform 4.3 / MariaDB 11.4, SPA React 19 servie en
  même origine — ce qui rend viable le cookie de session plutôt qu'un
  jeton en localStorage.
- Deux APIs séparées : Management (session ou clé) et Delivery
  (stateless, clé seule). Les fusionner ferait porter à chaque lecture de
  bundle le coût de la session.
- Stockage canonique en ICU MessageFormat, sérialisation par plateforme.
  Le format d'une plateforme ne contamine pas la base.
- Publication par releases immuables ; le déploiement est un déplacement
  de pointeur, donc le rollback aussi.
- Isolation multi-organisation par filtre Doctrine, avec un test
  d'architecture qui casse la CI si une entité échappe à l'invariant.

Éditeur, deux vues
- Par langue : source et cible, jamais douze colonnes. Grille virtualisée,
  saisie sans bouton « Enregistrer », panneau de contexte permanent.
- Par clé : une clé, toutes ses langues empilées et repliées. Répond à
  « ce libellé est-il prêt partout ? ».
- Mode Focus dans les deux : une file à vider, ⌘↵ pour enchaîner.
- Le traducteur ne voit jamais d'ICU : pastilles de variables, un champ
  par catégorie CLDR de la langue cible.

Administration
- Deux niveaux : projet (membres, clés API, plateformes) et organisation
  (annuaire des comptes, création de projets).
- Invitations par e-mail, jeton 256 bits stocké haché.
- Désactiver un compte coupe les sessions en cours, pas seulement les
  connexions suivantes.
- Les plateformes s'archivent ; ni elles ni les environnements ne se
  suppriment — la trace explique pourquoi telle clé existe.

CLI tqs
- PHAR autonome de 3 Mo, autoloader généré : le dépôt client ne dépend
  ni de Composer ni de la disponibilité de TQ-Slator.
- init / push / pull / status ; le sync est non destructif par défaut et
  son prune est scopé plateforme.

126 tests, PHPStan niveau 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 08:16:05 +02:00

107 lines
3.1 KiB
TypeScript

import type { TranslationStatus } from '@/api/types';
/**
* Signalétique des statuts — une seule définition, utilisée partout.
*
* Centralisée volontairement : cinq couleurs qui varieraient d'un écran à
* l'autre obligeraient l'utilisateur à réapprendre le code à chaque page, ce
* qui annule tout l'intérêt d'un code couleur.
*/
export const STATUS_META: Record<
TranslationStatus,
{ label: string; dot: string; text: string; bg: string }
> = {
untranslated: {
label: 'À traduire',
dot: 'bg-ink-300',
text: 'text-ink-500',
bg: 'bg-ink-100',
},
draft: {
label: 'Brouillon',
dot: 'bg-amber-500',
text: 'text-amber-700',
bg: 'bg-amber-50',
},
// Le seul statut qui désigne un bug en production : il doit être le plus
// visible de l'interface, d'où le rouge, réservé à lui seul.
needs_review: {
label: 'À revoir',
dot: 'bg-red-500',
text: 'text-red-700',
bg: 'bg-red-50',
},
translated: {
label: 'Traduite',
dot: 'bg-blue-500',
text: 'text-blue-700',
bg: 'bg-blue-50',
},
reviewed: {
label: 'Validée',
dot: 'bg-emerald-500',
text: 'text-emerald-700',
bg: 'bg-emerald-50',
},
};
export function StatusDot({ status, title }: { status: TranslationStatus; title?: string }) {
const meta = STATUS_META[status];
return (
<span
className={`inline-block size-2 shrink-0 rounded-full ${meta.dot}`}
title={title ?? meta.label}
aria-label={meta.label}
/>
);
}
export function StatusBadge({ status }: { status: TranslationStatus }) {
const meta = STATUS_META[status];
return (
<span
className={`inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-[11px] font-medium ${meta.bg} ${meta.text}`}
>
<span className={`size-1.5 rounded-full ${meta.dot}`} />
{meta.label}
</span>
);
}
/**
* Barre d'avancement segmentée.
*
* Une seule barre de pourcentage cacherait la répartition : « 60 % » ne dit pas
* s'il reste du travail de traduction ou de relecture, ni combien de traductions
* sont devenues obsolètes. Les segments répondent aux trois questions d'un coup.
*/
export function ProgressBar({
reviewed,
translated,
needsReview,
draft,
total,
}: {
reviewed: number;
translated: number;
needsReview: number;
draft: number;
total: number;
}) {
if (total === 0) {
return <div className="h-1.5 w-full rounded-full bg-ink-200" />;
}
const pct = (value: number) => `${(value / total) * 100}%`;
return (
<div className="flex h-1.5 w-full overflow-hidden rounded-full bg-ink-200">
<div className="bg-emerald-500" style={{ width: pct(reviewed) }} />
<div className="bg-blue-500" style={{ width: pct(translated) }} />
<div className="bg-red-500" style={{ width: pct(needsReview) }} />
<div className="bg-amber-500" style={{ width: pct(draft) }} />
</div>
);
}