From 15109acfe5369f4c8b0a4e9301aaefc9950b2428 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Tue, 22 Sep 2026 18:44:20 +0200 Subject: [PATCH] =?UTF-8?q?Cr=C3=A9neaux=20=C3=A0=20la=20main=20+=20grille?= =?UTF-8?q?=20des=20besoins=20en=20axe=20des=20temps=20(30=20min)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un poste ne génère plus de créneaux à sa création : la coordination crée elle-même ceux dont elle a besoin, avec ses propres horaires de début et de fin (ConfigGrid.tsx). La génération depuis les créneaux types reste disponible en option (bouton « Générer les créneaux »). La grille des besoins devient un vrai axe des temps : une colonne = 30 min, bornée par jour à la plage réellement utilisée, avec les cases fusionnées pour correspondre aux horaires de chaque créneau (buildTimeGrid / placeInTimeGrid). La « Vue bénévoles » garde son ancien modèle (une colonne par créneau distinct), plus adapté à son tableau bénévole × créneau. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 38 +++-- backend/app/api/positions.py | 6 +- backend/tests/test_shift_generation.py | 44 +++-- frontend/scripts/smoke.tsx | 53 ++++++ frontend/src/api/mock/mock-client.ts | 3 +- frontend/src/i18n/locales/fr.json | 5 +- frontend/src/lib/planning.ts | 127 +++++++++++++- frontend/src/pages/GrillePage.tsx | 205 +++++++++++++---------- frontend/src/pages/config/ConfigGrid.tsx | 172 +++++++++++++------ 9 files changed, 474 insertions(+), 179 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 577af41..74934b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,10 +118,17 @@ les tables sauf les tables d'association. (`VolunteerHomeView`) plutôt qu'un écran référent vide. - **`Shift`** : `position_id`, `day_part_id` (nullable ; le créneau type dont il est issu, `SET NULL` si le type est supprimé), `starts_at`, `ends_at` - (peut finir le lendemain), `min_staff`, `ideal_staff`. À la création d'un - `Position`, un `Shift` à 0/0 est généré par jour de l'édition et par - `DayPart` ; `POST /editions/{id}/positions/{pid}/generate-shifts` recrée les - manquants (après ajout d'un créneau type ou extension des dates). + (peut finir le lendemain), `min_staff`, `ideal_staff`. **Aucun créneau + n'est généré automatiquement à la création d'un `Position`** : la + coordination crée elle-même ceux dont elle a besoin, à l'horaire de son + choix (`POST /editions/{id}/shifts`, sans lien à un `DayPart`, + `day_part_id: null`) — écran « Postes & créneaux » (`ConfigGrid.tsx`), un + formulaire heure de début / heure de fin par jour. Repartir des créneaux + types reste possible en option, à la demande : + `POST /editions/{id}/positions/{pid}/generate-shifts` génère un `Shift` à + 0/0 par jour de l'édition et par `DayPart` manquant (bouton « Générer les + créneaux », idempotent — n'écrase jamais un créneau déjà généré, même + modifié depuis). - **`Availability`** : `volunteer_id`, `date`, `start_time`, `end_time` (`end_time <= start_time` ⇒ la fenêtre se termine le lendemain). Fenêtre horaire **libre** ; plusieurs par (`volunteer`, `date`). Unique @@ -425,13 +432,21 @@ effectivement tourné (`POST .../solver/suggestions`), ses bénévoles retenus passent malgré tout en tête (ce sont eux le résultat d'une vraie optimisation jointe, pas juste un score local). -**Colonnes de la grille des besoins** — une colonne représente un **créneau** -distinct (`starts_at` + `ends_at`), pas seulement une heure de début : deux -créneaux qui démarrent en même temps mais durent différemment ont chacun leur -colonne (`buildLattice` / `LatticeColumn` dans `frontend/src/lib/planning.ts`). -Largeur adaptative par jour (`columnWidthPx`) : peu de créneaux dans une -journée ⇒ colonnes larges, beaucoup de créneaux ⇒ largeur compacte avec -défilement horizontal — jamais de colonnes étroites par défaut. +**Colonnes de la grille des besoins** (`GrillePage.tsx`) — un vrai axe des +temps : une colonne = 30 min, bornée par jour à la plage réellement utilisée +(du créneau le plus matinal au plus tardif, arrondi à la demi-heure +englobante — pas les 24 h complètes). Les créneaux gardent des horaires +libres (`ShiftCreate`, § 2) ; ils sont replacés dans cette grille fixe en +fusionnant les cases qu'ils couvrent (`gridColumn: span N`), arrondis à la +tranche englobante s'ils ne tombent pas pile sur un multiple de 30 min +(`buildTimeGrid` / `placeInTimeGrid` dans `frontend/src/lib/planning.ts`). +Largeur adaptative par jour (`columnWidthPx`, en fonction du nombre de +tranches) : peu de tranches ⇒ colonnes larges, beaucoup ⇒ largeur compacte +avec défilement horizontal — jamais de colonnes étroites par défaut. +La « Vue bénévoles » (`VolunteerViewPage.tsx`) reste sur l'ancien modèle, +distinct : une colonne par **créneau** (signature `starts_at`/`ends_at`) +plutôt qu'un axe temporel continu (`buildLattice` / `LatticeColumn`), plus +adapté à son tableau bénévole × créneau. --- @@ -618,6 +633,7 @@ Aucune dépendance locale requise hors Docker. | 20 | Planches de badges bénévoles (PDF) : recto personnalisable (fond + champs nom/poste(s)/catégorie/rôle, éditeur glisser-déposer), verso fixe (même image pour tous), écran « Badges » dédié (`/badges`, COORDINATION) | à valider | | 21 | Formulaire public d'inscription (`/inscription`, sans authentification) : champs activables par la coordination (email, téléphone, vœux de poste — max 2, vœux d'artiste — un par soir, vœu de compagnon en texte libre, disponibilité/cotisation déclaratives, photo, commentaire), captcha hCaptcha, ouverture/fermeture, lien à partager, écran de configuration dédié (`/inscriptions`, COORDINATION) — les bénévoles créés rejoignent directement la liste | à valider | | 22 | Feuilles de poste (PDF, complément des badges) : une page par poste et par jour avec le référent, les créneaux, les bénévoles affectés et leur téléphone — bouton depuis la grille des besoins (COORDINATION) ; photo du bénévole affichée sur son écran et sur l'écran référent (en face du nom de chaque membre de l'équipe) | à valider | +| 23 | Créneaux : plus de génération automatique à la création d'un poste — création manuelle à horaire libre (`ConfigGrid.tsx`), génération depuis les créneaux types conservée en option (« Générer les créneaux ») ; grille des besoins redessinée en vrai axe des temps (une colonne = 30 min, bornée à la plage utilisée par jour, cases fusionnées pour chaque créneau — `buildTimeGrid`/`placeInTimeGrid`) | à 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/backend/app/api/positions.py b/backend/app/api/positions.py index 38c6756..f483472 100644 --- a/backend/app/api/positions.py +++ b/backend/app/api/positions.py @@ -58,9 +58,9 @@ def create_position( _check_lead_account(session, edition, payload.lead_account_id) position = Position(edition_id=edition.id, **payload.model_dump()) session.add(position) - session.flush() - # Un créneau à besoin 0/0 par jour et par créneau type de l'édition. - generate_missing_shifts(session, edition, position) + # Pas de créneaux générés automatiquement : la coordination choisit + # elle-même ceux dont elle a besoin (créés à la main, ou en repartant des + # créneaux types via `generate_shifts` ci-dessous). session.commit() session.refresh(position) return position diff --git a/backend/tests/test_shift_generation.py b/backend/tests/test_shift_generation.py index 2d273f2..c85830c 100644 --- a/backend/tests/test_shift_generation.py +++ b/backend/tests/test_shift_generation.py @@ -1,4 +1,9 @@ -"""Génération des créneaux d'un poste à partir des créneaux types.""" +"""Génération des créneaux d'un poste à partir des créneaux types. + +Depuis le passage à la création de créneaux à la main (voir +`test_shifts_api.py`), un `Position` ne génère plus rien automatiquement à sa +création : `generate-shifts` reste l'outil optionnel pour repartir des +créneaux types de l'édition.""" import datetime as dt @@ -13,12 +18,26 @@ def _create_position(client, edition, name="Bar"): ).json() -def test_creating_a_position_generates_one_shift_per_day_and_type(client, edition): +def _generate(client, edition, position): + return client.post(f"{_positions_url(edition)}/{position['id']}/generate-shifts") + + +def test_creating_a_position_generates_no_shifts(client, edition): position = _create_position(client, edition) shifts = client.get( f"/editions/{edition.id}/shifts", params={"position_id": position["id"]} ).json() + assert shifts == [] + + +def test_generate_shifts_creates_one_per_day_and_type(client, edition): + position = _create_position(client, edition) + resp = _generate(client, edition, position) # 4 jours (2026-07-09..12) x 3 créneaux types. + assert resp.json()["created"] == 12 + shifts = client.get( + f"/editions/{edition.id}/shifts", params={"position_id": position["id"]} + ).json() assert len(shifts) == 12 assert all(s["min_staff"] == 0 and s["ideal_staff"] == 0 for s in shifts) assert all(s["day_part_id"] is not None for s in shifts) @@ -26,20 +45,18 @@ def test_creating_a_position_generates_one_shift_per_day_and_type(client, editio def test_generate_is_idempotent(client, edition): position = _create_position(client, edition) - resp = client.post( - f"{_positions_url(edition)}/{position['id']}/generate-shifts" - ) + _generate(client, edition, position) + resp = _generate(client, edition, position) assert resp.status_code == 200 assert resp.json()["created"] == 0 def test_extending_the_edition_then_generating_adds_the_new_days(client, edition): position = _create_position(client, edition) + _generate(client, edition, position) client.patch(f"/editions/{edition.id}", json={"end_date": "2026-07-14"}) - resp = client.post( - f"{_positions_url(edition)}/{position['id']}/generate-shifts" - ) + resp = _generate(client, edition, position) # 2 jours de plus x 3 créneaux types. assert resp.json()["created"] == 6 shifts = client.get( @@ -50,13 +67,12 @@ def test_extending_the_edition_then_generating_adds_the_new_days(client, edition def test_adding_a_day_part_then_generating_adds_it_everywhere(client, edition): position = _create_position(client, edition) + _generate(client, edition, position) client.post( f"/editions/{edition.id}/day-parts", json={"label": "Nuit", "start_time": "02:00", "end_time": "05:00"}, ) - resp = client.post( - f"{_positions_url(edition)}/{position['id']}/generate-shifts" - ) + resp = _generate(client, edition, position) assert resp.json()["created"] == 4 # 1 par jour @@ -67,11 +83,12 @@ def test_day_part_before_the_day_boundary_rolls_to_the_next_calendar_day(client, suite de `Soirée` 19 h–00 h) est donc la fin de la nuit du jour *précédent* : il doit être généré sur le jour suivant, pas sur `day`.""" position = _create_position(client, edition) + _generate(client, edition, position) client.post( f"/editions/{edition.id}/day-parts", json={"label": "Nuit", "start_time": "02:00", "end_time": "05:00", "order": 3}, ) - client.post(f"{_positions_url(edition)}/{position['id']}/generate-shifts") + _generate(client, edition, position) shifts = client.get( f"/editions/{edition.id}/shifts", params={"position_id": position["id"]} @@ -89,6 +106,7 @@ def test_day_part_before_the_day_boundary_rolls_to_the_next_calendar_day(client, def test_generation_does_not_touch_staffing_already_set(client, edition): position = _create_position(client, edition) + _generate(client, edition, position) shift = client.get( f"/editions/{edition.id}/shifts", params={"position_id": position["id"]} ).json()[0] @@ -96,6 +114,6 @@ def test_generation_does_not_touch_staffing_already_set(client, edition): f"/editions/{edition.id}/shifts/{shift['id']}", json={"min_staff": 3, "ideal_staff": 5}, ) - client.post(f"{_positions_url(edition)}/{position['id']}/generate-shifts") + _generate(client, edition, position) again = client.get(f"/editions/{edition.id}/shifts/{shift['id']}").json() assert again["min_staff"] == 3 and again["ideal_staff"] == 5 diff --git a/frontend/scripts/smoke.tsx b/frontend/scripts/smoke.tsx index 5da7181..5b08feb 100644 --- a/frontend/scripts/smoke.tsx +++ b/frontend/scripts/smoke.tsx @@ -18,6 +18,7 @@ import type { AccountRead } from '@/api/schema'; import { queryKeys } from '@/api/hooks'; import { AuthContext, AuthProvider, type AuthContextValue } from '@/auth/auth-context'; import { routes } from '@/app/router'; +import { buildTimeGrid, placeInTimeGrid } from '@/lib/planning'; import { BadgesPage } from '@/pages/BadgesPage'; import { ConfigPage } from '@/pages/ConfigPage'; import { FormBuilderPage } from '@/pages/FormBuilderPage'; @@ -181,6 +182,57 @@ async function checkPositionSheets() { ); } +/** Créneaux (mode mock) : un poste créé ne génère plus aucun créneau ; la + * création manuelle à horaires libres fonctionne toujours ; « générer les + * créneaux » (créneaux types) reste disponible en option ; un créneau à + * horaires non alignés (19:15–22:45) se place bien dans la grille demi-heure + * (`buildTimeGrid`/`placeInTimeGrid`), arrondi à la tranche englobante. */ +async function checkShiftCreation() { + const position = await api.positions.create(e, { name: 'Smoke poste', category: 'OTHER' }); + const shiftsAfterCreate = await api.shifts.list(e, { positionId: position.id }); + + const edition = await api.editions.get(e); + const shift = await api.shifts.create(e, { + position_id: position.id, + starts_at: `${edition.start_date}T19:15:00`, + ends_at: `${edition.start_date}T22:45:00`, + min_staff: 1, + ideal_staff: 2, + }); + const generated = await api.positions.generateShifts(e, position.id); + + const grid = buildTimeGrid([shift]); + const day = grid[0]; + const placement = day ? placeInTimeGrid(grid, [shift]).get(day.date)?.[0] : undefined; + + console.log( + JSON.stringify( + { + shiftsAfterPositionCreate: shiftsAfterCreate.length, + manualShiftTimes: `${shift.starts_at}–${shift.ends_at}`, + generateShiftsStillOptIn: generated.created, + gridSlotCount: day?.slotLabels.length, + gridFirstSlot: day?.slotLabels[0], + placementSpan: placement?.columnSpan, + }, + null, + 2, + ), + ); + + if (shiftsAfterCreate.length !== 0) { + throw new Error('création de poste : ne devrait générer aucun créneau automatiquement'); + } + if (generated.created <= 0) { + throw new Error('generate-shifts : devrait rester disponible en option'); + } + // 19:15–22:45 (3 h 30) arrondi aux tranches de 30 min englobantes + // (19:00–23:00) = 8 tranches. + if (day?.slotLabels[0] !== '19:00' || placement?.columnSpan !== 8) { + throw new Error('grille demi-heure : placement inattendu pour un créneau non aligné'); + } +} + async function prefetchCore(client: QueryClient) { await Promise.all([ client.prefetchQuery({ queryKey: queryKeys.volunteers, queryFn: () => api.volunteers.list(e) }), @@ -250,6 +302,7 @@ function renderPublic(path: string) { await checkAuth().catch((err) => fail('checkAuth', err)); await checkPublicForm().catch((err) => fail('checkPublicForm', err)); await checkPositionSheets().catch((err) => fail('checkPositionSheets', err)); +await checkShiftCreation().catch((err) => fail('checkShiftCreation', err)); renderPublic('/connexion'); renderPublic('/connexion/coordination'); diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 6def28a..69cb177 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -1229,7 +1229,8 @@ export function createMockApi(): PlanningApi { updated_at: now, }; state.positions.push(position); - generateMissingShifts(position.id); + // Plus de génération automatique : la coordination ajoute les + // créneaux à la main, ou via `generateShifts` (opt-in) ci-dessous. return delay(position); }, update: (_edition, id, body) => { diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 0c9075a..e1eb7b5 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -157,7 +157,10 @@ "config.grid.deletePositionConfirm": "Supprimer ce poste et tous ses créneaux ?", "config.grid.generateShifts": "GÉNÉRER LES CRÉNEAUX", "config.grid.generated": "{count} créneau(x) ajouté(s).", - "config.grid.note": "Effectifs, créneaux, nom et catégorie du poste sont enregistrés immédiatement via l'API. Pour ajouter ou retirer des jours, modifiez les dates de l'édition dans l'onglet « Règles & édition ».", + "config.grid.shiftStart": "Début du créneau", + "config.grid.shiftEnd": "Fin du créneau", + "config.grid.addShift": "+ AJOUTER CE CRÉNEAU", + "config.grid.note": "Un poste ne crée aucun créneau à sa création : ajoutez-les à la main (heure de début et de fin) sur chaque jour, ou utilisez « Générer les créneaux » pour repartir des créneaux types de l'édition. Effectifs, créneaux, nom et catégorie du poste sont enregistrés immédiatement via l'API. Pour ajouter ou retirer des jours, modifiez les dates de l'édition dans l'onglet « Règles & édition ».", "config.vol.importTitle": "IMPORTER DES BÉNÉVOLES", "config.vol.importHint": "Trois façons d'ajouter des bénévoles : saisie manuelle, import d'un fichier CSV / Excel, ou (à venir) le formulaire d'inscription en ligne.", "config.vol.profiles": "PROFILS ENREGISTRÉS", diff --git a/frontend/src/lib/planning.ts b/frontend/src/lib/planning.ts index 0480e9d..42abba2 100644 --- a/frontend/src/lib/planning.ts +++ b/frontend/src/lib/planning.ts @@ -263,15 +263,126 @@ export function latticeFromMatrix(matrix: MatrixResponse, dayBoundary?: string) } /** - * Largeur (px) des colonnes d'une journée, en fonction du nombre de créneaux - * distincts qu'elle compte : peu de créneaux ⇒ colonnes larges plutôt que - * resserrées ; beaucoup de créneaux ⇒ largeur compacte (défilement horizontal). + * Largeur (px) des tranches de 30 min d'une journée, en fonction de leur + * nombre : une journée courte (peu de tranches) reste lisible en large, + * une journée dense (beaucoup de tranches) se resserre jusqu'à un plancher + * (défilement horizontal) plutôt que d'écraser le reste de la grille. */ -export function columnWidthPx(columnsInDay: number): number { - if (columnsInDay <= 2) return 160; - if (columnsInDay <= 4) return 120; - if (columnsInDay <= 6) return 92; - return 66; +export function columnWidthPx(slotsInDay: number): number { + if (slotsInDay <= 4) return 110; + if (slotsInDay <= 8) return 80; + if (slotsInDay <= 16) return 56; + return 42; +} + +// --------------------------------------------------------------------------- // +// Grille des besoins : une colonne = 30 min (§ 4 CLAUDE.md). Les créneaux, // +// eux, gardent des horaires libres (voir `ShiftCreate`) — ils sont replacés // +// dans cette grille fixe en fusionnant les cases qu'ils couvrent. // +// (Distinct de `buildLattice` ci-dessus, gardé pour la « Vue bénévoles », // +// une colonne par créneau distinct plutôt qu'un axe temporel continu.) // +// --------------------------------------------------------------------------- // +const TIME_GRID_SLOT_MINUTES = 30; + +export interface TimeGridDay { + date: string; // jour de festival + /** Étiquette d'horloge (`HH:MM`) du début de chaque tranche de 30 min. */ + slotLabels: string[]; + /** Minutes depuis `dayBoundary` du début de la 1re tranche (multiple de 30). */ + startOffset: number; +} + +export interface TimeGridPlacement { + item: T; + columnStart: number; // index de tranche dans le jour (0-based) + columnSpan: number; // nombre de tranches fusionnées (>= 1) +} + +const clockLabel = (totalMinutes: number): string => { + const wrapped = ((totalMinutes % (24 * 60)) + 24 * 60) % (24 * 60); + const h = Math.floor(wrapped / 60); + const m = wrapped % 60; + return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; +}; + +/** Jour de festival (§ 3 CLAUDE.md) d'un datetime naïf, selon `dayBoundary`. */ +function festivalDayOf(iso: string, boundaryMinutes: number): string { + const date = datePart(iso); + const clock = minutesOfDay(timePart(iso)); + return clock < boundaryMinutes ? addDays(date, -1) : date; +} + +/** + * Construit, pour chaque jour de festival couvert par `shifts`, l'axe des + * tranches de 30 min à afficher — bornée à la plage réellement utilisée ce + * jour-là (du début du créneau le plus matinal à la fin du plus tardif, + * arrondi au multiple de 30 inférieur/supérieur), pas les 24 h complètes. + */ +export function buildTimeGrid( + shifts: Array<{ starts_at: string; ends_at: string }>, + dayBoundary = '00:00', +): TimeGridDay[] { + const boundaryMinutes = minutesOfDay(dayBoundary); + const bounds = new Map(); + for (const s of shifts) { + const festivalDay = festivalDayOf(s.starts_at, boundaryMinutes); + const startClock = minutesOfDay(timePart(s.starts_at)); + const startOffset = (startClock - boundaryMinutes + 24 * 60) % (24 * 60); + const duration = naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at); + const endOffset = startOffset + duration; + const entry = bounds.get(festivalDay); + if (entry) { + entry.min = Math.min(entry.min, startOffset); + entry.max = Math.max(entry.max, endOffset); + } else { + bounds.set(festivalDay, { min: startOffset, max: endOffset }); + } + } + return [...bounds.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, { min, max }]) => { + const startOffset = Math.floor(min / TIME_GRID_SLOT_MINUTES) * TIME_GRID_SLOT_MINUTES; + const endOffset = Math.ceil(max / TIME_GRID_SLOT_MINUTES) * TIME_GRID_SLOT_MINUTES; + const slotCount = Math.max(1, (endOffset - startOffset) / TIME_GRID_SLOT_MINUTES); + const slotLabels = Array.from({ length: slotCount }, (_, i) => + clockLabel(boundaryMinutes + startOffset + i * TIME_GRID_SLOT_MINUTES), + ); + return { date, slotLabels, startOffset }; + }); +} + +/** + * Replace des éléments datés (`starts_at`/`ends_at`) dans la grille de + * `days` (voir `buildTimeGrid`) : tranche de départ + nombre de tranches + * fusionnées, groupés et triés par jour. Suppose une seule ligne par + * appelant (ex. un poste) : deux éléments qui se chevaucheraient dans le + * temps ne sont pas gérés (cas non prévu par la grille des besoins). + */ +export function placeInTimeGrid( + days: TimeGridDay[], + items: T[], + dayBoundary = '00:00', +): Map[]> { + const boundaryMinutes = minutesOfDay(dayBoundary); + const byDay = new Map(days.map((d) => [d.date, d])); + const placements = new Map[]>(); + for (const item of items) { + const festivalDay = festivalDayOf(item.starts_at, boundaryMinutes); + const day = byDay.get(festivalDay); + if (!day) continue; + const startClock = minutesOfDay(timePart(item.starts_at)); + const startOffset = (startClock - boundaryMinutes + 24 * 60) % (24 * 60); + const duration = naiveMinutes(item.ends_at) - naiveMinutes(item.starts_at); + const columnStart = Math.floor((startOffset - day.startOffset) / TIME_GRID_SLOT_MINUTES); + const columnEnd = Math.ceil( + (startOffset + duration - day.startOffset) / TIME_GRID_SLOT_MINUTES, + ); + const list = placements.get(festivalDay) ?? []; + list.push({ item, columnStart, columnSpan: Math.max(1, columnEnd - columnStart) }); + placements.set(festivalDay, list); + } + for (const list of placements.values()) list.sort((a, b) => a.columnStart - b.columnStart); + return placements; } /** Heures cumulées par bénévole. */ diff --git a/frontend/src/pages/GrillePage.tsx b/frontend/src/pages/GrillePage.tsx index d93d4a9..9e98841 100644 --- a/frontend/src/pages/GrillePage.tsx +++ b/frontend/src/pages/GrillePage.tsx @@ -14,13 +14,14 @@ import { Chip, HelpTip } from '@/components/ui'; import { EmptyNote, ErrorNote, Loading } from '@/components/state'; import { AssignmentPanel } from '@/pages/grille/AssignmentPanel'; import { + buildTimeGrid, categoryLabel, columnWidthPx, dayBoundaryFromDayParts, - latticeFromMatrix, - type LatticeColumn, + placeInTimeGrid, + type TimeGridDay, } from '@/lib/planning'; -import { datePart, shortDayLabel, timePart } from '@/lib/time'; +import { hourRangeLabel, shortDayLabel } from '@/lib/time'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; @@ -36,8 +37,14 @@ export function GrillePage() { const [selectedShiftId, setSelectedShiftId] = useState(null); const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]); - const lattice = useMemo( - () => (matrix.data ? latticeFromMatrix(matrix.data, dayBoundary) : { days: [], columns: [] }), + const days = useMemo( + () => + matrix.data + ? buildTimeGrid( + matrix.data.positions.flatMap((p) => p.shifts), + dayBoundary, + ) + : [], [matrix.data, dayBoundary], ); @@ -73,8 +80,7 @@ export function GrillePage() { } const categories = [...new Set(matrix.data.positions.map((p) => p.category))].sort(); - const shownDays = dayFilter === 'all' ? lattice.days : lattice.days.filter((d) => d.date === dayFilter); - const shownColumns = shownDays.flatMap((d) => d.columns); + const shownDays = dayFilter === 'all' ? days : days.filter((d) => d.date === dayFilter); const shownPositions = categoryFilter === 'all' ? matrix.data.positions @@ -83,8 +89,8 @@ export function GrillePage() { const gridTemplateColumns = [ '230px', ...shownDays.flatMap((day) => { - const width = columnWidthPx(day.columns.length); - return day.columns.map(() => `${width}px`); + const width = columnWidthPx(day.slotLabels.length); + return day.slotLabels.map(() => `${width}px`); }), ].join(' '); @@ -118,7 +124,7 @@ export function GrillePage() { setDayFilter('all')}> {t('filter.all')} - {lattice.days.map((day) => ( + {days.map((day) => ( setDayFilter(day.date)}> {shortDayLabel(`${day.date}T12:00`)} @@ -208,7 +214,7 @@ export function GrillePage() {
{shortDayLabel(`${day.date}T12:00`)} @@ -219,20 +225,23 @@ export function GrillePage() {
{t('grille.assignedRequired')}
- {shownColumns.map((column) => ( -
- {column.label} -
- ))} + {shownDays.map((day) => + day.slotLabels.map((label, i) => ( +
+ {label} +
+ )), + )} {shownPositions.map((position) => ( void; volunteerNamesByShift: Map; }) { - const byColumn = new Map(); - for (const shift of position.shifts) { - byColumn.set( - `${datePart(shift.starts_at)}|${timePart(shift.starts_at)}|${timePart(shift.ends_at)}`, - shift, - ); - } + const placements = placeInTimeGrid(days, position.shifts, dayBoundary); return ( <> @@ -322,66 +327,92 @@ function PositionRow({
{position.location}
- {columns.map((column) => { - const shift = byColumn.get(column.key); - if (!shift) { - return
; + {days.map((day) => { + const dayPlacements = placements.get(day.date) ?? []; + const cells: React.ReactNode[] = []; + let cursor = 0; + for (const { item: shift, columnStart, columnSpan } of dayPlacements) { + for (let i = cursor; i < Math.max(cursor, columnStart); i += 1) { + cells.push(
); + } + cells.push( + , + ); + cursor = Math.max(cursor, columnStart + columnSpan); } - const assigned = shift.assigned; - const undefinedNeed = shift.min_staff === 0 && shift.ideal_staff === 0; - const isCrit = !undefinedNeed && assigned < shift.min_staff; - const isOver = !undefinedNeed && assigned > shift.ideal_staff; - const isInc = !undefinedNeed && !isCrit && !isOver && assigned < shift.ideal_staff; - const isOk = !undefinedNeed && !isCrit && !isOver && !isInc; - const glyph = undefinedNeed - ? '·' - : isCrit - ? '▲' - : isOver - ? '▼' - : isInc - ? '◧' - : '✓'; - const selected = selectedShiftId === shift.shift_id; - const names = volunteerNamesByShift.get(shift.shift_id) ?? []; - return ( -
onSelect(shift.shift_id)} - className={cx( - 'group relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink', - undefinedNeed && 'bg-sand-dark hover:bg-sand', - isCrit && 'bg-hatch-orange', - isOver && 'bg-hatch-mauve hover:bg-mauve-hover', - isInc && 'bg-yellow-soft hover:bg-yellow', - isOk && 'bg-paper hover:bg-green/10', - )} - > - {assigned} - /{shift.ideal_staff} - - {glyph} - -
-
- {t('grille.cellPreview.staffing', { - assigned, - ideal: shift.ideal_staff, - min: shift.min_staff, - })} -
- {names.length > 0 ? ( - names.map((name, i) =>
{name}
) - ) : ( -
{t('grille.panel.noneAssigned')}
- )} -
- {selected ? ( - - ) : null} -
- ); + for (let i = cursor; i < day.slotLabels.length; i += 1) { + cells.push(
); + } + return cells; })} ); } + +function ShiftCell({ + shift, + span, + selected, + onSelect, + names, +}: { + shift: MatrixShift; + span: number; + selected: boolean; + onSelect: (id: string) => void; + names: string[]; +}) { + const assigned = shift.assigned; + const undefinedNeed = shift.min_staff === 0 && shift.ideal_staff === 0; + const isCrit = !undefinedNeed && assigned < shift.min_staff; + const isOver = !undefinedNeed && assigned > shift.ideal_staff; + const isInc = !undefinedNeed && !isCrit && !isOver && assigned < shift.ideal_staff; + const isOk = !undefinedNeed && !isCrit && !isOver && !isInc; + const glyph = undefinedNeed ? '·' : isCrit ? '▲' : isOver ? '▼' : isInc ? '◧' : '✓'; + + return ( +
onSelect(shift.shift_id)} + style={{ gridColumn: `span ${span}` }} + className={cx( + 'group relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink', + undefinedNeed && 'bg-sand-dark hover:bg-sand', + isCrit && 'bg-hatch-orange', + isOver && 'bg-hatch-mauve hover:bg-mauve-hover', + isInc && 'bg-yellow-soft hover:bg-yellow', + isOk && 'bg-paper hover:bg-green/10', + )} + > + {assigned} + /{shift.ideal_staff} + {glyph} +
+
+ {hourRangeLabel(shift.starts_at, shift.ends_at)} +
+
+ {t('grille.cellPreview.staffing', { + assigned, + ideal: shift.ideal_staff, + min: shift.min_staff, + })} +
+ {names.length > 0 ? ( + names.map((name, i) =>
{name}
) + ) : ( +
{t('grille.panel.noneAssigned')}
+ )} +
+ {selected ? ( + + ) : null} +
+ ); +} diff --git a/frontend/src/pages/config/ConfigGrid.tsx b/frontend/src/pages/config/ConfigGrid.tsx index e356400..d8811db 100644 --- a/frontend/src/pages/config/ConfigGrid.tsx +++ b/frontend/src/pages/config/ConfigGrid.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from 'react'; import { useCreatePosition, + useCreateShift, useDeletePosition, useDeleteShift, useEdition, @@ -15,7 +16,7 @@ import type { PositionCategory, PositionRead, ShiftRead } from '@/api/schema'; import { ErrorNote, Loading } from '@/components/state'; import { Btn, HelpTip } from '@/components/ui'; import { categoryLabel } from '@/lib/planning'; -import { hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time'; +import { addDays, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; @@ -143,60 +144,16 @@ export function ConfigGrid() { {updateShift.isError ? : null}
- {days.map((day) => { - const shifts = shiftsByDay.get(day) ?? []; - return ( -
-
- {longDayLabel(`${day}T12:00`)} -
- {shifts.map((shift) => ( -
- - {hourRangeLabel(shift.starts_at, shift.ends_at)} - - - updateShift.mutate({ - id: shift.id, - body: { min_staff: Math.max(0, shift.min_staff + delta) }, - }) - } - /> - - updateShift.mutate({ - id: shift.id, - body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) }, - }) - } - /> - -
- ))} - {shifts.length === 0 ? ( -
- {t('config.grid.emptyDay')} -
- ) : null} -
- ); - })} + {days.map((day) => ( + + ))}
@@ -285,6 +242,111 @@ function PositionHeader({ ); } +function DayCard({ + day, + positionId, + shifts, + updateShift, + deleteShift, +}: { + day: string; + positionId: string; + shifts: ShiftRead[]; + updateShift: ReturnType; + deleteShift: ReturnType; +}) { + const createShift = useCreateShift(); + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + + const addShift = () => { + if (!start || !end) return; + const endDay = end <= start ? addDays(day, 1) : day; + createShift.mutate( + { position_id: positionId, starts_at: `${day}T${start}:00`, ends_at: `${endDay}T${end}:00` }, + { onSuccess: () => { setStart(''); setEnd(''); } }, + ); + }; + + return ( +
+
+ {longDayLabel(`${day}T12:00`)} +
+ {shifts.map((shift) => ( +
+ + {hourRangeLabel(shift.starts_at, shift.ends_at)} + + + updateShift.mutate({ + id: shift.id, + body: { min_staff: Math.max(0, shift.min_staff + delta) }, + }) + } + /> + + updateShift.mutate({ + id: shift.id, + body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) }, + }) + } + /> + +
+ ))} + {shifts.length === 0 ? ( +
+ {t('config.grid.emptyDay')} +
+ ) : null} +
+ setStart(e.target.value)} + aria-label={t('config.grid.shiftStart')} + className="border-2 border-ink bg-paper px-[6px] py-[3px] font-mono text-[11px]" + /> + → + setEnd(e.target.value)} + aria-label={t('config.grid.shiftEnd')} + className="border-2 border-ink bg-paper px-[6px] py-[3px] font-mono text-[11px]" + /> + +
+ {createShift.isError ? : null} +
+ ); +} + function groupByDay(shifts: ShiftRead[]): Map { const map = new Map(); for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) {