Responsive mobile / tablette / desktop
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0f904f5e3d
commit
85e841ae4a
@@ -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 |
|
| 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 |
|
| 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 |
|
| 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
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
+140
-60
@@ -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 { API_MODE } from '@/api';
|
||||||
import { useVolunteers } from '@/api/hooks';
|
import { useVolunteers } from '@/api/hooks';
|
||||||
@@ -17,73 +18,122 @@ export function AppShell() {
|
|||||||
const initials = account
|
const initials = account
|
||||||
? account.email.slice(0, 2).toUpperCase()
|
? 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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<header className="sticky top-0 z-20 flex items-stretch border-b-[3px] border-ink bg-yellow">
|
<header className="sticky top-0 z-30 border-b-[3px] border-ink bg-yellow">
|
||||||
<div className="flex flex-col justify-center border-r-[3px] border-ink px-4 py-2">
|
<div className="flex items-stretch">
|
||||||
<div className="font-display text-[14px] font-black tracking-tight">
|
<div className="flex min-w-0 flex-col justify-center border-r-[3px] border-ink px-3 py-2 sm:px-4">
|
||||||
{t('header.title')}
|
<div className="truncate font-display text-[13px] font-black tracking-tight sm:text-[14px]">
|
||||||
|
{t('header.title')}
|
||||||
|
</div>
|
||||||
|
{isCoordinator ? (
|
||||||
|
<div className="truncate font-mono text-[9.5px] font-medium opacity-70">
|
||||||
|
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="truncate font-mono text-[9.5px] font-medium opacity-70">
|
||||||
|
{account ? t(roleLabelKey(account.role)) : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isCoordinator ? (
|
|
||||||
<div className="font-mono text-[9.5px] font-medium opacity-70">
|
|
||||||
{t('header.meta', { count: volunteers.data?.length ?? 0 })}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="font-mono text-[9.5px] font-medium opacity-70">
|
|
||||||
{account ? t(roleLabelKey(account.role)) : ''}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Rien à choisir avec un seul écran (bénévole, référent) : la
|
{/* Rien à choisir avec un seul écran (bénévole, référent) : la
|
||||||
barre d'onglets n'a de sens que pour la coordination. */}
|
barre d'onglets n'a de sens que pour la coordination. */}
|
||||||
<nav className={cx('flex overflow-x-auto', tabs.length <= 1 && 'hidden')}>
|
{hasNav ? (
|
||||||
{tabs.map((tab) => (
|
<nav className="hidden min-w-0 overflow-x-auto lg:flex">
|
||||||
<NavLink
|
{tabs.map((tab) => (
|
||||||
key={tab.to}
|
<TabLink key={tab.to} tab={tab} />
|
||||||
to={tab.to}
|
))}
|
||||||
className={({ isActive }) =>
|
</nav>
|
||||||
cx(
|
|
||||||
'relative flex min-w-[112px] flex-col items-start justify-center gap-[3px] border-r-2 border-ink px-4',
|
|
||||||
'font-display text-[11.5px] font-bold uppercase tracking-wide',
|
|
||||||
isActive ? 'bg-yellow' : 'hover:bg-yellow-hover',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{({ isActive }) => (
|
|
||||||
<>
|
|
||||||
<span className="font-mono text-[8.5px] font-medium opacity-60">{tab.num}</span>
|
|
||||||
<span>{t(tab.labelKey)}</span>
|
|
||||||
{isActive ? (
|
|
||||||
<span className="absolute inset-x-0 bottom-0 h-[5px] bg-ink" />
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-[10px] border-l-[3px] border-ink px-[14px]">
|
|
||||||
{API_MODE === 'mock' ? (
|
|
||||||
<div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider sm:block">
|
|
||||||
{t('header.mockMode')}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
<div
|
|
||||||
className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold"
|
<div className="ml-auto flex flex-none items-center gap-2 border-l-[3px] border-ink px-2 sm:gap-[10px] sm:px-[14px]">
|
||||||
title={account?.email}
|
{API_MODE === 'mock' ? (
|
||||||
>
|
<div className="hidden border-2 border-ink bg-blue px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider xl:block">
|
||||||
{initials}
|
{t('header.mockMode')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="hidden h-[30px] w-[30px] flex-none items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold sm:flex"
|
||||||
|
title={account?.email}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={logout}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-cream px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange sm:px-[10px]"
|
||||||
|
>
|
||||||
|
{t('auth.logout')}
|
||||||
|
</button>
|
||||||
|
{hasNav ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMenuOpen((open) => !open)}
|
||||||
|
aria-expanded={menuOpen}
|
||||||
|
aria-label={t(menuOpen ? 'header.closeMenu' : 'header.openMenu')}
|
||||||
|
className="flex h-[30px] w-[34px] flex-none cursor-pointer items-center justify-center border-2 border-ink bg-ink font-display text-[15px] font-black text-yellow md:hidden"
|
||||||
|
>
|
||||||
|
{menuOpen ? '✕' : '☰'}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={logout}
|
|
||||||
className="cursor-pointer border-2 border-ink bg-cream px-[10px] py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider hover:bg-orange"
|
|
||||||
>
|
|
||||||
{t('auth.logout')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{hasNav ? (
|
||||||
|
<>
|
||||||
|
{/* Tablette : seconde ligne, onglets à parts égales. */}
|
||||||
|
<nav className="hidden overflow-x-auto border-t-[3px] border-ink md:flex lg:hidden">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabLink key={tab.to} tab={tab} fill />
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
{/* Mobile : écran courant rappelé + menu déroulant. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMenuOpen((open) => !open)}
|
||||||
|
className="flex w-full cursor-pointer items-center gap-2 border-t-[3px] border-ink bg-ink px-3 py-[7px] text-left font-display text-[11px] font-extrabold uppercase tracking-wide text-cream md:hidden"
|
||||||
|
>
|
||||||
|
{currentTab ? (
|
||||||
|
<span className="font-mono text-[8.5px] font-medium text-yellow">{currentTab.num}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="flex-1 truncate">
|
||||||
|
{currentTab ? t(currentTab.labelKey) : t('header.openMenu')}
|
||||||
|
</span>
|
||||||
|
<span aria-hidden className="text-yellow">{menuOpen ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{menuOpen ? (
|
||||||
|
<nav className="flex flex-col border-t-[3px] border-ink bg-yellow md:hidden">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<NavLink
|
||||||
|
key={tab.to}
|
||||||
|
to={tab.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cx(
|
||||||
|
'flex items-center gap-3 border-b-2 border-ink px-4 py-3 last:border-b-0',
|
||||||
|
'font-display text-[12px] font-bold uppercase tracking-wide',
|
||||||
|
isActive ? 'bg-ink text-yellow' : 'hover:bg-yellow-hover',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[9px] font-medium opacity-60">{tab.num}</span>
|
||||||
|
<span>{t(tab.labelKey)}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="flex flex-1 flex-col">
|
<main className="flex flex-1 flex-col">
|
||||||
@@ -92,3 +142,33 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TabLink({
|
||||||
|
tab,
|
||||||
|
fill = false,
|
||||||
|
}: {
|
||||||
|
tab: { to: string; num: string; labelKey: string };
|
||||||
|
fill?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
to={tab.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
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 }) => (
|
||||||
|
<>
|
||||||
|
<span className="font-mono text-[8.5px] font-medium opacity-60">{tab.num}</span>
|
||||||
|
<span className="whitespace-nowrap">{t(tab.labelKey)}</span>
|
||||||
|
{isActive ? <span className="absolute inset-x-0 bottom-0 h-[5px] bg-ink" /> : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,8 @@
|
|||||||
"header.title": "GESTION DES BÉNÉVOLES",
|
"header.title": "GESTION DES BÉNÉVOLES",
|
||||||
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
|
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
|
||||||
"header.mockMode": "DONNÉES DE DÉMO",
|
"header.mockMode": "DONNÉES DE DÉMO",
|
||||||
|
"header.openMenu": "Menu",
|
||||||
|
"header.closeMenu": "Fermer le menu",
|
||||||
"tab.grille": "Grille des besoins",
|
"tab.grille": "Grille des besoins",
|
||||||
"tab.volunteers": "Vue par bénévole",
|
"tab.volunteers": "Vue par bénévole",
|
||||||
"tab.mobileVolunteer": "Bénévole · mobile",
|
"tab.mobileVolunteer": "Bénévole · mobile",
|
||||||
|
|||||||
@@ -23,7 +23,25 @@ import { cx } from '@/lib/cx';
|
|||||||
// l'écran ; les coordonnées enregistrées restent en mm.
|
// l'écran ; les coordonnées enregistrées restent en mm.
|
||||||
const DEFAULT_BADGE_W_MM = 85.6;
|
const DEFAULT_BADGE_W_MM = 85.6;
|
||||||
const DEFAULT_BADGE_H_MM = 54;
|
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 };
|
type FieldState = BadgeFieldIn & { enabled: boolean };
|
||||||
|
|
||||||
@@ -126,11 +144,12 @@ function BadgeCustomizeTab() {
|
|||||||
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
|
if (template.data) setFieldsState(fieldsFromServer(template.data.fields));
|
||||||
}, [template.data]);
|
}, [template.data]);
|
||||||
|
|
||||||
if (template.isLoading || !fields) return <Loading />;
|
|
||||||
if (template.error) return <ErrorNote error={template.error} />;
|
|
||||||
|
|
||||||
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
|
const badgeW = template.data?.badge_width_mm ?? DEFAULT_BADGE_W_MM;
|
||||||
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
|
const badgeH = template.data?.badge_height_mm ?? DEFAULT_BADGE_H_MM;
|
||||||
|
const scale = useEditorScale(badgeW);
|
||||||
|
|
||||||
|
if (template.isLoading || !fields) return <Loading />;
|
||||||
|
if (template.error) return <ErrorNote error={template.error} />;
|
||||||
|
|
||||||
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
|
const updateField = (type: BadgeFieldType, patch: Partial<FieldState>) => {
|
||||||
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
|
setFieldsState((prev) => (prev ? { ...prev, [type]: { ...prev[type], ...patch } } : prev));
|
||||||
@@ -157,7 +176,7 @@ function BadgeCustomizeTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4 lg:flex-row lg:items-start">
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3 sm:p-4 lg:flex-row lg:items-start">
|
||||||
<div className="flex flex-none flex-col gap-4">
|
<div className="flex flex-none flex-col gap-4">
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader>
|
<PanelHeader>
|
||||||
@@ -182,8 +201,8 @@ function BadgeCustomizeTab() {
|
|||||||
<div
|
<div
|
||||||
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
|
className="relative mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center select-none"
|
||||||
style={{
|
style={{
|
||||||
width: badgeW * SCALE,
|
width: badgeW * scale,
|
||||||
height: badgeH * SCALE,
|
height: badgeH * scale,
|
||||||
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
|
backgroundImage: frontUrl ? `url(${frontUrl})` : undefined,
|
||||||
}}
|
}}
|
||||||
onPointerDown={() => setSelected(null)}
|
onPointerDown={() => setSelected(null)}
|
||||||
@@ -194,6 +213,7 @@ function BadgeCustomizeTab() {
|
|||||||
field={fields[type]}
|
field={fields[type]}
|
||||||
badgeW={badgeW}
|
badgeW={badgeW}
|
||||||
badgeH={badgeH}
|
badgeH={badgeH}
|
||||||
|
scale={scale}
|
||||||
selected={selected === type}
|
selected={selected === type}
|
||||||
onSelect={() => setSelected(type)}
|
onSelect={() => setSelected(type)}
|
||||||
onChange={(patch) => updateField(type, patch)}
|
onChange={(patch) => updateField(type, patch)}
|
||||||
@@ -223,13 +243,13 @@ function BadgeCustomizeTab() {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
|
className="mt-3 overflow-hidden border-2 border-ink bg-sand-dark bg-cover bg-center"
|
||||||
style={{ width: badgeW * SCALE, height: badgeH * SCALE, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
|
style={{ width: badgeW * scale, height: badgeH * scale, backgroundImage: backUrl ? `url(${backUrl})` : undefined }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Panel className="min-w-[320px] flex-1">
|
<Panel className="min-w-0 flex-1 lg:min-w-[320px]">
|
||||||
<PanelHeader>{t('badges.fields')}</PanelHeader>
|
<PanelHeader>{t('badges.fields')}</PanelHeader>
|
||||||
<div className="flex flex-col divide-y-2 divide-ink/10">
|
<div className="flex flex-col divide-y-2 divide-ink/10">
|
||||||
{FIELD_TYPES.map((type) => (
|
{FIELD_TYPES.map((type) => (
|
||||||
@@ -353,6 +373,7 @@ function FieldBox({
|
|||||||
field,
|
field,
|
||||||
badgeW,
|
badgeW,
|
||||||
badgeH,
|
badgeH,
|
||||||
|
scale,
|
||||||
selected,
|
selected,
|
||||||
onSelect,
|
onSelect,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -360,6 +381,7 @@ function FieldBox({
|
|||||||
field: FieldState;
|
field: FieldState;
|
||||||
badgeW: number;
|
badgeW: number;
|
||||||
badgeH: number;
|
badgeH: number;
|
||||||
|
scale: number;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onChange: (patch: Partial<FieldState>) => void;
|
onChange: (patch: Partial<FieldState>) => void;
|
||||||
@@ -381,8 +403,8 @@ function FieldBox({
|
|||||||
const onDragMove = (e: React.PointerEvent) => {
|
const onDragMove = (e: React.PointerEvent) => {
|
||||||
if (!dragRef.current) return;
|
if (!dragRef.current) return;
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
const dxMm = (e.clientX - d.pointerXPx) / SCALE;
|
const dxMm = (e.clientX - d.pointerXPx) / scale;
|
||||||
const dyMm = (e.clientY - d.pointerYPx) / SCALE;
|
const dyMm = (e.clientY - d.pointerYPx) / scale;
|
||||||
onChange({
|
onChange({
|
||||||
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
|
x_mm: clamp(d.startXMm + dxMm, 0, badgeW - field.width_mm),
|
||||||
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
|
y_mm: clamp(d.startYMm + dyMm, 0, badgeH - field.height_mm),
|
||||||
@@ -399,8 +421,8 @@ function FieldBox({
|
|||||||
const onResizeMove = (e: React.PointerEvent) => {
|
const onResizeMove = (e: React.PointerEvent) => {
|
||||||
if (!resizeRef.current) return;
|
if (!resizeRef.current) return;
|
||||||
const d = resizeRef.current;
|
const d = resizeRef.current;
|
||||||
const dwMm = (e.clientX - d.pointerXPx) / SCALE;
|
const dwMm = (e.clientX - d.pointerXPx) / scale;
|
||||||
const dhMm = (e.clientY - d.pointerYPx) / SCALE;
|
const dhMm = (e.clientY - d.pointerYPx) / scale;
|
||||||
onChange({
|
onChange({
|
||||||
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
|
width_mm: clamp(d.startWMm + dwMm, 10, badgeW - field.x_mm),
|
||||||
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
|
height_mm: clamp(d.startHMm + dhMm, 5, badgeH - field.y_mm),
|
||||||
@@ -413,20 +435,20 @@ function FieldBox({
|
|||||||
onPointerMove={onDragMove}
|
onPointerMove={onDragMove}
|
||||||
onPointerUp={() => (dragRef.current = null)}
|
onPointerUp={() => (dragRef.current = null)}
|
||||||
className={cx(
|
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',
|
selected ? 'border-blue-strong' : 'border-ink/40',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
left: field.x_mm * SCALE,
|
left: field.x_mm * scale,
|
||||||
top: field.y_mm * SCALE,
|
top: field.y_mm * scale,
|
||||||
width: field.width_mm * SCALE,
|
width: field.width_mm * scale,
|
||||||
height: field.height_mm * SCALE,
|
height: field.height_mm * scale,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
|
className="pointer-events-none whitespace-pre-line font-sans leading-tight"
|
||||||
style={{
|
style={{
|
||||||
fontSize: field.font_size * (SCALE / 2.83), // pt -> 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,
|
fontWeight: field.bold ? 800 : 400,
|
||||||
color: field.color,
|
color: field.color,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -439,7 +461,7 @@ function FieldBox({
|
|||||||
onPointerDown={startResize}
|
onPointerDown={startResize}
|
||||||
onPointerMove={onResizeMove}
|
onPointerMove={onResizeMove}
|
||||||
onPointerUp={() => (resizeRef.current = null)}
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -570,7 +592,7 @@ function BadgeGenerateTab() {
|
|||||||
const disabled = generate.isPending || (mode === 'pick' && picked.size === 0);
|
const disabled = generate.isPending || (mode === 'pick' && picked.size === 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-4">
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-auto p-3 sm:p-4">
|
||||||
<Panel className="max-w-[560px]">
|
<Panel className="max-w-[560px]">
|
||||||
<PanelHeader>{t('badges.generate.title')}</PanelHeader>
|
<PanelHeader>{t('badges.generate.title')}</PanelHeader>
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<div className="flex flex-col gap-3 p-3">
|
||||||
|
|||||||
@@ -117,9 +117,9 @@ export function FormBuilderPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-4">
|
<div className="flex min-h-0 flex-1 flex-col overflow-auto p-3 sm:p-4">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
|
||||||
<Panel className="max-w-[420px] flex-1">
|
<Panel className="flex-1 lg:max-w-[420px]">
|
||||||
<PanelHeader>{t('form.link.title')}</PanelHeader>
|
<PanelHeader>{t('form.link.title')}</PanelHeader>
|
||||||
<div className="flex flex-col gap-3 p-3">
|
<div className="flex flex-col gap-3 p-3">
|
||||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||||
@@ -172,7 +172,7 @@ export function FormBuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel className="min-w-[320px] flex-1">
|
<Panel className="min-w-0 flex-1 lg:min-w-[320px]">
|
||||||
<PanelHeader>{t('form.fields.title')}</PanelHeader>
|
<PanelHeader>{t('form.fields.title')}</PanelHeader>
|
||||||
<div className="flex flex-col divide-y-2 divide-ink/10">
|
<div className="flex flex-col divide-y-2 divide-ink/10">
|
||||||
{FIELD_TYPES.map((type) => (
|
{FIELD_TYPES.map((type) => (
|
||||||
@@ -398,14 +398,14 @@ function PaymentOptionsForm() {
|
|||||||
return (
|
return (
|
||||||
<div className="mt-2 flex flex-col gap-[6px]">
|
<div className="mt-2 flex flex-col gap-[6px]">
|
||||||
{rows.map((o) => (
|
{rows.map((o) => (
|
||||||
<div key={o.id} className="flex items-center gap-1">
|
<div key={o.id} className="flex flex-wrap items-center gap-1 border-b border-sand-line pb-2 sm:flex-nowrap sm:border-b-0 sm:pb-0">
|
||||||
<input
|
<input
|
||||||
defaultValue={o.label}
|
defaultValue={o.label}
|
||||||
onBlur={(e) => {
|
onBlur={(e) => {
|
||||||
const v = e.target.value.trim();
|
const v = e.target.value.trim();
|
||||||
if (v && v !== o.label) update.mutate({ id: o.id, body: { label: v } });
|
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"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -449,19 +449,19 @@ function PaymentOptionsForm() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => remove.mutate(o.id)}
|
onClick={() => remove.mutate(o.id)}
|
||||||
aria-label={t('action.remove')}
|
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"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<div className="mt-1 flex items-center gap-1 border-t-2 border-ink pt-2">
|
<div className="mt-1 flex flex-wrap items-center gap-1 border-t-2 border-ink pt-2 sm:flex-nowrap">
|
||||||
<input
|
<input
|
||||||
value={label}
|
value={label}
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
onChange={(e) => setLabel(e.target.value)}
|
||||||
placeholder={t('form.customize.paymentOptions.label')}
|
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"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -519,7 +519,7 @@ function PaymentOptionsForm() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
|
className="ml-auto h-[26px] flex-none cursor-pointer border-2 border-ink bg-ink px-2 sm:ml-0 font-display text-[10px] font-extrabold uppercase text-cream hover:bg-yellow hover:text-ink disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{t('form.customize.paymentOptions.add')}
|
{t('form.customize.paymentOptions.add')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -88,7 +88,9 @@ export function GrillePage() {
|
|||||||
: matrix.data.positions.filter((p) => p.category === categoryFilter);
|
: matrix.data.positions.filter((p) => p.category === categoryFilter);
|
||||||
|
|
||||||
const gridTemplateColumns = [
|
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) => {
|
...shownDays.flatMap((day) => {
|
||||||
const width = columnWidthPx(day.slotLabels.length);
|
const width = columnWidthPx(day.slotLabels.length);
|
||||||
return day.slotLabels.map(() => `${width}px`);
|
return day.slotLabels.map(() => `${width}px`);
|
||||||
@@ -134,8 +136,8 @@ export function GrillePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
|
<div className="flex flex-wrap items-center gap-[10px] border-b-2 border-ink bg-sand px-3 py-[9px] sm:gap-[14px] sm:px-4">
|
||||||
<div className="flex items-center gap-[6px]">
|
<div className="flex flex-wrap items-center gap-[6px]">
|
||||||
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
|
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
|
||||||
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
|
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
|
||||||
{t('filter.all')}
|
{t('filter.all')}
|
||||||
@@ -163,7 +165,7 @@ export function GrillePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => runSolver.mutate()}
|
onClick={() => runSolver.mutate()}
|
||||||
@@ -199,7 +201,7 @@ export function GrillePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-2 border-ink bg-cream px-[9px] py-[5px] lg:ml-auto">
|
||||||
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
|
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
|
||||||
<LegendItem swatch="bg-hatch-orange" glyph="▲">
|
<LegendItem swatch="bg-hatch-orange" glyph="▲">
|
||||||
{t('grille.legend.critical')}
|
{t('grille.legend.critical')}
|
||||||
@@ -220,7 +222,7 @@ export function GrillePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 items-stretch">
|
<div className="flex min-h-0 flex-1 items-stretch">
|
||||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
<div className="max-h-[calc(100dvh-150px)] min-w-0 flex-1 overflow-auto lg:max-h-[calc(100vh-118px)]">
|
||||||
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
|
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
|
||||||
<div
|
<div
|
||||||
style={{ gridColumn: '1 / span 1', gridRow: 1 }}
|
style={{ gridColumn: '1 / span 1', gridRow: 1 }}
|
||||||
@@ -278,7 +280,25 @@ export function GrillePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
{/* Sous `lg:`, pas la place d'un panneau à côté de la grille : il
|
||||||
|
s'ouvre par-dessus (plein écran sur mobile, tiroir à droite sur
|
||||||
|
tablette) seulement quand un créneau est choisi. */}
|
||||||
|
{selected ? (
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
onClick={() => setSelectedShiftId(null)}
|
||||||
|
className="fixed inset-0 z-40 bg-ink/40 lg:hidden"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<aside
|
||||||
|
className={cx(
|
||||||
|
'overflow-auto border-ink bg-paper',
|
||||||
|
'lg:static lg:z-auto lg:block lg:max-h-[calc(100vh-118px)] lg:w-[392px] lg:flex-none lg:border-l-[3px]',
|
||||||
|
selected
|
||||||
|
? 'fixed inset-y-0 right-0 z-50 block w-full sm:w-[420px] sm:border-l-[3px]'
|
||||||
|
: 'hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<AssignmentPanel
|
<AssignmentPanel
|
||||||
key={selected.shift.shift_id}
|
key={selected.shift.shift_id}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ function PaymentConfirmationView({ token }: { token: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader>{t('publicForm.confirmation.title')}</PanelHeader>
|
<PanelHeader>{t('publicForm.confirmation.title')}</PanelHeader>
|
||||||
<div className="flex flex-col gap-3 p-4">
|
<div className="flex flex-col gap-3 p-4">
|
||||||
@@ -273,7 +273,7 @@ function MecenatInfoStep({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
|
||||||
<FormLogo url={logoUrl} />
|
<FormLogo url={logoUrl} />
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader>{t('publicForm.mecenat.title')}</PanelHeader>
|
<PanelHeader>{t('publicForm.mecenat.title')}</PanelHeader>
|
||||||
@@ -333,7 +333,7 @@ function CheckoutRetryPanel({ token, logoUrl }: { token: string; logoUrl: string
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
|
||||||
<FormLogo url={logoUrl} />
|
<FormLogo url={logoUrl} />
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader>{t('publicForm.retry.title')}</PanelHeader>
|
<PanelHeader>{t('publicForm.retry.title')}</PanelHeader>
|
||||||
@@ -406,7 +406,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
|||||||
|
|
||||||
if (volunteerId) {
|
if (volunteerId) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
|
||||||
<FormLogo url={logoUrl} />
|
<FormLogo url={logoUrl} />
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
|
<PanelHeader>{t('publicForm.success.title')}</PanelHeader>
|
||||||
@@ -437,7 +437,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
|||||||
|
|
||||||
if (!config.data.is_open) {
|
if (!config.data.is_open) {
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-6">
|
<div className="mx-auto flex max-w-[520px] flex-col gap-4 p-3 sm:p-6">
|
||||||
<FormLogo url={logoUrl} />
|
<FormLogo url={logoUrl} />
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
|
<PanelHeader>{t('publicForm.closed.title')}</PanelHeader>
|
||||||
@@ -524,7 +524,7 @@ function PublicSignupForm({ accessToken }: { accessToken: string }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-4">
|
<div className="mx-auto flex max-w-[560px] flex-col gap-4 p-3 sm:p-4">
|
||||||
<FormLogo url={logoUrl} />
|
<FormLogo url={logoUrl} />
|
||||||
<Panel>
|
<Panel>
|
||||||
<PanelHeader>{t('publicForm.title')}</PanelHeader>
|
<PanelHeader>{t('publicForm.title')}</PanelHeader>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function ConfigArtists() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fill,minmax(320px,1fr))]">
|
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),1fr))]">
|
||||||
{days.map((date) => (
|
{days.map((date) => (
|
||||||
<DayColumn key={date} date={date} artists={byDate.get(date) ?? []} />
|
<DayColumn key={date} date={date} artists={byDate.get(date) ?? []} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -109,8 +109,9 @@ export function ConfigGrid() {
|
|||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 items-stretch">
|
<div className="flex min-h-0 flex-1 flex-col items-stretch md:flex-row">
|
||||||
<div className="max-h-[calc(100vh-118px)] w-[250px] flex-none overflow-auto border-r-[3px] border-ink">
|
{/* Mobile : liste des postes au-dessus du détail, hauteur bornée. */}
|
||||||
|
<div className="max-h-[260px] w-full flex-none overflow-auto border-b-[3px] border-ink md:max-h-[calc(100vh-118px)] md:w-[250px] md:border-b-0 md:border-r-[3px]">
|
||||||
<div className="sticky top-0 z-[2] flex items-center gap-[6px] bg-ink px-[10px] py-[7px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
|
<div className="sticky top-0 z-[2] flex items-center gap-[6px] bg-ink px-[10px] py-[7px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
|
||||||
{t('config.grid.positionsHeader', { count: totals.count })}
|
{t('config.grid.positionsHeader', { count: totals.count })}
|
||||||
<HelpTip text={t('config.grid.note')} side="bottom" />
|
<HelpTip text={t('config.grid.note')} side="bottom" />
|
||||||
@@ -148,7 +149,7 @@ export function ConfigGrid() {
|
|||||||
{createPosition.isError ? <ErrorNote error={createPosition.error} /> : null}
|
{createPosition.isError ? <ErrorNote error={createPosition.error} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
|
<div className="min-w-0 flex-1 p-3 sm:p-4 md:max-h-[calc(100vh-118px)] md:overflow-auto">
|
||||||
<PositionHeader
|
<PositionHeader
|
||||||
key={position.id}
|
key={position.id}
|
||||||
position={position}
|
position={position}
|
||||||
@@ -158,7 +159,7 @@ export function ConfigGrid() {
|
|||||||
{positionShifts.isLoading ? <Loading /> : null}
|
{positionShifts.isLoading ? <Loading /> : null}
|
||||||
{updateShift.isError ? <ErrorNote error={updateShift.error} /> : null}
|
{updateShift.isError ? <ErrorNote error={updateShift.error} /> : null}
|
||||||
|
|
||||||
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]">
|
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(min(300px,100%),1fr))]">
|
||||||
{days.map((day) => (
|
{days.map((day) => (
|
||||||
<DayCard
|
<DayCard
|
||||||
key={day}
|
key={day}
|
||||||
@@ -197,15 +198,15 @@ function PositionHeader({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
|
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex w-full min-w-0 flex-col gap-1 sm:w-auto">
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
onBlur={commitName}
|
onBlur={commitName}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
|
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
|
||||||
className="border-2 border-ink bg-yellow px-2 py-1 font-display text-[22px] font-black tracking-tight"
|
className="w-full min-w-0 border-2 border-ink bg-yellow px-2 py-1 font-display text-[20px] font-black tracking-tight sm:text-[22px]"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<select
|
<select
|
||||||
value={position.category}
|
value={position.category}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -250,7 +251,7 @@ function PositionHeader({
|
|||||||
{updatePosition.isError ? <ErrorNote error={updatePosition.error} /> : null}
|
{updatePosition.isError ? <ErrorNote error={updatePosition.error} /> : null}
|
||||||
{generate.isError ? <ErrorNote error={generate.error} /> : null}
|
{generate.isError ? <ErrorNote error={generate.error} /> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="flex gap-2 sm:ml-auto">
|
||||||
<MiniStat value={`${totals.min} h`} label={t('config.grid.minTotal')} />
|
<MiniStat value={`${totals.min} h`} label={t('config.grid.minTotal')} />
|
||||||
<MiniStat value={`${totals.ideal} h`} label={t('config.grid.idealTotal')} accent />
|
<MiniStat value={`${totals.ideal} h`} label={t('config.grid.idealTotal')} accent />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function ConfigMail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
|
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fit,minmax(min(320px,100%),1fr))]">
|
||||||
<Panel shadow="lg">
|
<Panel shadow="lg">
|
||||||
<PanelHeader>
|
<PanelHeader>
|
||||||
{t('config.mail.templateTitle')} <HelpTip text={t('config.mail.smtpBanner')} side="bottom" />
|
{t('config.mail.templateTitle')} <HelpTip text={t('config.mail.smtpBanner')} side="bottom" />
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function ConfigRules() {
|
|||||||
const shifts = useShifts();
|
const shifts = useShifts();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid items-start gap-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
|
<div className="grid items-start gap-4 p-3 sm:p-4 [grid-template-columns:repeat(auto-fit,minmax(min(320px,100%),1fr))]">
|
||||||
<Panel shadow="lg">
|
<Panel shadow="lg">
|
||||||
<PanelHeader>{t('config.rules.editionTitle')}</PanelHeader>
|
<PanelHeader>{t('config.rules.editionTitle')}</PanelHeader>
|
||||||
<div className="flex flex-col gap-2 p-[13px]">
|
<div className="flex flex-col gap-2 p-[13px]">
|
||||||
|
|||||||
@@ -571,8 +571,10 @@ function issueParams(issue: ImportIssue): Record<string, string | number> {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mobile : nom + cotisation seulement (téléphone, email et vœux restent sur
|
||||||
|
// la fiche) ; toutes les colonnes à partir de `md:`.
|
||||||
const LIST_COLUMNS =
|
const LIST_COLUMNS =
|
||||||
'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.2fr) minmax(0,1.2fr)';
|
'grid-cols-[minmax(0,1fr)_auto] md:grid-cols-[minmax(0,2fr)_minmax(0,1.4fr)_minmax(0,2fr)_minmax(0,1.2fr)_minmax(0,1.2fr)]';
|
||||||
|
|
||||||
/** Résumé d'une ligne de la liste : mode de paiement (et mécénat), statut
|
/** Résumé d'une ligne de la liste : mode de paiement (et mécénat), statut
|
||||||
* d'un paiement en ligne non abouti. */
|
* d'un paiement en ligne non abouti. */
|
||||||
@@ -677,7 +679,7 @@ export function ConfigVolunteers() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 items-stretch">
|
<div className="flex min-h-0 flex-1 items-stretch">
|
||||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
|
<div className="min-w-0 flex-1 p-3 sm:p-4 lg:max-h-[calc(100vh-118px)] lg:overflow-auto">
|
||||||
<div className="mb-[14px] flex flex-wrap items-stretch gap-3">
|
<div className="mb-[14px] flex flex-wrap items-stretch gap-3">
|
||||||
<div className="flex flex-1 flex-col justify-center gap-[10px] border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg">
|
<div className="flex flex-1 flex-col justify-center gap-[10px] border-[3px] border-ink bg-paper p-[14px] shadow-brutal-lg">
|
||||||
<div>
|
<div>
|
||||||
@@ -757,13 +759,15 @@ export function ConfigVolunteers() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||||
<div
|
<div
|
||||||
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
|
className={cx(
|
||||||
style={{ gridTemplateColumns: LIST_COLUMNS }}
|
'grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream',
|
||||||
|
LIST_COLUMNS,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span>{t('config.vol.colName')}</span>
|
<span>{t('config.vol.colName')}</span>
|
||||||
<span>{t('config.vol.colPhone')}</span>
|
<span className="hidden md:block">{t('config.vol.colPhone')}</span>
|
||||||
<span>{t('config.vol.colEmail')}</span>
|
<span className="hidden md:block">{t('config.vol.colEmail')}</span>
|
||||||
<span>{t('config.vol.colWishes')}</span>
|
<span className="hidden md:block">{t('config.vol.colWishes')}</span>
|
||||||
<span>{t('config.vol.colFee')}</span>
|
<span>{t('config.vol.colFee')}</span>
|
||||||
</div>
|
</div>
|
||||||
{rows.map((volunteer) => {
|
{rows.map((volunteer) => {
|
||||||
@@ -774,10 +778,10 @@ export function ConfigVolunteers() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedId(volunteer.id)}
|
onClick={() => setSelectedId(volunteer.id)}
|
||||||
className={cx(
|
className={cx(
|
||||||
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
|
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[9px] text-left md:py-[7px]',
|
||||||
|
LIST_COLUMNS,
|
||||||
active ? 'bg-yellow' : 'hover:bg-cream',
|
active ? 'bg-yellow' : 'hover:bg-cream',
|
||||||
)}
|
)}
|
||||||
style={{ gridTemplateColumns: LIST_COLUMNS }}
|
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 items-center gap-[7px]">
|
<span className="flex min-w-0 items-center gap-[7px]">
|
||||||
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
|
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
|
||||||
@@ -788,13 +792,13 @@ export function ConfigVolunteers() {
|
|||||||
{volunteer.first_name} {volunteer.last_name}
|
{volunteer.first_name} {volunteer.last_name}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate font-mono text-[11px] opacity-80">
|
<span className="hidden truncate font-mono text-[11px] opacity-80 md:block">
|
||||||
{volunteer.phone ?? '—'}
|
{volunteer.phone ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate font-mono text-[10.5px] opacity-70">
|
<span className="hidden truncate font-mono text-[10.5px] opacity-70 md:block">
|
||||||
{volunteer.email ?? '—'}
|
{volunteer.email ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate font-mono text-[10px] opacity-70">
|
<span className="hidden truncate font-mono text-[10px] opacity-70 md:block">
|
||||||
{t('config.vol.wishesCount', {
|
{t('config.vol.wishesCount', {
|
||||||
count:
|
count:
|
||||||
volunteer.artist_ids.length +
|
volunteer.artist_ids.length +
|
||||||
@@ -815,8 +819,17 @@ export function ConfigVolunteers() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Fiche : à côté de la liste sur grand écran, par-dessus (plein écran
|
||||||
|
sur mobile, tiroir à droite sur tablette) en dessous de `lg:`. */}
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
<div
|
||||||
|
aria-hidden
|
||||||
|
onClick={() => setSelectedId(null)}
|
||||||
|
className="fixed inset-0 z-40 bg-ink/40 lg:hidden"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{selected ? (
|
||||||
|
<aside className="fixed inset-y-0 right-0 z-50 w-full overflow-auto bg-paper sm:w-[400px] sm:border-l-[3px] sm:border-ink lg:static lg:z-auto lg:max-h-[calc(100vh-118px)] lg:w-[360px] lg:flex-none lg:border-l-[3px]">
|
||||||
<div className="bg-ink px-[13px] py-[11px] text-cream">
|
<div className="bg-ink px-[13px] py-[11px] text-cream">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-[6px] font-mono text-[10px] text-yellow">
|
<div className="flex items-center gap-[6px] font-mono text-[10px] text-yellow">
|
||||||
@@ -826,7 +839,8 @@ export function ConfigVolunteers() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSelectedId(null)}
|
onClick={() => setSelectedId(null)}
|
||||||
className="cursor-pointer font-mono text-[11px] text-cream/70 hover:text-cream"
|
aria-label={t('action.close')}
|
||||||
|
className="h-8 w-8 cursor-pointer border-2 border-cream/40 font-mono text-[12px] text-cream/80 hover:text-cream lg:h-auto lg:w-auto lg:border-0"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export function AssignmentPanel({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="h-6 w-6 cursor-pointer border-2 border-cream bg-transparent font-display text-[12px] font-bold text-cream"
|
className="h-8 w-8 flex-none cursor-pointer border-2 border-cream bg-transparent font-display text-[12px] font-bold text-cream lg:h-6 lg:w-6"
|
||||||
aria-label={t('action.close')}
|
aria-label={t('action.close')}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
|
|||||||
Reference in New Issue
Block a user