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 |
|
||||
|--------|:---:|:---:|:---:|
|
||||
| 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
|
||||
|
||||
|
||||
@@ -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,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,32 +233,22 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assignments
|
||||
.filter((a) => a.volunteer_id === me.id)
|
||||
.forEach((a, i) => {
|
||||
a.status = i === 0 ? 'CONFIRMED' : 'PROPOSED';
|
||||
});
|
||||
|
||||
OVERLOADED.forEach((index) => {
|
||||
const v = volunteers[index];
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
.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,
|
||||
confirmed: c.confirmed,
|
||||
proposed: c.proposed,
|
||||
};
|
||||
}),
|
||||
assigned: counts.get(s.id) ?? 0,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -390,21 +382,18 @@ 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);
|
||||
// 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 = {
|
||||
id: `a${state.seq++}`,
|
||||
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;
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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 /> },
|
||||
|
||||
@@ -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
|
||||
// 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' },
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,16 +39,13 @@ export function MobileLeadPage() {
|
||||
if (error) return <ErrorNote error={error} />;
|
||||
if (!position || !shift) return null;
|
||||
|
||||
const team = (shiftAssignments.data ?? [])
|
||||
.filter(isActive)
|
||||
.map((a) => {
|
||||
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 ?? '',
|
||||
status: statusLabel(a.status),
|
||||
present: presence[a.id] === 'present',
|
||||
absent: presence[a.id] === 'absent',
|
||||
};
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 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>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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 { 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,7 +202,6 @@ 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}
|
||||
@@ -215,17 +210,6 @@ function VolunteerRow({
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={cx(
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user