Compare commits

...
2 Commits
Author SHA1 Message Date
AdamThomasandClaude Opus 5.5 ff57bbd863 Mode de paiement de la cotisation visible (en ligne, sur place)
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s
Jusqu'ici seul un paiement en ligne apparaissait ; un bénévole qui paiera
sur place ne se distinguait pas d'un bénévole sans cotisation. Mode déduit
des champs existants (lib/fee.ts:feeMode, exporting/xlsx.py:_payment_mode),
sans migration :
- fiche : section « Cotisation » toujours visible — paiement en ligne
  (type, montant, statut, mécénat et adresse du reçu) ou case « Paiera sur
  place » modifiable à la main
- liste : colonne « Cotisation » (Sur place / En ligne ✓ / Mécénat ✓ / —)
- export Excel : colonne « Mode de paiement »

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:47:06 +02:00
AdamThomasandClaude Opus 5.5 a6e7b6e7fb Créneaux de nuit rattachés à leur journée de festival
Un créneau saisi « 00:00–01:00 » sur la carte du samedi était enregistré
le samedi à 00:00 (nuit de vendredi à samedi), donc affiché la veille dans
la grille des besoins. Une heure de début avant le début de journée
(premier créneau type) désigne désormais la nuit qui suit : dimanche
00:00, à la suite de la soirée du samedi.

Même « journée de festival » partout (validation/engine.py:day_boundary,
festival_day ; miroir front lib/planning.ts:festivalDay) : écran Postes &
créneaux, feuilles de poste (une seule page pour une soirée qui passe
minuit) et chaînage du solveur (00h–01h enchaîne 23h–00h).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:47:06 +02:00
13 changed files with 320 additions and 40 deletions
+11
View File
@@ -538,6 +538,15 @@ Sémantique figée :
créneau entièrement à découvert.
- « Journée » (cumul 8 h) : du début du premier `DayPart` du jour au début du
premier `DayPart` du lendemain (le `DayPart` ne sert plus qu'à ça).
**Même « journée de festival » partout** (`app/validation/engine.py:
day_boundary`/`festival_day`, miroir front `lib/planning.ts:festivalDay`) :
une soirée qui passe minuit (22h–23h, 23h–00h, 00h–01h…) reste d'un seul
tenant sur le jour où elle a commencé — grille des besoins, écran « Postes
& créneaux », feuilles de poste, chaînage du solveur. À la saisie d'un
créneau sur la carte d'un jour (`ConfigGrid.tsx`,
`shiftDatesForFestivalDay`), une heure de début avant cette borne désigne
la nuit qui suit (00:00 sur le samedi ⇒ dimanche 00:00), pas le petit
matin du même jour calendaire.
- Cumul : `> 8 h 00` strict (8 h 00 pile passe).
- Repos : `< 8 h 00` strict.
- Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement.
@@ -852,6 +861,8 @@ Aucune dépendance locale requise hors Docker.
| 35 | Type de paiement à montant libre (`FormPaymentOption.free_amount`, migration `0021`) : case « Montant libre » sur l'écran Inscriptions, montant configuré = minimum, saisie du montant sur le formulaire public (validée côté client et serveur, 10 000 € max) — pensé pour le mécénat | à valider |
| 36 | Écran Inscriptions : état « Actif / Inactif » des types de paiement (`SumUpStatus`, `FormBuilderPage.tsx`) — sans identifiants SumUp ou sans type de paiement, le formulaire public n'affiche qu'une case « cotisation » (ni choix du type, ni étape mécénat, ni paiement en ligne), ce que rien ne signalait | à valider |
| 37 | Cerfa mécénat : nouveau modèle PDF, date du versement repositionnée, date de signature remplie (date du paiement), case « Virement, prélèvement, carte bancaire » cochée (paiement SumUp) ; message de confirmation du paiement annonçant l'email et le reçu fiscal | à valider |
| 38 | Créneaux de nuit : une heure de début avant le début de journée (premier créneau type) saisie sur la carte d'un jour désigne la nuit qui suit (00:00 le samedi ⇒ dimanche 00:00) ; écran « Postes & créneaux », feuilles de poste et chaînage du solveur regroupent par journée de festival, comme la grille et la validation | à valider |
| 39 | Mode de paiement de la cotisation visible partout (`lib/fee.ts:feeMode`, `app/exporting/xlsx.py:_payment_mode`) : en ligne (type, montant, statut, mécénat), sur place, ou rien — section « Cotisation » sur la fiche (case « Paiera sur place » modifiable à la main), colonne « Cotisation » dans la liste, colonne « Mode de paiement » dans l'export Excel | à valider |
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
+11 -2
View File
@@ -17,7 +17,7 @@ from app.api.deps import get_edition
from app.api.schemas import PositionSheetsRequest
from app.auth.deps import require_coordinator
from app.db import get_session
from app.models import Account, Artist, Assignment, Edition, Position, Shift, Volunteer
from app.models import Account, Artist, Assignment, DayPart, Edition, Position, Shift, Volunteer
from app.sheets.pdf import (
PositionDaySheet,
SheetArtist,
@@ -25,6 +25,7 @@ from app.sheets.pdf import (
SheetVolunteer,
generate_position_sheets_pdf,
)
from app.validation.engine import day_boundary, festival_day
router = APIRouter(
prefix="/editions/{edition_id}/position-sheets", tags=["position-sheets"]
@@ -69,6 +70,13 @@ def generate_position_sheets(
for vs in volunteers_by_shift.values():
vs.sort(key=lambda v: (v.last_name, v.first_name))
# « Jour » = journée de festival (CLAUDE.md § 3), pas date du calendrier :
# une soirée qui passe minuit (23h–00h, 00h–01h…) reste sur une seule
# feuille, celle du jour où elle a commencé.
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
# Un jour sans aucun besoin réel (créneau 0/0 généré automatiquement,
# jamais rempli) n'apparaît pas — évite des pages « personne affecté »
# pour des créneaux qui ne représentent aucun besoin.
@@ -77,7 +85,8 @@ def generate_position_sheets(
undefined_need = shift.min_staff == 0 and shift.ideal_staff == 0
if undefined_need and not volunteers_by_shift.get(shift.id):
continue
shifts_by_position_day[(shift.position_id, shift.starts_at.date())].append(shift)
day = festival_day(shift.starts_at, boundary)
shifts_by_position_day[(shift.position_id, day)].append(shift)
Referent = aliased(Volunteer)
referent_rows = session.exec(
+13
View File
@@ -24,6 +24,7 @@ _HEADER = [
"Artistes souhaités",
"Compagnons souhaités",
"Note de compagnon",
"Mode de paiement",
"Cotisation choisie",
"Montant (€)",
"Statut du paiement",
@@ -56,6 +57,17 @@ def _safe_text(value: str) -> str:
return f"'{value}" if value.startswith(_FORMULA_PREFIXES) else value
def _payment_mode(v: VolunteerRead) -> str:
"""En ligne (un checkout SumUp a été créé), sur place (« je participe à
la cotisation » sans paiement en ligne), ou rien — même règle que
`frontend/src/lib/fee.ts:feeMode`."""
if v.fee_payment_status is not None:
return "En ligne"
if v.willing_to_pay_fee:
return "Sur place"
return ""
def _yes_no(value: bool) -> str:
return "Oui" if value else "Non"
@@ -97,6 +109,7 @@ def generate_volunteers_xlsx(
_join_names(v.artist_ids, artist_names),
_join_names(v.wished_companion_ids, volunteer_names),
_safe_text(v.companion_wish_note or ""),
_payment_mode(v),
_safe_text(v.fee_payment_label or ""),
v.fee_amount if v.fee_amount is not None else "",
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
+8 -1
View File
@@ -10,6 +10,7 @@ from app.models import (
Artist,
Assignment,
Availability,
DayPart,
Edition,
Shift,
Volunteer,
@@ -18,6 +19,7 @@ from app.models import (
VolunteerPositionWish,
)
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
from app.validation.engine import day_boundary, festival_day
from app.validation.models import AvailabilityWindow, ShiftInfo
@@ -41,6 +43,11 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
)
shifts_by_id = {s.id: s for s in shifts}
# « Même jour » = même journée de festival (CLAUDE.md § 3) : 00h–01h
# enchaîne bien 23h–00h de la veille au calendrier.
boundary = day_boundary(
session.exec(select(DayPart).where(DayPart.edition_id == edition.id))
)
# Prédécesseur(s) : créneaux (n'importe quel poste) du même jour dont la
# fin coïncide avec le début de celui-ci — sert au malus de chaînage
# (voir `engine._score`), pour limiter les enchaînements de shifts plutôt
@@ -49,7 +56,7 @@ def build_solver_input(*, session: Session, edition: Edition) -> SolverInput:
predecessor_ids: dict = {}
by_day: dict = defaultdict(list)
for s in shifts:
by_day[s.starts_at.date()].append(s)
by_day[festival_day(s.starts_at, boundary)].append(s)
for day_shifts in by_day.values():
ids_by_end: dict = defaultdict(list)
for s in day_shifts:
+13 -5
View File
@@ -107,26 +107,34 @@ def _check_outside_availability(data: ValidationInput) -> list[Violation]:
# --------------------------------------------------------------------------- #
# DAILY_HOURS_EXCEEDED (avertissement) #
# --------------------------------------------------------------------------- #
def _day_boundary(day_parts: tuple) -> dt.time:
def day_boundary(day_parts) -> dt.time:
"""Début d'une « journée de festival » : début du premier créneau type
(`DayPart`, par `order`), minuit à défaut — voir CLAUDE.md § 3. Accepte
aussi bien les `DayPartInfo` de ce module que les lignes `DayPart`."""
day_parts = list(day_parts)
if not day_parts:
return dt.time(0, 0)
return min(day_parts, key=lambda part: part.order).start_time
def _day_key(moment: dt.datetime, boundary: dt.time) -> dt.date:
def festival_day(moment: dt.datetime, boundary: dt.time) -> dt.date:
"""Journée de festival d'un instant : avant `boundary`, c'est encore la
nuit de la veille (ex. 01:00 le dimanche ⇒ samedi). Partagé avec le
solveur et les feuilles de poste, pour qu'une soirée qui passe minuit
reste d'un seul tenant partout."""
if moment.time() >= boundary:
return moment.date()
return moment.date() - dt.timedelta(days=1)
def _check_daily_hours(data: ValidationInput) -> list[Violation]:
boundary = _day_boundary(data.day_parts)
boundary = day_boundary(data.day_parts)
candidate = data.candidate_shift
key = _day_key(candidate.starts_at, boundary)
key = festival_day(candidate.starts_at, boundary)
total = candidate.ends_at - candidate.starts_at
for other in data.existing_shifts:
if _day_key(other.starts_at, boundary) == key:
if festival_day(other.starts_at, boundary) == key:
total += other.ends_at - other.starts_at
if total > _EIGHT_HOURS:
+18 -2
View File
@@ -427,6 +427,22 @@ def test_export_volunteers_as_excel(client, edition, session):
assert row[6] == "Chilla"
def test_export_says_how_each_volunteer_pays(client, edition, session):
on_site = make_volunteer(session, edition, first_name="Alex", last_name="A")
on_site.willing_to_pay_fee = True
online = make_volunteer(session, edition, first_name="Sam", last_name="B")
_mark_mecenat_paid(session, online)
make_volunteer(session, edition, first_name="Jo", last_name="C")
session.add(on_site)
session.commit()
resp = client.get(_url(edition, "/export"))
rows = list(load_workbook(io.BytesIO(resp.content)).active.iter_rows(values_only=True))
column = rows[0].index("Mode de paiement")
by_first_name = {row[0]: row[column] for row in rows[1:]}
assert by_first_name == {"Alex": "Sur place", "Sam": "En ligne", "Jo": None}
def test_export_neutralizes_formula_injection(client, edition, session):
# CWE-1236 : un bénévole inscrit via le formulaire public (sans
# authentification) contrôle son nom, son commentaire, etc. — ouverts
@@ -443,9 +459,9 @@ def test_export_neutralizes_formula_injection(client, edition, session):
resp = client.get(_url(edition, "/export"))
workbook = load_workbook(io.BytesIO(resp.content))
row = list(workbook.active.iter_rows(values_only=True))[1]
header, row = list(workbook.active.iter_rows(values_only=True))[:2]
assert row[0] == "'=cmd|'/c calc'!A0"
assert row[17] == "'@SUM(1+1)*cmd|' /C calc'!A0"
assert row[header.index("Commentaire")] == "'@SUM(1+1)*cmd|' /C calc'!A0"
def test_export_volunteers_is_coordinator_only(edition, make_account, as_account):
+25
View File
@@ -149,3 +149,28 @@ def test_position_ids_filter_scopes_the_sheets(client, edition, session):
resp = client.post(_url(edition), json={"position_ids": [str(welcome.id)]})
assert resp.status_code == 200
def test_a_night_that_passes_midnight_stays_on_its_festival_day(client, edition, session):
# Début de journée = 06:00 (premier créneau type de la fixture) : 00h–02h
# le 10 appartient encore à la soirée du 9 — une seule page, pas deux.
from io import BytesIO
from pypdf import PdfReader
position = make_position(session, edition, name="Bar de nuit")
for start, end in [
(dt_at(9, 22), dt_at(9, 23)),
(dt_at(9, 23), dt_at(10, 0)),
(dt_at(10, 0), dt_at(10, 1)),
(dt_at(10, 1), dt_at(10, 2)),
]:
make_shift(session, edition, position, start=start, end=end)
resp = client.post(_url(edition), json={})
assert resp.status_code == 200
reader = PdfReader(BytesIO(resp.content))
assert len(reader.pages) == 1
text = reader.pages[0].extract_text()
assert "jeudi 9 juillet" in text.lower()
assert "01:00" in text
+27
View File
@@ -0,0 +1,27 @@
"""Construction du `SolverInput` depuis la base (`app/solver/loader.py`)."""
from app.solver.loader import build_solver_input
from tests.factories import dt_at, make_position, make_shift
def test_predecessor_is_found_across_midnight_within_the_festival_day(edition, session):
# Début de journée = 06:00 (fixture) : 23h–00h le 9 puis 00h–01h le 10
# appartiennent à la même soirée — le second enchaîne le premier.
bar = make_position(session, edition, name="Bar")
before = make_shift(session, edition, bar, start=dt_at(9, 23), end=dt_at(10, 0))
after = make_shift(session, edition, bar, start=dt_at(10, 0), end=dt_at(10, 1))
data = build_solver_input(session=session, edition=edition)
by_id = {s.id: s for s in data.shifts}
assert by_id[str(after.id)].predecessor_shift_ids == frozenset({str(before.id)})
def test_no_predecessor_across_the_festival_day_boundary(edition, session):
# 05h–06h le 10 clôt la journée du 9 ; 06h–07h le 10 ouvre la suivante.
bar = make_position(session, edition, name="Bar")
make_shift(session, edition, bar, start=dt_at(10, 5), end=dt_at(10, 6))
morning = make_shift(session, edition, bar, start=dt_at(10, 6), end=dt_at(10, 7))
data = build_solver_input(session=session, edition=edition)
by_id = {s.id: s for s in data.shifts}
assert not by_id[str(morning.id)].predecessor_shift_ids
+14
View File
@@ -160,6 +160,7 @@
"config.grid.shiftStart": "Début du créneau",
"config.grid.shiftEnd": "Fin du créneau",
"config.grid.addShift": "+ AJOUTER CE CRÉNEAU",
"config.grid.nightHint": "Une heure de début avant {boundary} compte pour la nuit qui suit (ex. 00:00 → après minuit, à la suite de la soirée de ce jour).",
"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.",
@@ -310,6 +311,19 @@
"config.vol.publicForm.title": "Formulaire public",
"config.vol.publicForm.hint": "Réponses saisies par le bénévole lui-même via le formulaire public d'inscription.",
"config.vol.colWishes": "VŒUX",
"config.vol.colFee": "COTISATION",
"config.vol.fee.title": "Cotisation",
"config.vol.fee.online": "Paiement en ligne (SumUp)",
"config.vol.fee.mecenat": "Mécénat — reçu fiscal",
"config.vol.fee.onSite": "Paiera sur place",
"config.vol.fee.none": "aucune cotisation déclarée",
"config.vol.fee.onSiteShort": "Sur place",
"config.vol.fee.onlineShort": "En ligne",
"config.vol.fee.mecenatShort": "Mécénat",
"config.vol.fee.short.pending": "en attente",
"config.vol.fee.short.paid": "payée",
"config.vol.fee.short.failed": "échouée",
"config.vol.fee.short.expired": "expirée",
"config.vol.wishesCount": "{count} vœu(x)",
"config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
"config.vol.avail.title": "Disponibilités",
+18
View File
@@ -0,0 +1,18 @@
import type { VolunteerRead } from '@/api/schema';
/** Comment un bénévole règle sa cotisation — déduit des champs existants,
* même règle que `app/exporting/xlsx.py:_payment_mode` :
* - `ONLINE` : un paiement SumUp a été lancé (type, montant et statut sur
* `fee_payment_*`) ;
* - `ON_SITE` : « je participe à la cotisation » sans paiement en ligne
* (« Je paierai sur place » au formulaire, ou case cochée à la main) ;
* - `NONE` : rien de déclaré. */
export type FeeMode = 'ONLINE' | 'ON_SITE' | 'NONE';
export function feeMode(
volunteer: Pick<VolunteerRead, 'fee_payment_status' | 'willing_to_pay_fee'>,
): FeeMode {
if (volunteer.fee_payment_status !== null) return 'ONLINE';
if (volunteer.willing_to_pay_fee) return 'ON_SITE';
return 'NONE';
}
+24
View File
@@ -312,6 +312,30 @@ function festivalDayOf(iso: string, boundaryMinutes: number): string {
return clock < boundaryMinutes ? addDays(date, -1) : date;
}
/** Jour de festival d'un datetime naïf (`dayBoundary` = `HH:MM`, voir
* `dayBoundaryFromDayParts`) : avant cette heure, c'est encore la nuit de la
* veille. Même règle que `app/validation/engine.py:festival_day`. */
export function festivalDay(iso: string, dayBoundary = '00:00'): string {
return festivalDayOf(iso, minutesOfDay(dayBoundary));
}
/**
* Datetimes d'un créneau saisi `start`–`end` (`HH:MM`) sur la journée de
* festival `day` : une heure de début avant `dayBoundary` désigne la nuit
* qui suit (00:00 le samedi ⇒ dimanche 00:00, après 23:00 le samedi), pas
* le petit matin du même jour calendaire ; une fin `<=` début passe minuit.
*/
export function shiftDatesForFestivalDay(
day: string,
start: string,
end: string,
dayBoundary = '00:00',
): { starts_at: string; ends_at: string } {
const startDay = minutesOfDay(start) < minutesOfDay(dayBoundary) ? addDays(day, 1) : day;
const endDay = end <= start ? addDays(startDay, 1) : startDay;
return { starts_at: `${startDay}T${start}:00`, ends_at: `${endDay}T${end}:00` };
}
/**
* 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
+34 -10
View File
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
import {
useCreatePosition,
useCreateShift,
useDayParts,
useDeletePosition,
useDeleteShift,
useEdition,
@@ -15,8 +16,13 @@ import {
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 { addDays, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
import {
categoryLabel,
dayBoundaryFromDayParts,
festivalDay,
shiftDatesForFestivalDay,
} from '@/lib/planning';
import { hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
@@ -47,6 +53,8 @@ export function ConfigGrid() {
const editionQuery = useEdition();
const positions = usePositions();
const allShifts = useShifts();
const dayParts = useDayParts();
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
const createPosition = useCreatePosition();
const [selectedId, setSelectedId] = useState<string | null>(null);
@@ -88,10 +96,17 @@ export function ConfigGrid() {
);
}
const shiftsByDay = groupByDay(positionShifts.data ?? []);
const days = editionQuery.data
? enumerateDays(editionQuery.data.start_date, editionQuery.data.end_date)
: [...shiftsByDay.keys()];
const shiftsByDay = groupByDay(positionShifts.data ?? [], dayBoundary);
// Jours de l'édition, plus tout jour de festival qui porterait un créneau
// hors de ces dates (sinon invisible ici, donc impossible à supprimer).
const days = [
...new Set([
...(editionQuery.data
? enumerateDays(editionQuery.data.start_date, editionQuery.data.end_date)
: []),
...shiftsByDay.keys(),
]),
].sort();
return (
<div className="flex min-h-0 flex-1 items-stretch">
@@ -150,6 +165,7 @@ export function ConfigGrid() {
day={day}
positionId={position.id}
shifts={shiftsByDay.get(day) ?? []}
dayBoundary={dayBoundary}
updateShift={updateShift}
deleteShift={deleteShift}
/>
@@ -246,12 +262,14 @@ function DayCard({
day,
positionId,
shifts,
dayBoundary,
updateShift,
deleteShift,
}: {
day: string;
positionId: string;
shifts: ShiftRead[];
dayBoundary: string;
updateShift: ReturnType<typeof useUpdateShift>;
deleteShift: ReturnType<typeof useDeleteShift>;
}) {
@@ -261,9 +279,8 @@ function DayCard({
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` },
{ position_id: positionId, ...shiftDatesForFestivalDay(day, start, end, dayBoundary) },
{ onSuccess: () => { setStart(''); setEnd(''); } },
);
};
@@ -342,15 +359,22 @@ function DayCard({
{t('config.grid.addShift')}
</button>
</div>
{dayBoundary !== '00:00' ? (
<div className="bg-sand px-[9px] pb-[7px] font-mono text-[9.5px] opacity-70">
{t('config.grid.nightHint', { boundary: dayBoundary })}
</div>
) : null}
{createShift.isError ? <ErrorNote error={createShift.error} /> : null}
</div>
);
}
function groupByDay(shifts: ShiftRead[]): Map<string, ShiftRead[]> {
/** Regroupe par journée de festival (pas par date du calendrier) : 00h–01h
* le dimanche reste avec la soirée du samedi, triée après 23h. */
function groupByDay(shifts: ShiftRead[], dayBoundary: string): Map<string, ShiftRead[]> {
const map = new Map<string, ShiftRead[]>();
for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) {
const day = s.starts_at.slice(0, 10);
const day = festivalDay(s.starts_at, dayBoundary);
map.set(day, [...(map.get(day) ?? []), s]);
}
return map;
+104 -20
View File
@@ -37,6 +37,7 @@ import type {
} from '@/api/schema';
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
import { feeMode } from '@/lib/fee';
import { foldAccents } from '@/lib/planning';
import { t } from '@/i18n';
import { shortDayLabel } from '@/lib/time';
@@ -188,14 +189,9 @@ function PublicFormAnswers({
const hasAnything =
volunteer.has_photo ||
Boolean(volunteer.companion_wish_note) ||
volunteer.self_declared_available ||
volunteer.fee_payment_status !== null;
volunteer.self_declared_available;
if (!hasAnything) return null;
const feeStatusKey = volunteer.fee_payment_status
? FEE_STATUS_LABEL_KEY[volunteer.fee_payment_status]
: null;
return (
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
<FieldLabel>
@@ -232,20 +228,75 @@ function PublicFormAnswers({
/>
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
</label>
{feeStatusKey ? (
<span className="border-2 border-ink bg-cream px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{volunteer.fee_payment_label ? `${volunteer.fee_payment_label} (${volunteer.fee_amount} €) — ` : ''}
{t(feeStatusKey)}
</span>
) : null}
</div>
</div>
);
}
{volunteer.mecenat_address ? (
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address}, {volunteer.mecenat_postal_code}{' '}
{volunteer.mecenat_city}, {volunteer.mecenat_country}
</p>
) : null}
/**
* Cotisation du bénévole (voir `lib/fee.ts:feeMode`) : paiement en ligne
* (type, montant, statut, mécénat), paiement sur place, ou rien. Toujours
* affichée, pour qu'un « rien » se voie aussi ; le paiement sur place reste
* modifiable à la main (bénévole ajouté sans le formulaire, changement
* d'avis…), un paiement en ligne non — son statut vient de SumUp.
*/
function FeeSection({
volunteer,
patch,
}: {
volunteer: VolunteerRead;
patch: (body: VolunteerUpdate) => void;
}) {
const mode = feeMode(volunteer);
return (
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
<FieldLabel>{t('config.vol.fee.title')}</FieldLabel>
{mode === 'ONLINE' && volunteer.fee_payment_status ? (
<>
<div className="flex flex-wrap items-center gap-[6px]">
<span className="border-2 border-ink bg-blue px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{t('config.vol.fee.online')}
</span>
{volunteer.mecenat_address ? (
<span className="border-2 border-ink bg-mauve px-[8px] py-[3px] font-mono text-[10.5px] font-bold">
{t('config.vol.fee.mecenat')}
</span>
) : null}
<span
className={cx(
'border-2 border-ink px-[8px] py-[3px] font-mono text-[10.5px] font-bold',
volunteer.fee_payment_status === 'PAID' ? 'bg-green text-cream' : 'bg-yellow-soft',
)}
>
{t(FEE_STATUS_LABEL_KEY[volunteer.fee_payment_status])}
</span>
</div>
{volunteer.fee_payment_label ? (
<p className="font-mono text-[11px]">
{volunteer.fee_payment_label} — {volunteer.fee_amount} €
</p>
) : null}
{volunteer.mecenat_address ? (
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address},{' '}
{volunteer.mecenat_postal_code} {volunteer.mecenat_city}, {volunteer.mecenat_country}
</p>
) : null}
</>
) : (
<label className="flex cursor-pointer items-center gap-[7px]">
<input
type="checkbox"
checked={volunteer.willing_to_pay_fee}
onChange={(e) => patch({ willing_to_pay_fee: e.target.checked })}
className="h-[15px] w-[15px] accent-ink"
/>
<span className="font-mono text-[11px] font-bold">{t('config.vol.fee.onSite')}</span>
{mode === 'NONE' ? (
<span className="font-mono text-[10px] opacity-60">— {t('config.vol.fee.none')}</span>
) : null}
</label>
)}
</div>
);
}
@@ -483,6 +534,35 @@ function issueParams(issue: ImportIssue): Record<string, string | number> {
return out;
}
const LIST_COLUMNS =
'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
* d'un paiement en ligne non abouti. */
function FeeTag({ volunteer }: { volunteer: VolunteerRead }) {
const mode = feeMode(volunteer);
if (mode === 'NONE') return <span className="font-mono text-[10px] opacity-40">—</span>;
if (mode === 'ON_SITE') {
return (
<span className="truncate font-mono text-[10px] font-bold">{t('config.vol.fee.onSiteShort')}</span>
);
}
const paid = volunteer.fee_payment_status === 'PAID';
return (
<span className={cx('truncate font-mono text-[10px] font-bold', paid ? 'text-green' : 'text-orange-dark')}>
{t(volunteer.mecenat_address ? 'config.vol.fee.mecenatShort' : 'config.vol.fee.onlineShort')}
{paid ? ' ✓' : ` · ${t(FEE_STATUS_SHORT_KEY[volunteer.fee_payment_status ?? 'PENDING'])}`}
</span>
);
}
const FEE_STATUS_SHORT_KEY: Record<NonNullable<VolunteerRead['fee_payment_status']>, string> = {
PENDING: 'config.vol.fee.short.pending',
PAID: 'config.vol.fee.short.paid',
FAILED: 'config.vol.fee.short.failed',
EXPIRED: 'config.vol.fee.short.expired',
};
export function ConfigVolunteers() {
const volunteers = useVolunteers();
const artists = useArtists();
@@ -641,12 +721,13 @@ export function ConfigVolunteers() {
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
style={{ gridTemplateColumns: LIST_COLUMNS }}
>
<span>{t('config.vol.colName')}</span>
<span>{t('config.vol.colPhone')}</span>
<span>{t('config.vol.colEmail')}</span>
<span>{t('config.vol.colWishes')}</span>
<span>{t('config.vol.colFee')}</span>
</div>
{rows.map((volunteer) => {
const active = volunteer.id === selected?.id;
@@ -659,7 +740,7 @@ export function ConfigVolunteers() {
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
active ? 'bg-yellow' : 'hover:bg-cream',
)}
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
style={{ gridTemplateColumns: LIST_COLUMNS }}
>
<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">
@@ -684,6 +765,7 @@ export function ConfigVolunteers() {
volunteer.wished_companion_ids.length,
})}
</span>
<FeeTag volunteer={volunteer} />
</button>
);
})}
@@ -784,6 +866,8 @@ export function ConfigVolunteers() {
searchPlaceholder={t('config.vol.searchCompanion')}
/>
<FeeSection volunteer={selected} patch={patch} />
<PublicFormAnswers volunteer={selected} patch={patch} />
<AvailabilityEditor volunteerId={selected.id} />