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:
co-authored by
Claude Sonnet 5
parent
6e16db4b7e
commit
8d333deb23
+9
-6
@@ -38,7 +38,7 @@ Visibilité des onglets par rôle :
|
|||||||
|
|
||||||
| Onglet | Coordination | Référent | Bénévole |
|
| 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 | — | — | ✅ |
|
| 04 Bénévole · mobile | — | — | ✅ |
|
||||||
| 05 Référent · mobile | — | ✅ | — |
|
| 05 Référent · mobile | — | ✅ | — |
|
||||||
|
|
||||||
@@ -46,18 +46,21 @@ L'accueil après connexion dépend du rôle (`homePath`).
|
|||||||
|
|
||||||
## Écrans (onglets)
|
## É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 |
|
| # | 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) |
|
| 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) | lecture¹ |
|
||||||
| 04 | `/mobile-benevole` | `assignments?volunteer_id` (du compte) | `PATCH /assignments/{id}` (confirmer)¹ |
|
|
||||||
| 05 | `/mobile-referent` | poste du référent → `assignments?shift_id` | pointage **local** (hors périmètre) |
|
| 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. |
|
| 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
|
¹ 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
|
créneaux : l'écran dégrade proprement (créneaux sans détail, message d'info).
|
||||||
détail, message d'info). Fonctionne intégralement en mode `mock`.
|
Fonctionne intégralement en mode `mock`.
|
||||||
|
|
||||||
## Commandes
|
## Commandes
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { ConfigPage } from '@/pages/ConfigPage';
|
|||||||
import { GrillePage } from '@/pages/GrillePage';
|
import { GrillePage } from '@/pages/GrillePage';
|
||||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||||
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||||
import { PublicationPage } from '@/pages/PublicationPage';
|
|
||||||
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
||||||
|
|
||||||
const e = EDITION_ID;
|
const e = EDITION_ID;
|
||||||
@@ -156,7 +155,6 @@ if (coordinator) {
|
|||||||
setAuthToken(coordinatorToken);
|
setAuthToken(coordinatorToken);
|
||||||
await renderProtected('/grille', coordinator, createElement(GrillePage));
|
await renderProtected('/grille', coordinator, createElement(GrillePage));
|
||||||
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
await renderProtected('/benevoles', coordinator, createElement(VolunteerViewPage));
|
||||||
await renderProtected('/publication', coordinator, createElement(PublicationPage));
|
|
||||||
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
await renderProtected('/configuration', coordinator, createElement(ConfigPage));
|
||||||
}
|
}
|
||||||
if (volunteerAccount) {
|
if (volunteerAccount) {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { hoursBetween } from '@/lib/time';
|
|||||||
import type {
|
import type {
|
||||||
AccountRead,
|
AccountRead,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AssignmentStatus,
|
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
PositionRead,
|
PositionRead,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
@@ -105,12 +104,6 @@ function iso(dateStr: string, hour: number): string {
|
|||||||
return `${dateStr}T${String(hour).padStart(2, '0')}:00:00`;
|
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 {
|
function demoEmail(firstName: string, lastName: string): string {
|
||||||
const n = (s: string) => s.toLowerCase().normalize('NFD').replace(/[^a-z]/g, '');
|
const n = (s: string) => s.toLowerCase().normalize('NFD').replace(/[^a-z]/g, '');
|
||||||
return `${n(firstName)}.${n(lastName)}@mail.fr`;
|
return `${n(firstName)}.${n(lastName)}@mail.fr`;
|
||||||
@@ -240,33 +233,23 @@ export function buildMockState(): MockState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const v of pool.slice(0, target)) {
|
for (const v of pool.slice(0, target)) {
|
||||||
const status = rollStatus(rnd());
|
|
||||||
assignments.push({
|
assignments.push({
|
||||||
id: `a${seq}`,
|
id: `a${seq}`,
|
||||||
edition_id,
|
edition_id,
|
||||||
volunteer_id: v.id,
|
volunteer_id: v.id,
|
||||||
shift_id: shift.id,
|
shift_id: shift.id,
|
||||||
status,
|
|
||||||
source: 'SOLVER',
|
source: 'SOLVER',
|
||||||
locked: false,
|
locked: false,
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
});
|
});
|
||||||
seq += 1;
|
seq += 1;
|
||||||
if (status !== 'DECLINED') {
|
busy.add(`${v.id}|${shift.id}`);
|
||||||
busy.add(`${v.id}|${shift.id}`);
|
dayHours.set(`${v.id}|${dateStr}`, (dayHours.get(`${v.id}|${dateStr}`) ?? 0) + duration);
|
||||||
dayHours.set(`${v.id}|${dateStr}`, (dayHours.get(`${v.id}|${dateStr}`) ?? 0) + duration);
|
totalHours.set(v.id, (totalHours.get(v.id) ?? 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) => {
|
OVERLOADED.forEach((index) => {
|
||||||
const v = volunteers[index];
|
const v = volunteers[index];
|
||||||
const friday = shifts.filter((s) => s.starts_at.slice(0, 10) === '2026-10-17');
|
const friday = shifts.filter((s) => s.starts_at.slice(0, 10) === '2026-10-17');
|
||||||
@@ -284,7 +267,6 @@ export function buildMockState(): MockState {
|
|||||||
edition_id,
|
edition_id,
|
||||||
volunteer_id: v.id,
|
volunteer_id: v.id,
|
||||||
shift_id: shift.id,
|
shift_id: shift.id,
|
||||||
status: 'CONFIRMED',
|
|
||||||
source: 'MANUAL',
|
source: 'MANUAL',
|
||||||
locked: false,
|
locked: false,
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
AccountRead,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
|
TokenResponse,
|
||||||
Violation,
|
Violation,
|
||||||
} from '../schema';
|
} from '../schema';
|
||||||
import { ACTIVE_ASSIGNMENT_STATUSES } from '../schema';
|
|
||||||
import type { AccountRead, TokenResponse } from '../schema';
|
|
||||||
import { getAuthToken } from '../session';
|
import { getAuthToken } from '../session';
|
||||||
import { ApiError, type PlanningApi } from '../types';
|
import { ApiError, type PlanningApi } from '../types';
|
||||||
import { buildMockState, type MockState } from './dataset';
|
import { buildMockState, type MockState } from './dataset';
|
||||||
@@ -85,8 +85,7 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
|
|||||||
(a) =>
|
(a) =>
|
||||||
a.volunteer_id === volunteerId &&
|
a.volunteer_id === volunteerId &&
|
||||||
a.id !== excludeAssignmentId &&
|
a.id !== excludeAssignmentId &&
|
||||||
a.shift_id !== candidate.id &&
|
a.shift_id !== candidate.id,
|
||||||
ACTIVE_ASSIGNMENT_STATUSES.includes(a.status),
|
|
||||||
)
|
)
|
||||||
.map((a) => shiftById(a.shift_id));
|
.map((a) => shiftById(a.shift_id));
|
||||||
|
|
||||||
@@ -180,12 +179,9 @@ function hasBlocking(violations: Violation[]): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildMatrix(): MatrixResponse {
|
function buildMatrix(): MatrixResponse {
|
||||||
const counts = new Map<string, { confirmed: number; proposed: number }>();
|
const counts = new Map<string, number>();
|
||||||
for (const a of state.assignments) {
|
for (const a of state.assignments) {
|
||||||
const entry = counts.get(a.shift_id) ?? { confirmed: 0, proposed: 0 };
|
counts.set(a.shift_id, (counts.get(a.shift_id) ?? 0) + 1);
|
||||||
if (a.status === 'CONFIRMED') entry.confirmed += 1;
|
|
||||||
if (a.status === 'PROPOSED') entry.proposed += 1;
|
|
||||||
counts.set(a.shift_id, entry);
|
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
edition_id: state.positions[0]?.edition_id ?? '',
|
edition_id: state.positions[0]?.edition_id ?? '',
|
||||||
@@ -199,18 +195,14 @@ function buildMatrix(): MatrixResponse {
|
|||||||
shifts: state.shifts
|
shifts: state.shifts
|
||||||
.filter((s) => s.position_id === position.id)
|
.filter((s) => s.position_id === position.id)
|
||||||
.sort((a, b) => a.starts_at.localeCompare(b.starts_at))
|
.sort((a, b) => a.starts_at.localeCompare(b.starts_at))
|
||||||
.map((s) => {
|
.map((s) => ({
|
||||||
const c = counts.get(s.id) ?? { confirmed: 0, proposed: 0 };
|
shift_id: s.id,
|
||||||
return {
|
starts_at: s.starts_at,
|
||||||
shift_id: s.id,
|
ends_at: s.ends_at,
|
||||||
starts_at: s.starts_at,
|
min_staff: s.min_staff,
|
||||||
ends_at: s.ends_at,
|
ideal_staff: s.ideal_staff,
|
||||||
min_staff: s.min_staff,
|
assigned: counts.get(s.id) ?? 0,
|
||||||
ideal_staff: s.ideal_staff,
|
})),
|
||||||
confirmed: c.confirmed,
|
|
||||||
proposed: c.proposed,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -390,13 +382,11 @@ export function createMockApi(): PlanningApi {
|
|||||||
) {
|
) {
|
||||||
throw new ApiError(409, 'assignment.duplicate');
|
throw new ApiError(409, 'assignment.duplicate');
|
||||||
}
|
}
|
||||||
const status = body.status ?? 'PROPOSED';
|
// Une affectation est toujours validée à la création : une violation
|
||||||
let warnings: Violation[] = [];
|
// bloquante l'interdit, les avertissements sont renvoyés.
|
||||||
if (ACTIVE_ASSIGNMENT_STATUSES.includes(status)) {
|
const warnings = runValidation(body.volunteer_id, shift);
|
||||||
warnings = runValidation(body.volunteer_id, shift);
|
if (hasBlocking(warnings)) {
|
||||||
if (hasBlocking(warnings)) {
|
throw new ApiError(422, 'error.validation_failed', warnings);
|
||||||
throw new ApiError(422, 'error.validation_failed', warnings);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const now = touch();
|
const now = touch();
|
||||||
const assignment: AssignmentRead = {
|
const assignment: AssignmentRead = {
|
||||||
@@ -404,7 +394,6 @@ export function createMockApi(): PlanningApi {
|
|||||||
edition_id: shift.edition_id,
|
edition_id: shift.edition_id,
|
||||||
volunteer_id: body.volunteer_id,
|
volunteer_id: body.volunteer_id,
|
||||||
shift_id: body.shift_id,
|
shift_id: body.shift_id,
|
||||||
status,
|
|
||||||
source: body.source ?? 'MANUAL',
|
source: body.source ?? 'MANUAL',
|
||||||
locked: body.locked ?? false,
|
locked: body.locked ?? false,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -416,19 +405,11 @@ export function createMockApi(): PlanningApi {
|
|||||||
update: (_edition, id, body) => {
|
update: (_edition, id, body) => {
|
||||||
const a = state.assignments.find((x) => x.id === id) ?? notFound();
|
const a = state.assignments.find((x) => x.id === id) ?? notFound();
|
||||||
Object.assign(a, {
|
Object.assign(a, {
|
||||||
...('status' in body ? { status: body.status } : {}),
|
|
||||||
...('source' in body ? { source: body.source } : {}),
|
...('source' in body ? { source: body.source } : {}),
|
||||||
...('locked' in body ? { locked: body.locked } : {}),
|
...('locked' in body ? { locked: body.locked } : {}),
|
||||||
updated_at: touch(),
|
updated_at: touch(),
|
||||||
});
|
});
|
||||||
let warnings: Violation[] = [];
|
return delay({ assignment: a, warnings: [] as 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 });
|
|
||||||
},
|
},
|
||||||
remove: (_edition, id) => {
|
remove: (_edition, id) => {
|
||||||
state.assignments = state.assignments.filter((x) => x.id !== 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 {
|
export function mockVolunteerHours(volunteerId: string): number {
|
||||||
return state.assignments
|
return state.assignments
|
||||||
.filter(
|
.filter((a) => a.volunteer_id === volunteerId)
|
||||||
(a) => a.volunteer_id === volunteerId && ACTIVE_ASSIGNMENT_STATUSES.includes(a.status),
|
|
||||||
)
|
|
||||||
.reduce((sum, a) => {
|
.reduce((sum, a) => {
|
||||||
const s = state.shifts.find((x) => x.id === a.shift_id);
|
const s = state.shifts.find((x) => x.id === a.shift_id);
|
||||||
return s ? sum + hoursBetween(s.starts_at, s.ends_at) : sum;
|
return s ? sum + hoursBetween(s.starts_at, s.ends_at) : sum;
|
||||||
|
|||||||
@@ -12,16 +12,14 @@ export type PositionCategory =
|
|||||||
| 'STAGE'
|
| 'STAGE'
|
||||||
| 'OTHER';
|
| '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 AssignmentSource = 'SOLVER' | 'MANUAL';
|
||||||
export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL';
|
export type TshirtSize = 'XS' | 'S' | 'M' | 'L' | 'XL' | 'XXL' | 'XXXL';
|
||||||
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
|
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
|
||||||
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
|
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
|
||||||
export type AuthMethod = 'password' | 'magic_link';
|
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 =
|
export type ViolationCode =
|
||||||
| 'SHIFT_OVERLAP'
|
| 'SHIFT_OVERLAP'
|
||||||
| 'OUTSIDE_AVAILABILITY'
|
| 'OUTSIDE_AVAILABILITY'
|
||||||
@@ -149,7 +147,6 @@ export interface AssignmentRead {
|
|||||||
edition_id: string;
|
edition_id: string;
|
||||||
volunteer_id: string;
|
volunteer_id: string;
|
||||||
shift_id: string;
|
shift_id: string;
|
||||||
status: AssignmentStatus;
|
|
||||||
source: AssignmentSource;
|
source: AssignmentSource;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -159,13 +156,11 @@ export interface AssignmentRead {
|
|||||||
export interface AssignmentCreate {
|
export interface AssignmentCreate {
|
||||||
volunteer_id: string;
|
volunteer_id: string;
|
||||||
shift_id: string;
|
shift_id: string;
|
||||||
status?: AssignmentStatus;
|
|
||||||
source?: AssignmentSource;
|
source?: AssignmentSource;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssignmentUpdate {
|
export interface AssignmentUpdate {
|
||||||
status?: AssignmentStatus;
|
|
||||||
source?: AssignmentSource;
|
source?: AssignmentSource;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
}
|
}
|
||||||
@@ -194,8 +189,8 @@ export interface MatrixShift {
|
|||||||
ends_at: string;
|
ends_at: string;
|
||||||
min_staff: number;
|
min_staff: number;
|
||||||
ideal_staff: number;
|
ideal_staff: number;
|
||||||
confirmed: number;
|
/** Nombre d'affectations sur ce créneau. */
|
||||||
proposed: number;
|
assigned: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MatrixPosition {
|
export interface MatrixPosition {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink, Outlet } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
import { API_MODE } from '@/api';
|
import { API_MODE } from '@/api';
|
||||||
import { useAssignments, useVolunteers } from '@/api/hooks';
|
import { useVolunteers } from '@/api/hooks';
|
||||||
import { useAuth } from '@/auth/auth-context';
|
import { useAuth } from '@/auth/auth-context';
|
||||||
import { NAV_TABS, canAccess, roleLabelKey } from '@/auth/roles';
|
import { NAV_TABS, canAccess, roleLabelKey } from '@/auth/roles';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
@@ -12,9 +12,7 @@ export function AppShell() {
|
|||||||
const isCoordinator = account?.role === 'COORDINATOR';
|
const isCoordinator = account?.role === 'COORDINATOR';
|
||||||
|
|
||||||
const volunteers = useVolunteers({ enabled: isCoordinator });
|
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 tabs = account ? NAV_TABS.filter((tab) => canAccess(tab.to, account.role)) : [];
|
||||||
const initials = account
|
const initials = account
|
||||||
? account.email.slice(0, 2).toUpperCase()
|
? account.email.slice(0, 2).toUpperCase()
|
||||||
@@ -70,11 +68,6 @@ export function AppShell() {
|
|||||||
{t('header.mockMode')}
|
{t('header.mockMode')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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
|
<div
|
||||||
className="flex h-[30px] w-[30px] items-center justify-center border-2 border-ink bg-orange font-display text-[11px] font-extrabold"
|
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}
|
title={account?.email}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { ConfigPage } from '@/pages/ConfigPage';
|
|||||||
import { GrillePage } from '@/pages/GrillePage';
|
import { GrillePage } from '@/pages/GrillePage';
|
||||||
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
import { MobileLeadPage } from '@/pages/MobileLeadPage';
|
||||||
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
import { MobileVolunteerPage } from '@/pages/MobileVolunteerPage';
|
||||||
import { PublicationPage } from '@/pages/PublicationPage';
|
|
||||||
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
import { VolunteerViewPage } from '@/pages/VolunteerViewPage';
|
||||||
|
|
||||||
export const routes: RouteObject[] = [
|
export const routes: RouteObject[] = [
|
||||||
@@ -24,7 +23,6 @@ export const routes: RouteObject[] = [
|
|||||||
{ index: true, element: <HomeRedirect /> },
|
{ index: true, element: <HomeRedirect /> },
|
||||||
{ path: 'grille', element: <GrillePage /> },
|
{ path: 'grille', element: <GrillePage /> },
|
||||||
{ path: 'benevoles', element: <VolunteerViewPage /> },
|
{ path: 'benevoles', element: <VolunteerViewPage /> },
|
||||||
{ path: 'publication', element: <PublicationPage /> },
|
|
||||||
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
|
{ path: 'mobile-benevole', element: <MobileVolunteerPage /> },
|
||||||
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
{ path: 'mobile-referent', element: <MobileLeadPage /> },
|
||||||
{ path: 'configuration', element: <ConfigPage /> },
|
{ path: 'configuration', element: <ConfigPage /> },
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { Role } from '@/api/schema';
|
import type { Role } from '@/api/schema';
|
||||||
|
|
||||||
// Visibilité des onglets par rôle (cf. demande produit) :
|
// Visibilité des onglets par rôle (cf. demande produit) :
|
||||||
// 01 Grille · 02 Vue par bénévole · 03 Confirmations · 06 Configuration → coordination
|
// 01 Grille · 02 Vue par bénévole · 06 Configuration → coordination
|
||||||
// 04 Bénévole · mobile → bénévoles
|
// 04 Bénévole · mobile → bénévoles
|
||||||
// 05 Référent · mobile → référents
|
// 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[]> = {
|
export const ROUTE_ROLES: Record<string, Role[]> = {
|
||||||
'/grille': ['COORDINATOR'],
|
'/grille': ['COORDINATOR'],
|
||||||
'/benevoles': ['COORDINATOR'],
|
'/benevoles': ['COORDINATOR'],
|
||||||
'/publication': ['COORDINATOR'],
|
|
||||||
'/configuration': ['COORDINATOR'],
|
'/configuration': ['COORDINATOR'],
|
||||||
'/mobile-benevole': ['VOLUNTEER'],
|
'/mobile-benevole': ['VOLUNTEER'],
|
||||||
'/mobile-referent': ['LEAD'],
|
'/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 }> = [
|
export const NAV_TABS: Array<{ to: string; num: string; labelKey: string }> = [
|
||||||
{ to: '/grille', num: '01', labelKey: 'tab.grille' },
|
{ to: '/grille', num: '01', labelKey: 'tab.grille' },
|
||||||
{ to: '/benevoles', num: '02', labelKey: 'tab.volunteers' },
|
{ to: '/benevoles', num: '02', labelKey: 'tab.volunteers' },
|
||||||
{ to: '/publication', num: '03', labelKey: 'tab.publication' },
|
|
||||||
{ to: '/mobile-benevole', num: '04', labelKey: 'tab.mobileVolunteer' },
|
{ to: '/mobile-benevole', num: '04', labelKey: 'tab.mobileVolunteer' },
|
||||||
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
|
{ to: '/mobile-referent', num: '05', labelKey: 'tab.mobileLead' },
|
||||||
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
{ to: '/configuration', num: '06', labelKey: 'tab.config' },
|
||||||
|
|||||||
@@ -50,20 +50,13 @@
|
|||||||
"header.title": "GESTION DES BÉNÉVOLES",
|
"header.title": "GESTION DES BÉNÉVOLES",
|
||||||
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
|
"header.meta": "BÉNÉVOLAT · {count} INSCRITS",
|
||||||
"header.mockMode": "DONNÉES DE DÉMO",
|
"header.mockMode": "DONNÉES DE DÉMO",
|
||||||
"header.proposed": "{count} PROPOSITION(S)",
|
|
||||||
|
|
||||||
"tab.grille": "Grille des besoins",
|
"tab.grille": "Grille des besoins",
|
||||||
"tab.volunteers": "Vue par bénévole",
|
"tab.volunteers": "Vue par bénévole",
|
||||||
"tab.publication": "Confirmations",
|
|
||||||
"tab.mobileVolunteer": "Bénévole · mobile",
|
"tab.mobileVolunteer": "Bénévole · mobile",
|
||||||
"tab.mobileLead": "Référent · mobile",
|
"tab.mobileLead": "Référent · mobile",
|
||||||
"tab.config": "Configuration",
|
"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.bar": "Bar",
|
||||||
"position.category.ticketing": "Billetterie",
|
"position.category.ticketing": "Billetterie",
|
||||||
"position.category.welcome": "Accueil",
|
"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.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.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.slotDuration": "CRÉNEAU DE {hours} H",
|
||||||
"grille.panel.confirmed": "CONFIRMÉS",
|
"grille.panel.assigned": "AFFECTÉS",
|
||||||
"grille.panel.proposedCount": "PROPOSÉS {count}",
|
|
||||||
"grille.panel.min": "MINIMUM {count}",
|
"grille.panel.min": "MINIMUM {count}",
|
||||||
"grille.panel.ideal": "IDÉAL {count}",
|
"grille.panel.ideal": "IDÉAL {count}",
|
||||||
"grille.panel.assignmentsTitle": "AFFECTATIONS ({count})",
|
"grille.panel.assignmentsTitle": "AFFECTATIONS ({count})",
|
||||||
@@ -117,8 +109,7 @@
|
|||||||
"volunteers.filter.all": "TOUS",
|
"volunteers.filter.all": "TOUS",
|
||||||
"volunteers.filter.overloaded": "SURCHARGÉS",
|
"volunteers.filter.overloaded": "SURCHARGÉS",
|
||||||
"volunteers.filter.unassigned": "SANS AFFECTATION",
|
"volunteers.filter.unassigned": "SANS AFFECTATION",
|
||||||
"volunteers.legend.confirmed": "AFFECTATION CONFIRMÉE",
|
"volunteers.legend.assigned": "AFFECTATION",
|
||||||
"volunteers.legend.proposed": "AFFECTATION PROPOSÉE",
|
|
||||||
"volunteers.count": "{count} BÉNÉVOLES",
|
"volunteers.count": "{count} BÉNÉVOLES",
|
||||||
"volunteers.totalHours": "TOTAL H",
|
"volunteers.totalHours": "TOTAL H",
|
||||||
"volunteers.name": "NOM",
|
"volunteers.name": "NOM",
|
||||||
@@ -126,19 +117,7 @@
|
|||||||
"volunteers.badge.overloaded": "▲ SURCHARGE",
|
"volunteers.badge.overloaded": "▲ SURCHARGE",
|
||||||
"volunteers.badge.unassigned": "○ OUBLIÉ",
|
"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.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.",
|
"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.",
|
||||||
|
|
||||||
"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.",
|
|
||||||
|
|
||||||
"design.noteTitle": "CHOIX DE CONCEPTION",
|
"design.noteTitle": "CHOIX DE CONCEPTION",
|
||||||
|
|
||||||
@@ -147,10 +126,8 @@
|
|||||||
"mobileVol.greeting": "SALUT {name}",
|
"mobileVol.greeting": "SALUT {name}",
|
||||||
"mobileVol.slots": "{count} CRÉNEAUX",
|
"mobileVol.slots": "{count} CRÉNEAUX",
|
||||||
"mobileVol.hours": "{count} HEURES",
|
"mobileVol.hours": "{count} HEURES",
|
||||||
"mobileVol.toConfirm": "⚠ {count} CRÉNEAU(X) À CONFIRMER",
|
|
||||||
"mobileVol.location": "📍 {location}",
|
"mobileVol.location": "📍 {location}",
|
||||||
"mobileVol.confirm": "JE CONFIRME",
|
"mobileVol.slotConfirmed": "✓ TU ES ATTENDU SUR CE CRÉNEAU",
|
||||||
"mobileVol.confirmed": "✓ CRÉNEAU CONFIRMÉ",
|
|
||||||
"mobileVol.noSlots": "Aucun créneau pour l'instant. La coordination t'affectera bientôt.",
|
"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.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.",
|
"mobileVol.detailsUnavailable": "Le détail des créneaux (poste, horaires) n'est pas encore exposé aux bénévoles par l'API.",
|
||||||
|
|||||||
@@ -5,23 +5,14 @@ import type {
|
|||||||
ShiftRead,
|
ShiftRead,
|
||||||
Violation,
|
Violation,
|
||||||
} from '@/api/schema';
|
} from '@/api/schema';
|
||||||
import { ACTIVE_ASSIGNMENT_STATUSES } from '@/api/schema';
|
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
import { datePart, hoursBetween, naiveMinutes, timePart } from './time';
|
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 {
|
export function categoryLabel(category: PositionCategory): string {
|
||||||
return t(`position.category.${category.toLowerCase()}`);
|
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. */
|
/** Colonne de la grille : une (date, heure de début) distincte. */
|
||||||
export interface LatticeColumn {
|
export interface LatticeColumn {
|
||||||
key: string; // `YYYY-MM-DD|HH:MM`
|
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 }))));
|
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(
|
export function hoursByVolunteer(
|
||||||
assignments: AssignmentRead[],
|
assignments: AssignmentRead[],
|
||||||
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
|
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
|
||||||
): Map<string, number> {
|
): Map<string, number> {
|
||||||
const totals = new Map<string, number>();
|
const totals = new Map<string, number>();
|
||||||
for (const a of assignments) {
|
for (const a of assignments) {
|
||||||
if (!isActive(a)) continue;
|
|
||||||
const shift = shiftsById.get(a.shift_id);
|
const shift = shiftsById.get(a.shift_id);
|
||||||
if (!shift) continue;
|
if (!shift) continue;
|
||||||
totals.set(
|
totals.set(
|
||||||
@@ -88,7 +78,7 @@ export function isOverloaded(
|
|||||||
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
|
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const shifts = assignments
|
const shifts = assignments
|
||||||
.filter((a) => a.volunteer_id === volunteerId && isActive(a))
|
.filter((a) => a.volunteer_id === volunteerId)
|
||||||
.map((a) => shiftsById.get(a.shift_id))
|
.map((a) => shiftsById.get(a.shift_id))
|
||||||
.filter((s): s is ShiftRead => Boolean(s))
|
.filter((s): s is ShiftRead => Boolean(s))
|
||||||
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
|
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ function PositionRow({
|
|||||||
if (!shift) {
|
if (!shift) {
|
||||||
return <div key={column.key} className="h-9 border-b border-r border-ink bg-hatch-sand" />;
|
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 isCrit = assigned < shift.min_staff;
|
||||||
const isInc = !isCrit && assigned < shift.ideal_staff;
|
const isInc = !isCrit && assigned < shift.ideal_staff;
|
||||||
const glyph = isCrit ? '▲' : isInc ? '◧' : '✓';
|
const glyph = isCrit ? '▲' : isInc ? '◧' : '✓';
|
||||||
@@ -225,12 +225,9 @@ function PositionRow({
|
|||||||
isInc && 'bg-yellow-soft hover:bg-yellow',
|
isInc && 'bg-yellow-soft hover:bg-yellow',
|
||||||
!isCrit && !isInc && 'bg-paper hover:bg-green/10',
|
!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>
|
<span className="font-mono text-[13px] font-bold">{assigned}</span>
|
||||||
{shift.proposed > 0 ? (
|
|
||||||
<span className="font-mono text-[9px] opacity-60">+{shift.proposed}</span>
|
|
||||||
) : null}
|
|
||||||
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</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')}>
|
<span className={cx('font-mono text-[10px] font-bold', !isCrit && !isInc && 'text-green')}>
|
||||||
{glyph}
|
{glyph}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useAuth } from '@/auth/auth-context';
|
|||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
|
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
|
||||||
import { hourRangeLabel } from '@/lib/time';
|
import { hourRangeLabel } from '@/lib/time';
|
||||||
import { isActive, statusLabel } from '@/lib/planning';
|
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
|
|
||||||
@@ -40,20 +39,17 @@ export function MobileLeadPage() {
|
|||||||
if (error) return <ErrorNote error={error} />;
|
if (error) return <ErrorNote error={error} />;
|
||||||
if (!position || !shift) return null;
|
if (!position || !shift) return null;
|
||||||
|
|
||||||
const team = (shiftAssignments.data ?? [])
|
const team = (shiftAssignments.data ?? []).map((a) => {
|
||||||
.filter(isActive)
|
const volunteer = volunteersById.get(a.volunteer_id);
|
||||||
.map((a) => {
|
return {
|
||||||
const volunteer = volunteersById.get(a.volunteer_id);
|
id: a.id,
|
||||||
return {
|
name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
|
||||||
id: a.id,
|
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
|
||||||
name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
|
phone: volunteer?.phone ?? '',
|
||||||
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
|
present: presence[a.id] === 'present',
|
||||||
phone: volunteer?.phone ?? '',
|
absent: presence[a.id] === 'absent',
|
||||||
status: statusLabel(a.status),
|
};
|
||||||
present: presence[a.id] === 'present',
|
});
|
||||||
absent: presence[a.id] === 'absent',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const presentCount = team.filter((m) => m.present).length;
|
const presentCount = team.filter((m) => m.present).length;
|
||||||
const absentCount = team.filter((m) => m.absent).length;
|
const absentCount = team.filter((m) => m.absent).length;
|
||||||
@@ -110,7 +106,7 @@ export function MobileLeadPage() {
|
|||||||
{member.name}
|
{member.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="block font-mono text-[10.5px] opacity-65">
|
<span className="block font-mono text-[10.5px] opacity-65">
|
||||||
{member.status} · {member.phone}
|
{member.phone}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{member.present ? (
|
{member.present ? (
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { ApiError } from '@/api';
|
import { ApiError } from '@/api';
|
||||||
import {
|
import { useAssignments, usePositions, useShifts, useVolunteer } from '@/api/hooks';
|
||||||
useAssignments,
|
|
||||||
usePositions,
|
|
||||||
useShifts,
|
|
||||||
useUpdateAssignment,
|
|
||||||
useVolunteer,
|
|
||||||
} from '@/api/hooks';
|
|
||||||
import { useAuth } from '@/auth/auth-context';
|
import { useAuth } from '@/auth/auth-context';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
import { ErrorNote, Loading } from '@/components/state';
|
||||||
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
|
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
|
||||||
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
|
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
|
||||||
import { isActive } from '@/lib/planning';
|
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
export function MobileVolunteerPage() {
|
export function MobileVolunteerPage() {
|
||||||
@@ -25,7 +18,6 @@ export function MobileVolunteerPage() {
|
|||||||
});
|
});
|
||||||
const shifts = useShifts();
|
const shifts = useShifts();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
const updateAssignment = useUpdateAssignment();
|
|
||||||
|
|
||||||
const shiftsById = useMemo(
|
const shiftsById = useMemo(
|
||||||
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
||||||
@@ -54,7 +46,6 @@ export function MobileVolunteerPage() {
|
|||||||
(positions.error instanceof ApiError && positions.error.status === 403);
|
(positions.error instanceof ApiError && positions.error.status === 403);
|
||||||
|
|
||||||
const slots = (assignments.data ?? [])
|
const slots = (assignments.data ?? [])
|
||||||
.filter(isActive)
|
|
||||||
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) ?? null }))
|
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) ?? null }))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const av = a.shift ? naiveMinutes(a.shift.starts_at) : 0;
|
const av = a.shift ? naiveMinutes(a.shift.starts_at) : 0;
|
||||||
@@ -69,7 +60,6 @@ export function MobileVolunteerPage() {
|
|||||||
0,
|
0,
|
||||||
) * 10,
|
) * 10,
|
||||||
) / 10;
|
) / 10;
|
||||||
const toConfirm = slots.filter((s) => s.assignment.status !== 'CONFIRMED').length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-start gap-[26px] p-6">
|
<div className="flex flex-wrap items-start gap-[26px] p-6">
|
||||||
@@ -91,12 +81,6 @@ export function MobileVolunteerPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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]">
|
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
|
||||||
{detailsUnavailable ? (
|
{detailsUnavailable ? (
|
||||||
<div className="border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed">
|
<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 })}
|
{t('mobileVol.location', { location: position.location })}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{assignment.status === 'CONFIRMED' ? (
|
<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">
|
||||||
<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.slotConfirmed')}
|
||||||
{t('mobileVol.confirmed')}
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,7 +4,7 @@ import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/ho
|
|||||||
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
|
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
|
||||||
import { Chip } from '@/components/ui';
|
import { Chip } from '@/components/ui';
|
||||||
import { ErrorNote, Loading } from '@/components/state';
|
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 { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
import { cx } from '@/lib/cx';
|
import { cx } from '@/lib/cx';
|
||||||
@@ -39,7 +39,6 @@ export function VolunteerViewPage() {
|
|||||||
const all = assignments.data ?? [];
|
const all = assignments.data ?? [];
|
||||||
const byVolunteer = new Map<string, AssignmentRead[]>();
|
const byVolunteer = new Map<string, AssignmentRead[]>();
|
||||||
for (const a of all) {
|
for (const a of all) {
|
||||||
if (!isActive(a)) continue;
|
|
||||||
const list = byVolunteer.get(a.volunteer_id) ?? [];
|
const list = byVolunteer.get(a.volunteer_id) ?? [];
|
||||||
list.push(a);
|
list.push(a);
|
||||||
byVolunteer.set(a.volunteer_id, list);
|
byVolunteer.set(a.volunteer_id, list);
|
||||||
@@ -92,10 +91,7 @@ export function VolunteerViewPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
|
<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>
|
<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-blue border-ink">{t('volunteers.legend.assigned')}</LegendItem>
|
||||||
<LegendItem swatch="bg-paper border-ink border-dashed">
|
|
||||||
{t('volunteers.legend.proposed')}
|
|
||||||
</LegendItem>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -206,24 +202,12 @@ function VolunteerRow({
|
|||||||
{columns.map((column) => {
|
{columns.map((column) => {
|
||||||
const cell = row.cellByKey.get(column.key);
|
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) 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={column.key}
|
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)}
|
||||||
{abbr(cell.shift.position_id)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -7,14 +7,13 @@ import {
|
|||||||
useDeleteAssignment,
|
useDeleteAssignment,
|
||||||
usePositions,
|
usePositions,
|
||||||
useShifts,
|
useShifts,
|
||||||
useUpdateAssignment,
|
|
||||||
useValidateAssignment,
|
useValidateAssignment,
|
||||||
useVolunteers,
|
useVolunteers,
|
||||||
} from '@/api/hooks';
|
} from '@/api/hooks';
|
||||||
import type { MatrixShift, PositionCategory, VolunteerRead } from '@/api/schema';
|
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 { 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 { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
@@ -41,7 +40,6 @@ export function AssignmentPanel({
|
|||||||
const shifts = useShifts();
|
const shifts = useShifts();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
const deleteAssignment = useDeleteAssignment();
|
const deleteAssignment = useDeleteAssignment();
|
||||||
const updateAssignment = useUpdateAssignment();
|
|
||||||
|
|
||||||
const shiftsById = useMemo(
|
const shiftsById = useMemo(
|
||||||
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
||||||
@@ -58,7 +56,7 @@ export function AssignmentPanel({
|
|||||||
const slotsByVolunteer = useMemo(() => {
|
const slotsByVolunteer = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
for (const a of allAssignments.data ?? []) {
|
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;
|
return map;
|
||||||
}, [allAssignments.data]);
|
}, [allAssignments.data]);
|
||||||
@@ -114,11 +112,7 @@ export function AssignmentPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-[9px] flex flex-wrap gap-[14px] font-mono text-[10px]">
|
<div className="mt-[9px] flex flex-wrap gap-[14px] font-mono text-[10px]">
|
||||||
<span>
|
<span>
|
||||||
{t('grille.panel.confirmed')}{' '}
|
{t('grille.panel.assigned')} <b className="text-[15px]">{shift.assigned}</b>
|
||||||
<b className="text-[15px]">{shift.confirmed}</b>
|
|
||||||
</span>
|
|
||||||
<span className="opacity-70">
|
|
||||||
{t('grille.panel.proposedCount', { count: shift.proposed })}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="opacity-70">{t('grille.panel.min', { count: shift.min_staff })}</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>
|
<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) })}
|
{t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
|
||||||
</span>
|
</span>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => deleteAssignment.mutate(assignment.id)}
|
onClick={() => deleteAssignment.mutate(assignment.id)}
|
||||||
|
|||||||
Reference in New Issue
Block a user