Frontend : suppression du statut d'affectation (suite Étape 9)

Le back-end (Étape 9) a retiré AssignmentStatus : une affectation est valide
dès sa création, la retirer = supprimer la ligne.

- schema.ts : AssignmentRead/Create/Update sans `status` ;
  MatrixShift.{confirmed, proposed} → `assigned`.
- mock : générateur sans statut (plus de rollStatus / DECLINED) ; matrice
  = compte unique ; create → validation toujours exécutée ; PATCH ne porte
  plus que source/locked ; runValidation compte toutes les affectations.
- lib/planning : suppression de isActive() et statusLabel().
- Grille + panneau : cellule « affectés / idéal », plus de +proposés ni de
  badge confirmé/proposé ni de bouton « confirmer ».
- Vue par bénévole : une seule trame d'affectation (bleu).
- Mobile bénévole : plus de bouton « JE CONFIRME » ni de bannière.
- Mobile référent : plus de libellé de statut par équipier.
- Onglet 03 « Confirmations » supprimé (route, page, nav, ROUTE_ROLES) —
  il n'a plus d'objet. Coordination : onglets 01, 02, 06.
- i18n : clés de statut / de l'écran Confirmations retirées.
- Vérifs : build tsc + vite, eslint (0 problème), npm run smoke.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2
This commit is contained in:
AdamThomas
2026-09-10 07:51:11 +02:00
co-authored by Claude Sonnet 5
parent 6e16db4b7e
commit 8d333deb23
16 changed files with 75 additions and 353 deletions
+9 -6
View File
@@ -38,7 +38,7 @@ Visibilité des onglets par rôle :
| Onglet | Coordination | Référent | Bénévole |
|--------|:---:|:---:|:---:|
| 01 Grille · 02 Vue bénévole · 03 Confirmations · 06 Configuration | ✅ | — | — |
| 01 Grille · 02 Vue bénévole · 06 Configuration | ✅ | — | — |
| 04 Bénévole · mobile | — | — | ✅ |
| 05 Référent · mobile | — | ✅ | — |
@@ -46,18 +46,21 @@ L'accueil après connexion dépend du rôle (`homePath`).
## Écrans (onglets)
Depuis l'Étape 9 back-end, une affectation n'a plus de statut : elle est
valide dès sa création, la retirer = supprimer la ligne. Il n'y a donc plus
d'écran « Confirmations » (ancien onglet 03).
| # | Route | Source de données | Actions |
|---|-------|-------------------|---------|
| 01 | `/grille` | `GET /matrix` | clic cellule → panneau : `GET /assignments?shift_id`, `POST /assignments/validate` par candidat, `POST` / `PATCH` / `DELETE /assignments` |
| 01 | `/grille` | `GET /matrix` (`assigned` / `ideal`) | clic cellule → panneau : `GET /assignments?shift_id`, `POST /assignments/validate` par candidat, `POST` / `DELETE /assignments` |
| 02 | `/benevoles` | `volunteers` + `shifts` + `assignments` | lecture (surcharge / oubliés calculés côté client) |
| 03 | `/publication` | `assignments` (statut `PROPOSED`) | lecture — publication versionnée & emails **hors périmètre** (CLAUDE.md §4) |
| 04 | `/mobile-benevole` | `assignments?volunteer_id` (du compte) | `PATCH /assignments/{id}` (confirmer)¹ |
| 04 | `/mobile-benevole` | `assignments?volunteer_id` (du compte) | lecture¹ |
| 05 | `/mobile-referent` | poste du référent → `assignments?shift_id` | pointage **local** (hors périmètre) |
| 06 | `/configuration` | | `positions` CRUD, `shifts` CRUD (min/idéal, ajout/retrait), `volunteers` CRUD (dont suppression RGPD). Messages = maquette locale. |
¹ En mode `http`, l'API n'expose pas encore aux bénévoles le détail des
créneaux ni l'auto-confirmation : l'écran dégrade proprement (créneaux sans
détail, message d'info). Fonctionne intégralement en mode `mock`.
créneaux : l'écran dégrade proprement (créneaux sans détail, message d'info).
Fonctionne intégralement en mode `mock`.
## Commandes
-2
View File
@@ -22,7 +22,6 @@ import { ConfigPage } from '@/pages/ConfigPage';
import { GrillePage } from '@/pages/GrillePage';
import { MobileLeadPage } from '@/pages/MobileLeadPage';
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
import { PublicationPage } from '@/pages/PublicationPage';
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
const e = EDITION_ID;
@@ -156,7 +155,6 @@ if (coordinator) {
setAuthToken(coordinatorToken);
await renderProtected('/grille', coordinator, createElement(GrillePage));
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
await renderProtected('/publication', coordinator, createElement(PublicationPage));
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
}
if (volunteerAccount) {
+3 -21
View File
@@ -3,7 +3,6 @@ import { hoursBetween } from '@/lib/time';
import type {
AccountRead,
AssignmentRead,
AssignmentStatus,
PositionCategory,
PositionRead,
ShiftRead,
@@ -105,12 +104,6 @@ function iso(dateStr: string, hour: number): string {
return `${dateStr}T${String(hour).padStart(2, '0')}:00:00`;
}
function rollStatus(roll: number): AssignmentStatus {
if (roll < 0.72) return 'CONFIRMED';
if (roll < 0.95) return 'PROPOSED';
return 'DECLINED';
}
function demoEmail(firstName: string, lastName: string): string {
const n = (s: string) => s.toLowerCase().normalize('NFD').replace(/[^a-z]/g, '');
return `${n(firstName)}.${n(lastName)}@mail.fr`;
@@ -240,33 +233,23 @@ export function buildMockState(): MockState {
}
for (const v of pool.slice(0, target)) {
const status = rollStatus(rnd());
assignments.push({
id: `a${seq}`,
edition_id,
volunteer_id: v.id,
shift_id: shift.id,
status,
source: 'SOLVER',
locked: false,
created_at: NOW,
updated_at: NOW,
});
seq += 1;
if (status !== 'DECLINED') {
busy.add(`${v.id}|${shift.id}`);
dayHours.set(`${v.id}|${dateStr}`, (dayHours.get(`${v.id}|${dateStr}`) ?? 0) + duration);
totalHours.set(v.id, (totalHours.get(v.id) ?? 0) + duration);
}
busy.add(`${v.id}|${shift.id}`);
dayHours.set(`${v.id}|${dateStr}`, (dayHours.get(`${v.id}|${dateStr}`) ?? 0) + duration);
totalHours.set(v.id, (totalHours.get(v.id) ?? 0) + duration);
}
}
assignments
.filter((a) => a.volunteer_id === me.id)
.forEach((a, i) => {
a.status = i === 0 ? 'CONFIRMED' : 'PROPOSED';
});
OVERLOADED.forEach((index) => {
const v = volunteers[index];
const friday = shifts.filter((s) => s.starts_at.slice(0, 10) === '2026-10-17');
@@ -284,7 +267,6 @@ export function buildMockState(): MockState {
edition_id,
volunteer_id: v.id,
shift_id: shift.id,
status: 'CONFIRMED',
source: 'MANUAL',
locked: false,
created_at: NOW,
+21 -42
View File
@@ -1,14 +1,14 @@
import { hoursBetween, naiveMinutes } from '@/lib/time';
import type {
AccountRead,
AssignmentRead,
AssignmentValidateResult,
MatrixResponse,
ShiftRead,
TokenResponse,
Violation,
} from '../schema';
import { ACTIVE_ASSIGNMENT_STATUSES } from '../schema';
import type { AccountRead, TokenResponse } from '../schema';
import { getAuthToken } from '../session';
import { ApiError, type PlanningApi } from '../types';
import { buildMockState, type MockState } from './dataset';
@@ -85,8 +85,7 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
(a) =>
a.volunteer_id === volunteerId &&
a.id !== excludeAssignmentId &&
a.shift_id !== candidate.id &&
ACTIVE_ASSIGNMENT_STATUSES.includes(a.status),
a.shift_id !== candidate.id,
)
.map((a) => shiftById(a.shift_id));
@@ -180,12 +179,9 @@ function hasBlocking(violations: Violation[]): boolean {
}
function buildMatrix(): MatrixResponse {
const counts = new Map<string, { confirmed: number; proposed: number }>();
const counts = new Map<string, number>();
for (const a of state.assignments) {
const entry = counts.get(a.shift_id) ?? { confirmed: 0, proposed: 0 };
if (a.status === 'CONFIRMED') entry.confirmed += 1;
if (a.status === 'PROPOSED') entry.proposed += 1;
counts.set(a.shift_id, entry);
counts.set(a.shift_id, (counts.get(a.shift_id) ?? 0) + 1);
}
return {
edition_id: state.positions[0]?.edition_id ?? '',
@@ -199,18 +195,14 @@ function buildMatrix(): MatrixResponse {
shifts: state.shifts
.filter((s) => s.position_id === position.id)
.sort((a, b) => a.starts_at.localeCompare(b.starts_at))
.map((s) => {
const c = counts.get(s.id) ?? { confirmed: 0, proposed: 0 };
return {
shift_id: s.id,
starts_at: s.starts_at,
ends_at: s.ends_at,
min_staff: s.min_staff,
ideal_staff: s.ideal_staff,
confirmed: c.confirmed,
proposed: c.proposed,
};
}),
.map((s) => ({
shift_id: s.id,
starts_at: s.starts_at,
ends_at: s.ends_at,
min_staff: s.min_staff,
ideal_staff: s.ideal_staff,
assigned: counts.get(s.id) ?? 0,
})),
})),
};
}
@@ -390,13 +382,11 @@ export function createMockApi(): PlanningApi {
) {
throw new ApiError(409, 'assignment.duplicate');
}
const status = body.status ?? 'PROPOSED';
let warnings: Violation[] = [];
if (ACTIVE_ASSIGNMENT_STATUSES.includes(status)) {
warnings = runValidation(body.volunteer_id, shift);
if (hasBlocking(warnings)) {
throw new ApiError(422, 'error.validation_failed', warnings);
}
// Une affectation est toujours validée à la création : une violation
// bloquante l'interdit, les avertissements sont renvoyés.
const warnings = runValidation(body.volunteer_id, shift);
if (hasBlocking(warnings)) {
throw new ApiError(422, 'error.validation_failed', warnings);
}
const now = touch();
const assignment: AssignmentRead = {
@@ -404,7 +394,6 @@ export function createMockApi(): PlanningApi {
edition_id: shift.edition_id,
volunteer_id: body.volunteer_id,
shift_id: body.shift_id,
status,
source: body.source ?? 'MANUAL',
locked: body.locked ?? false,
created_at: now,
@@ -416,19 +405,11 @@ export function createMockApi(): PlanningApi {
update: (_edition, id, body) => {
const a = state.assignments.find((x) => x.id === id) ?? notFound();
Object.assign(a, {
...('status' in body ? { status: body.status } : {}),
...('source' in body ? { source: body.source } : {}),
...('locked' in body ? { locked: body.locked } : {}),
updated_at: touch(),
});
let warnings: Violation[] = [];
if (ACTIVE_ASSIGNMENT_STATUSES.includes(a.status)) {
warnings = runValidation(a.volunteer_id, shiftById(a.shift_id), a.id);
if (hasBlocking(warnings)) {
throw new ApiError(422, 'error.validation_failed', warnings);
}
}
return delay({ assignment: a, warnings });
return delay({ assignment: a, warnings: [] as Violation[] });
},
remove: (_edition, id) => {
state.assignments = state.assignments.filter((x) => x.id !== id);
@@ -487,12 +468,10 @@ export function createMockApi(): PlanningApi {
};
}
/** Heures actives d'un bénévole (utilitaire mock, exposé pour les tests). */
/** Heures affectées d'un bénévole (utilitaire mock, exposé pour les tests). */
export function mockVolunteerHours(volunteerId: string): number {
return state.assignments
.filter(
(a) => a.volunteer_id === volunteerId && ACTIVE_ASSIGNMENT_STATUSES.includes(a.status),
)
.filter((a) => a.volunteer_id === volunteerId)
.reduce((sum, a) => {
const s = state.shifts.find((x) => x.id === a.shift_id);
return s ? sum + hoursBetween(s.starts_at, s.ends_at) : sum;
+4 -9
View File
@@ -12,16 +12,14 @@ export type PositionCategory =
| 'STAGE'
| 'OTHER';
export type AssignmentStatus = 'PROPOSED' | 'CONFIRMED' | 'DECLINED' | 'CANCELLED';
// Depuis l'Étape 9 back-end : une affectation est valide dès sa création,
// il n'y a plus de statut (proposée / confirmée…). La retirer = supprimer la ligne.
export type AssignmentSource = 'SOLVER' | 'MANUAL';
export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL';
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
export type AuthMethod = 'password' | 'magic_link';
/** Statuts « actifs » : seuls comptés pour chevauchement / cumul / repos. */
export const ACTIVE_ASSIGNMENT_STATUSES: AssignmentStatus[] = ['PROPOSED', 'CONFIRMED'];
export type ViolationCode =
| 'SHIFT_OVERLAP'
| 'OUTSIDE_AVAILABILITY'
@@ -149,7 +147,6 @@ export interface AssignmentRead {
edition_id: string;
volunteer_id: string;
shift_id: string;
status: AssignmentStatus;
source: AssignmentSource;
locked: boolean;
created_at: string;
@@ -159,13 +156,11 @@ export interface AssignmentRead {
export interface AssignmentCreate {
volunteer_id: string;
shift_id: string;
status?: AssignmentStatus;
source?: AssignmentSource;
locked?: boolean;
}
export interface AssignmentUpdate {
status?: AssignmentStatus;
source?: AssignmentSource;
locked?: boolean;
}
@@ -194,8 +189,8 @@ export interface MatrixShift {
ends_at: string;
min_staff: number;
ideal_staff: number;
confirmed: number;
proposed: number;
/** Nombre d'affectations sur ce créneau. */
assigned: number;
}
export interface MatrixPosition {
+1 -8
View File
@@ -1,7 +1,7 @@
import { NavLink, Outlet } from 'react-router-dom';
import { API_MODE } from '@/api';
import { useAssignments, useVolunteers } from '@/api/hooks';
import { useVolunteers } from '@/api/hooks';
import { useAuth } from '@/auth/auth-context';
import { NAV_TABS, canAccess, roleLabelKey } from '@/auth/roles';
import { t } from '@/i18n';
@@ -12,9 +12,7 @@ export function AppShell() {
const isCoordinator = account?.role === 'COORDINATOR';
const volunteers = useVolunteers({ enabled: isCoordinator });
const assignments = useAssignments(undefined, { enabled: isCoordinator });
const proposedCount = (assignments.data ?? []).filter((a) => a.status === 'PROPOSED').length;
const tabs = account ? NAV_TABS.filter((tab) => canAccess(tab.to, account.role)) : [];
const initials = account
? account.email.slice(0, 2).toUpperCase()
@@ -70,11 +68,6 @@ export function AppShell() {
{t('header.mockMode')}
</div>
) : null}
{isCoordinator && proposedCount > 0 ? (
<div className="hidden items-center gap-2 border-2 border-ink bg-hatch-ink px-[10px] py-[6px] font-display text-[10.5px] font-extrabold uppercase tracking-wider text-yellow md:flex">
{t('header.proposed', { count: proposedCount })}
</div>
) : null}
<div
className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold"
title={account?.email}
-2
View File
@@ -8,7 +8,6 @@ import { ConfigPage } from '@/pages/ConfigPage';
import { GrillePage } from '@/pages/GrillePage';
import { MobileLeadPage } from '@/pages/MobileLeadPage';
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
import { PublicationPage } from '@/pages/PublicationPage';
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
export const routes: RouteObject[] = [
@@ -24,7 +23,6 @@ export const routes: RouteObject[] = [
{ index: true, element: <HomeRedirect /> },
{ path: 'grille', element: <GrillePage /> },
{ path: 'benevoles', element: <VolunteerViewPage /> },
{ path: 'publication', element: <PublicationPage /> },
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
{ path: 'mobile-referent', element: <MobileLeadPage /> },
{ path: 'configuration', element: <ConfigPage /> },
+4 -5
View File
@@ -1,13 +1,13 @@
import type { Role } from '@/api/schema';
// Visibilité des onglets par rôle (cf. demande produit) :
// 01 Grille · 02 Vue par bénévole · 03 Confirmations · 06 Configuration → coordination
// 04 Bénévole · mobile → bénévoles
// 05 Référent · mobile → référents
// 01 Grille · 02 Vue par bénévole · 06 Configuration → coordination
// 04 Bénévole · mobile → bénévoles
// 05 Référent · mobile → référents
// (l'onglet 03 « Confirmations » a disparu avec le statut d'affectation, Étape 9.)
export const ROUTE_ROLES: Record<string, Role[]> = {
'/grille': ['COORDINATOR'],
'/benevoles': ['COORDINATOR'],
'/publication': ['COORDINATOR'],
'/configuration': ['COORDINATOR'],
'/mobile-benevole': ['VOLUNTEER'],
'/mobile-referent': ['LEAD'],
@@ -16,7 +16,6 @@ export const ROUTE_ROLES: Record<string, Role[]> = {
export const NAV_TABS: Array<{ to: string; num: string; labelKey: string }> = [
{ to: '/grille', num: '01', labelKey: 'tab.grille' },
{ to: '/benevoles', num: '02', labelKey: 'tab.volunteers' },
{ to: '/publication', num: '03', labelKey: 'tab.publication' },
{ to: '/mobile-benevole', num: '04', labelKey: 'tab.mobileVolunteer' },
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
+4 -27
View File
@@ -50,20 +50,13 @@
"header.title": "GESTION DES BÉNÉVOLES",
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
"header.mockMode": "DONNÉES DE DÉMO",
"header.proposed": "{count} PROPOSITION(S)",
"tab.grille": "Grille des besoins",
"tab.volunteers": "Vue par bénévole",
"tab.publication": "Confirmations",
"tab.mobileVolunteer": "Bénévole · mobile",
"tab.mobileLead": "Référent · mobile",
"tab.config": "Configuration",
"assignment.status.proposed": "Proposée",
"assignment.status.confirmed": "Confirmée",
"assignment.status.declined": "Refusée",
"assignment.status.cancelled": "Annulée",
"position.category.bar": "Bar",
"position.category.ticketing": "Billetterie",
"position.category.welcome": "Accueil",
@@ -98,8 +91,7 @@
"grille.panel.empty.title": "Cliquez une cellule de la grille pour affecter des bénévoles.",
"grille.panel.empty.body": "Le panneau s'ouvre ici, sans quitter la grille : candidats triés par charge, alertes bloquantes et avertissements du moteur de validation.",
"grille.panel.slotDuration": "CRÉNEAU DE {hours} H",
"grille.panel.confirmed": "CONFIRMÉS",
"grille.panel.proposedCount": "PROPOSÉS {count}",
"grille.panel.assigned": "AFFECTÉS",
"grille.panel.min": "MINIMUM {count}",
"grille.panel.ideal": "IDÉAL {count}",
"grille.panel.assignmentsTitle": "AFFECTATIONS ({count})",
@@ -117,8 +109,7 @@
"volunteers.filter.all": "TOUS",
"volunteers.filter.overloaded": "SURCHARGÉS",
"volunteers.filter.unassigned": "SANS AFFECTATION",
"volunteers.legend.confirmed": "AFFECTATION CONFIRMÉE",
"volunteers.legend.proposed": "AFFECTATION PROPOSÉE",
"volunteers.legend.assigned": "AFFECTATION",
"volunteers.count": "{count} BÉNÉVOLES",
"volunteers.totalHours": "TOTAL H",
"volunteers.name": "NOM",
@@ -126,19 +117,7 @@
"volunteers.badge.overloaded": "▲ SURCHARGE",
"volunteers.badge.unassigned": "○ OUBLIÉ",
"volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.",
"volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation active.",
"publication.stat.proposed": "AFFECTATIONS PROPOSÉES",
"publication.stat.concerned": "BÉNÉVOLES CONCERNÉS",
"publication.stat.confirmed": "AFFECTATIONS CONFIRMÉES",
"publication.toConfirmTitle": "EN ATTENTE DE CONFIRMATION",
"publication.allConfirmed": "Aucune affectation en attente : tout est confirmé.",
"publication.mailPreviewTitle": "APERÇU DU MESSAGE (MAQUETTE)",
"publication.mailTo": "À : {count} bénévoles concernés",
"publication.mailSubject": "Objet — Ton planning bénévole a été mis à jour",
"publication.mailBody": "Ton planning pour le festival vient d'être mis à jour. Merci de confirmer chaque créneau depuis ton planning personnel.",
"publication.mailCta": "VOIR MON PLANNING →",
"publication.outOfScope": "La publication versionnée et l'envoi d'emails / SMS sont hors périmètre du premier incrément (CLAUDE.md §4). Cet écran liste les affectations « proposées » que les bénévoles n'ont pas encore confirmées.",
"volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation.",
"design.noteTitle": "CHOIX DE CONCEPTION",
@@ -147,10 +126,8 @@
"mobileVol.greeting": "SALUT {name}",
"mobileVol.slots": "{count} CRÉNEAUX",
"mobileVol.hours": "{count} HEURES",
"mobileVol.toConfirm": "⚠ {count} CRÉNEAU(X) À CONFIRMER",
"mobileVol.location": "📍 {location}",
"mobileVol.confirm": "JE CONFIRME",
"mobileVol.confirmed": "✓ CRÉNEAU CONFIRMÉ",
"mobileVol.slotConfirmed": "✓ TU ES ATTENDU SUR CE CRÉNEAU",
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.",
"mobileVol.noAccount": "Ce compte n'est rattaché à aucun bénévole. Contactez la coordination.",
"mobileVol.detailsUnavailable": "Le détail des créneaux (poste, horaires) n'est pas encore exposé aux bénévoles par l'API.",
+2 -12
View File
@@ -5,23 +5,14 @@ import type {
ShiftRead,
Violation,
} from '@/api/schema';
import { ACTIVE_ASSIGNMENT_STATUSES } from '@/api/schema';
import { t } from '@/i18n';
import { datePart, hoursBetween, naiveMinutes, timePart } from './time';
export function isActive(a: AssignmentRead): boolean {
return ACTIVE_ASSIGNMENT_STATUSES.includes(a.status);
}
export function categoryLabel(category: PositionCategory): string {
return t(`position.category.${category.toLowerCase()}`);
}
export function statusLabel(status: string): string {
return t(`assignment.status.${status.toLowerCase()}`);
}
/** Colonne de la grille : une (date, heure de début) distincte. */
export interface LatticeColumn {
key: string; // `YYYY-MM-DD|HH:MM`
@@ -63,14 +54,13 @@ export function latticeFromMatrix(matrix: MatrixResponse) {
return buildLattice(matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at }))));
}
/** Heures actives cumulées par bénévole. */
/** Heures cumulées par bénévole. */
export function hoursByVolunteer(
assignments: AssignmentRead[],
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
): Map<string, number> {
const totals = new Map<string, number>();
for (const a of assignments) {
if (!isActive(a)) continue;
const shift = shiftsById.get(a.shift_id);
if (!shift) continue;
totals.set(
@@ -88,7 +78,7 @@ export function isOverloaded(
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
): boolean {
const shifts = assignments
.filter((a) => a.volunteer_id === volunteerId && isActive(a))
.filter((a) => a.volunteer_id === volunteerId)
.map((a) => shiftsById.get(a.shift_id))
.filter((s): s is ShiftRead => Boolean(s))
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
+3 -6
View File
@@ -210,7 +210,7 @@ function PositionRow({
if (!shift) {
return <div key={column.key} className="h-9 border-b border-r border-ink bg-hatch-sand" />;
}
const assigned = shift.confirmed + shift.proposed;
const assigned = shift.assigned;
const isCrit = assigned < shift.min_staff;
const isInc = !isCrit && assigned < shift.ideal_staff;
const glyph = isCrit ? '▲' : isInc ? '◧' : '✓';
@@ -225,12 +225,9 @@ function PositionRow({
isInc && 'bg-yellow-soft hover:bg-yellow',
!isCrit && !isInc && 'bg-paper hover:bg-green/10',
)}
title={`${shift.confirmed} confirmé(s) · ${shift.proposed} proposé(s) · min ${shift.min_staff} · idéal ${shift.ideal_staff}`}
title={`${assigned} affecté(s) · min ${shift.min_staff} · idéal ${shift.ideal_staff}`}
>
<span className="font-mono text-[13px] font-bold">{shift.confirmed}</span>
{shift.proposed > 0 ? (
<span className="font-mono text-[9px] opacity-60">+{shift.proposed}</span>
) : null}
<span className="font-mono text-[13px] font-bold">{assigned}</span>
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
<span className={cx('font-mono text-[10px] font-bold', !isCrit && !isInc && 'text-green')}>
{glyph}
+12 -16
View File
@@ -5,7 +5,6 @@ import { useAuth } from '@/auth/auth-context';
import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel } from '@/lib/time';
import { isActive, statusLabel } from '@/lib/planning';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
@@ -40,20 +39,17 @@ export function MobileLeadPage() {
if (error) return <ErrorNote error={error} />;
if (!position || !shift) return null;
const team = (shiftAssignments.data ?? [])
.filter(isActive)
.map((a) => {
const volunteer = volunteersById.get(a.volunteer_id);
return {
id: a.id,
name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
phone: volunteer?.phone ?? '',
status: statusLabel(a.status),
present: presence[a.id] === 'present',
absent: presence[a.id] === 'absent',
};
});
const team = (shiftAssignments.data ?? []).map((a) => {
const volunteer = volunteersById.get(a.volunteer_id);
return {
id: a.id,
name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
phone: volunteer?.phone ?? '',
present: presence[a.id] === 'present',
absent: presence[a.id] === 'absent',
};
});
const presentCount = team.filter((m) => m.present).length;
const absentCount = team.filter((m) => m.absent).length;
@@ -110,7 +106,7 @@ export function MobileLeadPage() {
{member.name}
</span>
<span className="block font-mono text-[10.5px] opacity-65">
{member.status} · {member.phone}
{member.phone}
</span>
</span>
{member.present ? (
+4 -39
View File
@@ -1,18 +1,11 @@
import { useMemo } from 'react';
import { ApiError } from '@/api';
import {
useAssignments,
usePositions,
useShifts,
useUpdateAssignment,
useVolunteer,
} from '@/api/hooks';
import { useAssignments, usePositions, useShifts, useVolunteer } from '@/api/hooks';
import { useAuth } from '@/auth/auth-context';
import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
import { isActive } from '@/lib/planning';
import { t } from '@/i18n';
export function MobileVolunteerPage() {
@@ -25,7 +18,6 @@ export function MobileVolunteerPage() {
});
const shifts = useShifts();
const positions = usePositions();
const updateAssignment = useUpdateAssignment();
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
@@ -54,7 +46,6 @@ export function MobileVolunteerPage() {
(positions.error instanceof ApiError && positions.error.status === 403);
const slots = (assignments.data ?? [])
.filter(isActive)
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) ?? null }))
.sort((a, b) => {
const av = a.shift ? naiveMinutes(a.shift.starts_at) : 0;
@@ -69,7 +60,6 @@ export function MobileVolunteerPage() {
0,
) * 10,
) / 10;
const toConfirm = slots.filter((s) => s.assignment.status !== 'CONFIRMED').length;
return (
<div className="flex flex-wrap items-start gap-[26px] p-6">
@@ -91,12 +81,6 @@ export function MobileVolunteerPage() {
</div>
</div>
{toConfirm > 0 ? (
<div className="border-b-[3px] border-ink bg-orange px-4 py-[11px] font-display text-[14px] font-black leading-snug">
{t('mobileVol.toConfirm', { count: toConfirm })}
</div>
) : null}
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
{detailsUnavailable ? (
<div className="border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed">
@@ -128,28 +112,9 @@ export function MobileVolunteerPage() {
{t('mobileVol.location', { location: position.location })}
</div>
) : null}
{assignment.status === 'CONFIRMED' ? (
<div className="mt-[11px] flex min-h-[62px] items-center justify-center gap-[9px] border-[3px] border-ink bg-green font-display text-[17px] font-black text-white">
{t('mobileVol.confirmed')}
</div>
) : (
<button
type="button"
disabled={updateAssignment.isPending}
onClick={() =>
updateAssignment.mutate({ id: assignment.id, body: { status: 'CONFIRMED' } })
}
className="mt-[11px] min-h-[62px] w-full cursor-pointer border-[3px] border-ink bg-yellow font-display text-[19px] font-black shadow-brutal-md hover:bg-green hover:text-white disabled:opacity-60"
>
{t('mobileVol.confirm')}
</button>
)}
{updateAssignment.error instanceof ApiError &&
updateAssignment.variables?.id === assignment.id ? (
<div className="mt-2 border-2 border-ink bg-orange px-2 py-1 font-display text-[10px] font-bold">
{t(updateAssignment.error.code)}
</div>
) : null}
<div className="mt-[11px] flex min-h-[52px] items-center justify-center border-[3px] border-ink bg-green font-display text-[15px] font-black text-white">
{t('mobileVol.slotConfirmed')}
</div>
</div>
</div>
);
-115
View File
@@ -1,115 +0,0 @@
import { useMemo } from 'react';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import { Panel, PanelHeader, StatCard } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
import { hourRangeLabel, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
export function PublicationPage() {
const assignments = useAssignments();
const volunteers = useVolunteers();
const shifts = useShifts();
const positions = usePositions();
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data],
);
const positionsById = useMemo(
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data],
);
const volunteersById = useMemo(
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
[volunteers.data],
);
const rows = useMemo(() => {
return (assignments.data ?? [])
.filter((a) => a.status === 'PROPOSED')
.map((a) => {
const shift = shiftsById.get(a.shift_id);
const position = shift ? positionsById.get(shift.position_id) : undefined;
const volunteer = volunteersById.get(a.volunteer_id);
return {
id: a.id,
volunteerName: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
positionName: position?.name ?? '',
label: shift ? `${shortDayLabel(shift.starts_at)} · ${hourRangeLabel(shift.starts_at, shift.ends_at)}` : '',
};
})
.sort((a, b) => a.volunteerName.localeCompare(b.volunteerName, 'fr'));
}, [assignments.data, shiftsById, positionsById, volunteersById]);
if (assignments.isLoading || shifts.isLoading) return <Loading />;
if (assignments.error) return <ErrorNote error={assignments.error} />;
const confirmed = (assignments.data ?? []).filter((a) => a.status === 'CONFIRMED').length;
const concerned = new Set(rows.map((r) => r.volunteerName)).size;
return (
<div className="grid items-start gap-[18px] p-[18px] lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
<div>
<div className="mb-[14px] grid grid-cols-3 gap-[10px]">
<StatCard value={rows.length} label={t('publication.stat.proposed')} tone="yellow" />
<StatCard value={concerned} label={t('publication.stat.concerned')} />
<StatCard value={confirmed} label={t('publication.stat.confirmed')} tone="green" />
</div>
<Panel shadow="lg">
<PanelHeader hatched>{t('publication.toConfirmTitle')}</PanelHeader>
{rows.length === 0 ? (
<div className="flex items-center gap-3 px-[14px] py-[22px]">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-[3px] border-ink bg-green font-display text-[15px] font-extrabold text-white">
✓
</span>
<span className="font-display text-[13px] font-extrabold leading-snug">
{t('publication.allConfirmed')}
</span>
</div>
) : (
rows.map((row) => (
<div
key={row.id}
className="flex items-center gap-[10px] border-b border-sand-line px-3 py-2"
>
<span className="flex-none border-2 border-dashed border-ink bg-paper px-[6px] py-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide">
{t('assignment.status.proposed')}
</span>
<span className="min-w-0 flex-1 font-display text-[12.5px] font-bold">
{row.volunteerName}
<span className="block font-mono text-[9.5px] opacity-65">
{row.positionName} · {row.label}
</span>
</span>
</div>
))
)}
</Panel>
</div>
<div>
<Panel shadow="lg" className="mb-[14px]">
<PanelHeader>{t('publication.mailPreviewTitle')}</PanelHeader>
<div className="px-[14px] py-[13px]">
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
{t('publication.mailTo', { count: concerned })}
</div>
<div className="my-2 border-t-2 border-ink pt-[9px] font-display text-[13.5px] font-extrabold leading-snug">
{t('publication.mailSubject')}
</div>
<p className="font-display text-[12.5px] leading-relaxed">{t('publication.mailBody')}</p>
<div className="mt-[11px] inline-block border-2 border-ink bg-yellow px-3 py-[9px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal">
{t('publication.mailCta')}
</div>
</div>
</Panel>
<div className="border-[3px] border-dashed border-ink bg-paper p-4 font-mono text-[10.5px] leading-relaxed opacity-75">
{t('publication.outOfScope')}
</div>
</div>
</div>
);
}
+4 -20
View File
@@ -4,7 +4,7 @@ import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/ho
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
import { Chip } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
import { buildLattice, hoursByVolunteer, isActive, isOverloaded } from '@/lib/planning';
import { buildLattice, hoursByVolunteer, isOverloaded } from '@/lib/planning';
import { datePart, shortDayLabel, timePart } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
@@ -39,7 +39,6 @@ export function VolunteerViewPage() {
const all = assignments.data ?? [];
const byVolunteer = new Map<string, AssignmentRead[]>();
for (const a of all) {
if (!isActive(a)) continue;
const list = byVolunteer.get(a.volunteer_id) ?? [];
list.push(a);
byVolunteer.set(a.volunteer_id, list);
@@ -92,10 +91,7 @@ export function VolunteerViewPage() {
</div>
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
<LegendItem swatch="bg-blue border-ink">{t('volunteers.legend.confirmed')}</LegendItem>
<LegendItem swatch="bg-paper border-ink border-dashed">
{t('volunteers.legend.proposed')}
</LegendItem>
<LegendItem swatch="bg-blue border-ink">{t('volunteers.legend.assigned')}</LegendItem>
</div>
</div>
@@ -206,24 +202,12 @@ function VolunteerRow({
{columns.map((column) => {
const cell = row.cellByKey.get(column.key);
if (!cell) return <div key={column.key} className="h-8 border-b border-r border-ink bg-sand" />;
if (cell.status === 'CONFIRMED') {
return (
<div
key={column.key}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold"
>
{abbr(cell.shift.position_id)}
</div>
);
}
return (
<div
key={column.key}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-paper"
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold"
>
<span className="border-[1.5px] border-dashed border-ink px-[3px] py-[1px] font-mono text-[8.5px] font-bold">
{abbr(cell.shift.position_id)}
</span>
{abbr(cell.shift.position_id)}
</div>
);
})}
+4 -23
View File
@@ -7,14 +7,13 @@ import {
useDeleteAssignment,
usePositions,
useShifts,
useUpdateAssignment,
useValidateAssignment,
useVolunteers,
} from '@/api/hooks';
import type { MatrixShift, PositionCategory, VolunteerRead } from '@/api/schema';
import { Badge, Btn } from '@/components/ui';
import { Btn } from '@/components/ui';
import { Loading } from '@/components/state';
import { categoryLabel, hoursByVolunteer, isActive, violationText } from '@/lib/planning';
import { categoryLabel, hoursByVolunteer, violationText } from '@/lib/planning';
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
@@ -41,7 +40,6 @@ export function AssignmentPanel({
const shifts = useShifts();
const positions = usePositions();
const deleteAssignment = useDeleteAssignment();
const updateAssignment = useUpdateAssignment();
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
@@ -58,7 +56,7 @@ export function AssignmentPanel({
const slotsByVolunteer = useMemo(() => {
const map = new Map<string, number>();
for (const a of allAssignments.data ?? []) {
if (isActive(a)) map.set(a.volunteer_id, (map.get(a.volunteer_id) ?? 0) + 1);
map.set(a.volunteer_id, (map.get(a.volunteer_id) ?? 0) + 1);
}
return map;
}, [allAssignments.data]);
@@ -114,11 +112,7 @@ export function AssignmentPanel({
</div>
<div className="mt-[9px] flex flex-wrap gap-[14px] font-mono text-[10px]">
<span>
{t('grille.panel.confirmed')}{' '}
<b className="text-[15px]">{shift.confirmed}</b>
</span>
<span className="opacity-70">
{t('grille.panel.proposedCount', { count: shift.proposed })}
{t('grille.panel.assigned')} <b className="text-[15px]">{shift.assigned}</b>
</span>
<span className="opacity-70">{t('grille.panel.min', { count: shift.min_staff })}</span>
<span className="opacity-70">{t('grille.panel.ideal', { count: shift.ideal_staff })}</span>
@@ -149,19 +143,6 @@ export function AssignmentPanel({
{t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
</span>
</span>
{assignment.status === 'CONFIRMED' ? (
<Badge tone="confirmed">{t('assignment.status.confirmed')}</Badge>
) : (
<button
type="button"
onClick={() =>
updateAssignment.mutate({ id: assignment.id, body: { status: 'CONFIRMED' } })
}
className="border-2 border-dashed border-ink bg-paper px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide hover:bg-green hover:text-white"
>
{t('assignment.status.proposed')}
</button>
)}
<button
type="button"
onClick={() => deleteAssignment.mutate(assignment.id)}