diff --git a/CLAUDE.md b/CLAUDE.md
index abbfebe..30740e3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -917,6 +917,7 @@ Aucune dépendance locale requise hors Docker.
| 42 | Retrait des disponibilités déclarées (table `availability` supprimée, migration `0022` ; API, éditeur de la fiche bénévole, badge de couverture du panneau d'affectation, colonnes des statistiques) et des limites de temps dans la journée (règles `OUTSIDE_AVAILABILITY`, `DAILY_HOURS_EXCEEDED`, `INSUFFICIENT_REST`, malus de disponibilité et de repos du solveur, filtre « Surchargés » de la vue bénévoles) — seul le chevauchement reste contrôlé. La case « je suis disponible » du formulaire public est conservée | à valider |
| 43 | Logs de diagnostic (ligne par requête avec route modèle, statut, durée, code d'erreur et compte ; identifiant `X-Request-ID` repris dans chaque log et affiché à l'utilisateur sous une erreur ; exceptions non gérées tracées ; journal d'accès d'uvicorn coupé car il exposait le jeton du formulaire public ; logs métier SumUp, connexion, import, plannings, solveur) et rapprochement SumUp ↔ bénévole : nom du bénévole dans la description du checkout (pas de métadonnées libres dans l'API), code et identifiant de transaction enregistrés au paiement (`Volunteer.fee_transaction_code`/`fee_transaction_id`, migration `0023`), affichés sur la fiche et dans l'export Excel | à valider |
| 44 | Un bénévole peut être affecté à deux postes en même temps : `SHIFT_OVERLAP` passe de `BLOCKING` à `WARNING` (affectation enregistrée, avertissement affiché) ; le solveur ne l'exclut plus mais lui applique un fort malus (`_ALREADY_BUSY_PENALTY`) — proposé seulement pour un créneau sous son minimum, faute de mieux — repris dans le classement par défaut du panneau et dans le mock | à valider |
+| 45 | Responsive mobile / tablette / desktop : en-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation de la grille des besoins et fiche bénévole en tiroir par-dessus sous `lg:` (plein écran en mobile) au lieu d'une colonne fixe qui écrasait la grille, colonne des postes de la grille rétrécie, liste des bénévoles réduite à nom + cotisation en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle de l'écran (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
diff --git a/frontend/src/app/AppShell.tsx b/frontend/src/app/AppShell.tsx
index e558cbf..2e6d7d9 100644
--- a/frontend/src/app/AppShell.tsx
+++ b/frontend/src/app/AppShell.tsx
@@ -1,4 +1,5 @@
-import { NavLink, Outlet } from 'react-router-dom';
+import { useEffect, useState } from 'react';
+import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { API_MODE } from '@/api';
import { useVolunteers } from '@/api/hooks';
@@ -17,73 +18,122 @@ export function AppShell() {
const initials = account
? account.email.slice(0, 2).toUpperCase()
: '··';
+ const hasNav = tabs.length > 1;
+
+ // Sous `lg:`, les onglets ne tiennent plus à côté du titre : ils passent
+ // sur une seconde ligne (tablette) ou dans un menu déroulant (mobile),
+ // refermé à chaque changement d'écran.
+ const [menuOpen, setMenuOpen] = useState(false);
+ const { pathname } = useLocation();
+ useEffect(() => setMenuOpen(false), [pathname]);
+ const currentTab = tabs.find((tab) => tab.to === pathname);
return (
- {/* Rien à choisir avec un seul écran (bénévole, référent) : la
- barre d'onglets n'a de sens que pour la coordination. */}
-
-
-
- {API_MODE === 'mock' ? (
-
- {t('header.mockMode')}
-
+ {/* Rien à choisir avec un seul écran (bénévole, référent) : la
+ barre d'onglets n'a de sens que pour la coordination. */}
+ {hasNav ? (
+
) : null}
-
- {initials}
+
+
+ {API_MODE === 'mock' ? (
+
+ {t('header.mockMode')}
+
+ ) : null}
+
+ {initials}
+
+
+ {hasNav ? (
+
+ ) : null}
-
+
+ {hasNav ? (
+ <>
+ {/* Tablette : seconde ligne, onglets à parts égales. */}
+
+ {/* Mobile : écran courant rappelé + menu déroulant. */}
+
+ {menuOpen ? (
+
+ ) : null}
+ >
+ ) : null}
@@ -92,3 +142,33 @@ export function AppShell() {
);
}
+
+function TabLink({
+ tab,
+ fill = false,
+}: {
+ tab: { to: string; num: string; labelKey: string };
+ fill?: boolean;
+}) {
+ return (
+
+ cx(
+ 'relative flex flex-none flex-col items-start justify-center gap-[3px] border-r-2 border-ink px-4 last:border-r-0',
+ 'font-display text-[11.5px] font-bold uppercase tracking-wide',
+ fill ? 'min-w-[104px] flex-1 py-2' : 'min-w-[112px]',
+ isActive ? 'bg-yellow' : 'hover:bg-yellow-hover',
+ )
+ }
+ >
+ {({ isActive }) => (
+ <>
+ {tab.num}
+ {t(tab.labelKey)}
+ {isActive ? : null}
+ >
+ )}
+
+ );
+}
diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json
index fcd24b8..5f14a7f 100644
--- a/frontend/src/i18n/locales/fr.json
+++ b/frontend/src/i18n/locales/fr.json
@@ -54,6 +54,8 @@
"header.title": "GESTION DES BÉNÉVOLES",
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
"header.mockMode": "DONNÉES DE DÉMO",
+ "header.openMenu": "Menu",
+ "header.closeMenu": "Fermer le menu",
"tab.grille": "Grille des besoins",
"tab.volunteers": "Vue par bénévole",
"tab.mobileVolunteer": "Bénévole · mobile",
diff --git a/frontend/src/pages/BadgesPage.tsx b/frontend/src/pages/BadgesPage.tsx
index 1222ac8..9b9a013 100644
--- a/frontend/src/pages/BadgesPage.tsx
+++ b/frontend/src/pages/BadgesPage.tsx
@@ -23,7 +23,25 @@ import { cx } from '@/lib/cx';
// l'écran ; les coordonnées enregistrées restent en mm.
const DEFAULT_BADGE_W_MM = 85.6;
const DEFAULT_BADGE_H_MM = 54;
-const SCALE = 4.6; // px par mm à l'écran
+const SCALE = 4.6; // px par mm à l'écran (au plus)
+// Marges horizontales autour de l'aperçu (page + panneau + bordures), pour
+// le faire tenir dans un écran étroit.
+const PREVIEW_GUTTER_PX = 90;
+
+/** Échelle d'édition : `SCALE`, réduite sur un écran trop étroit pour un
+ * badge à pleine taille (téléphone, tablette en portrait). Les coordonnées
+ * enregistrées restent en mm, seule la conversion px ↔ mm suit l'échelle. */
+function useEditorScale(badgeWidthMm: number): number {
+ const [viewport, setViewport] = useState(() =>
+ typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth,
+ );
+ useEffect(() => {
+ const onResize = () => setViewport(window.innerWidth);
+ window.addEventListener('resize', onResize);
+ return () => window.removeEventListener('resize', onResize);
+ }, []);
+ return Math.min(SCALE, Math.max(1, (viewport - PREVIEW_GUTTER_PX) / badgeWidthMm));
+}
type FieldState = BadgeFieldIn & { enabled: boolean };
@@ -126,11 +144,12 @@ function BadgeCustomizeTab() {
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
}, [template.data]);
- if (template.isLoading || !fields) return ;
- if (template.error) return ;
-
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
+ const scale = useEditorScale(badgeW);
+
+ if (template.isLoading || !fields) return ;
+ if (template.error) return ;
const updateField = (type: BadgeFieldType, patch: Partial) => {
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
@@ -157,7 +176,7 @@ function BadgeCustomizeTab() {
};
return (
-
+
@@ -182,8 +201,8 @@ function BadgeCustomizeTab() {