Grille & vue bénévole : message quand il n'y a pas de données

- components/state : composant EmptyNote (encadré pointillés).
- GrillePage : si aucun créneau, message au lieu de la grille vide.
- VolunteerViewPage : si aucun bénévole, message ; si un filtre ne
  renvoie rien, message à la place du tableau (avant, l'en-tête collant
  restait affiché).

typecheck + lint + build + smoke OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-10 21:22:10 +02:00
co-authored by Claude Sonnet 5
parent 385c46c241
commit 06f8736c33
4 changed files with 47 additions and 13 deletions
+11
View File
@@ -1,6 +1,17 @@
import type { ReactNode } from 'react';
import { ApiError } from '@/api';
import { t } from '@/i18n';
export function EmptyNote({ children }: { children: ReactNode }) {
return (
<div className="m-4 flex max-w-[640px] items-center gap-[14px] border-[3px] border-dashed border-ink bg-paper p-[22px]">
<span className="h-9 w-9 flex-none border-[3px] border-ink bg-yellow" />
<span className="font-display text-[14px] font-extrabold leading-snug">{children}</span>
</div>
);
}
export function Loading() {
return (
<div className="flex items-center gap-3 p-6 font-mono text-[11px] uppercase tracking-widest opacity-60">
+2
View File
@@ -90,6 +90,7 @@
"grille.positionsCount": "{count} POSTES",
"grille.assignedRequired": "AFFECTÉS / IDÉAL",
"grille.footnote": "Chaque cellule est un créneau. Nombre = bénévoles affectés, / = effectif idéal. Cliquer ouvre le panneau d'affectation à droite.",
"grille.empty": "Aucun créneau pour le moment. Créez des postes dans Configuration → « Postes & créneaux » — un créneau est généré par jour et par créneau type.",
"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",
@@ -119,6 +120,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.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».",
"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",
+10 -1
View File
@@ -3,7 +3,7 @@ import { useMemo, useState } from 'react';
import { useMatrix } from '@/api/hooks';
import type { MatrixPosition, MatrixShift, PositionCategory } from '@/api/schema';
import { Chip } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { AssignmentPanel } from '@/pages/grille/AssignmentPanel';
import { categoryLabel, latticeFromMatrix, type LatticeColumn } from '@/lib/planning';
import { datePart, shortDayLabel, timePart } from '@/lib/time';
@@ -25,6 +25,15 @@ export function GrillePage() {
if (matrix.error) return <ErrorNote error={matrix.error} />;
if (!matrix.data) return null;
const hasShifts = matrix.data.positions.some((p) => p.shifts.length > 0);
if (!hasShifts) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<EmptyNote>{t('grille.empty')}</EmptyNote>
</div>
);
}
const categories = [...new Set(matrix.data.positions.map((p) => p.category))].sort();
const shownDays = dayFilter === 'all' ? lattice.days : lattice.days.filter((d) => d.date === dayFilter);
const shownColumns = shownDays.flatMap((d) => d.columns);
+23 -11
View File
@@ -3,7 +3,7 @@ import { useMemo, useState } from 'react';
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
import { Chip } from '@/components/ui';
import { ErrorNote, Loading } from '@/components/state';
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { buildLattice, hoursByVolunteer, isOverloaded } from '@/lib/planning';
import { datePart, shortDayLabel, timePart } from '@/lib/time';
import { t } from '@/i18n';
@@ -67,10 +67,24 @@ export function VolunteerViewPage() {
});
}, [volunteers.data, assignments.data, shiftsById, hours, filter]);
if (volunteers.isLoading || shifts.isLoading || assignments.isLoading) return <Loading />;
const error = volunteers.error ?? shifts.error ?? assignments.error;
if (
volunteers.isLoading ||
shifts.isLoading ||
assignments.isLoading ||
positions.isLoading
)
return <Loading />;
const error = volunteers.error ?? shifts.error ?? assignments.error ?? positions.error;
if (error) return <ErrorNote error={error} />;
if ((volunteers.data ?? []).length === 0) {
return (
<div className="flex min-h-0 flex-1 flex-col">
<EmptyNote>{t('volunteers.noData')}</EmptyNote>
</div>
);
}
const gridTemplateColumns = `230px repeat(${lattice.columns.length}, 62px) 84px`;
const abbr = (positionId: string) => (positionsById.get(positionId)?.name ?? positionId).slice(0, 4).toUpperCase();
@@ -96,6 +110,9 @@ export function VolunteerViewPage() {
</div>
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
{rows.length === 0 ? (
<EmptyNote>{t('volunteers.empty')}</EmptyNote>
) : (
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
<div className="sticky left-0 top-0 z-[7] flex items-center border-b-2 border-r-[3px] border-ink border-b-cream bg-ink px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
{t('volunteers.count', { count: rows.length })}
@@ -139,18 +156,13 @@ export function VolunteerViewPage() {
/>
))}
</div>
)}
{rows.length === 0 ? (
<div className="m-[18px] flex max-w-[640px] items-center gap-[14px] border-[3px] border-dashed border-ink bg-paper p-[22px]">
<span className="h-9 w-9 flex-none border-[3px] border-ink bg-yellow" />
<span className="font-display text-[14px] font-extrabold leading-snug">
{t('volunteers.empty')}
</span>
</div>
) : null}
{rows.length > 0 ? (
<div className="px-[14px] py-[10px] font-mono text-[9.5px] leading-relaxed opacity-60">
{t('volunteers.footnote')}
</div>
) : null}
</div>
</div>
);