É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:
co-authored by
Claude Sonnet 5
parent
9521034207
commit
e0a0c56307
@@ -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
|
||||
@@ -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 #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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({
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -35,6 +35,29 @@ export interface Violation {
|
||||
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 //
|
||||
// --------------------------------------------------------------------------- //
|
||||
|
||||
@@ -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<EditionRead>;
|
||||
update(editionId: string, body: EditionUpdate): Promise<EditionRead>;
|
||||
};
|
||||
volunteers: {
|
||||
list(editionId: string): Promise<VolunteerRead[]>;
|
||||
get(editionId: string, id: string): Promise<VolunteerRead>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <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 (editionQuery.error) return <ErrorNote error={editionQuery.error} />;
|
||||
|
||||
if (!position) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })}
|
||||
className="cursor-pointer border-[3px] border-ink bg-yellow px-4 py-3 font-display text-[12px] font-extrabold uppercase shadow-brutal"
|
||||
>
|
||||
{t('config.grid.addPosition')}
|
||||
</button>
|
||||
<div className="flex flex-col gap-3 p-6">
|
||||
<Btn disabled={createPosition.isPending} onClick={addPosition}>
|
||||
{createPosition.isPending ? t('app.loading') : t('config.grid.addPosition')}
|
||||
</Btn>
|
||||
{createPosition.isError ? <ErrorNote error={createPosition.error} /> : null}
|
||||
</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 (
|
||||
<div className="flex min-h-0 flex-1 items-stretch">
|
||||
@@ -87,99 +123,97 @@ export function ConfigGrid() {
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })}
|
||||
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={createPosition.isPending}
|
||||
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>
|
||||
{createPosition.isError ? <ErrorNote error={createPosition.error} /> : null}
|
||||
</div>
|
||||
|
||||
<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]">
|
||||
<div>
|
||||
<div className="font-display text-[24px] font-black tracking-tight">{position.name}</div>
|
||||
<div className="font-mono text-[10.5px] opacity-65">
|
||||
{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>
|
||||
<PositionHeader
|
||||
key={position.id}
|
||||
position={position}
|
||||
totals={totals}
|
||||
/>
|
||||
|
||||
{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))]">
|
||||
{byDay.map(([day, shifts]) => (
|
||||
<div key={day} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
|
||||
{longDayLabel(`${day}T12:00`)}
|
||||
</div>
|
||||
{shifts.map((shift) => (
|
||||
<div
|
||||
key={shift.id}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
|
||||
>
|
||||
<span className="w-[92px] flex-none font-mono text-[11px]">
|
||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
</span>
|
||||
<Stepper
|
||||
label={t('config.grid.min')}
|
||||
value={shift.min_staff}
|
||||
onChange={(delta) =>
|
||||
updateShift.mutate({
|
||||
id: shift.id,
|
||||
body: { min_staff: Math.max(0, shift.min_staff + delta) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Stepper
|
||||
label={t('config.grid.ideal')}
|
||||
accent
|
||||
value={shift.ideal_staff}
|
||||
onChange={(delta) =>
|
||||
updateShift.mutate({
|
||||
id: shift.id,
|
||||
body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteShift.mutate(shift.id)}
|
||||
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
|
||||
aria-label={t('action.remove')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{days.map((day) => {
|
||||
const shifts = shiftsByDay.get(day) ?? [];
|
||||
return (
|
||||
<div key={day} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
|
||||
{longDayLabel(`${day}T12:00`)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const start = `${day}T12:00:00`;
|
||||
const end = `${day}T17:00:00`;
|
||||
createShift.mutate({
|
||||
position_id: position.id,
|
||||
starts_at: start,
|
||||
ends_at: end,
|
||||
min_staff: 2,
|
||||
ideal_staff: 3,
|
||||
});
|
||||
}}
|
||||
className="w-full cursor-pointer bg-cream p-2 font-display text-[10px] font-extrabold uppercase tracking-wide hover:bg-yellow"
|
||||
>
|
||||
{t('config.grid.addShift')}
|
||||
</button>
|
||||
</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}
|
||||
{shifts.map((shift) => (
|
||||
<div
|
||||
key={shift.id}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
|
||||
>
|
||||
<span className="w-[92px] flex-none font-mono text-[11px]">
|
||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
</span>
|
||||
<Stepper
|
||||
label={t('config.grid.min')}
|
||||
value={shift.min_staff}
|
||||
onChange={(delta) =>
|
||||
updateShift.mutate({
|
||||
id: shift.id,
|
||||
body: { min_staff: Math.max(0, shift.min_staff + delta) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Stepper
|
||||
label={t('config.grid.ideal')}
|
||||
accent
|
||||
value={shift.ideal_staff}
|
||||
onChange={(delta) =>
|
||||
updateShift.mutate({
|
||||
id: shift.id,
|
||||
body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteShift.mutate(shift.id)}
|
||||
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
|
||||
aria-label={t('action.remove')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{shifts.length === 0 ? (
|
||||
<div className="px-[9px] py-[10px] font-mono text-[10px] opacity-55">
|
||||
{t('config.grid.emptyDay')}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={createShift.isPending}
|
||||
onClick={() =>
|
||||
createShift.mutate({
|
||||
position_id: position.id,
|
||||
starts_at: `${day}T12:00:00`,
|
||||
ends_at: `${day}T17:00:00`,
|
||||
min_staff: 2,
|
||||
ideal_staff: 3,
|
||||
})
|
||||
}
|
||||
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')}
|
||||
</button>
|
||||
</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">
|
||||
@@ -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[]>();
|
||||
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 }) {
|
||||
|
||||
@@ -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() {
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.rules.editionTitle')}</PanelHeader>
|
||||
<div className="flex flex-col gap-2 p-[13px]">
|
||||
<InfoRow label={t('config.rules.editionId')} value={EDITION_ID} mono />
|
||||
<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.positionsCount')} value={String(positions.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')}
|
||||
<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.volunteersCount')} value={String(volunteers.data?.length ?? '…')} />
|
||||
<InfoRow label={t('config.rules.positionsCount')} value={String(positions.data?.length ?? '…')} />
|
||||
<InfoRow label={t('config.rules.shiftsCount')} value={String(shifts.data?.length ?? '…')} />
|
||||
</div>
|
||||
</div>
|
||||
</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 }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
|
||||
Reference in New Issue
Block a user