From 85e841ae4abf9d2fae074f99b08fdc1f0760b2a2 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Tue, 27 Oct 2026 18:05:16 +0100 Subject: [PATCH] Responsive mobile / tablette / desktop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En-tête (onglets sur une seconde ligne en tablette, menu déroulant en mobile), panneau d'affectation et fiche bénévole en tiroir par-dessus sous lg: (plein écran en mobile), colonne des postes de la grille rétrécie, liste des bénévoles réduite en mobile, écran « Postes & créneaux » empilé, aperçu de l'éditeur de badges mis à l'échelle (glisser-déposer tactile), ligne des types de paiement repliée, marges réduites — desktop inchangé. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + frontend/src/app/AppShell.tsx | 200 ++++++++++++------ frontend/src/i18n/locales/fr.json | 2 + frontend/src/pages/BadgesPage.tsx | 64 ++++-- frontend/src/pages/FormBuilderPage.tsx | 18 +- frontend/src/pages/GrillePage.tsx | 34 ++- frontend/src/pages/PublicSignupPage.tsx | 12 +- frontend/src/pages/config/ConfigArtists.tsx | 2 +- frontend/src/pages/config/ConfigGrid.tsx | 17 +- frontend/src/pages/config/ConfigMail.tsx | 2 +- frontend/src/pages/config/ConfigRules.tsx | 2 +- .../src/pages/config/ConfigVolunteers.tsx | 42 ++-- frontend/src/pages/grille/AssignmentPanel.tsx | 2 +- 13 files changed, 269 insertions(+), 129 deletions(-) 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 (
-
-
-
- {t('header.title')} +
+
+
+
+ {t('header.title')} +
+ {isCoordinator ? ( +
+ {t('header.meta', { count: volunteers.data?.length ?? 0 })} +
+ ) : ( +
+ {account ? t(roleLabelKey(account.role)) : ''} +
+ )}
- {isCoordinator ? ( -
- {t('header.meta', { count: volunteers.data?.length ?? 0 })} -
- ) : ( -
- {account ? t(roleLabelKey(account.role)) : ''} -
- )} -
- {/* 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() {
setSelected(null)} @@ -194,6 +213,7 @@ function BadgeCustomizeTab() { field={fields[type]} badgeW={badgeW} badgeH={badgeH} + scale={scale} selected={selected === type} onSelect={() => setSelected(type)} onChange={(patch) => updateField(type, patch)} @@ -223,13 +243,13 @@ function BadgeCustomizeTab() { />
- + {t('badges.fields')}
{FIELD_TYPES.map((type) => ( @@ -353,6 +373,7 @@ function FieldBox({ field, badgeW, badgeH, + scale, selected, onSelect, onChange, @@ -360,6 +381,7 @@ function FieldBox({ field: FieldState; badgeW: number; badgeH: number; + scale: number; selected: boolean; onSelect: () => void; onChange: (patch: Partial) => void; @@ -381,8 +403,8 @@ function FieldBox({ const onDragMove = (e: React.PointerEvent) => { if (!dragRef.current) return; const d = dragRef.current; - const dxMm = (e.clientX - d.pointerXPx) / SCALE; - const dyMm = (e.clientY - d.pointerYPx) / SCALE; + const dxMm = (e.clientX - d.pointerXPx) / scale; + const dyMm = (e.clientY - d.pointerYPx) / scale; onChange({ x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm), y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm), @@ -399,8 +421,8 @@ function FieldBox({ const onResizeMove = (e: React.PointerEvent) => { if (!resizeRef.current) return; const d = resizeRef.current; - const dwMm = (e.clientX - d.pointerXPx) / SCALE; - const dhMm = (e.clientY - d.pointerYPx) / SCALE; + const dwMm = (e.clientX - d.pointerXPx) / scale; + const dhMm = (e.clientY - d.pointerYPx) / scale; onChange({ width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm), height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm), @@ -413,20 +435,20 @@ function FieldBox({ onPointerMove={onDragMove} onPointerUp={() => (dragRef.current = null)} className={cx( - 'absolute flex cursor-move items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]', + 'absolute flex cursor-move touch-none items-start overflow-hidden border-[1.5px] bg-white/55 px-1 py-[1px]', selected ? 'border-blue-strong' : 'border-ink/40', )} style={{ - left: field.x_mm * SCALE, - top: field.y_mm * SCALE, - width: field.width_mm * SCALE, - height: field.height_mm * SCALE, + left: field.x_mm * scale, + top: field.y_mm * scale, + width: field.width_mm * scale, + height: field.height_mm * scale, }} > px approx. à l'échelle du badge + fontSize: field.font_size * (scale / 2.83), // pt -> px approx. à l'échelle du badge fontWeight: field.bold ? 800 : 400, color: field.color, width: '100%', @@ -439,7 +461,7 @@ function FieldBox({ onPointerDown={startResize} onPointerMove={onResizeMove} onPointerUp={() => (resizeRef.current = null)} - className="absolute bottom-0 right-0 h-[10px] w-[10px] cursor-nwse-resize border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow" + className="absolute bottom-0 right-0 h-[14px] w-[14px] cursor-nwse-resize touch-none lg:h-[10px] lg:w-[10px] border-l-[1.5px] border-t-[1.5px] border-ink bg-yellow" />
); @@ -570,7 +592,7 @@ function BadgeGenerateTab() { const disabled = generate.isPending || (mode === 'pick' && picked.size === 0); return ( -
+
{t('badges.generate.title')}
diff --git a/frontend/src/pages/FormBuilderPage.tsx b/frontend/src/pages/FormBuilderPage.tsx index 8359101..1b80c1a 100644 --- a/frontend/src/pages/FormBuilderPage.tsx +++ b/frontend/src/pages/FormBuilderPage.tsx @@ -117,9 +117,9 @@ export function FormBuilderPage() { }; return ( -
+
- + {t('form.link.title')}

@@ -172,7 +172,7 @@ export function FormBuilderPage() {

- + {t('form.fields.title')}
{FIELD_TYPES.map((type) => ( @@ -398,14 +398,14 @@ function PaymentOptionsForm() { return (
{rows.map((o) => ( -
+
{ const v = e.target.value.trim(); if (v && v !== o.label) update.mutate({ id: o.id, body: { label: v } }); }} - className="min-w-0 flex-1 border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold" + className="min-w-0 flex-1 basis-full border-2 border-ink bg-paper px-2 py-1 font-display text-[12px] font-bold sm:basis-0" /> remove.mutate(o.id)} aria-label={t('action.remove')} - className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange" + className="ml-auto h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange sm:ml-0" > ✕
))} -
+
setLabel(e.target.value)} placeholder={t('form.customize.paymentOptions.label')} - className="min-w-0 flex-1 border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold" + className="min-w-0 flex-1 basis-full border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold sm:basis-0" /> {t('form.customize.paymentOptions.add')} diff --git a/frontend/src/pages/GrillePage.tsx b/frontend/src/pages/GrillePage.tsx index fdd2114..cc25740 100644 --- a/frontend/src/pages/GrillePage.tsx +++ b/frontend/src/pages/GrillePage.tsx @@ -88,7 +88,9 @@ export function GrillePage() { : matrix.data.positions.filter((p) => p.category === categoryFilter); const gridTemplateColumns = [ - '230px', + // Colonne des postes rétrécie sur petit écran, pour laisser voir au + // moins quelques tranches horaires à côté. + 'clamp(132px, 36vw, 230px)', ...shownDays.flatMap((day) => { const width = columnWidthPx(day.slotLabels.length); return day.slotLabels.map(() => `${width}px`); @@ -134,8 +136,8 @@ export function GrillePage() { return (
-
-
+
+
{t('grille.filter.day')} setDayFilter('all')}> {t('filter.all')} @@ -163,7 +165,7 @@ export function GrillePage() { ))}
-
+
-
+
{t('legend.title')} {t('grille.legend.critical')} @@ -220,7 +222,7 @@ export function GrillePage() {
-
+
-