Édition éditable (dates) + robustesse de l'écran Postes & créneaux

Back-end :
- GET /editions/{id} (tout compte de l'édition) et PATCH /editions/{id}
  (COORDINATION) : name, year, start_date, end_date, retention_days.
  Refus si end_date < start_date (edition.dates_inconsistent). Tests.

Front-end :
- api : EditionRead/Update, editions.get/update (http + mock), hooks
  useEdition / useUpdateEdition.
- ConfigGrid : le bouton « ajouter un poste » sélectionne le poste créé et
  affiche l'état (pending) + les erreurs (ErrorNote) — avant, un échec ou
  même un succès passaient inaperçus. Renommage du poste + choix de la
  catégorie + suppression dans l'en-tête. Une colonne par jour de l'édition
  (même vide) → on peut ajouter un créneau n'importe quel jour.
- ConfigRules : formulaire d'édition (nom + dates) → c'est là qu'on ajoute
  ou retire des jours ; compteur de jours ; retour d'enregistrement.
- i18n mis à jour.

144 tests back OK ; typecheck + lint + build + smoke front OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-10 20:02:50 +02:00
co-authored by Claude Sonnet 5
parent 9521034207
commit e0a0c56307
14 changed files with 517 additions and 115 deletions
+39
View File
@@ -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
+22
View File
@@ -19,6 +19,28 @@ class ApiModel(BaseModel):
model_config = ConfigDict(from_attributes=True) 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 # # Volunteer #
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
+1
View File
@@ -5,6 +5,7 @@
"error.unauthorized": "Authentification requise", "error.unauthorized": "Authentification requise",
"error.validation_failed": "La validation a échoué", "error.validation_failed": "La validation a échoué",
"error.conflict": "Conflit avec une ressource existante.", "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.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.", "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.", "volunteer.duplicate_email": "Un bénévole avec cet email existe déjà dans l'édition.",
+2
View File
@@ -4,6 +4,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.api.accounts import router as account_router from app.api.accounts import router as account_router
from app.api.assignments import router as assignment_router from app.api.assignments import router as assignment_router
from app.api.auth import router as auth_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.health import router as health_router
from app.api.imports import router as import_router from app.api.imports import router as import_router
from app.api.matrix import router as matrix_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(health_router)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(edition_router)
app.include_router(account_router) app.include_router(account_router)
app.include_router(import_router) app.include_router(import_router)
app.include_router(volunteer_router) app.include_router(volunteer_router)
+57
View File
@@ -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)
+14
View File
@@ -10,6 +10,7 @@ import type {
AssignmentCreate, AssignmentCreate,
AssignmentUpdate, AssignmentUpdate,
AssignmentValidateResult, AssignmentValidateResult,
EditionUpdate,
PlanningNotificationRequest, PlanningNotificationRequest,
PositionCreate, PositionCreate,
PositionUpdate, PositionUpdate,
@@ -22,6 +23,7 @@ import type {
const edition = EDITION_ID; const edition = EDITION_ID;
export const queryKeys = { export const queryKeys = {
edition: ['edition', edition] as const,
volunteers: ['volunteers', edition] as const, volunteers: ['volunteers', edition] as const,
positions: ['positions', edition] as const, positions: ['positions', edition] as const,
shifts: (positionId?: string) => ['shifts', edition, positionId ?? null] as const, shifts: (positionId?: string) => ['shifts', edition, positionId ?? null] as const,
@@ -35,6 +37,10 @@ export const queryKeys = {
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
// Lectures // // Lectures //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
export function useEdition() {
return useQuery({ queryKey: queryKeys.edition, queryFn: () => api.editions.get(edition) });
}
export function useVolunteers(options?: { enabled?: boolean }) { export function useVolunteers(options?: { enabled?: boolean }) {
return useQuery({ return useQuery({
queryKey: queryKeys.volunteers, 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() { export function useCreateVolunteer() {
const invalidate = useInvalidate(); const invalidate = useInvalidate();
return useMutation({ return useMutation({
+6
View File
@@ -2,6 +2,7 @@ import type {
AssignmentCreate, AssignmentCreate,
AssignmentUpdate, AssignmentUpdate,
AssignmentValidateRequest, AssignmentValidateRequest,
EditionUpdate,
LoginRequest, LoginRequest,
MagicLinkRequest, MagicLinkRequest,
PlanningNotificationRequest, PlanningNotificationRequest,
@@ -66,6 +67,11 @@ export function createHttpApi(baseUrl: string): PlanningApi {
const base = (editionId: string) => `/editions/${editionId}`; const base = (editionId: string) => `/editions/${editionId}`;
return { return {
editions: {
get: (editionId) => request(base(editionId)),
update: (editionId, body: EditionUpdate) =>
request(base(editionId), { method: 'PATCH', body: JSON.stringify(body) }),
},
volunteers: { volunteers: {
list: (editionId) => request(`${base(editionId)}/volunteers`), list: (editionId) => request(`${base(editionId)}/volunteers`),
get: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`), get: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`),
+24 -1
View File
@@ -3,6 +3,7 @@ import { hoursBetween } from '@/lib/time';
import type { import type {
AccountRead, AccountRead,
AssignmentRead, AssignmentRead,
EditionRead,
PositionCategory, PositionCategory,
PositionRead, PositionRead,
ShiftRead, ShiftRead,
@@ -19,6 +20,7 @@ export const DEMO_COORDINATOR_EMAIL = 'coordination@lessonsdautomne.fr';
export const DEMO_COORDINATOR_PASSWORD = 'demo'; export const DEMO_COORDINATOR_PASSWORD = 'demo';
export interface MockState { export interface MockState {
edition: EditionRead;
volunteers: VolunteerRead[]; volunteers: VolunteerRead[];
positions: PositionRead[]; positions: PositionRead[];
shifts: ShiftRead[]; 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 { function shiftsOverlap(a: ShiftRead | undefined, b: ShiftRead): boolean {
+18
View File
@@ -4,6 +4,7 @@ import type {
AccountRead, AccountRead,
AssignmentRead, AssignmentRead,
AssignmentValidateResult, AssignmentValidateResult,
EditionUpdate,
MatrixResponse, MatrixResponse,
PlanningNotificationRequest, PlanningNotificationRequest,
ShiftRead, ShiftRead,
@@ -210,6 +211,23 @@ function buildMatrix(): MatrixResponse {
export function createMockApi(): PlanningApi { export function createMockApi(): PlanningApi {
return { 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: { volunteers: {
list: () => list: () =>
delay( delay(
+23
View File
@@ -35,6 +35,29 @@ export interface Violation {
params: Record<string, string | number>; params: Record<string, string | number>;
} }
// --------------------------------------------------------------------------- //
// 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 // // Volunteer //
// --------------------------------------------------------------------------- // // --------------------------------------------------------------------------- //
+6
View File
@@ -6,6 +6,8 @@ import type {
AssignmentValidateRequest, AssignmentValidateRequest,
AssignmentValidateResult, AssignmentValidateResult,
AssignmentWriteResult, AssignmentWriteResult,
EditionRead,
EditionUpdate,
LoginRequest, LoginRequest,
MagicLinkRequest, MagicLinkRequest,
MatrixResponse, MatrixResponse,
@@ -30,6 +32,10 @@ import type {
* réel) et mémoire (jeu de démonstration). Les écrans ne dépendent que d'ici. * réel) et mémoire (jeu de démonstration). Les écrans ne dépendent que d'ici.
*/ */
export interface PlanningApi { export interface PlanningApi {
editions: {
get(editionId: string): Promise<EditionRead>;
update(editionId: string, body: EditionUpdate): Promise<EditionRead>;
};
volunteers: { volunteers: {
list(editionId: string): Promise<VolunteerRead[]>; list(editionId: string): Promise<VolunteerRead[]>;
get(editionId: string, id: string): Promise<VolunteerRead>; get(editionId: string, id: string): Promise<VolunteerRead>;
+12 -4
View File
@@ -164,7 +164,10 @@
"config.grid.min": "MIN", "config.grid.min": "MIN",
"config.grid.ideal": "IDÉAL", "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.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.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.", "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.previewTitle": "APERÇU",
"config.mail.previewSender": "DE : expéditeur configuré côté serveur", "config.mail.previewSender": "DE : expéditeur configuré côté serveur",
"config.rules.editionTitle": "ÉDITION & CONNEXION", "config.rules.editionTitle": "ÉDITION",
"config.rules.editionId": "Identifiant d'é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.apiMode": "Mode API",
"config.rules.volunteersCount": "Bénévoles", "config.rules.volunteersCount": "Bénévoles",
"config.rules.positionsCount": "Postes", "config.rules.positionsCount": "Postes",
"config.rules.shiftsCount": "Créneaux", "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.thresholdsTitle": "MOTEUR DE VALIDATION",
"config.rules.maxDaily": "Maximum d'heures par journée", "config.rules.maxDaily": "Maximum d'heures par journée",
"config.rules.minRest": "Repos minimum entre deux créneaux", "config.rules.minRest": "Repos minimum entre deux créneaux",
+147 -46
View File
@@ -3,19 +3,47 @@ import { useMemo, useState } from 'react';
import { import {
useCreatePosition, useCreatePosition,
useCreateShift, useCreateShift,
useDeletePosition,
useDeleteShift, useDeleteShift,
useEdition,
usePositions, usePositions,
useShifts, useShifts,
useUpdatePosition,
useUpdateShift, useUpdateShift,
} from '@/api/hooks'; } from '@/api/hooks';
import type { 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 } from '@/components/ui';
import { categoryLabel } from '@/lib/planning'; 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 { t } from '@/i18n';
import { cx } from '@/lib/cx'; 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() { export function ConfigGrid() {
const editionQuery = useEdition();
const positions = usePositions(); const positions = usePositions();
const allShifts = useShifts(); const allShifts = useShifts();
const createPosition = useCreatePosition(); const createPosition = useCreatePosition();
@@ -39,23 +67,31 @@ export function ConfigGrid() {
return { min: Math.round(min), ideal: Math.round(ideal), count: (allShifts.data ?? []).length }; return { min: Math.round(min), ideal: Math.round(ideal), count: (allShifts.data ?? []).length };
}, [allShifts.data]); }, [allShifts.data]);
if (positions.isLoading) return <Loading />; const addPosition = () =>
createPosition.mutate(
{ name: t('config.grid.newPositionName'), category: 'OTHER' },
{ onSuccess: (created) => setSelectedId(created.id) },
);
if (positions.isLoading || editionQuery.isLoading) return <Loading />;
if (positions.error) return <ErrorNote error={positions.error} />; if (positions.error) return <ErrorNote error={positions.error} />;
if (editionQuery.error) return <ErrorNote error={editionQuery.error} />;
if (!position) { if (!position) {
return ( return (
<div className="p-6"> <div className="flex flex-col gap-3 p-6">
<button <Btn disabled={createPosition.isPending} onClick={addPosition}>
type="button" {createPosition.isPending ? t('app.loading') : t('config.grid.addPosition')}
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })} </Btn>
className="cursor-pointer border-[3px] border-ink bg-yellow px-4 py-3 font-display text-[12px] font-extrabold uppercase shadow-brutal" {createPosition.isError ? <ErrorNote error={createPosition.error} /> : null}
>
{t('config.grid.addPosition')}
</button>
</div> </div>
); );
} }
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 ( return (
<div className="flex min-h-0 flex-1 items-stretch"> <div className="flex min-h-0 flex-1 items-stretch">
@@ -87,32 +123,30 @@ export function ConfigGrid() {
})} })}
<button <button
type="button" type="button"
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })} disabled={createPosition.isPending}
className="w-full cursor-pointer border-t-[3px] border-ink bg-cream p-3 font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-yellow" onClick={addPosition}
className="w-full cursor-pointer border-t-[3px] border-ink bg-cream p-3 font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:opacity-50"
> >
{t('config.grid.addPosition')} {createPosition.isPending ? t('app.loading') : t('config.grid.addPosition')}
</button> </button>
{createPosition.isError ? <ErrorNote error={createPosition.error} /> : null}
</div> </div>
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4"> <div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]"> <PositionHeader
<div> key={position.id}
<div className="font-display text-[24px] font-black tracking-tight">{position.name}</div> position={position}
<div className="font-mono text-[10.5px] opacity-65"> totals={totals}
{categoryLabel(position.category)} />
{position.location ? ` · ${position.location}` : ''}
</div>
</div>
<div className="ml-auto flex gap-2">
<MiniStat value={`${totals.min} h`} label={t('config.grid.minTotal')} />
<MiniStat value={`${totals.ideal} h`} label={t('config.grid.idealTotal')} accent />
</div>
</div>
{positionShifts.isLoading ? <Loading /> : null} {positionShifts.isLoading ? <Loading /> : null}
{createShift.isError ? <ErrorNote error={createShift.error} /> : null}
{updateShift.isError ? <ErrorNote error={updateShift.error} /> : null}
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]"> <div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]">
{byDay.map(([day, shifts]) => ( {days.map((day) => {
const shifts = shiftsByDay.get(day) ?? [];
return (
<div key={day} className="border-[3px] border-ink bg-paper shadow-brutal-lg"> <div key={day} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream"> <div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
{longDayLabel(`${day}T12:00`)} {longDayLabel(`${day}T12:00`)}
@@ -156,30 +190,30 @@ export function ConfigGrid() {
</button> </button>
</div> </div>
))} ))}
{shifts.length === 0 ? (
<div className="px-[9px] py-[10px] font-mono text-[10px] opacity-55">
{t('config.grid.emptyDay')}
</div>
) : null}
<button <button
type="button" type="button"
onClick={() => { disabled={createShift.isPending}
const start = `${day}T12:00:00`; onClick={() =>
const end = `${day}T17:00:00`;
createShift.mutate({ createShift.mutate({
position_id: position.id, position_id: position.id,
starts_at: start, starts_at: `${day}T12:00:00`,
ends_at: end, ends_at: `${day}T17:00:00`,
min_staff: 2, min_staff: 2,
ideal_staff: 3, ideal_staff: 3,
}); })
}} }
className="w-full cursor-pointer bg-cream p-2 font-display text-[10px] font-extrabold uppercase tracking-wide hover:bg-yellow" className="w-full cursor-pointer bg-cream p-2 font-display text-[10px] font-extrabold uppercase tracking-wide hover:bg-yellow disabled:opacity-50"
> >
{t('config.grid.addShift')} {t('config.grid.addShift')}
</button> </button>
</div> </div>
))} );
{byDay.length === 0 ? ( })}
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[10.5px] leading-relaxed opacity-70">
{t('config.grid.noShifts')}
</div>
) : null}
</div> </div>
<div className="mt-[14px] max-w-[760px] border-[3px] border-dashed border-ink p-[11px] font-mono text-[10.5px] leading-relaxed opacity-75"> <div className="mt-[14px] max-w-[760px] border-[3px] border-dashed border-ink p-[11px] font-mono text-[10.5px] leading-relaxed opacity-75">
@@ -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 (
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
<div className="flex flex-col gap-1">
<input
value={name}
onChange={(e) => 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"
/>
<div className="flex items-center gap-2">
<select
value={position.category}
onChange={(e) =>
updatePosition.mutate({
id: position.id,
body: { category: e.target.value as PositionCategory },
})
}
className="border-2 border-ink bg-paper px-2 py-1 font-mono text-[11px]"
>
{CATEGORIES.map((code) => (
<option key={code} value={code}>
{categoryLabel(code)}
</option>
))}
</select>
<button
type="button"
onClick={() => {
if (window.confirm(t('config.grid.deletePositionConfirm'))) {
deletePosition.mutate(position.id);
}
}}
className="cursor-pointer border-2 border-ink bg-cream px-2 py-1 font-display text-[10px] font-extrabold uppercase hover:bg-orange"
>
{t('config.grid.deletePosition')}
</button>
</div>
{updatePosition.isError ? <ErrorNote error={updatePosition.error} /> : null}
</div>
<div className="ml-auto flex gap-2">
<MiniStat value={`${totals.min} h`} label={t('config.grid.minTotal')} />
<MiniStat value={`${totals.ideal} h`} label={t('config.grid.idealTotal')} accent />
</div>
</div>
);
}
function groupByDay(shifts: ShiftRead[]): 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 = datePart(s.starts_at); const day = s.starts_at.slice(0, 10);
map.set(day, [...(map.get(day) ?? []), s]); map.set(day, [...(map.get(day) ?? []), s]);
} }
return [...map.entries()]; return map;
} }
function MiniStat({ value, label, accent = false }: { value: string; label: string; accent?: boolean }) { function MiniStat({ value, label, accent = false }: { value: string; label: string; accent?: boolean }) {
+90 -8
View File
@@ -1,6 +1,15 @@
import { API_MODE, EDITION_ID } from '@/api'; import { useEffect, useState } from 'react';
import { usePositions, useShifts, useVolunteers } from '@/api/hooks';
import { Panel, PanelHeader } from '@/components/ui'; 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'; import { t } from '@/i18n';
const RULE_CODES = [ const RULE_CODES = [
@@ -11,8 +20,8 @@ const RULE_CODES = [
'MISSING_REQUIRED_SKILL', 'MISSING_REQUIRED_SKILL',
] as const; ] as const;
// Écran informatif : les seuils du moteur sont figés (CLAUDE.md §3) et il n'y a // Les seuils du moteur sont figés (CLAUDE.md §3). L'édition (nom, dates) est
// pas encore d'endpoint de configuration d'édition. // éditable — c'est là qu'on ajoute ou retire des jours.
export function ConfigRules() { export function ConfigRules() {
const volunteers = useVolunteers(); const volunteers = useVolunteers();
const positions = usePositions(); const positions = usePositions();
@@ -23,13 +32,12 @@ export function ConfigRules() {
<Panel shadow="lg"> <Panel shadow="lg">
<PanelHeader>{t('config.rules.editionTitle')}</PanelHeader> <PanelHeader>{t('config.rules.editionTitle')}</PanelHeader>
<div className="flex flex-col gap-2 p-[13px]"> <div className="flex flex-col gap-2 p-[13px]">
<InfoRow label={t('config.rules.editionId')} value={EDITION_ID} mono /> <EditionForm />
<div className="mt-1 border-t-2 border-ink pt-2 flex flex-col gap-1">
<InfoRow label={t('config.rules.apiMode')} value={API_MODE.toUpperCase()} /> <InfoRow label={t('config.rules.apiMode')} value={API_MODE.toUpperCase()} />
<InfoRow label={t('config.rules.volunteersCount')} value={String(volunteers.data?.length ?? '…')} /> <InfoRow label={t('config.rules.volunteersCount')} value={String(volunteers.data?.length ?? '…')} />
<InfoRow label={t('config.rules.positionsCount')} value={String(positions.data?.length ?? '…')} /> <InfoRow label={t('config.rules.positionsCount')} value={String(positions.data?.length ?? '…')} />
<InfoRow label={t('config.rules.shiftsCount')} value={String(shifts.data?.length ?? '…')} /> <InfoRow label={t('config.rules.shiftsCount')} value={String(shifts.data?.length ?? '…')} />
<div className="mt-1 border-t-2 border-ink pt-2 font-mono text-[10px] leading-relaxed opacity-70">
{t('config.rules.editionNote')}
</div> </div>
</div> </div>
</Panel> </Panel>
@@ -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 <div className="font-mono text-[10px] opacity-60">…</div>;
if (edition.error) return <ErrorNote error={edition.error} />;
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 (
<div className="flex flex-col gap-2">
<label className="flex items-baseline justify-between gap-3">
<span className="font-display text-[11px] font-bold">{t('config.rules.editionName')}</span>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-[190px] border-2 border-ink bg-yellow px-2 py-1 font-display text-[12px] font-bold"
/>
</label>
<label className="flex items-baseline justify-between gap-3">
<span className="font-display text-[11px] font-bold">{t('config.rules.editionStart')}</span>
<input
type="date"
value={start}
onChange={(e) => setStart(e.target.value)}
className="border-2 border-ink bg-paper px-2 py-1 font-mono text-[11px]"
/>
</label>
<label className="flex items-baseline justify-between gap-3">
<span className="font-display text-[11px] font-bold">{t('config.rules.editionEnd')}</span>
<input
type="date"
value={end}
onChange={(e) => setEnd(e.target.value)}
className="border-2 border-ink bg-paper px-2 py-1 font-mono text-[11px]"
/>
</label>
<div className="font-mono text-[10px] opacity-65">
{t('config.rules.editionDays', { count: dayCount })}
</div>
<Btn
disabled={!dirty || update.isPending}
onClick={() =>
update.mutate({ name: name.trim() || null, start_date: start, end_date: end })
}
>
{update.isPending ? t('app.loading') : t('config.rules.editionSave')}
</Btn>
{update.isError ? <ErrorNote error={update.error} /> : null}
{update.isSuccess && !dirty ? (
<div className="font-mono text-[10px] text-green">{t('config.rules.editionSaved')}</div>
) : null}
</div>
);
}
function InfoRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) { function InfoRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
return ( return (
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">