Compare commits
2
Commits
c60bc11795
...
ff57bbd863
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff57bbd863 | ||
|
|
a6e7b6e7fb |
@@ -538,6 +538,15 @@ Sémantique figée :
|
|||||||
créneau entièrement à découvert.
|
créneau entièrement à découvert.
|
||||||
- « Journée » (cumul 8 h) : du début du premier `DayPart` du jour au début du
|
- « 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).
|
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).
|
- Cumul : `> 8 h 00` strict (8 h 00 pile passe).
|
||||||
- Repos : `< 8 h 00` strict.
|
- Repos : `< 8 h 00` strict.
|
||||||
- Créneaux jointifs (`end_at == start_at`) : **pas** un chevauchement.
|
- 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 |
|
| 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 |
|
| 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 |
|
| 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
|
**Reste hors périmètre** (modèle prêt à les accueillir) : SMS, constructeur
|
||||||
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
de formulaire, pointage sur site, IA. Frontend : squelette + 6 écrans +
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from app.api.deps import get_edition
|
|||||||
from app.api.schemas import PositionSheetsRequest
|
from app.api.schemas import PositionSheetsRequest
|
||||||
from app.auth.deps import require_coordinator
|
from app.auth.deps import require_coordinator
|
||||||
from app.db import get_session
|
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 (
|
from app.sheets.pdf import (
|
||||||
PositionDaySheet,
|
PositionDaySheet,
|
||||||
SheetArtist,
|
SheetArtist,
|
||||||
@@ -25,6 +25,7 @@ from app.sheets.pdf import (
|
|||||||
SheetVolunteer,
|
SheetVolunteer,
|
||||||
generate_position_sheets_pdf,
|
generate_position_sheets_pdf,
|
||||||
)
|
)
|
||||||
|
from app.validation.engine import day_boundary, festival_day
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
prefix="/editions/{edition_id}/position-sheets", tags=["position-sheets"]
|
prefix="/editions/{edition_id}/position-sheets", tags=["position-sheets"]
|
||||||
@@ -69,6 +70,13 @@ def generate_position_sheets(
|
|||||||
for vs in volunteers_by_shift.values():
|
for vs in volunteers_by_shift.values():
|
||||||
vs.sort(key=lambda v: (v.last_name, v.first_name))
|
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,
|
# 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é »
|
# jamais rempli) n'apparaît pas — évite des pages « personne affecté »
|
||||||
# pour des créneaux qui ne représentent aucun besoin.
|
# 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
|
undefined_need = shift.min_staff == 0 and shift.ideal_staff == 0
|
||||||
if undefined_need and not volunteers_by_shift.get(shift.id):
|
if undefined_need and not volunteers_by_shift.get(shift.id):
|
||||||
continue
|
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 = aliased(Volunteer)
|
||||||
referent_rows = session.exec(
|
referent_rows = session.exec(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ _HEADER = [
|
|||||||
"Artistes souhaités",
|
"Artistes souhaités",
|
||||||
"Compagnons souhaités",
|
"Compagnons souhaités",
|
||||||
"Note de compagnon",
|
"Note de compagnon",
|
||||||
|
"Mode de paiement",
|
||||||
"Cotisation choisie",
|
"Cotisation choisie",
|
||||||
"Montant (€)",
|
"Montant (€)",
|
||||||
"Statut du paiement",
|
"Statut du paiement",
|
||||||
@@ -56,6 +57,17 @@ def _safe_text(value: str) -> str:
|
|||||||
return f"'{value}" if value.startswith(_FORMULA_PREFIXES) else value
|
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:
|
def _yes_no(value: bool) -> str:
|
||||||
return "Oui" if value else "Non"
|
return "Oui" if value else "Non"
|
||||||
|
|
||||||
@@ -97,6 +109,7 @@ def generate_volunteers_xlsx(
|
|||||||
_join_names(v.artist_ids, artist_names),
|
_join_names(v.artist_ids, artist_names),
|
||||||
_join_names(v.wished_companion_ids, volunteer_names),
|
_join_names(v.wished_companion_ids, volunteer_names),
|
||||||
_safe_text(v.companion_wish_note or ""),
|
_safe_text(v.companion_wish_note or ""),
|
||||||
|
_payment_mode(v),
|
||||||
_safe_text(v.fee_payment_label or ""),
|
_safe_text(v.fee_payment_label or ""),
|
||||||
v.fee_amount if v.fee_amount is not None else "",
|
v.fee_amount if v.fee_amount is not None else "",
|
||||||
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
|
_FEE_STATUS_LABELS.get(v.fee_payment_status, ""),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.models import (
|
|||||||
Artist,
|
Artist,
|
||||||
Assignment,
|
Assignment,
|
||||||
Availability,
|
Availability,
|
||||||
|
DayPart,
|
||||||
Edition,
|
Edition,
|
||||||
Shift,
|
Shift,
|
||||||
Volunteer,
|
Volunteer,
|
||||||
@@ -18,6 +19,7 @@ from app.models import (
|
|||||||
VolunteerPositionWish,
|
VolunteerPositionWish,
|
||||||
)
|
)
|
||||||
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
|
from app.solver.models import SolverInput, SolverShift, SolverVolunteer
|
||||||
|
from app.validation.engine import day_boundary, festival_day
|
||||||
from app.validation.models import AvailabilityWindow, ShiftInfo
|
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}
|
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
|
# 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
|
# 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
|
# (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 = {}
|
predecessor_ids: dict = {}
|
||||||
by_day: dict = defaultdict(list)
|
by_day: dict = defaultdict(list)
|
||||||
for s in shifts:
|
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():
|
for day_shifts in by_day.values():
|
||||||
ids_by_end: dict = defaultdict(list)
|
ids_by_end: dict = defaultdict(list)
|
||||||
for s in day_shifts:
|
for s in day_shifts:
|
||||||
|
|||||||
@@ -107,26 +107,34 @@ def _check_outside_availability(data: ValidationInput) -> list[Violation]:
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# DAILY_HOURS_EXCEEDED (avertissement) #
|
# 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:
|
if not day_parts:
|
||||||
return dt.time(0, 0)
|
return dt.time(0, 0)
|
||||||
return min(day_parts, key=lambda part: part.order).start_time
|
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:
|
if moment.time() >= boundary:
|
||||||
return moment.date()
|
return moment.date()
|
||||||
return moment.date() - dt.timedelta(days=1)
|
return moment.date() - dt.timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
def _check_daily_hours(data: ValidationInput) -> list[Violation]:
|
def _check_daily_hours(data: ValidationInput) -> list[Violation]:
|
||||||
boundary = _day_boundary(data.day_parts)
|
boundary = day_boundary(data.day_parts)
|
||||||
candidate = data.candidate_shift
|
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
|
total = candidate.ends_at - candidate.starts_at
|
||||||
for other in data.existing_shifts:
|
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
|
total += other.ends_at - other.starts_at
|
||||||
|
|
||||||
if total > _EIGHT_HOURS:
|
if total > _EIGHT_HOURS:
|
||||||
|
|||||||
@@ -427,6 +427,22 @@ def test_export_volunteers_as_excel(client, edition, session):
|
|||||||
assert row[6] == "Chilla"
|
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):
|
def test_export_neutralizes_formula_injection(client, edition, session):
|
||||||
# CWE-1236 : un bénévole inscrit via le formulaire public (sans
|
# CWE-1236 : un bénévole inscrit via le formulaire public (sans
|
||||||
# authentification) contrôle son nom, son commentaire, etc. — ouverts
|
# 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"))
|
resp = client.get(_url(edition, "/export"))
|
||||||
workbook = load_workbook(io.BytesIO(resp.content))
|
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[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):
|
def test_export_volunteers_is_coordinator_only(edition, make_account, as_account):
|
||||||
|
|||||||
@@ -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)]})
|
resp = client.post(_url(edition), json={"position_ids": [str(welcome.id)]})
|
||||||
assert resp.status_code == 200
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -160,6 +160,7 @@
|
|||||||
"config.grid.shiftStart": "Début du créneau",
|
"config.grid.shiftStart": "Début du créneau",
|
||||||
"config.grid.shiftEnd": "Fin du créneau",
|
"config.grid.shiftEnd": "Fin du créneau",
|
||||||
"config.grid.addShift": "+ AJOUTER CE 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.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.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.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.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.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.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.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.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.",
|
||||||
"config.vol.avail.title": "Disponibilités",
|
"config.vol.avail.title": "Disponibilités",
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -312,6 +312,30 @@ function festivalDayOf(iso: string, boundaryMinutes: number): string {
|
|||||||
return clock < boundaryMinutes ? addDays(date, -1) : date;
|
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
|
* 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
|
* tranches de 30 min à afficher — bornée à la plage réellement utilisée ce
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
|
|||||||
import {
|
import {
|
||||||
useCreatePosition,
|
useCreatePosition,
|
||||||
useCreateShift,
|
useCreateShift,
|
||||||
|
useDayParts,
|
||||||
useDeletePosition,
|
useDeletePosition,
|
||||||
useDeleteShift,
|
useDeleteShift,
|
||||||
useEdition,
|
useEdition,
|
||||||
@@ -15,8 +16,13 @@ import {
|
|||||||
import type { PositionCategory, PositionRead, ShiftRead } from '@/api/schema';
|
import type { PositionCategory, PositionRead, ShiftRead } from '@/api/schema';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { Btn, HelpTip } from '@/components/ui';
|
import { Btn, HelpTip } from '@/components/ui';
|
||||||
import { categoryLabel } from '@/lib/planning';
|
import {
|
||||||
import { addDays, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
|
categoryLabel,
|
||||||
|
dayBoundaryFromDayParts,
|
||||||
|
festivalDay,
|
||||||
|
shiftDatesForFestivalDay,
|
||||||
|
} from '@/lib/planning';
|
||||||
|
import { hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
@@ -47,6 +53,8 @@ export function ConfigGrid() {
|
|||||||
const editionQuery = useEdition();
|
const editionQuery = useEdition();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
const allShifts = useShifts();
|
const allShifts = useShifts();
|
||||||
|
const dayParts = useDayParts();
|
||||||
|
const dayBoundary = useMemo(() => dayBoundaryFromDayParts(dayParts.data ?? []), [dayParts.data]);
|
||||||
const createPosition = useCreatePosition();
|
const createPosition = useCreatePosition();
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -88,10 +96,17 @@ export function ConfigGrid() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const shiftsByDay = groupByDay(positionShifts.data ?? []);
|
const shiftsByDay = groupByDay(positionShifts.data ?? [], dayBoundary);
|
||||||
const days = editionQuery.data
|
// Jours de l'édition, plus tout jour de festival qui porterait un créneau
|
||||||
? enumerateDays(editionQuery.data.start_date, editionQuery.data.end_date)
|
// hors de ces dates (sinon invisible ici, donc impossible à supprimer).
|
||||||
: [...shiftsByDay.keys()];
|
const days = [
|
||||||
|
...new Set([
|
||||||
|
...(editionQuery.data
|
||||||
|
? enumerateDays(editionQuery.data.start_date, editionQuery.data.end_date)
|
||||||
|
: []),
|
||||||
|
...shiftsByDay.keys(),
|
||||||
|
]),
|
||||||
|
].sort();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-0 flex-1 items-stretch">
|
<div className="flex min-h-0 flex-1 items-stretch">
|
||||||
@@ -150,6 +165,7 @@ export function ConfigGrid() {
|
|||||||
day={day}
|
day={day}
|
||||||
positionId={position.id}
|
positionId={position.id}
|
||||||
shifts={shiftsByDay.get(day) ?? []}
|
shifts={shiftsByDay.get(day) ?? []}
|
||||||
|
dayBoundary={dayBoundary}
|
||||||
updateShift={updateShift}
|
updateShift={updateShift}
|
||||||
deleteShift={deleteShift}
|
deleteShift={deleteShift}
|
||||||
/>
|
/>
|
||||||
@@ -246,12 +262,14 @@ function DayCard({
|
|||||||
day,
|
day,
|
||||||
positionId,
|
positionId,
|
||||||
shifts,
|
shifts,
|
||||||
|
dayBoundary,
|
||||||
updateShift,
|
updateShift,
|
||||||
deleteShift,
|
deleteShift,
|
||||||
}: {
|
}: {
|
||||||
day: string;
|
day: string;
|
||||||
positionId: string;
|
positionId: string;
|
||||||
shifts: ShiftRead[];
|
shifts: ShiftRead[];
|
||||||
|
dayBoundary: string;
|
||||||
updateShift: ReturnType<typeof useUpdateShift>;
|
updateShift: ReturnType<typeof useUpdateShift>;
|
||||||
deleteShift: ReturnType<typeof useDeleteShift>;
|
deleteShift: ReturnType<typeof useDeleteShift>;
|
||||||
}) {
|
}) {
|
||||||
@@ -261,9 +279,8 @@ function DayCard({
|
|||||||
|
|
||||||
const addShift = () => {
|
const addShift = () => {
|
||||||
if (!start || !end) return;
|
if (!start || !end) return;
|
||||||
const endDay = end <= start ? addDays(day, 1) : day;
|
|
||||||
createShift.mutate(
|
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(''); } },
|
{ onSuccess: () => { setStart(''); setEnd(''); } },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -342,15 +359,22 @@ function DayCard({
|
|||||||
{t('config.grid.addShift')}
|
{t('config.grid.addShift')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
{createShift.isError ? <ErrorNote error={createShift.error} /> : null}
|
||||||
</div>
|
</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[]>();
|
const map = new Map<string, ShiftRead[]>();
|
||||||
for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) {
|
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]);
|
map.set(day, [...(map.get(day) ?? []), s]);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
} from '@/api/schema';
|
} from '@/api/schema';
|
||||||
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
|
import { Chip, FieldLabel, HelpTip, TextInput } from '@/components/ui';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
|
import { feeMode } from '@/lib/fee';
|
||||||
import { foldAccents } from '@/lib/planning';
|
import { foldAccents } from '@/lib/planning';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { shortDayLabel } from '@/lib/time';
|
import { shortDayLabel } from '@/lib/time';
|
||||||
@@ -188,14 +189,9 @@ function PublicFormAnswers({
|
|||||||
const hasAnything =
|
const hasAnything =
|
||||||
volunteer.has_photo ||
|
volunteer.has_photo ||
|
||||||
Boolean(volunteer.companion_wish_note) ||
|
Boolean(volunteer.companion_wish_note) ||
|
||||||
volunteer.self_declared_available ||
|
volunteer.self_declared_available;
|
||||||
volunteer.fee_payment_status !== null;
|
|
||||||
if (!hasAnything) return null;
|
if (!hasAnything) return null;
|
||||||
|
|
||||||
const feeStatusKey = volunteer.fee_payment_status
|
|
||||||
? FEE_STATUS_LABEL_KEY[volunteer.fee_payment_status]
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
|
<div className="flex flex-col gap-2 border-2 border-ink bg-sand p-[9px]">
|
||||||
<FieldLabel>
|
<FieldLabel>
|
||||||
@@ -232,20 +228,75 @@ function PublicFormAnswers({
|
|||||||
/>
|
/>
|
||||||
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
|
<span className="font-mono text-[11px]">{t('form.field.available')}</span>
|
||||||
</label>
|
</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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{volunteer.mecenat_address ? (
|
/**
|
||||||
<p className="font-mono text-[10.5px] leading-relaxed opacity-70">
|
* Cotisation du bénévole (voir `lib/fee.ts:feeMode`) : paiement en ligne
|
||||||
{t('config.vol.mecenatAddress')} : {volunteer.mecenat_address}, {volunteer.mecenat_postal_code}{' '}
|
* (type, montant, statut, mécénat), paiement sur place, ou rien. Toujours
|
||||||
{volunteer.mecenat_city}, {volunteer.mecenat_country}
|
* affichée, pour qu'un « rien » se voie aussi ; le paiement sur place reste
|
||||||
</p>
|
* modifiable à la main (bénévole ajouté sans le formulaire, changement
|
||||||
) : null}
|
* 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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -483,6 +534,35 @@ function issueParams(issue: ImportIssue): Record<string, string | number> {
|
|||||||
return out;
|
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() {
|
export function ConfigVolunteers() {
|
||||||
const volunteers = useVolunteers();
|
const volunteers = useVolunteers();
|
||||||
const artists = useArtists();
|
const artists = useArtists();
|
||||||
@@ -641,12 +721,13 @@ export function ConfigVolunteers() {
|
|||||||
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||||
<div
|
<div
|
||||||
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
|
className="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.colName')}</span>
|
||||||
<span>{t('config.vol.colPhone')}</span>
|
<span>{t('config.vol.colPhone')}</span>
|
||||||
<span>{t('config.vol.colEmail')}</span>
|
<span>{t('config.vol.colEmail')}</span>
|
||||||
<span>{t('config.vol.colWishes')}</span>
|
<span>{t('config.vol.colWishes')}</span>
|
||||||
|
<span>{t('config.vol.colFee')}</span>
|
||||||
</div>
|
</div>
|
||||||
{rows.map((volunteer) => {
|
{rows.map((volunteer) => {
|
||||||
const active = volunteer.id === selected?.id;
|
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',
|
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
|
||||||
active ? 'bg-yellow' : 'hover:bg-cream',
|
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 min-w-0 items-center gap-[7px]">
|
||||||
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
|
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
|
||||||
@@ -684,6 +765,7 @@ export function ConfigVolunteers() {
|
|||||||
volunteer.wished_companion_ids.length,
|
volunteer.wished_companion_ids.length,
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
<FeeTag volunteer={volunteer} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -784,6 +866,8 @@ export function ConfigVolunteers() {
|
|||||||
searchPlaceholder={t('config.vol.searchCompanion')}
|
searchPlaceholder={t('config.vol.searchCompanion')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FeeSection volunteer={selected} patch={patch} />
|
||||||
|
|
||||||
<PublicFormAnswers volunteer={selected} patch={patch} />
|
<PublicFormAnswers volunteer={selected} patch={patch} />
|
||||||
|
|
||||||
<AvailabilityEditor volunteerId={selected.id} />
|
<AvailabilityEditor volunteerId={selected.id} />
|
||||||
|
|||||||
Reference in New Issue
Block a user