diff --git a/backend/app/api/editions.py b/backend/app/api/editions.py new file mode 100644 index 0000000..9ae8953 --- /dev/null +++ b/backend/app/api/editions.py @@ -0,0 +1,39 @@ +"""Lecture et mise à jour d'une édition (dates, nom, rétention).""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlmodel import Session + +from app.api.deps import get_edition +from app.api.schemas import EditionRead, EditionUpdate +from app.auth.deps import current_account_in_edition, require_coordinator +from app.db import get_session +from app.models import Account, Edition + +router = APIRouter(prefix="/editions", tags=["editions"]) + + +@router.get("/{edition_id}", response_model=EditionRead) +def read_edition( + edition: Edition = Depends(get_edition), + _account: Account = Depends(current_account_in_edition), +) -> Edition: + return edition + + +@router.patch("/{edition_id}", response_model=EditionRead) +def update_edition( + payload: EditionUpdate, + edition: Edition = Depends(get_edition), + _guard: Account = Depends(require_coordinator), + session: Session = Depends(get_session), +) -> Edition: + for field, value in payload.model_dump(exclude_unset=True).items(): + setattr(edition, field, value) + if edition.end_date < edition.start_date: + raise HTTPException( + status_code=422, detail={"code": "edition.dates_inconsistent"} + ) + session.add(edition) + session.commit() + session.refresh(edition) + return edition diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 926f19b..723e7e7 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -19,6 +19,28 @@ class ApiModel(BaseModel): model_config = ConfigDict(from_attributes=True) + +# --------------------------------------------------------------------------- # +# Edition # +# --------------------------------------------------------------------------- # +class EditionRead(ApiModel): + id: uuid.UUID + year: int + name: str | None + start_date: dt.date + end_date: dt.date + retention_days: int | None + created_at: dt.datetime + updated_at: dt.datetime + + +class EditionUpdate(ApiModel): + name: str | None = None + year: int | None = None + start_date: dt.date | None = None + end_date: dt.date | None = None + retention_days: int | None = None + # --------------------------------------------------------------------------- # # Volunteer # # --------------------------------------------------------------------------- # diff --git a/backend/app/i18n/locales/fr.json b/backend/app/i18n/locales/fr.json index faf27e0..13244c2 100644 --- a/backend/app/i18n/locales/fr.json +++ b/backend/app/i18n/locales/fr.json @@ -5,6 +5,7 @@ "error.unauthorized": "Authentification requise", "error.validation_failed": "La validation a échoué", "error.conflict": "Conflit avec une ressource existante.", + "edition.dates_inconsistent": "La date de fin doit être postérieure ou égale à la date de début.", "shift.ends_not_after_starts": "La fin du créneau doit être postérieure à son début.", "shift.position_wrong_edition": "Le poste n'appartient pas à cette édition.", "volunteer.duplicate_email": "Un bénévole avec cet email existe déjà dans l'édition.", diff --git a/backend/app/main.py b/backend/app/main.py index 2270b44..aa5bde1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.accounts import router as account_router from app.api.assignments import router as assignment_router from app.api.auth import router as auth_router +from app.api.editions import router as edition_router from app.api.health import router as health_router from app.api.imports import router as import_router from app.api.matrix import router as matrix_router @@ -30,6 +31,7 @@ app.add_middleware( app.include_router(health_router) app.include_router(auth_router) +app.include_router(edition_router) app.include_router(account_router) app.include_router(import_router) app.include_router(volunteer_router) diff --git a/backend/tests/test_api_editions.py b/backend/tests/test_api_editions.py new file mode 100644 index 0000000..589c587 --- /dev/null +++ b/backend/tests/test_api_editions.py @@ -0,0 +1,57 @@ +import datetime as dt +import uuid + +from app.models import Role + + +def _url(edition): + return f"/editions/{edition.id}" + + +def test_read_edition_exposes_the_date_range(client, edition): + body = client.get(_url(edition)).json() + assert body["start_date"] == "2026-07-09" + assert body["end_date"] == "2026-07-12" + assert body["year"] == 2026 + + +def test_coordinator_can_extend_the_edition(client, edition): + resp = client.patch(_url(edition), json={"end_date": "2026-07-15"}) + assert resp.status_code == 200 + assert resp.json()["end_date"] == "2026-07-15" + assert client.get(_url(edition)).json()["end_date"] == "2026-07-15" + + +def test_end_before_start_is_rejected(client, edition): + resp = client.patch(_url(edition), json={"end_date": "2026-07-01"}) + assert resp.status_code == 422 + assert resp.json()["detail"]["code"] == "edition.dates_inconsistent" + + +def test_lead_cannot_update_the_edition(edition, make_account, as_account): + lead = make_account(Role.LEAD, email="lead@e.fr") + resp = as_account(lead).patch(_url(edition), json={"name": "Renommée"}) + assert resp.status_code == 403 + + +def test_volunteer_can_read_but_not_update(edition, session, make_account, as_account): + from tests.factories import make_volunteer + + volunteer = make_volunteer(session, edition, email="v@e.fr") + account = make_account(Role.VOLUNTEER, volunteer_id=volunteer.id, email="v@e.fr") + client = as_account(account) + assert client.get(_url(edition)).status_code == 200 + assert client.patch(_url(edition), json={"name": "X"}).status_code == 403 + + +def test_unknown_edition_is_404(client): + assert client.get(f"/editions/{uuid.uuid4()}").status_code == 404 + + +def test_update_keeps_unchanged_fields(client, edition): + resp = client.patch(_url(edition), json={"name": "Festival 2026"}) + assert resp.status_code == 200 + body = resp.json() + assert body["name"] == "Festival 2026" + assert body["start_date"] == "2026-07-09" + assert dt.date.fromisoformat(body["end_date"]) == dt.date(2026, 7, 12) diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index a28d4a1..1b0a02b 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -10,6 +10,7 @@ import type { AssignmentCreate, AssignmentUpdate, AssignmentValidateResult, + EditionUpdate, PlanningNotificationRequest, PositionCreate, PositionUpdate, @@ -22,6 +23,7 @@ import type { const edition = EDITION_ID; export const queryKeys = { + edition: ['edition', edition] as const, volunteers: ['volunteers', edition] as const, positions: ['positions', edition] as const, shifts: (positionId?: string) => ['shifts', edition, positionId ?? null] as const, @@ -35,6 +37,10 @@ export const queryKeys = { // --------------------------------------------------------------------------- // // Lectures // // --------------------------------------------------------------------------- // +export function useEdition() { + return useQuery({ queryKey: queryKeys.edition, queryFn: () => api.editions.get(edition) }); +} + export function useVolunteers(options?: { enabled?: boolean }) { return useQuery({ queryKey: queryKeys.volunteers, @@ -108,6 +114,14 @@ function useInvalidate() { }; } +export function useUpdateEdition() { + const client = useQueryClient(); + return useMutation({ + mutationFn: (body: EditionUpdate) => api.editions.update(edition, body), + onSuccess: () => client.invalidateQueries({ queryKey: ['edition', edition] }), + }); +} + export function useCreateVolunteer() { const invalidate = useInvalidate(); return useMutation({ diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 1d99113..05b74e4 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -2,6 +2,7 @@ import type { AssignmentCreate, AssignmentUpdate, AssignmentValidateRequest, + EditionUpdate, LoginRequest, MagicLinkRequest, PlanningNotificationRequest, @@ -66,6 +67,11 @@ export function createHttpApi(baseUrl: string): PlanningApi { const base = (editionId: string) => `/editions/${editionId}`; return { + editions: { + get: (editionId) => request(base(editionId)), + update: (editionId, body: EditionUpdate) => + request(base(editionId), { method: 'PATCH', body: JSON.stringify(body) }), + }, volunteers: { list: (editionId) => request(`${base(editionId)}/volunteers`), get: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`), diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index 0ef0c13..43d52cd 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -3,6 +3,7 @@ import { hoursBetween } from '@/lib/time'; import type { AccountRead, AssignmentRead, + EditionRead, PositionCategory, PositionRead, ShiftRead, @@ -19,6 +20,7 @@ export const DEMO_COORDINATOR_EMAIL = 'coordination@lessonsdautomne.fr'; export const DEMO_COORDINATOR_PASSWORD = 'demo'; export interface MockState { + edition: EditionRead; volunteers: VolunteerRead[]; positions: PositionRead[]; shifts: ShiftRead[]; @@ -325,7 +327,28 @@ export function buildMockState(): MockState { }); }); - return { volunteers, positions, shifts, assignments, accounts, passwords, availability, seq }; + const edition: EditionRead = { + id: edition_id, + year: 2026, + name: "Les Sons d'Automne", + start_date: DAY_DATES[0], + end_date: DAY_DATES[DAY_DATES.length - 1], + retention_days: 365, + created_at: NOW, + updated_at: NOW, + }; + + return { + edition, + volunteers, + positions, + shifts, + assignments, + accounts, + passwords, + availability, + seq, + }; } function shiftsOverlap(a: ShiftRead | undefined, b: ShiftRead): boolean { diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index cfdf76c..02e7eb9 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -4,6 +4,7 @@ import type { AccountRead, AssignmentRead, AssignmentValidateResult, + EditionUpdate, MatrixResponse, PlanningNotificationRequest, ShiftRead, @@ -210,6 +211,23 @@ function buildMatrix(): MatrixResponse { export function createMockApi(): PlanningApi { return { + editions: { + get: () => delay(state.edition), + update: (_edition, body: EditionUpdate) => { + const next = { ...state.edition }; + if ('name' in body) next.name = body.name ?? null; + if (body.year != null) next.year = body.year; + if (body.start_date) next.start_date = body.start_date; + if (body.end_date) next.end_date = body.end_date; + if ('retention_days' in body) next.retention_days = body.retention_days ?? null; + if (next.end_date < next.start_date) { + throw new ApiError(422, 'edition.dates_inconsistent'); + } + next.updated_at = new Date().toISOString(); + state.edition = next; + return delay(state.edition); + }, + }, volunteers: { list: () => delay( diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index cbe7453..29320aa 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -35,6 +35,29 @@ export interface Violation { params: Record; } +// --------------------------------------------------------------------------- // +// Edition // +// --------------------------------------------------------------------------- // +export interface EditionRead { + id: string; + year: number; + name: string | null; + /** Dates ISO (YYYY-MM-DD). */ + start_date: string; + end_date: string; + retention_days: number | null; + created_at: string; + updated_at: string; +} + +export interface EditionUpdate { + name?: string | null; + year?: number; + start_date?: string; + end_date?: string; + retention_days?: number | null; +} + // --------------------------------------------------------------------------- // // Volunteer // // --------------------------------------------------------------------------- // diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 39aba5c..82e0d4b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -6,6 +6,8 @@ import type { AssignmentValidateRequest, AssignmentValidateResult, AssignmentWriteResult, + EditionRead, + EditionUpdate, LoginRequest, MagicLinkRequest, MatrixResponse, @@ -30,6 +32,10 @@ import type { * réel) et mémoire (jeu de démonstration). Les écrans ne dépendent que d'ici. */ export interface PlanningApi { + editions: { + get(editionId: string): Promise; + update(editionId: string, body: EditionUpdate): Promise; + }; volunteers: { list(editionId: string): Promise; get(editionId: string, id: string): Promise; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index b354a62..eb82266 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -164,7 +164,10 @@ "config.grid.min": "MIN", "config.grid.ideal": "IDÉAL", "config.grid.noShifts": "Aucun créneau sur ce poste. Ajoutez-en un pour qu'il apparaisse dans la grille.", - "config.grid.note": "Les modifications d'effectif (min / idéal) et les créneaux sont enregistrés immédiatement via l'API et se répercutent sur la grille des besoins (onglet 01).", + "config.grid.emptyDay": "Aucun créneau ce jour.", + "config.grid.deletePosition": "SUPPRIMER LE POSTE", + "config.grid.deletePositionConfirm": "Supprimer ce poste et tous ses créneaux ?", + "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.vol.importTitle": "IMPORTER DES INSCRIPTIONS", "config.vol.importHint": "L'import de fichier CSV / Excel se fait via l'endpoint dédié. Ce bouton crée 4 bénévoles d'exemple via l'API.", @@ -202,13 +205,18 @@ "config.mail.previewTitle": "APERÇU", "config.mail.previewSender": "DE : expéditeur configuré côté serveur", - "config.rules.editionTitle": "ÉDITION & CONNEXION", - "config.rules.editionId": "Identifiant d'édition", + "config.rules.editionTitle": "ÉDITION", + "config.rules.editionName": "Nom", + "config.rules.editionStart": "Premier jour", + "config.rules.editionEnd": "Dernier jour", + "config.rules.editionDays": "{count} jour(s)", + "config.rules.editionSave": "ENREGISTRER L'ÉDITION", + "config.rules.editionSaved": "Édition enregistrée.", "config.rules.apiMode": "Mode API", "config.rules.volunteersCount": "Bénévoles", "config.rules.positionsCount": "Postes", "config.rules.shiftsCount": "Créneaux", - "config.rules.editionNote": "Il n'y a pas encore d'endpoint de configuration d'édition : nom, dates et rétention se règlent en base. En mode HTTP, VITE_EDITION_ID désigne l'édition.", + "edition.dates_inconsistent": "La date de fin doit être postérieure ou égale à la date de début.", "config.rules.thresholdsTitle": "MOTEUR DE VALIDATION", "config.rules.maxDaily": "Maximum d'heures par journée", "config.rules.minRest": "Repos minimum entre deux créneaux", diff --git a/frontend/src/pages/config/ConfigGrid.tsx b/frontend/src/pages/config/ConfigGrid.tsx index 079ca2e..3edd66b 100644 --- a/frontend/src/pages/config/ConfigGrid.tsx +++ b/frontend/src/pages/config/ConfigGrid.tsx @@ -3,19 +3,47 @@ import { useMemo, useState } from 'react'; import { useCreatePosition, useCreateShift, + useDeletePosition, useDeleteShift, + useEdition, usePositions, useShifts, + useUpdatePosition, useUpdateShift, } from '@/api/hooks'; -import type { ShiftRead } from '@/api/schema'; +import type { PositionCategory, PositionRead, ShiftRead } from '@/api/schema'; import { ErrorNote, Loading } from '@/components/state'; +import { Btn } from '@/components/ui'; import { categoryLabel } from '@/lib/planning'; -import { datePart, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time'; +import { hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time'; import { t } from '@/i18n'; import { cx } from '@/lib/cx'; +const CATEGORIES: PositionCategory[] = [ + 'BAR', + 'TICKETING', + 'WELCOME', + 'PARKING', + 'CLEANING', + 'CATERING', + 'SETUP', + 'STAGE', + 'OTHER', +]; + +function enumerateDays(start: string, end: string): string[] { + const out: string[] = []; + const cursor = new Date(`${start}T00:00:00Z`); + const last = new Date(`${end}T00:00:00Z`); + while (cursor <= last) { + out.push(cursor.toISOString().slice(0, 10)); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return out; +} + export function ConfigGrid() { + const editionQuery = useEdition(); const positions = usePositions(); const allShifts = useShifts(); const createPosition = useCreatePosition(); @@ -39,23 +67,31 @@ export function ConfigGrid() { return { min: Math.round(min), ideal: Math.round(ideal), count: (allShifts.data ?? []).length }; }, [allShifts.data]); - if (positions.isLoading) return ; + const addPosition = () => + createPosition.mutate( + { name: t('config.grid.newPositionName'), category: 'OTHER' }, + { onSuccess: (created) => setSelectedId(created.id) }, + ); + + if (positions.isLoading || editionQuery.isLoading) return ; if (positions.error) return ; + if (editionQuery.error) return ; + if (!position) { return ( -
- +
+ + {createPosition.isPending ? t('app.loading') : t('config.grid.addPosition')} + + {createPosition.isError ? : null}
); } - const byDay = groupByDay(positionShifts.data ?? []); + const shiftsByDay = groupByDay(positionShifts.data ?? []); + const days = editionQuery.data + ? enumerateDays(editionQuery.data.start_date, editionQuery.data.end_date) + : [...shiftsByDay.keys()]; return (
@@ -87,99 +123,97 @@ export function ConfigGrid() { })} + {createPosition.isError ? : null}
-
-
-
{position.name}
-
- {categoryLabel(position.category)} - {position.location ? ` · ${position.location}` : ''} -
-
-
- - -
-
+ {positionShifts.isLoading ? : null} + {createShift.isError ? : null} + {updateShift.isError ? : null}
- {byDay.map(([day, shifts]) => ( -
-
- {longDayLabel(`${day}T12:00`)} -
- {shifts.map((shift) => ( -
- - {hourRangeLabel(shift.starts_at, shift.ends_at)} - - - updateShift.mutate({ - id: shift.id, - body: { min_staff: Math.max(0, shift.min_staff + delta) }, - }) - } - /> - - updateShift.mutate({ - id: shift.id, - body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) }, - }) - } - /> - + {days.map((day) => { + const shifts = shiftsByDay.get(day) ?? []; + return ( +
+
+ {longDayLabel(`${day}T12:00`)}
- ))} - -
- ))} - {byDay.length === 0 ? ( -
- {t('config.grid.noShifts')} -
- ) : null} + {shifts.map((shift) => ( +
+ + {hourRangeLabel(shift.starts_at, shift.ends_at)} + + + updateShift.mutate({ + id: shift.id, + body: { min_staff: Math.max(0, shift.min_staff + delta) }, + }) + } + /> + + updateShift.mutate({ + id: shift.id, + body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) }, + }) + } + /> + +
+ ))} + {shifts.length === 0 ? ( +
+ {t('config.grid.emptyDay')} +
+ ) : null} + +
+ ); + })}
@@ -190,13 +224,80 @@ export function ConfigGrid() { ); } -function groupByDay(shifts: ShiftRead[]): Array<[string, ShiftRead[]]> { +function PositionHeader({ + position, + totals, +}: { + position: PositionRead; + totals: { min: number; ideal: number }; +}) { + const updatePosition = useUpdatePosition(); + const deletePosition = useDeletePosition(); + const [name, setName] = useState(position.name); + + const commitName = () => { + const trimmed = name.trim(); + if (trimmed && trimmed !== position.name) { + updatePosition.mutate({ id: position.id, body: { name: trimmed } }); + } + }; + + return ( +
+
+ setName(e.target.value)} + onBlur={commitName} + onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} + className="border-2 border-ink bg-yellow px-2 py-1 font-display text-[22px] font-black tracking-tight" + /> +
+ + +
+ {updatePosition.isError ? : null} +
+
+ + +
+
+ ); +} + +function groupByDay(shifts: ShiftRead[]): Map { const map = new Map(); for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) { - const day = datePart(s.starts_at); + const day = s.starts_at.slice(0, 10); map.set(day, [...(map.get(day) ?? []), s]); } - return [...map.entries()]; + return map; } function MiniStat({ value, label, accent = false }: { value: string; label: string; accent?: boolean }) { diff --git a/frontend/src/pages/config/ConfigRules.tsx b/frontend/src/pages/config/ConfigRules.tsx index f2b0bff..0133d0b 100644 --- a/frontend/src/pages/config/ConfigRules.tsx +++ b/frontend/src/pages/config/ConfigRules.tsx @@ -1,6 +1,15 @@ -import { API_MODE, EDITION_ID } from '@/api'; -import { usePositions, useShifts, useVolunteers } from '@/api/hooks'; -import { Panel, PanelHeader } from '@/components/ui'; +import { useEffect, useState } from 'react'; + +import { API_MODE } from '@/api'; +import { + useEdition, + usePositions, + useShifts, + useUpdateEdition, + useVolunteers, +} from '@/api/hooks'; +import { ErrorNote } from '@/components/state'; +import { Btn, Panel, PanelHeader } from '@/components/ui'; import { t } from '@/i18n'; const RULE_CODES = [ @@ -11,8 +20,8 @@ const RULE_CODES = [ 'MISSING_REQUIRED_SKILL', ] as const; -// Écran informatif : les seuils du moteur sont figés (CLAUDE.md §3) et il n'y a -// pas encore d'endpoint de configuration d'édition. +// Les seuils du moteur sont figés (CLAUDE.md §3). L'édition (nom, dates) est +// éditable — c'est là qu'on ajoute ou retire des jours. export function ConfigRules() { const volunteers = useVolunteers(); const positions = usePositions(); @@ -23,13 +32,12 @@ export function ConfigRules() { {t('config.rules.editionTitle')}
- - - - - -
- {t('config.rules.editionNote')} + +
+ + + +
@@ -70,6 +78,80 @@ export function ConfigRules() { ); } +function EditionForm() { + const edition = useEdition(); + const update = useUpdateEdition(); + const [name, setName] = useState(''); + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + + useEffect(() => { + if (!edition.data) return; + setName(edition.data.name ?? ''); + setStart(edition.data.start_date); + setEnd(edition.data.end_date); + }, [edition.data]); + + if (edition.isLoading) return
…
; + if (edition.error) return ; + + const dirty = + edition.data != null && + (name !== (edition.data.name ?? '') || + start !== edition.data.start_date || + end !== edition.data.end_date); + const dayCount = + start && end + ? Math.round((Date.parse(`${end}T00:00Z`) - Date.parse(`${start}T00:00Z`)) / 86400000) + 1 + : 0; + + return ( +
+ + + +
+ {t('config.rules.editionDays', { count: dayCount })} +
+ + update.mutate({ name: name.trim() || null, start_date: start, end_date: end }) + } + > + {update.isPending ? t('app.loading') : t('config.rules.editionSave')} + + {update.isError ? : null} + {update.isSuccess && !dirty ? ( +
{t('config.rules.editionSaved')}
+ ) : null} +
+ ); +} + function InfoRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { return (