From 9778629827dc5bfcdbbe53bd925128cc788b56b6 Mon Sep 17 00:00:00 2001 From: pr-daaif Date: Thu, 16 Jul 2026 13:06:45 +0100 Subject: [PATCH] =?UTF-8?q?feat(r1.2):=20=C3=A9crans=20web=20du=20r=C3=A9f?= =?UTF-8?q?=C3=A9rentiel=20=E2=80=94=207=20=C3=A9crans=20fid=C3=A8les=20?= =?UTF-8?q?=C3=A0=20maquette-r1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sites : liste + carte Leaflet/OSM réelle (pins maquette, positions PostGIS), création en modale avec position posée au clic sur la carte - Fiche site : arbre site→zones, appareils rattachés, ajout de zone - Ascenseurs : table dense, filtres site/statut, strie rouge à l'arrêt ; fiche appareil (organes ± ajout/retrait, statut, QR réel) ; création identité → rattachement → organes ; étiquette A6 imprimable (objet papier, window.print n'imprime qu'elle) - Personnes & équipes : invitation → lien d'activation affiché à copier (pas d'email en R1, décision explicite), renvoi de lien, équipes ; page /activation (choix du mot de passe, connexion directe) - Catégories : ajout, renommage inline, désactivation - navigation et actions pilotées par la matrice (usePermissions) — l'API re-vérifie chaque requête - e2e Playwright : recette R1 officielle rejouée intégralement (site → zone → appareil+organe → étiquette → invitation → activation) + parcours « la matrice pilote l'UI » ; purge idempotente en globalSetup - alias Vite @siop/shared → source TS (exports nommés CJS ↯ workspace lié) Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 3 +- apps/api/prisma/cleanup-e2e.ts | 20 ++ apps/web/e2e/global-setup.ts | 9 +- apps/web/e2e/parcours-r1.spec.ts | 96 ++++++ apps/web/package.json | 5 + apps/web/src/App.tsx | 39 ++- apps/web/src/api/referentiel.ts | 190 +++++++++++ apps/web/src/auth/use-permissions.ts | 19 ++ apps/web/src/components/avatar.tsx | 8 +- apps/web/src/components/carte-geo.tsx | 77 +++++ apps/web/src/components/nav-icons.tsx | 6 + apps/web/src/components/statut-equipement.tsx | 13 + apps/web/src/components/ui/modale.tsx | 29 ++ apps/web/src/layout/coquille.tsx | 33 +- apps/web/src/lib/lieux.test.ts | 29 ++ apps/web/src/lib/lieux.ts | 21 ++ apps/web/src/pages/activation.tsx | 96 ++++++ apps/web/src/pages/ascenseurs.tsx | 97 ++++++ apps/web/src/pages/categories.tsx | 136 ++++++++ apps/web/src/pages/etiquette.tsx | 76 +++++ apps/web/src/pages/fiche-ascenseur.tsx | 202 ++++++++++++ apps/web/src/pages/fiche-site.tsx | 165 ++++++++++ apps/web/src/pages/nouvel-ascenseur.tsx | 206 ++++++++++++ apps/web/src/pages/personnes.tsx | 297 ++++++++++++++++++ apps/web/src/pages/sites.tsx | 172 ++++++++++ apps/web/src/styles/app.css | 214 +++++++++++++ apps/web/vite.config.ts | 7 +- docs/journal/journal.md | 17 + pnpm-lock.yaml | 103 ++++++ 29 files changed, 2350 insertions(+), 35 deletions(-) create mode 100644 apps/api/prisma/cleanup-e2e.ts create mode 100644 apps/web/e2e/parcours-r1.spec.ts create mode 100644 apps/web/src/api/referentiel.ts create mode 100644 apps/web/src/auth/use-permissions.ts create mode 100644 apps/web/src/components/carte-geo.tsx create mode 100644 apps/web/src/components/statut-equipement.tsx create mode 100644 apps/web/src/components/ui/modale.tsx create mode 100644 apps/web/src/lib/lieux.test.ts create mode 100644 apps/web/src/lib/lieux.ts create mode 100644 apps/web/src/pages/activation.tsx create mode 100644 apps/web/src/pages/ascenseurs.tsx create mode 100644 apps/web/src/pages/categories.tsx create mode 100644 apps/web/src/pages/etiquette.tsx create mode 100644 apps/web/src/pages/fiche-ascenseur.tsx create mode 100644 apps/web/src/pages/fiche-site.tsx create mode 100644 apps/web/src/pages/nouvel-ascenseur.tsx create mode 100644 apps/web/src/pages/personnes.tsx create mode 100644 apps/web/src/pages/sites.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 3733c78..9488241 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,5 +42,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - ✅ **R0 CLOSE** (tag `release/r0`) — en production : `https://siop2.apps.enset.top` (Dokploy ENSET, profil démo, vérifiée en ligne). Restes non bloquants : secret `DOKPLOY_WEBHOOK_URL` (CD), sauvegardes PostgreSQL Dokploy. Production client SPELEV : attend les accès au serveur du partenaire. - ✅ **R1 — maquettes validées** (16/07) : `maquette-r1.html`, 7 écrans + 4 décisions (statuts d'équipement ≠ statuts OT ; organe sans emplacement par construction ; invitation par lien 7 j ; catégories jamais supprimées si utilisées). - ✅ **R1.1 — socle backend** : migration `r1_referentiel` (Category, Location site→zone + PostGIS générée, Asset, AssetComponent, Team, invitation User), 21 nouvelles opérations au contrat (26 total), 4 modules API + invitations/activation sous `@RequirePermission`, seed parc maquette (5 sites, 8 appareils, organes, 2 équipes), 36 tests (96 %/85 %), CI sur `postgis/postgis:18-3.6`. -- 🔄 **R1.2 — reprise ici** : `apps/web` — écrans Sites (+ carte Leaflet/OSM à intégrer), Fiche site, Ascenseurs, Nouvel ascenseur, Étiquette QR imprimable, Personnes & équipes (invitation + lien à copier), Catégories — fidèles à maquette-r1.html ; puis e2e Playwright du parcours de recette R1 et déploiement. +- ✅ **R1.2 — écrans web du référentiel** : 7 écrans fidèles à maquette-r1.html (Sites + carte Leaflet/OSM, fiche site, ascenseurs, fiche appareil + QR réel, création, étiquette A6 imprimable, personnes & équipes avec lien d'activation à copier, catégories) + page /activation ; navigation et actions pilotées par la matrice (`usePermissions`) ; recette R1 rejouée en e2e Playwright (dont activation d'un invité) ; alias Vite `@siop/shared` → source TS (leçon CJS/workspace). +- 🔄 **R1.3 — reprise ici** : recette R1 avec le référent (revue pixel ↔ maquette-r1), déploiement Dokploy, tag `release/r1` → ouverture R2 Exploitation (OT, demandes, portail — maquettes R0 déjà validées ; il manquera préventif/bilan à maquetter). - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5. diff --git a/apps/api/prisma/cleanup-e2e.ts b/apps/api/prisma/cleanup-e2e.ts new file mode 100644 index 0000000..a495d21 --- /dev/null +++ b/apps/api/prisma/cleanup-e2e.ts @@ -0,0 +1,20 @@ +/** Purge des données créées par les parcours Playwright (motifs dédiés). + * Appelé par le globalSetup e2e — la base locale reste propre entre les runs. */ +import 'dotenv/config'; +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function main() { + await prisma.asset.deleteMany({ where: { reference: { startsWith: 'E2E-' } } }); + await prisma.location.deleteMany({ where: { name: { contains: 'E2E' }, parentId: { not: null } } }); + await prisma.location.deleteMany({ where: { name: { contains: 'E2E' } } }); + await prisma.user.deleteMany({ where: { email: { startsWith: 'recrue-' } } }); +} + +main() + .catch((e) => { + console.error(e); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/apps/web/e2e/global-setup.ts b/apps/web/e2e/global-setup.ts index 7d17ff4..3637763 100644 --- a/apps/web/e2e/global-setup.ts +++ b/apps/web/e2e/global-setup.ts @@ -1,11 +1,14 @@ import { execSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -/** Seed idempotent avant le parcours : mêmes comptes en local et en CI. */ +/** Seed idempotent + purge des restes du run précédent : mêmes données + * en local et en CI. */ export default function globalSetup() { - execSync('pnpm --filter @siop/api seed', { - cwd: fileURLToPath(new URL('../../..', import.meta.url)), + const cwd = fileURLToPath(new URL('../../..', import.meta.url)); + execSync('pnpm --filter @siop/api exec tsx prisma/cleanup-e2e.ts', { + cwd, stdio: 'inherit', env: process.env, }); + execSync('pnpm --filter @siop/api seed', { cwd, stdio: 'inherit', env: process.env }); } diff --git a/apps/web/e2e/parcours-r1.spec.ts b/apps/web/e2e/parcours-r1.spec.ts new file mode 100644 index 0000000..5649b96 --- /dev/null +++ b/apps/web/e2e/parcours-r1.spec.ts @@ -0,0 +1,96 @@ +import { expect, test } from '@playwright/test'; + +/** + * Recette R1 (plan de releases) : créer un site → un ascenseur → ses organes + * → imprimer l'étiquette QR → inviter un technicien (activation incluse). + */ + +const suffix = Date.now().toString(36); + +test('recette R1 complète, de la création du site à l’activation de l’invité', async ({ + page, +}) => { + // Connexion démo (Administrateur — création tous azimuts) + await page.goto('/connexion'); + await page.getByRole('button', { name: /Yasmine Alaoui/ }).click(); + await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible(); + + // 1 · Sites : le parc seedé est là, on crée un site + await page.getByRole('link', { name: 'Sites' }).click(); + await expect(page.getByRole('cell', { name: /Tour Atlas/ })).toBeVisible(); + await page.getByRole('button', { name: '+ Nouveau site' }).click(); + await page.getByLabel('Nom du site *').fill(`Site E2E ${suffix}`); + await page.getByLabel('Ville').fill('Casablanca'); + await page.getByRole('button', { name: 'Créer le site' }).click(); + + // → fiche du site créé ; on lui ajoute une zone + await expect(page.getByRole('heading', { name: `Site E2E ${suffix}` })).toBeVisible(); + await page.getByRole('button', { name: '+ Ajouter un emplacement' }).click(); + await page.getByLabel('Nom de la zone *').fill(`Hall E2E ${suffix}`); + await page.getByRole('button', { name: 'Ajouter', exact: true }).click(); + await expect(page.locator('.arbre')).toContainText(`Hall E2E ${suffix}`); + + // 2 · Nouvel ascenseur avec un organe + await page.getByRole('button', { name: '+ Nouvel ascenseur' }).click(); + await page.getByLabel('Référence *').fill(`E2E-${suffix}`); + await page.getByLabel('Marque *').fill('Otis'); + await page.getByLabel('Catégorie *').selectOption({ label: 'Ascenseur électrique' }); + await page.getByLabel('Emplacement dans le site *').selectOption({ label: `Hall E2E ${suffix}` }); + await page.getByLabel("Type de l'organe 1").selectOption({ label: 'Portes cabine / palières' }); + await page.getByLabel("Désignation de l'organe 1").fill('Fermator 40/10'); + await page.getByRole('button', { name: "Créer l'ascenseur (QR généré)" }).click(); + + // → fiche appareil : identité, organe, QR + await expect( + page.getByRole('heading', { name: new RegExp(`Ascenseur E2E-${suffix}`) }), + ).toBeVisible(); + await expect(page.getByText('Portes cabine / palières')).toBeVisible(); + + // 3 · Étiquette QR imprimable + await page.getByRole('button', { name: 'Étiquette QR' }).click(); + await expect(page.locator('.etiquette')).toContainText(`Ascenseur E2E-${suffix}`); + await expect(page.locator('.etiquette .consigne')).toContainText('Scannez ce code'); + + // 4 · Inviter un technicien → récupérer le lien d'activation + await page.getByRole('link', { name: 'Personnes' }).click(); + await page.getByRole('button', { name: '+ Inviter' }).click(); + await page.getByLabel('Nom complet *').fill('Recrue E2E'); + await page.getByLabel('Email professionnel *').fill(`recrue-${suffix}@spelev.ma`); + await page.getByLabel('Rôle *').selectOption({ label: 'Technicien' }); + await page.getByRole('button', { name: "Envoyer l'invitation" }).click(); + const lien = await page.getByTestId('lien-activation').textContent(); + expect(lien).toContain('/activation?token='); + await page.getByRole('button', { name: 'Fermer' }).click(); + await expect(page.getByRole('cell', { name: /Recrue E2E/ })).toBeVisible(); + await expect(page.getByText('Invitation envoyée')).toBeVisible(); + + // 5 · La recrue active son compte via le lien et entre directement + await page.locator('.compte').click(); + await page.getByRole('menuitem', { name: 'Se déconnecter' }).click(); + await page.goto(lien!); + await page.getByLabel('Mot de passe (8 caractères minimum)').fill('MotDePasse!123'); + await page.getByLabel('Confirmez le mot de passe').fill('MotDePasse!123'); + await page.getByRole('button', { name: 'Activer mon compte' }).click(); + await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible(); + await expect(page.locator('.topbar')).toContainText('Recrue E2E'); + await expect(page.locator('.topbar')).toContainText('Technicien'); +}); + +test('la matrice pilote l’UI : un technicien voit le parc sans boutons de création', async ({ + page, +}) => { + await page.goto('/connexion'); + await page.getByRole('button', { name: /Ahmed Benali/ }).click(); + await expect(page.getByRole('heading', { name: 'Tableau de bord' })).toBeVisible(); + + // Catégories (SETTINGS) absent de sa sidebar ; Ascenseurs accessible + await expect(page.locator('.nav')).not.toContainText('Catégories'); + await page.getByRole('link', { name: 'Ascenseurs' }).click(); + await expect(page.getByRole('cell', { name: 'A1', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: '+ Nouvel ascenseur' })).toHaveCount(0); + + // Fiche : statuts visibles, pas de retrait d'organe + await page.getByRole('cell', { name: 'A1', exact: true }).click(); + await expect(page.getByText('Treuil / machinerie')).toBeVisible(); + await expect(page.getByRole('button', { name: /Retirer/ })).toHaveCount(0); +}); diff --git a/apps/web/package.json b/apps/web/package.json index c807b8e..5680501 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,21 +15,26 @@ }, "dependencies": { "@fontsource/manrope": "^5.1.0", + "@radix-ui/react-dialog": "^1.1.19", "@radix-ui/react-dropdown-menu": "^2.1.4", "@siop/shared": "workspace:*", "@tanstack/react-query": "^5.62.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "leaflet": "^1.9.4", "lucide-react": "^0.469.0", "openapi-fetch": "^0.13.4", + "qrcode.react": "^4.2.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-leaflet": "^5.0.0", "react-router-dom": "^7.1.0", "tailwind-merge": "^2.6.0" }, "devDependencies": { "@playwright/test": "^1.61.1", "@tailwindcss/vite": "^4.1.0", + "@types/leaflet": "^1.9.21", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.4", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index c2c19d4..03bc9bd 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,36 +1,43 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import type { ReactNode } from 'react'; import { Coquille } from '@/layout/coquille'; +import PageActivation from '@/pages/activation'; +import PageAscenseurs from '@/pages/ascenseurs'; +import PageCategories from '@/pages/categories'; import PageConnexion from '@/pages/connexion'; import PageDesign from '@/pages/design'; +import PageEtiquette from '@/pages/etiquette'; +import PageFicheAscenseur from '@/pages/fiche-ascenseur'; +import PageFicheSite from '@/pages/fiche-site'; +import PageNouvelAscenseur from '@/pages/nouvel-ascenseur'; +import PagePersonnes from '@/pages/personnes'; +import PageSites from '@/pages/sites'; import PageTableauDeBord from '@/pages/tableau-de-bord'; const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false } }, }); +const dansCoquille = (page: ReactNode) => {page}; + export default function App() { return ( } /> - - - - } - /> - - - - } - /> + } /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> + )} /> diff --git a/apps/web/src/api/referentiel.ts b/apps/web/src/api/referentiel.ts new file mode 100644 index 0000000..8f8b001 --- /dev/null +++ b/apps/web/src/api/referentiel.ts @@ -0,0 +1,190 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { + AssetComponentCreate, + AssetCreate, + AssetUpdate, + CategoryCreate, + CategoryUpdate, + InvitationCreate, + LocationCreate, + TeamCreate, + UserUpdate, +} from '@siop/shared'; +import { api } from './client'; + +/** Hooks R1 — chaque appel passe par le client typé (règle d'or). */ + +async function unwrap(res: { data?: T; error?: unknown; response: Response }): Promise { + if (res.error || res.data === undefined) { + const message = + (res.error as { message?: string } | undefined)?.message ?? + `Le serveur a répondu ${res.response.status}`; + throw new Error(Array.isArray(message) ? message.join(' — ') : message); + } + return res.data; +} + +export function useLocations() { + return useQuery({ + queryKey: ['locations'], + queryFn: async () => (await unwrap(await api.GET('/locations'))).locations, + }); +} + +export function useAssets() { + return useQuery({ + queryKey: ['assets'], + queryFn: async () => (await unwrap(await api.GET('/assets'))).assets, + }); +} + +export function useAsset(id: string | undefined) { + return useQuery({ + queryKey: ['assets', id], + enabled: !!id, + queryFn: async () => + unwrap(await api.GET('/assets/{id}', { params: { path: { id: id! } } })), + }); +} + +export function useCategories() { + return useQuery({ + queryKey: ['categories'], + queryFn: async () => (await unwrap(await api.GET('/categories'))).categories, + }); +} + +export function useTeams() { + return useQuery({ + queryKey: ['teams'], + queryFn: async () => (await unwrap(await api.GET('/teams'))).teams, + }); +} + +export function useUsers() { + return useQuery({ + queryKey: ['users'], + queryFn: async () => (await unwrap(await api.GET('/users'))).users, + }); +} + +export function useRoles() { + return useQuery({ + queryKey: ['roles'], + queryFn: async () => (await unwrap(await api.GET('/roles'))).roles, + }); +} + +function useInvalidate(...keys: string[]) { + const queryClient = useQueryClient(); + return () => + Promise.all(keys.map((key) => queryClient.invalidateQueries({ queryKey: [key] }))); +} + +export function useCreateLocation() { + const invalidate = useInvalidate('locations'); + return useMutation({ + mutationFn: async (body: LocationCreate) => + unwrap(await api.POST('/locations', { body })), + onSuccess: invalidate, + }); +} + +export function useCreateAsset() { + const invalidate = useInvalidate('assets', 'locations'); + return useMutation({ + mutationFn: async (body: AssetCreate) => unwrap(await api.POST('/assets', { body })), + onSuccess: invalidate, + }); +} + +export function useUpdateAsset(id: string) { + const invalidate = useInvalidate('assets'); + return useMutation({ + mutationFn: async (body: AssetUpdate) => + unwrap(await api.PATCH('/assets/{id}', { params: { path: { id } }, body })), + onSuccess: invalidate, + }); +} + +export function useAddComponent(assetId: string) { + const invalidate = useInvalidate('assets'); + return useMutation({ + mutationFn: async (body: AssetComponentCreate) => + unwrap( + await api.POST('/assets/{id}/components', { + params: { path: { id: assetId } }, + body, + }), + ), + onSuccess: invalidate, + }); +} + +export function useRemoveComponent(assetId: string) { + const invalidate = useInvalidate('assets'); + return useMutation({ + mutationFn: async (componentId: string) => { + const res = await api.DELETE('/assets/{id}/components/{componentId}', { + params: { path: { id: assetId, componentId } }, + }); + if (res.response.status >= 400) throw new Error('Suppression impossible'); + }, + onSuccess: invalidate, + }); +} + +export function useCreateCategory() { + const invalidate = useInvalidate('categories'); + return useMutation({ + mutationFn: async (body: CategoryCreate) => + unwrap(await api.POST('/categories', { body })), + onSuccess: invalidate, + }); +} + +export function useUpdateCategory() { + const invalidate = useInvalidate('categories'); + return useMutation({ + mutationFn: async ({ id, ...body }: CategoryUpdate & { id: string }) => + unwrap(await api.PATCH('/categories/{id}', { params: { path: { id } }, body })), + onSuccess: invalidate, + }); +} + +export function useCreateTeam() { + const invalidate = useInvalidate('teams', 'users'); + return useMutation({ + mutationFn: async (body: TeamCreate) => unwrap(await api.POST('/teams', { body })), + onSuccess: invalidate, + }); +} + +export function useInviteUser() { + const invalidate = useInvalidate('users'); + return useMutation({ + mutationFn: async (body: InvitationCreate) => + unwrap(await api.POST('/users/invitations', { body })), + onSuccess: invalidate, + }); +} + +export function useResendInvitation() { + const invalidate = useInvalidate('users'); + return useMutation({ + mutationFn: async (userId: string) => + unwrap( + await api.POST('/users/{id}/invitation', { params: { path: { id: userId } } }), + ), + onSuccess: invalidate, + }); +} + +export function useUpdateUser() { + const invalidate = useInvalidate('users', 'teams'); + return useMutation({ + mutationFn: async ({ id, ...body }: UserUpdate & { id: string }) => + unwrap(await api.PATCH('/users/{id}', { params: { path: { id } }, body })), + onSuccess: invalidate, + }); +} diff --git a/apps/web/src/auth/use-permissions.ts b/apps/web/src/auth/use-permissions.ts new file mode 100644 index 0000000..fe553bf --- /dev/null +++ b/apps/web/src/auth/use-permissions.ts @@ -0,0 +1,19 @@ +import type { ObjectCategory, PermissionRight } from '@siop/shared'; +import { useMe } from './use-auth'; + +/** Lecture UI de la matrice (l'API re-vérifie chaque requête, invariant R0). */ +export function usePermissions() { + const { data: me } = useMe(); + const can = (category: ObjectCategory, right: PermissionRight): boolean => { + const entry = me?.permissions.find((p) => p.objectCategory === category); + if (!entry) return false; + switch (right) { + case 'view': return entry.canView; + case 'viewOther': return entry.canViewOther; + case 'create': return entry.canCreate; + case 'edit': return entry.canEdit; + case 'delete': return entry.canDelete; + } + }; + return { can, chargé: !!me }; +} diff --git a/apps/web/src/components/avatar.tsx b/apps/web/src/components/avatar.tsx index 0ed9b73..f2a867d 100644 --- a/apps/web/src/components/avatar.tsx +++ b/apps/web/src/components/avatar.tsx @@ -1,8 +1,7 @@ -import type { RoleName } from '@siop/shared'; import { cn } from '@/lib/cn'; /** Couleur d'avatar par rôle (tokens uniquement — répartition de la maquette). */ -const ROLE_TOKEN: Record = { +const ROLE_TOKEN: Record = { Administrateur: 'var(--graf-1)', Dispatcher: 'var(--primaire)', Technicien: 'var(--graf-3)', @@ -18,13 +17,14 @@ export function Avatar({ className, }: { initials: string; - roleName?: RoleName; + roleName?: string; className?: string; }) { + const couleur = roleName ? ROLE_TOKEN[roleName] : undefined; return (