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
@@ -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,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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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