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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AwswqSChc3Rwtu61pRXiY2
This commit is contained in:
AdamThomas
2026-09-10 07:51:11 +02:00
co-authored by Claude Sonnet 5
parent 6e16db4b7e
commit 8d333deb23
16 changed files with 75 additions and 353 deletions
+3 -6
View File
@@ -210,7 +210,7 @@ function PositionRow({
if (!shift) {
return <div key={column.key} className="h-9 border-b border-r border-ink bg-hatch-sand" />;
}
const assigned = shift.confirmed + shift.proposed;
const assigned = shift.assigned;
const isCrit = assigned < shift.min_staff;
const isInc = !isCrit && assigned < shift.ideal_staff;
const glyph = isCrit ? '▲' : isInc ? '◧' : '✓';
@@ -225,12 +225,9 @@ function PositionRow({
isInc && 'bg-yellow-soft hover:bg-yellow',
!isCrit && !isInc && 'bg-paper hover:bg-green/10',
)}
title={`${shift.confirmed} confirmé(s) · ${shift.proposed} proposé(s) · min ${shift.min_staff} · idéal ${shift.ideal_staff}`}
title={`${assigned} affecté(s) · min ${shift.min_staff} · idéal ${shift.ideal_staff}`}
>
<span className="font-mono text-[13px] font-bold">{shift.confirmed}</span>
{shift.proposed > 0 ? (
<span className="font-mono text-[9px] opacity-60">+{shift.proposed}</span>
) : null}
<span className="font-mono text-[13px] font-bold">{assigned}</span>
<span className="font-mono text-[9px] opacity-50">/{shift.ideal_staff}</span>
<span className={cx('font-mono text-[10px] font-bold', !isCrit && !isInc && 'text-green')}>
{glyph}
+12 -16
View File
@@ -5,7 +5,6 @@ import { useAuth } from '@/auth/auth-context';
import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel } from '@/lib/time';
import { isActive, statusLabel } from '@/lib/planning';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
@@ -40,20 +39,17 @@ export function MobileLeadPage() {
if (error) return <ErrorNote error={error} />;
if (!position || !shift) return null;
const team = (shiftAssignments.data ?? [])
.filter(isActive)
.map((a) => {
const volunteer = volunteersById.get(a.volunteer_id);
return {
id: a.id,
name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
phone: volunteer?.phone ?? '',
status: statusLabel(a.status),
present: presence[a.id] === 'present',
absent: presence[a.id] === 'absent',
};
});
const team = (shiftAssignments.data ?? []).map((a) => {
const volunteer = volunteersById.get(a.volunteer_id);
return {
id: a.id,
name: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
initials: volunteer ? `${volunteer.first_name[0]}${volunteer.last_name[0]}` : '?',
phone: volunteer?.phone ?? '',
present: presence[a.id] === 'present',
absent: presence[a.id] === 'absent',
};
});
const presentCount = team.filter((m) => m.present).length;
const absentCount = team.filter((m) => m.absent).length;
@@ -110,7 +106,7 @@ export function MobileLeadPage() {
{member.name}
</span>
<span className="block font-mono text-[10.5px] opacity-65">
{member.status} · {member.phone}
{member.phone}
</span>
</span>
{member.present ? (
+4 -39
View File
@@ -1,18 +1,11 @@
import { useMemo } from 'react';
import { ApiError } from '@/api';
import {
useAssignments,
usePositions,
useShifts,
useUpdateAssignment,
useVolunteer,
} from '@/api/hooks';
import { useAssignments, usePositions, useShifts, useVolunteer } from '@/api/hooks';
import { useAuth } from '@/auth/auth-context';
import { ErrorNote, Loading } from '@/components/state';
import { DesignNote, PhoneFrame } from '@/pages/mobile/PhoneFrame';
import { hourRangeLabel, hoursBetween, longDayLabel, naiveMinutes } from '@/lib/time';
import { isActive } from '@/lib/planning';
import { t } from '@/i18n';
export function MobileVolunteerPage() {
@@ -25,7 +18,6 @@ export function MobileVolunteerPage() {
});
const shifts = useShifts();
const positions = usePositions();
const updateAssignment = useUpdateAssignment();
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
@@ -54,7 +46,6 @@ export function MobileVolunteerPage() {
(positions.error instanceof ApiError && positions.error.status === 403);
const slots = (assignments.data ?? [])
.filter(isActive)
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) ?? null }))
.sort((a, b) => {
const av = a.shift ? naiveMinutes(a.shift.starts_at) : 0;
@@ -69,7 +60,6 @@ export function MobileVolunteerPage() {
0,
) * 10,
) / 10;
const toConfirm = slots.filter((s) => s.assignment.status !== 'CONFIRMED').length;
return (
<div className="flex flex-wrap items-start gap-[26px] p-6">
@@ -91,12 +81,6 @@ export function MobileVolunteerPage() {
</div>
</div>
{toConfirm > 0 ? (
<div className="border-b-[3px] border-ink bg-orange px-4 py-[11px] font-display text-[14px] font-black leading-snug">
{t('mobileVol.toConfirm', { count: toConfirm })}
</div>
) : null}
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
{detailsUnavailable ? (
<div className="border-2 border-dashed border-ink bg-yellow-soft p-3 font-mono text-[10px] leading-relaxed">
@@ -128,28 +112,9 @@ export function MobileVolunteerPage() {
{t('mobileVol.location', { location: position.location })}
</div>
) : null}
{assignment.status === 'CONFIRMED' ? (
<div className="mt-[11px] flex min-h-[62px] items-center justify-center gap-[9px] border-[3px] border-ink bg-green font-display text-[17px] font-black text-white">
{t('mobileVol.confirmed')}
</div>
) : (
<button
type="button"
disabled={updateAssignment.isPending}
onClick={() =>
updateAssignment.mutate({ id: assignment.id, body: { status: 'CONFIRMED' } })
}
className="mt-[11px] min-h-[62px] w-full cursor-pointer border-[3px] border-ink bg-yellow font-display text-[19px] font-black shadow-brutal-md hover:bg-green hover:text-white disabled:opacity-60"
>
{t('mobileVol.confirm')}
</button>
)}
{updateAssignment.error instanceof ApiError &&
updateAssignment.variables?.id === assignment.id ? (
<div className="mt-2 border-2 border-ink bg-orange px-2 py-1 font-display text-[10px] font-bold">
{t(updateAssignment.error.code)}
</div>
) : null}
<div className="mt-[11px] flex min-h-[52px] items-center justify-center border-[3px] border-ink bg-green font-display text-[15px] font-black text-white">
{t('mobileVol.slotConfirmed')}
</div>
</div>
</div>
);
-115
View File
@@ -1,115 +0,0 @@
import { useMemo } from 'react';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import { Panel, PanelHeader, StatCard } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
import { hourRangeLabel, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
export function PublicationPage() {
const assignments = useAssignments();
const volunteers = useVolunteers();
const shifts = useShifts();
const positions = usePositions();
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
[shifts.data],
);
const positionsById = useMemo(
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
[positions.data],
);
const volunteersById = useMemo(
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
[volunteers.data],
);
const rows = useMemo(() => {
return (assignments.data ?? [])
.filter((a) => a.status === 'PROPOSED')
.map((a) => {
const shift = shiftsById.get(a.shift_id);
const position = shift ? positionsById.get(shift.position_id) : undefined;
const volunteer = volunteersById.get(a.volunteer_id);
return {
id: a.id,
volunteerName: volunteer ? `${volunteer.first_name} ${volunteer.last_name}` : a.volunteer_id,
positionName: position?.name ?? '',
label: shift ? `${shortDayLabel(shift.starts_at)} · ${hourRangeLabel(shift.starts_at, shift.ends_at)}` : '',
};
})
.sort((a, b) => a.volunteerName.localeCompare(b.volunteerName, 'fr'));
}, [assignments.data, shiftsById, positionsById, volunteersById]);
if (assignments.isLoading || shifts.isLoading) return <Loading />;
if (assignments.error) return <ErrorNote error={assignments.error} />;
const confirmed = (assignments.data ?? []).filter((a) => a.status === 'CONFIRMED').length;
const concerned = new Set(rows.map((r) => r.volunteerName)).size;
return (
<div className="grid items-start gap-[18px] p-[18px] lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
<div>
<div className="mb-[14px] grid grid-cols-3 gap-[10px]">
<StatCard value={rows.length} label={t('publication.stat.proposed')} tone="yellow" />
<StatCard value={concerned} label={t('publication.stat.concerned')} />
<StatCard value={confirmed} label={t('publication.stat.confirmed')} tone="green" />
</div>
<Panel shadow="lg">
<PanelHeader hatched>{t('publication.toConfirmTitle')}</PanelHeader>
{rows.length === 0 ? (
<div className="flex items-center gap-3 px-[14px] py-[22px]">
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-[3px] border-ink bg-green font-display text-[15px] font-extrabold text-white">
✓
</span>
<span className="font-display text-[13px] font-extrabold leading-snug">
{t('publication.allConfirmed')}
</span>
</div>
) : (
rows.map((row) => (
<div
key={row.id}
className="flex items-center gap-[10px] border-b border-sand-line px-3 py-2"
>
<span className="flex-none border-2 border-dashed border-ink bg-paper px-[6px] py-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide">
{t('assignment.status.proposed')}
</span>
<span className="min-w-0 flex-1 font-display text-[12.5px] font-bold">
{row.volunteerName}
<span className="block font-mono text-[9.5px] opacity-65">
{row.positionName} · {row.label}
</span>
</span>
</div>
))
)}
</Panel>
</div>
<div>
<Panel shadow="lg" className="mb-[14px]">
<PanelHeader>{t('publication.mailPreviewTitle')}</PanelHeader>
<div className="px-[14px] py-[13px]">
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
{t('publication.mailTo', { count: concerned })}
</div>
<div className="my-2 border-t-2 border-ink pt-[9px] font-display text-[13.5px] font-extrabold leading-snug">
{t('publication.mailSubject')}
</div>
<p className="font-display text-[12.5px] leading-relaxed">{t('publication.mailBody')}</p>
<div className="mt-[11px] inline-block border-2 border-ink bg-yellow px-3 py-[9px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal">
{t('publication.mailCta')}
</div>
</div>
</Panel>
<div className="border-[3px] border-dashed border-ink bg-paper p-4 font-mono text-[10.5px] leading-relaxed opacity-75">
{t('publication.outOfScope')}
</div>
</div>
</div>
);
}
+4 -20
View File
@@ -4,7 +4,7 @@ import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/ho
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
import { Chip } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
import { buildLattice, hoursByVolunteer, isActive, isOverloaded } from '@/lib/planning';
import { buildLattice, hoursByVolunteer, isOverloaded } from '@/lib/planning';
import { datePart, shortDayLabel, timePart } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
@@ -39,7 +39,6 @@ export function VolunteerViewPage() {
const all = assignments.data ?? [];
const byVolunteer = new Map<string, AssignmentRead[]>();
for (const a of all) {
if (!isActive(a)) continue;
const list = byVolunteer.get(a.volunteer_id) ?? [];
list.push(a);
byVolunteer.set(a.volunteer_id, list);
@@ -92,10 +91,7 @@ export function VolunteerViewPage() {
</div>
<div className="ml-auto flex items-center gap-3 border-2 border-ink bg-cream px-[9px] py-[5px]">
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
<LegendItem swatch="bg-blue border-ink">{t('volunteers.legend.confirmed')}</LegendItem>
<LegendItem swatch="bg-paper border-ink border-dashed">
{t('volunteers.legend.proposed')}
</LegendItem>
<LegendItem swatch="bg-blue border-ink">{t('volunteers.legend.assigned')}</LegendItem>
</div>
</div>
@@ -206,24 +202,12 @@ function VolunteerRow({
{columns.map((column) => {
const cell = row.cellByKey.get(column.key);
if (!cell) return <div key={column.key} className="h-8 border-b border-r border-ink bg-sand" />;
if (cell.status === 'CONFIRMED') {
return (
<div
key={column.key}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold"
>
{abbr(cell.shift.position_id)}
</div>
);
}
return (
<div
key={column.key}
className="flex h-8 items-center justify-center border-b border-r border-ink bg-paper"
className="flex h-8 items-center justify-center border-b border-r border-ink bg-blue font-mono text-[9px] font-bold"
>
<span className="border-[1.5px] border-dashed border-ink px-[3px] py-[1px] font-mono text-[8.5px] font-bold">
{abbr(cell.shift.position_id)}
</span>
{abbr(cell.shift.position_id)}
</div>
);
})}
+4 -23
View File
@@ -7,14 +7,13 @@ import {
useDeleteAssignment,
usePositions,
useShifts,
useUpdateAssignment,
useValidateAssignment,
useVolunteers,
} from '@/api/hooks';
import type { MatrixShift, PositionCategory, VolunteerRead } from '@/api/schema';
import { Badge, Btn } from '@/components/ui';
import { Btn } from '@/components/ui';
import { Loading } from '@/components/state';
import { categoryLabel, hoursByVolunteer, isActive, violationText } from '@/lib/planning';
import { categoryLabel, hoursByVolunteer, violationText } from '@/lib/planning';
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
@@ -41,7 +40,6 @@ export function AssignmentPanel({
const shifts = useShifts();
const positions = usePositions();
const deleteAssignment = useDeleteAssignment();
const updateAssignment = useUpdateAssignment();
const shiftsById = useMemo(
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
@@ -58,7 +56,7 @@ export function AssignmentPanel({
const slotsByVolunteer = useMemo(() => {
const map = new Map<string, number>();
for (const a of allAssignments.data ?? []) {
if (isActive(a)) map.set(a.volunteer_id, (map.get(a.volunteer_id) ?? 0) + 1);
map.set(a.volunteer_id, (map.get(a.volunteer_id) ?? 0) + 1);
}
return map;
}, [allAssignments.data]);
@@ -114,11 +112,7 @@ export function AssignmentPanel({
</div>
<div className="mt-[9px] flex flex-wrap gap-[14px] font-mono text-[10px]">
<span>
{t('grille.panel.confirmed')}{' '}
<b className="text-[15px]">{shift.confirmed}</b>
</span>
<span className="opacity-70">
{t('grille.panel.proposedCount', { count: shift.proposed })}
{t('grille.panel.assigned')} <b className="text-[15px]">{shift.assigned}</b>
</span>
<span className="opacity-70">{t('grille.panel.min', { count: shift.min_staff })}</span>
<span className="opacity-70">{t('grille.panel.ideal', { count: shift.ideal_staff })}</span>
@@ -149,19 +143,6 @@ export function AssignmentPanel({
{t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
</span>
</span>
{assignment.status === 'CONFIRMED' ? (
<Badge tone="confirmed">{t('assignment.status.confirmed')}</Badge>
) : (
<button
type="button"
onClick={() =>
updateAssignment.mutate({ id: assignment.id, body: { status: 'CONFIRMED' } })
}
className="border-2 border-dashed border-ink bg-paper px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide hover:bg-green hover:text-white"
>
{t('assignment.status.proposed')}
</button>
)}
<button
type="button"
onClick={() => deleteAssignment.mutate(assignment.id)}