Créneaux à la main + grille des besoins en axe des temps (30 min)
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f1d88302a4
commit
15109acfe5
@@ -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 +
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<T> {
|
||||
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<string, { min: number; max: number }>();
|
||||
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<T extends { starts_at: string; ends_at: string }>(
|
||||
days: TimeGridDay[],
|
||||
items: T[],
|
||||
dayBoundary = '00:00',
|
||||
): Map<string, TimeGridPlacement<T>[]> {
|
||||
const boundaryMinutes = minutesOfDay(dayBoundary);
|
||||
const byDay = new Map(days.map((d) => [d.date, d]));
|
||||
const placements = new Map<string, TimeGridPlacement<T>[]>();
|
||||
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. */
|
||||
|
||||
@@ -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<string | null>(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() {
|
||||
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
|
||||
{t('filter.all')}
|
||||
</Chip>
|
||||
{lattice.days.map((day) => (
|
||||
{days.map((day) => (
|
||||
<Chip key={day.date} active={dayFilter === day.date} onClick={() => setDayFilter(day.date)}>
|
||||
{shortDayLabel(`${day.date}T12:00`)}
|
||||
</Chip>
|
||||
@@ -208,7 +214,7 @@ export function GrillePage() {
|
||||
<div
|
||||
key={day.date}
|
||||
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
|
||||
style={{ gridColumn: `span ${day.columns.length}` }}
|
||||
style={{ gridColumn: `span ${day.slotLabels.length}` }}
|
||||
>
|
||||
<span className="font-display text-[12px] font-black tracking-tight">
|
||||
{shortDayLabel(`${day.date}T12:00`)}
|
||||
@@ -219,20 +225,23 @@ export function GrillePage() {
|
||||
<div className="sticky left-0 top-[27px] z-[6] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60">
|
||||
{t('grille.assignedRequired')}
|
||||
</div>
|
||||
{shownColumns.map((column) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className="sticky top-[27px] z-[3] overflow-hidden text-ellipsis whitespace-nowrap border-b-[3px] border-r border-ink bg-sand px-[2px] py-[5px] text-center font-mono text-[9.5px]"
|
||||
>
|
||||
{column.label}
|
||||
</div>
|
||||
))}
|
||||
{shownDays.map((day) =>
|
||||
day.slotLabels.map((label, i) => (
|
||||
<div
|
||||
key={`${day.date}|${i}`}
|
||||
className="sticky top-[27px] z-[3] overflow-hidden text-ellipsis whitespace-nowrap border-b-[3px] border-r border-ink bg-sand px-[2px] py-[5px] text-center font-mono text-[9.5px]"
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
|
||||
{shownPositions.map((position) => (
|
||||
<PositionRow
|
||||
key={position.id}
|
||||
position={position}
|
||||
columns={shownColumns}
|
||||
days={shownDays}
|
||||
dayBoundary={dayBoundary}
|
||||
selectedShiftId={selectedShiftId}
|
||||
onSelect={setSelectedShiftId}
|
||||
volunteerNamesByShift={volunteerNamesByShift}
|
||||
@@ -295,24 +304,20 @@ function LegendItem({
|
||||
|
||||
function PositionRow({
|
||||
position,
|
||||
columns,
|
||||
days,
|
||||
dayBoundary,
|
||||
selectedShiftId,
|
||||
onSelect,
|
||||
volunteerNamesByShift,
|
||||
}: {
|
||||
position: MatrixPosition;
|
||||
columns: LatticeColumn[];
|
||||
days: TimeGridDay[];
|
||||
dayBoundary: string;
|
||||
selectedShiftId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
volunteerNamesByShift: Map<string, string[]>;
|
||||
}) {
|
||||
const byColumn = new Map<string, MatrixShift>();
|
||||
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({
|
||||
</span>
|
||||
<span className="truncate font-mono text-[8.5px] opacity-55">{position.location}</span>
|
||||
</div>
|
||||
{columns.map((column) => {
|
||||
const shift = byColumn.get(column.key);
|
||||
if (!shift) {
|
||||
return <div key={column.key} className="h-9 border-b border-r border-ink" />;
|
||||
{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(<div key={`${day.date}|empty|${i}`} className="h-9 border-b border-r border-ink" />);
|
||||
}
|
||||
cells.push(
|
||||
<ShiftCell
|
||||
key={shift.shift_id}
|
||||
shift={shift}
|
||||
span={columnSpan}
|
||||
selected={selectedShiftId === shift.shift_id}
|
||||
onSelect={onSelect}
|
||||
names={volunteerNamesByShift.get(shift.shift_id) ?? []}
|
||||
/>,
|
||||
);
|
||||
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 (
|
||||
<div
|
||||
key={column.key}
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-[13px] font-bold">{assigned}</span>
|
||||
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
|
||||
<span className={cx('font-mono text-[10px] font-bold', isOk && 'text-green')}>
|
||||
{glyph}
|
||||
</span>
|
||||
<div className="pointer-events-none absolute left-1/2 top-full z-20 mt-[4px] w-max max-w-[220px] -translate-x-1/2 border-2 border-ink bg-ink px-[9px] py-[7px] text-left font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100">
|
||||
<div className="mb-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide text-yellow">
|
||||
{t('grille.cellPreview.staffing', {
|
||||
assigned,
|
||||
ideal: shift.ideal_staff,
|
||||
min: shift.min_staff,
|
||||
})}
|
||||
</div>
|
||||
{names.length > 0 ? (
|
||||
names.map((name, i) => <div key={i}>{name}</div>)
|
||||
) : (
|
||||
<div className="opacity-70">{t('grille.panel.noneAssigned')}</div>
|
||||
)}
|
||||
</div>
|
||||
{selected ? (
|
||||
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
for (let i = cursor; i < day.slotLabels.length; i += 1) {
|
||||
cells.push(<div key={`${day.date}|empty|${i}`} className="h-9 border-b border-r border-ink" />);
|
||||
}
|
||||
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 (
|
||||
<div
|
||||
onClick={() => 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',
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-[13px] font-bold">{assigned}</span>
|
||||
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
|
||||
<span className={cx('font-mono text-[10px] font-bold', isOk && 'text-green')}>{glyph}</span>
|
||||
<div className="pointer-events-none absolute left-1/2 top-full z-20 mt-[4px] w-max max-w-[220px] -translate-x-1/2 border-2 border-ink bg-ink px-[9px] py-[7px] text-left font-mono text-[10px] font-normal normal-case leading-relaxed tracking-normal text-cream opacity-0 shadow-brutal-md transition-opacity duration-100 group-hover:opacity-100">
|
||||
<div className="mb-[1px] font-mono text-[9px] font-bold text-cream opacity-80">
|
||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
</div>
|
||||
<div className="mb-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide text-yellow">
|
||||
{t('grille.cellPreview.staffing', {
|
||||
assigned,
|
||||
ideal: shift.ideal_staff,
|
||||
min: shift.min_staff,
|
||||
})}
|
||||
</div>
|
||||
{names.length > 0 ? (
|
||||
names.map((name, i) => <div key={i}>{name}</div>)
|
||||
) : (
|
||||
<div className="opacity-70">{t('grille.panel.noneAssigned')}</div>
|
||||
)}
|
||||
</div>
|
||||
{selected ? (
|
||||
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? <ErrorNote error={updateShift.error} /> : null}
|
||||
|
||||
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]">
|
||||
{days.map((day) => {
|
||||
const shifts = shiftsByDay.get(day) ?? [];
|
||||
return (
|
||||
<div key={day} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
|
||||
{longDayLabel(`${day}T12:00`)}
|
||||
</div>
|
||||
{shifts.map((shift) => (
|
||||
<div
|
||||
key={shift.id}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
|
||||
>
|
||||
<span className="w-[92px] flex-none font-mono text-[11px]">
|
||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
</span>
|
||||
<Stepper
|
||||
label={t('config.grid.min')}
|
||||
value={shift.min_staff}
|
||||
onChange={(delta) =>
|
||||
updateShift.mutate({
|
||||
id: shift.id,
|
||||
body: { min_staff: Math.max(0, shift.min_staff + delta) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Stepper
|
||||
label={t('config.grid.ideal')}
|
||||
accent
|
||||
value={shift.ideal_staff}
|
||||
onChange={(delta) =>
|
||||
updateShift.mutate({
|
||||
id: shift.id,
|
||||
body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteShift.mutate(shift.id)}
|
||||
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
|
||||
aria-label={t('action.remove')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{shifts.length === 0 ? (
|
||||
<div className="px-[9px] py-[10px] font-mono text-[10px] opacity-55">
|
||||
{t('config.grid.emptyDay')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{days.map((day) => (
|
||||
<DayCard
|
||||
key={day}
|
||||
day={day}
|
||||
positionId={position.id}
|
||||
shifts={shiftsByDay.get(day) ?? []}
|
||||
updateShift={updateShift}
|
||||
deleteShift={deleteShift}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,6 +242,111 @@ function PositionHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function DayCard({
|
||||
day,
|
||||
positionId,
|
||||
shifts,
|
||||
updateShift,
|
||||
deleteShift,
|
||||
}: {
|
||||
day: string;
|
||||
positionId: string;
|
||||
shifts: ShiftRead[];
|
||||
updateShift: ReturnType<typeof useUpdateShift>;
|
||||
deleteShift: ReturnType<typeof useDeleteShift>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
|
||||
{longDayLabel(`${day}T12:00`)}
|
||||
</div>
|
||||
{shifts.map((shift) => (
|
||||
<div
|
||||
key={shift.id}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
|
||||
>
|
||||
<span className="w-[92px] flex-none font-mono text-[11px]">
|
||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
</span>
|
||||
<Stepper
|
||||
label={t('config.grid.min')}
|
||||
value={shift.min_staff}
|
||||
onChange={(delta) =>
|
||||
updateShift.mutate({
|
||||
id: shift.id,
|
||||
body: { min_staff: Math.max(0, shift.min_staff + delta) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Stepper
|
||||
label={t('config.grid.ideal')}
|
||||
accent
|
||||
value={shift.ideal_staff}
|
||||
onChange={(delta) =>
|
||||
updateShift.mutate({
|
||||
id: shift.id,
|
||||
body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteShift.mutate(shift.id)}
|
||||
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
|
||||
aria-label={t('action.remove')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{shifts.length === 0 ? (
|
||||
<div className="px-[9px] py-[10px] font-mono text-[10px] opacity-55">
|
||||
{t('config.grid.emptyDay')}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-[6px] border-t-2 border-ink bg-sand px-[9px] py-[7px]">
|
||||
<input
|
||||
type="time"
|
||||
value={start}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
<span className="font-mono text-[11px] opacity-60">→</span>
|
||||
<input
|
||||
type="time"
|
||||
value={end}
|
||||
onChange={(e) => 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]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!start || !end || createShift.isPending}
|
||||
onClick={addShift}
|
||||
className="ml-auto cursor-pointer border-2 border-ink bg-cream px-[8px] py-[4px] font-display text-[9.5px] font-extrabold uppercase hover:bg-yellow disabled:opacity-50"
|
||||
>
|
||||
{t('config.grid.addShift')}
|
||||
</button>
|
||||
</div>
|
||||
{createShift.isError ? <ErrorNote error={createShift.error} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByDay(shifts: ShiftRead[]): Map<string, ShiftRead[]> {
|
||||
const map = new Map<string, ShiftRead[]>();
|
||||
for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) {
|
||||
|
||||
Reference in New Issue
Block a user