Frontend : consommation de l'API CRUD (matrice, affectations, validation)
Remplace le magasin d'état fabriqué par une vraie couche API typée, alignée sur le back-end (Étape 5) : - src/api/ : schema.ts (miroir de backend/app/api/schemas.py), client HTTP, implémentation mock en mémoire (même surface + portage du moteur de validation), sélection mock/http via VITE_API_MODE, hooks React Query (lectures + mutations avec invalidation). - Grille des besoins ← GET /matrix ; treillis de colonnes dérivé des créneaux réels (plus de plages figées). - Panneau d'affectation ← GET /assignments?shift_id, POST /assignments/validate par candidat (alertes bloquantes / avertissements du moteur), POST / PATCH / DELETE /assignments. - Vue par bénévole ← volunteers + shifts + assignments (surcharge / oubliés calculés côté client). - Confirmations (ex-« Publication ») ← affectations PROPOSED ; publication versionnée et emails signalés hors périmètre. - Mobiles ← assignments filtrées ; confirmation = PATCH ; pointage référent local (hors périmètre). - Configuration ← positions & shifts CRUD (min/idéal, ajout/retrait), volunteers CRUD dont suppression RGPD ; messages = maquette locale. - i18n mis à jour, aucun texte d'interface en dur. - Vérifs : build tsc + vite, eslint (0 problème), npm run smoke (API mock + rendu headless des 6 écrans avec données préchargées). 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
ac469f0cd1
commit
c9dda65361
+115
-131
@@ -1,54 +1,57 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
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 { AssignmentPanel } from '@/pages/grille/AssignmentPanel';
|
||||
import {
|
||||
DAY_PARTS,
|
||||
DEMO_CATEGORIES,
|
||||
FESTIVAL_DAYS,
|
||||
categoryLabelKey,
|
||||
slotKey,
|
||||
} from '@/domain/festival';
|
||||
import { indexPlanning } from '@/domain/selectors';
|
||||
import type { PositionCategory } from '@/domain/types';
|
||||
import { categoryLabel, latticeFromMatrix, type LatticeColumn } from '@/lib/planning';
|
||||
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
type DayFilter = 'all' | string;
|
||||
type CategoryFilter = 'all' | PositionCategory;
|
||||
|
||||
export function GrillePage() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const [dayFilter, setDayFilter] = useState<DayFilter>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState<CategoryFilter>('all');
|
||||
const matrix = useMatrix();
|
||||
const [dayFilter, setDayFilter] = useState<'all' | string>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState<'all' | PositionCategory>('all');
|
||||
const [selectedShiftId, setSelectedShiftId] = useState<string | null>(null);
|
||||
|
||||
const index = useMemo(
|
||||
() => indexPlanning(state.assignments, state.volunteers),
|
||||
[state.assignments, state.volunteers],
|
||||
const lattice = useMemo(
|
||||
() => (matrix.data ? latticeFromMatrix(matrix.data) : { days: [], columns: [] }),
|
||||
[matrix.data],
|
||||
);
|
||||
|
||||
const shownDays =
|
||||
dayFilter === 'all' ? FESTIVAL_DAYS : FESTIVAL_DAYS.filter((d) => d.id === dayFilter);
|
||||
if (matrix.isLoading) return <Loading />;
|
||||
if (matrix.error) return <ErrorNote error={matrix.error} />;
|
||||
if (!matrix.data) return null;
|
||||
|
||||
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);
|
||||
const shownPositions =
|
||||
categoryFilter === 'all'
|
||||
? state.positions
|
||||
: state.positions.filter((p) => p.category === categoryFilter);
|
||||
? matrix.data.positions
|
||||
: matrix.data.positions.filter((p) => p.category === categoryFilter);
|
||||
|
||||
const gridTemplateColumns = `210px repeat(${shownDays.length * DAY_PARTS.length}, 66px)`;
|
||||
const gridTemplateColumns = `230px repeat(${shownColumns.length}, 66px)`;
|
||||
|
||||
const selected = selectedShiftId
|
||||
? matrix.data.positions
|
||||
.flatMap((p) => p.shifts.map((s) => ({ position: p, shift: s })))
|
||||
.find((entry) => entry.shift.shift_id === selectedShiftId) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{/* Barre de filtres + légende */}
|
||||
<div className="flex flex-wrap items-center gap-[14px] border-b-2 border-ink bg-sand px-4 py-[9px]">
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="font-mono text-[9px] opacity-60">{t('grille.filter.day')}</span>
|
||||
<Chip active={dayFilter === 'all'} onClick={() => setDayFilter('all')}>
|
||||
{t('filter.all')}
|
||||
</Chip>
|
||||
{FESTIVAL_DAYS.map((day) => (
|
||||
<Chip key={day.id} active={dayFilter === day.id} onClick={() => setDayFilter(day.id)}>
|
||||
{day.shortLabel}
|
||||
{lattice.days.map((day) => (
|
||||
<Chip key={day.date} active={dayFilter === day.date} onClick={() => setDayFilter(day.date)}>
|
||||
{shortDayLabel(`${day.date}T12:00`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
@@ -58,82 +61,68 @@ export function GrillePage() {
|
||||
<Chip active={categoryFilter === 'all'} onClick={() => setCategoryFilter('all')}>
|
||||
{t('filter.all')}
|
||||
</Chip>
|
||||
{DEMO_CATEGORIES.map((category) => (
|
||||
{categories.map((category) => (
|
||||
<Chip
|
||||
key={category}
|
||||
active={categoryFilter === category}
|
||||
onClick={() => setCategoryFilter(category)}
|
||||
>
|
||||
{t(categoryLabelKey(category)).toUpperCase()}
|
||||
{categoryLabel(category).toUpperCase()}
|
||||
</Chip>
|
||||
))}
|
||||
</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('grille.legend.title')}</span>
|
||||
<LegendItem swatchClass="bg-hatch-orange" glyph="▲">
|
||||
<span className="font-mono text-[8.5px] opacity-60">{t('legend.title')}</span>
|
||||
<LegendItem swatch="bg-hatch-orange" glyph="▲">
|
||||
{t('grille.legend.critical')}
|
||||
</LegendItem>
|
||||
<LegendItem swatchClass="bg-yellow-soft" glyph="◧">
|
||||
<LegendItem swatch="bg-yellow-soft" glyph="◧">
|
||||
{t('grille.legend.incomplete')}
|
||||
</LegendItem>
|
||||
<LegendItem swatchClass="bg-paper" glyph="✓">
|
||||
<LegendItem swatch="bg-paper" glyph="✓">
|
||||
{t('grille.legend.complete')}
|
||||
</LegendItem>
|
||||
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
|
||||
<span className="h-0 w-0 border-l-[9px] border-t-[9px] border-l-transparent border-t-blue-strong" />
|
||||
{t('grille.legend.unpublished')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 items-stretch">
|
||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
||||
<div
|
||||
className="grid w-max border-b-[3px] border-ink"
|
||||
style={{ gridTemplateColumns }}
|
||||
>
|
||||
{/* Ligne 1 : coin + entêtes de journée */}
|
||||
<div className="grid w-max border-b-[3px] border-ink" style={{ gridTemplateColumns }}>
|
||||
<div className="sticky left-0 top-0 z-[6] 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('grille.positionsCount', { count: state.positions.length })}
|
||||
{t('grille.positionsCount', { count: matrix.data.positions.length })}
|
||||
</div>
|
||||
{shownDays.map((day) => (
|
||||
<div
|
||||
key={day.id}
|
||||
key={day.date}
|
||||
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
|
||||
style={{ gridColumn: 'span 4' }}
|
||||
style={{ gridColumn: `span ${day.columns.length}` }}
|
||||
>
|
||||
<span className="font-display text-[12px] font-black tracking-tight">
|
||||
{day.shortLabel}
|
||||
{shortDayLabel(`${day.date}T12:00`)}
|
||||
</span>
|
||||
<span className="font-mono text-[8.5px] font-medium text-yellow">{day.tag}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Ligne 2 : légende de colonne + libellés de plage */}
|
||||
<div className="sticky left-0 top-[27px] z-[6] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60">
|
||||
{t('grille.assignedRequired')}
|
||||
</div>
|
||||
{shownDays.map((day) =>
|
||||
DAY_PARTS.map((part) => (
|
||||
<div
|
||||
key={`${day.id}-${part.code}`}
|
||||
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
|
||||
>
|
||||
{part.label}
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
{shownColumns.map((column) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
|
||||
>
|
||||
{column.time}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Lignes de postes */}
|
||||
{shownPositions.map((position) => (
|
||||
<PositionRow
|
||||
key={position.id}
|
||||
position={position}
|
||||
shownDays={shownDays}
|
||||
bySlot={index.bySlot}
|
||||
selectedSlot={state.selectedSlot}
|
||||
onSelect={(slot) => dispatch({ type: 'selectSlot', slot })}
|
||||
columns={shownColumns}
|
||||
selectedShiftId={selectedShiftId}
|
||||
onSelect={setSelectedShiftId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -143,8 +132,14 @@ export function GrillePage() {
|
||||
</div>
|
||||
|
||||
<aside className="max-h-[calc(100vh-118px)] w-[392px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
||||
{state.selectedSlot ? (
|
||||
<AssignmentPanel slot={state.selectedSlot} index={index} />
|
||||
{selected ? (
|
||||
<AssignmentPanel
|
||||
positionName={selected.position.name}
|
||||
positionLocation={selected.position.location}
|
||||
positionCategory={selected.position.category}
|
||||
shift={selected.shift}
|
||||
onClose={() => setSelectedShiftId(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 px-[18px] py-[26px]">
|
||||
<div className="h-11 w-11 border-[3px] border-ink bg-yellow shadow-brutal-md" />
|
||||
@@ -163,11 +158,11 @@ export function GrillePage() {
|
||||
}
|
||||
|
||||
function LegendItem({
|
||||
swatchClass,
|
||||
swatch,
|
||||
glyph,
|
||||
children,
|
||||
}: {
|
||||
swatchClass: string;
|
||||
swatch: string;
|
||||
glyph: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
@@ -176,7 +171,7 @@ function LegendItem({
|
||||
<span
|
||||
className={cx(
|
||||
'flex h-4 w-[22px] items-center justify-center border-[1.5px] border-ink font-mono text-[9px] font-bold',
|
||||
swatchClass,
|
||||
swatch,
|
||||
)}
|
||||
>
|
||||
{glyph}
|
||||
@@ -188,75 +183,64 @@ function LegendItem({
|
||||
|
||||
function PositionRow({
|
||||
position,
|
||||
shownDays,
|
||||
bySlot,
|
||||
selectedSlot,
|
||||
columns,
|
||||
selectedShiftId,
|
||||
onSelect,
|
||||
}: {
|
||||
position: import('@/domain/types').Position;
|
||||
shownDays: typeof FESTIVAL_DAYS;
|
||||
bySlot: Map<string, import('@/domain/types').Assignment[]>;
|
||||
selectedSlot: string | null;
|
||||
onSelect: (slot: string) => void;
|
||||
position: MatrixPosition;
|
||||
columns: LatticeColumn[];
|
||||
selectedShiftId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const byColumn = new Map<string, MatrixShift>();
|
||||
for (const shift of position.shifts) {
|
||||
byColumn.set(`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}`, shift);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="sticky left-0 z-[2] flex h-9 flex-col justify-center gap-[1px] border-b border-r-[3px] border-ink bg-cream px-2">
|
||||
<span className="font-display text-[12px] font-bold tracking-tight">{position.name}</span>
|
||||
<span className="font-mono text-[8.5px] opacity-55">{position.zone}</span>
|
||||
<span className="truncate font-display text-[12px] font-bold tracking-tight">
|
||||
{position.name}
|
||||
</span>
|
||||
<span className="truncate font-mono text-[8.5px] opacity-55">{position.location}</span>
|
||||
</div>
|
||||
{shownDays.map((day) =>
|
||||
DAY_PARTS.map((part) => {
|
||||
const need = position.needs.find(
|
||||
(n) => n.dayId === day.id && n.dayPartCode === part.code,
|
||||
);
|
||||
const key = slotKey(position.id, day.id, part.code);
|
||||
if (!need) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="h-9 border-b border-r border-ink bg-hatch-sand"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const list = bySlot.get(key) ?? [];
|
||||
const count = list.length;
|
||||
const isCrit = count < need.minStaff;
|
||||
const isInc = count >= need.minStaff && count < need.idealStaff;
|
||||
const pending = list.some((a) => a.pending);
|
||||
const selected = selectedSlot === key;
|
||||
const glyph = isCrit ? '▲' : isInc ? '◧' : '✓';
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => onSelect(key)}
|
||||
className={cx(
|
||||
'relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
|
||||
isCrit && 'bg-hatch-orange',
|
||||
isInc && 'bg-yellow-soft hover:bg-yellow',
|
||||
!isCrit && !isInc && 'bg-paper hover:bg-green/10',
|
||||
)}
|
||||
>
|
||||
<span className="font-mono text-[14px] font-bold">{count}</span>
|
||||
<span className="font-mono text-[10px] opacity-60">/{need.idealStaff}</span>
|
||||
<span
|
||||
className={cx(
|
||||
'font-mono text-[10px] font-bold',
|
||||
!isCrit && !isInc && 'text-green',
|
||||
)}
|
||||
>
|
||||
{glyph}
|
||||
</span>
|
||||
{pending ? (
|
||||
<span className="absolute right-0 top-0 h-0 w-0 border-l-[9px] border-t-[9px] border-l-transparent border-t-blue-strong" />
|
||||
) : null}
|
||||
{selected ? (
|
||||
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
{columns.map((column) => {
|
||||
const shift = byColumn.get(column.key);
|
||||
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 isCrit = assigned < shift.min_staff;
|
||||
const isInc = !isCrit && assigned < shift.ideal_staff;
|
||||
const glyph = isCrit ? '▲' : isInc ? '◧' : '✓';
|
||||
const selected = selectedShiftId === shift.shift_id;
|
||||
return (
|
||||
<div
|
||||
key={column.key}
|
||||
onClick={() => onSelect(shift.shift_id)}
|
||||
className={cx(
|
||||
'relative flex h-9 cursor-pointer items-center justify-center gap-[3px] border-b border-r border-ink',
|
||||
isCrit && 'bg-hatch-orange',
|
||||
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}`}
|
||||
>
|
||||
<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-[9px] opacity-50">/{shift.ideal_staff}</span>
|
||||
<span className={cx('font-mono text-[10px] font-bold', !isCrit && !isInc && 'text-green')}>
|
||||
{glyph}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="pointer-events-none absolute inset-0 border-[3px] border-blue-strong" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,40 +1,54 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { slotKey } from '@/domain/festival';
|
||||
import { indexPlanning } from '@/domain/selectors';
|
||||
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
||||
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';
|
||||
|
||||
const LEAD_SLOT = slotKey('BAR', 'ven', 'S');
|
||||
type Presence = Record<string, 'present' | 'absent'>;
|
||||
|
||||
export function MobileLeadPage() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const index = useMemo(
|
||||
() => indexPlanning(state.assignments, state.volunteers),
|
||||
[state.assignments, state.volunteers],
|
||||
const positions = usePositions();
|
||||
const shifts = useShifts();
|
||||
const volunteers = useVolunteers();
|
||||
const [presence, setPresence] = useState<Presence>({});
|
||||
|
||||
// Poste de démonstration : le premier ; créneau le plus fourni.
|
||||
const position = (positions.data ?? [])[0];
|
||||
const positionShifts = useMemo(
|
||||
() => (shifts.data ?? []).filter((s) => s.position_id === position?.id),
|
||||
[shifts.data, position?.id],
|
||||
);
|
||||
const shift = positionShifts[Math.floor(positionShifts.length / 2)] ?? positionShifts[0];
|
||||
const shiftAssignments = useAssignments(shift ? { shiftId: shift.id } : undefined);
|
||||
|
||||
const volunteersById = useMemo(
|
||||
() => new Map((volunteers.data ?? []).map((v) => [v.id, v])),
|
||||
[volunteers.data],
|
||||
);
|
||||
|
||||
const position = state.positions.find((p) => p.id === 'BAR')!;
|
||||
const leadDemo = state.volunteers[1];
|
||||
if (positions.isLoading || shifts.isLoading) return <Loading />;
|
||||
const error = positions.error ?? shifts.error ?? volunteers.error;
|
||||
if (error) return <ErrorNote error={error} />;
|
||||
if (!position || !shift) return null;
|
||||
|
||||
const team = (index.bySlot.get(LEAD_SLOT) ?? []).map((assignment) => {
|
||||
const volunteer = state.volunteers.find((v) => v.id === assignment.volunteerId)!;
|
||||
const presence = state.presence[assignment.id];
|
||||
return {
|
||||
assignmentId: assignment.id,
|
||||
name: volunteer.fullName,
|
||||
initials: volunteer.initials,
|
||||
phone: volunteer.phone,
|
||||
status:
|
||||
assignment.status === 'CONFIRMED'
|
||||
? t('mobileLead.statusConfirmed')
|
||||
: t('mobileLead.statusProposed'),
|
||||
present: presence === 'present',
|
||||
absent: presence === 'absent',
|
||||
};
|
||||
});
|
||||
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 presentCount = team.filter((m) => m.present).length;
|
||||
const absentCount = team.filter((m) => m.absent).length;
|
||||
@@ -43,18 +57,18 @@ export function MobileLeadPage() {
|
||||
<div className="flex flex-wrap items-start gap-[26px] p-6">
|
||||
<PhoneFrame time={t('mobileLead.statusTime')}>
|
||||
<div className="bg-ink px-4 pb-[15px] pt-3 text-cream">
|
||||
<div className="font-mono text-[10px] text-yellow">
|
||||
{t('mobileLead.kicker', { name: leadDemo.fullName })}
|
||||
</div>
|
||||
<div className="mt-[5px] font-display text-[27px] font-black leading-tight tracking-tight">
|
||||
{`${position.name} — ${position.zone}`.toUpperCase()}
|
||||
</div>
|
||||
<div className="font-display text-[13px] font-semibold opacity-80">
|
||||
{position.zone} · {position.location}
|
||||
<div className="font-mono text-[10px] text-yellow">{t('mobileLead.kicker')}</div>
|
||||
<div className="mt-[5px] font-display text-[25px] font-black leading-tight tracking-tight">
|
||||
{position.name.toUpperCase()}
|
||||
</div>
|
||||
{position.location ? (
|
||||
<div className="font-display text-[13px] font-semibold opacity-80">{position.location}</div>
|
||||
) : null}
|
||||
<div className="mt-3 flex items-stretch gap-2">
|
||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||
{t('mobileLead.inProgress', { hours: t('mobileLead.shiftHours') })}
|
||||
{t('mobileLead.inProgress', {
|
||||
hours: hourRangeLabel(shift.starts_at, shift.ends_at),
|
||||
})}
|
||||
</div>
|
||||
<div className="border-2 border-cream px-[9px] py-[6px] font-display text-[12px] font-extrabold">
|
||||
{t('mobileLead.present', { present: presentCount, total: team.length })}
|
||||
@@ -81,7 +95,7 @@ export function MobileLeadPage() {
|
||||
{t('mobileLead.teamTitle')}
|
||||
</div>
|
||||
{team.map((member) => (
|
||||
<div key={member.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-md">
|
||||
<div key={member.id} className="border-[3px] border-ink bg-paper shadow-brutal-md">
|
||||
<div className="flex items-center gap-[10px] px-[11px] py-[10px]">
|
||||
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-yellow font-display text-[13px] font-extrabold">
|
||||
{member.initials}
|
||||
@@ -108,9 +122,7 @@ export function MobileLeadPage() {
|
||||
<div className="grid grid-cols-2 border-t-[3px] border-ink">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({ type: 'setPresence', assignmentId: member.assignmentId, value: 'present' })
|
||||
}
|
||||
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'present' }))}
|
||||
className={cx(
|
||||
'min-h-[56px] cursor-pointer border-r-[3px] border-ink bg-cream font-display text-[15px] font-black hover:bg-green hover:text-white',
|
||||
)}
|
||||
@@ -119,9 +131,7 @@ export function MobileLeadPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({ type: 'setPresence', assignmentId: member.assignmentId, value: 'absent' })
|
||||
}
|
||||
onClick={() => setPresence((p) => ({ ...p, [member.id]: 'absent' }))}
|
||||
className="min-h-[56px] cursor-pointer bg-cream font-display text-[15px] font-black hover:bg-orange"
|
||||
>
|
||||
{t('mobileLead.markAbsent')}
|
||||
@@ -129,9 +139,11 @@ export function MobileLeadPage() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="border-[3px] border-dashed border-ink p-[11px] font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileLead.nextSlot')}
|
||||
</div>
|
||||
{team.length === 0 ? (
|
||||
<div className="border-[3px] border-dashed border-ink p-[11px] font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileLead.emptyTeam')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
|
||||
@@ -1,45 +1,54 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { absoluteWindow, dayPart, festivalDay } from '@/domain/festival';
|
||||
import { indexPlanning } from '@/domain/selectors';
|
||||
import { useAssignments, usePositions, useShifts, useUpdateAssignment, useVolunteers } from '@/api/hooks';
|
||||
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 { ENV } from '@/lib/env';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
const DEMO_VOLUNTEER_ID = ENV.demoVolunteerId;
|
||||
|
||||
export function MobileVolunteerPage() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const index = useMemo(
|
||||
() => indexPlanning(state.assignments, state.volunteers),
|
||||
[state.assignments, state.volunteers],
|
||||
const volunteers = useVolunteers();
|
||||
const shifts = useShifts();
|
||||
const positions = usePositions();
|
||||
|
||||
const me = useMemo(() => {
|
||||
const list = volunteers.data ?? [];
|
||||
return list.find((v) => v.id === DEMO_VOLUNTEER_ID) ?? list[0];
|
||||
}, [volunteers.data]);
|
||||
|
||||
const assignments = useAssignments(me ? { volunteerId: me.id } : undefined);
|
||||
const updateAssignment = useUpdateAssignment();
|
||||
|
||||
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 me = state.volunteers[0];
|
||||
const slots = (index.byVolunteer.get(me.id) ?? [])
|
||||
.slice()
|
||||
.sort(
|
||||
(a, b) =>
|
||||
absoluteWindow(a.dayId, a.dayPartCode).start - absoluteWindow(b.dayId, b.dayPartCode).start,
|
||||
)
|
||||
.map((assignment) => {
|
||||
const position = state.positions.find((p) => p.id === assignment.positionId)!;
|
||||
const positionIndex = state.positions.findIndex((p) => p.id === assignment.positionId);
|
||||
const lead = state.volunteers[(positionIndex * 5 + 3) % state.volunteers.length];
|
||||
return {
|
||||
assignmentId: assignment.id,
|
||||
day: festivalDay(assignment.dayId).longLabel,
|
||||
hours: dayPart(assignment.dayPartCode).label,
|
||||
position: position.name.toUpperCase(),
|
||||
zone: position.zone,
|
||||
location: position.location,
|
||||
leadName: lead.fullName,
|
||||
leadPhone: lead.phone,
|
||||
leadPhoneRaw: lead.phone.replace(/\s/g, ''),
|
||||
confirmed: assignment.status === 'CONFIRMED',
|
||||
};
|
||||
});
|
||||
if (volunteers.isLoading || shifts.isLoading) return <Loading />;
|
||||
const error = volunteers.error ?? shifts.error ?? assignments.error;
|
||||
if (error) return <ErrorNote error={error} />;
|
||||
if (!me) return null;
|
||||
|
||||
const toConfirm = slots.filter((s) => !s.confirmed).length;
|
||||
const totalHours = index.totalHours.get(me.id) ?? 0;
|
||||
const slots = (assignments.data ?? [])
|
||||
.filter(isActive)
|
||||
.map((a) => ({ assignment: a, shift: shiftsById.get(a.shift_id) }))
|
||||
.filter((entry): entry is { assignment: typeof entry.assignment; shift: NonNullable<typeof entry.shift> } =>
|
||||
Boolean(entry.shift),
|
||||
)
|
||||
.sort((a, b) => naiveMinutes(a.shift.starts_at) - naiveMinutes(b.shift.starts_at));
|
||||
|
||||
const totalHours = Math.round(
|
||||
slots.reduce((sum, s) => sum + hoursBetween(s.shift.starts_at, s.shift.ends_at), 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">
|
||||
@@ -47,7 +56,7 @@ export function MobileVolunteerPage() {
|
||||
<div className="bg-ink px-4 pb-4 pt-3 text-cream">
|
||||
<div className="font-mono text-[10px] text-yellow">{t('mobileVol.kicker')}</div>
|
||||
<div className="mt-[5px] font-display text-[30px] font-black leading-tight tracking-tight">
|
||||
{t('mobileVol.greeting', { name: me.firstName.toUpperCase() })}
|
||||
{t('mobileVol.greeting', { name: me.first_name.toUpperCase() })}
|
||||
</div>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="bg-yellow px-[9px] py-[7px] font-display text-[12px] font-extrabold text-ink">
|
||||
@@ -66,63 +75,52 @@ export function MobileVolunteerPage() {
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col gap-[14px] px-[14px] pb-[26px] pt-[14px]">
|
||||
{slots.map((slot) => (
|
||||
<div key={slot.assignmentId} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
|
||||
<span className="font-display text-[15px] font-black">{slot.day}</span>
|
||||
<span className="ml-auto font-mono text-[17px] font-bold text-yellow">
|
||||
{slot.hours}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-[11px]">
|
||||
<div className="font-display text-[25px] font-black leading-tight tracking-tight">
|
||||
{slot.position}
|
||||
{slots.map(({ assignment, shift }) => {
|
||||
const position = positionsById.get(shift.position_id);
|
||||
return (
|
||||
<div key={assignment.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="flex items-baseline gap-2 bg-ink px-[11px] py-2 text-cream">
|
||||
<span className="font-display text-[15px] font-black">
|
||||
{longDayLabel(shift.starts_at)}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[16px] font-bold text-yellow">
|
||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-display text-[14px] font-semibold opacity-75">{slot.zone}</div>
|
||||
<div className="mt-[9px] flex flex-col gap-[5px] border-t-2 border-ink pt-[9px]">
|
||||
<div className="font-mono text-[12px]">
|
||||
{t('mobileVol.location', { location: slot.location })}
|
||||
<div className="p-[11px]">
|
||||
<div className="font-display text-[23px] font-black leading-tight tracking-tight">
|
||||
{(position?.name ?? '').toUpperCase()}
|
||||
</div>
|
||||
<div className="font-mono text-[12px]">
|
||||
{t('mobileVol.lead', { name: slot.leadName })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-[11px] flex gap-[9px]">
|
||||
<a
|
||||
href={`tel:${slot.leadPhoneRaw}`}
|
||||
className="flex min-h-[52px] flex-none items-center justify-center border-[3px] border-ink bg-cream px-[14px] font-display text-[13px] font-extrabold no-underline"
|
||||
>
|
||||
{t('mobileVol.call', { phone: slot.leadPhone })}
|
||||
</a>
|
||||
{position?.location ? (
|
||||
<div className="mt-[9px] border-t-2 border-ink pt-[9px] font-mono text-[12px]">
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
{slot.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"
|
||||
onClick={() => dispatch({ type: 'confirmSlot', assignmentId: slot.assignmentId })}
|
||||
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"
|
||||
>
|
||||
{t('mobileVol.confirm')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{slots.length === 0 ? (
|
||||
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[11px] leading-relaxed opacity-70">
|
||||
{t('mobileVol.noSlots')}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="border-[3px] border-ink bg-blue p-3 shadow-brutal-lg">
|
||||
<div className="font-display text-[12px] font-black uppercase tracking-widest">
|
||||
{t('mobileVol.orgMessageTitle')}
|
||||
</div>
|
||||
<p className="mt-[7px] font-display text-[14px] font-semibold leading-relaxed">
|
||||
{t('mobileVol.orgMessageBody')}
|
||||
</p>
|
||||
<div className="mt-2 font-mono text-[10px] opacity-70">
|
||||
{t('mobileVol.orgMessageMeta')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PhoneFrame>
|
||||
|
||||
|
||||
@@ -1,77 +1,86 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
||||
import { Panel, PanelHeader, StatCard } from '@/components/ui';
|
||||
import { buildDiff, countConcernedVolunteers } from '@/domain/publication';
|
||||
import { indexPlanning } from '@/domain/selectors';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { hourRangeLabel, shortDayLabel } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
export function PublicationPage() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const assignments = useAssignments();
|
||||
const volunteers = useVolunteers();
|
||||
const shifts = useShifts();
|
||||
const positions = usePositions();
|
||||
|
||||
const diff = useMemo(() => buildDiff(state), [state]);
|
||||
const concerned = countConcernedVolunteers(diff);
|
||||
const index = useMemo(
|
||||
() => indexPlanning(state.assignments, state.volunteers),
|
||||
[state.assignments, state.volunteers],
|
||||
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 me = state.volunteers[0];
|
||||
const sampleVolunteer =
|
||||
diff.length > 0
|
||||
? state.volunteers.find((v) => v.fullName === diff[0].volunteerName) ?? me
|
||||
: me;
|
||||
const sampleFirstName = sampleVolunteer.firstName;
|
||||
const sampleSlots = (index.byVolunteer.get(sampleVolunteer.id) ?? []).length;
|
||||
const sampleHours = index.totalHours.get(sampleVolunteer.id) ?? 0;
|
||||
const when = state.published ? t('time.now') : t('time.yesterday');
|
||||
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={diff.length} label={t('publication.stat.changes')} tone="yellow" />
|
||||
<StatCard value={rows.length} label={t('publication.stat.proposed')} tone="yellow" />
|
||||
<StatCard value={concerned} label={t('publication.stat.concerned')} />
|
||||
<StatCard value={`v${state.version}`} label={t('publication.stat.version', { when })} />
|
||||
<StatCard value={confirmed} label={t('publication.stat.confirmed')} tone="green" />
|
||||
</div>
|
||||
|
||||
<Panel shadow="lg">
|
||||
<div className="flex items-center gap-[10px] bg-hatch-ink px-3 py-[9px] text-yellow">
|
||||
<span className="font-display text-[13px] font-black tracking-wide">
|
||||
{t('publication.draftTitle')}
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[9.5px] opacity-90">
|
||||
{t('publication.diffWith', { version: state.version })}
|
||||
</span>
|
||||
</div>
|
||||
{diff.length === 0 ? (
|
||||
<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.noDiff')}
|
||||
{t('publication.allConfirmed')}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
diff.map((entry, i) => (
|
||||
rows.map((row) => (
|
||||
<div
|
||||
key={i}
|
||||
key={row.id}
|
||||
className="flex items-center gap-[10px] border-b border-sand-line px-3 py-2"
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
'flex-none border-2 border-ink px-[6px] py-[3px] font-display text-[9px] font-extrabold uppercase tracking-wide',
|
||||
entry.kind === 'ADD' ? 'bg-green text-white' : 'bg-orange text-ink',
|
||||
)}
|
||||
>
|
||||
{entry.kind === 'ADD' ? t('publication.add') : t('publication.remove')}
|
||||
<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">
|
||||
{entry.volunteerName}
|
||||
{row.volunteerName}
|
||||
<span className="block font-mono text-[9.5px] opacity-65">
|
||||
{entry.positionName} {entry.zone} · {entry.dayShortLabel} · {entry.dayPartLabel}
|
||||
{row.positionName} · {row.label}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -84,51 +93,22 @@ export function PublicationPage() {
|
||||
<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.mailFrom', { from: state.config.mailFrom })}
|
||||
</div>
|
||||
<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.mailSubjectPrefix', { subject: state.config.mailSubject })}
|
||||
{t('publication.mailSubject')}
|
||||
</div>
|
||||
<p className="font-display text-[12.5px] leading-relaxed">
|
||||
{t('publication.mailGreeting', { name: sampleFirstName })}
|
||||
<br />
|
||||
<br />
|
||||
{t('publication.mailBody', { slots: sampleSlots, hours: sampleHours })}
|
||||
<br />
|
||||
<br />
|
||||
{t('publication.mailBody2')}
|
||||
</p>
|
||||
<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 className="mt-[11px] font-mono text-[9px] leading-relaxed opacity-55">
|
||||
{t('publication.magicLinkNote', { days: state.config.magicLinkDays })}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{diff.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'publish' })}
|
||||
className="w-full cursor-pointer border-[3px] border-ink bg-orange p-4 font-display text-[16px] font-black uppercase tracking-wide shadow-brutal-lg hover:bg-yellow"
|
||||
>
|
||||
{t('publication.publishButton', { count: concerned })}
|
||||
</button>
|
||||
<div className="mt-[9px] font-mono text-[9.5px] leading-relaxed opacity-60">
|
||||
{t('publication.publishNote', { version: state.version + 1 })}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="border-[3px] border-ink bg-green p-4 font-display text-[15px] font-black leading-snug text-white shadow-brutal-lg">
|
||||
{t('publication.publishedState', { version: state.version, when })}
|
||||
</div>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,69 +1,79 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { useAssignments, usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
||||
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
|
||||
import { Chip } from '@/components/ui';
|
||||
import { DAY_PARTS, FESTIVAL_DAYS } from '@/domain/festival';
|
||||
import { indexPlanning, isOverloaded } from '@/domain/selectors';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { buildLattice, hoursByVolunteer, isActive, isOverloaded } from '@/lib/planning';
|
||||
import { datePart, shortDayLabel, timePart } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
type VolunteerFilter = 'all' | 'overloaded' | 'unassigned';
|
||||
type Filter = 'all' | 'overloaded' | 'unassigned';
|
||||
|
||||
export function VolunteerViewPage() {
|
||||
const { state } = usePlanning();
|
||||
const [filter, setFilter] = useState<VolunteerFilter>('all');
|
||||
const volunteers = useVolunteers();
|
||||
const assignments = useAssignments();
|
||||
const shifts = useShifts();
|
||||
const positions = usePositions();
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
|
||||
const index = useMemo(
|
||||
() => indexPlanning(state.assignments, state.volunteers),
|
||||
[state.assignments, state.volunteers],
|
||||
const shiftsById = useMemo(
|
||||
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
||||
[shifts.data],
|
||||
);
|
||||
const positionsById = useMemo(
|
||||
() => new Map(state.positions.map((p) => [p.id, p])),
|
||||
[state.positions],
|
||||
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
|
||||
[positions.data],
|
||||
);
|
||||
const lattice = useMemo(
|
||||
() => buildLattice(shifts.data ?? []),
|
||||
[shifts.data],
|
||||
);
|
||||
const hours = useMemo(
|
||||
() => hoursByVolunteer(assignments.data ?? [], shiftsById),
|
||||
[assignments.data, shiftsById],
|
||||
);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return state.volunteers
|
||||
.filter((volunteer) => {
|
||||
if (filter === 'overloaded') return isOverloaded(volunteer, index, state.config);
|
||||
if (filter === 'unassigned') return (index.byVolunteer.get(volunteer.id) ?? []).length === 0;
|
||||
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);
|
||||
}
|
||||
return (volunteers.data ?? [])
|
||||
.filter((v) => {
|
||||
const own = byVolunteer.get(v.id) ?? [];
|
||||
if (filter === 'overloaded') return isOverloaded(v.id, all, shiftsById);
|
||||
if (filter === 'unassigned') return own.length === 0;
|
||||
return true;
|
||||
})
|
||||
.map((volunteer) => {
|
||||
const own = index.byVolunteer.get(volunteer.id) ?? [];
|
||||
const over = isOverloaded(volunteer, index, state.config);
|
||||
const cells = FESTIVAL_DAYS.flatMap((day) =>
|
||||
DAY_PARTS.map((part) => {
|
||||
const assignment = own.find(
|
||||
(a) => a.dayId === day.id && a.dayPartCode === part.code,
|
||||
);
|
||||
if (assignment) {
|
||||
const position = positionsById.get(assignment.positionId);
|
||||
return {
|
||||
kind: assignment.status === 'CONFIRMED' ? ('confirmed' as const) : ('proposed' as const),
|
||||
code: (position?.id ?? assignment.positionId).slice(0, 4),
|
||||
title: `${position?.name ?? ''} ${position?.zone ?? ''} · ${
|
||||
assignment.status === 'CONFIRMED'
|
||||
? t('assignment.status.confirmed')
|
||||
: t('assignment.status.proposed')
|
||||
}`.trim(),
|
||||
};
|
||||
}
|
||||
if (volunteer.availability.has(`${day.id}.${part.code}`)) {
|
||||
return { kind: 'free' as const, code: '', title: '' };
|
||||
}
|
||||
return { kind: 'none' as const, code: '', title: '' };
|
||||
}),
|
||||
);
|
||||
.map((v) => {
|
||||
const own = byVolunteer.get(v.id) ?? [];
|
||||
const cellByKey = new Map<string, AssignmentRead & { shift: ShiftRead }>();
|
||||
for (const a of own) {
|
||||
const shift = shiftsById.get(a.shift_id);
|
||||
if (shift) cellByKey.set(`${datePart(shift.starts_at)}|${timePart(shift.starts_at)}`, { ...a, shift });
|
||||
}
|
||||
return {
|
||||
volunteer,
|
||||
cells,
|
||||
total: index.totalHours.get(volunteer.id) ?? 0,
|
||||
over,
|
||||
volunteer: v,
|
||||
cellByKey,
|
||||
total: Math.round((hours.get(v.id) ?? 0) * 10) / 10,
|
||||
over: isOverloaded(v.id, all, shiftsById),
|
||||
empty: own.length === 0,
|
||||
};
|
||||
});
|
||||
}, [state.volunteers, state.config, filter, index, positionsById]);
|
||||
}, [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 (error) return <ErrorNote error={error} />;
|
||||
|
||||
const gridTemplateColumns = `230px repeat(${lattice.columns.length}, 62px) 84px`;
|
||||
const abbr = (positionId: string) => (positionsById.get(positionId)?.name ?? positionId).slice(0, 4).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
@@ -80,65 +90,57 @@ export function VolunteerViewPage() {
|
||||
{t('volunteers.filter.unassigned')}
|
||||
</Chip>
|
||||
</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 swatchClass="bg-blue border-ink">
|
||||
{t('volunteers.legend.confirmed')}
|
||||
</LegendItem>
|
||||
<LegendItem swatchClass="bg-paper border-ink border-dashed">
|
||||
<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 swatchClass="bg-dots-sand border-sand-line">
|
||||
{t('volunteers.legend.availability')}
|
||||
</LegendItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto">
|
||||
<div
|
||||
className="grid w-max border-b-[3px] border-ink"
|
||||
style={{ gridTemplateColumns: '210px repeat(16, 62px) 84px' }}
|
||||
>
|
||||
{/* Ligne 1 */}
|
||||
<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 })}
|
||||
</div>
|
||||
{FESTIVAL_DAYS.map((day) => (
|
||||
{lattice.days.map((day) => (
|
||||
<div
|
||||
key={day.id}
|
||||
key={day.date}
|
||||
className="sticky top-0 z-[4] flex items-baseline gap-[7px] border-b-2 border-r-[3px] border-ink border-b-cream border-r-cream bg-ink px-[9px] py-[6px] text-cream"
|
||||
style={{ gridColumn: 'span 4' }}
|
||||
style={{ gridColumn: `span ${day.columns.length}` }}
|
||||
>
|
||||
<span className="font-display text-[12px] font-black">{day.shortLabel}</span>
|
||||
<span className="font-mono text-[8.5px] font-medium text-yellow">{day.tag}</span>
|
||||
<span className="font-display text-[12px] font-black">
|
||||
{shortDayLabel(`${day.date}T12:00`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="sticky right-0 top-0 z-[7] flex items-center border-b-2 border-l-[3px] border-ink bg-yellow px-2 py-[6px] font-display text-[10px] font-extrabold uppercase tracking-wider">
|
||||
{t('volunteers.totalHours')}
|
||||
</div>
|
||||
|
||||
{/* Ligne 2 */}
|
||||
<div className="sticky left-0 top-[27px] z-[7] flex items-center border-b-[3px] border-r-[3px] border-ink bg-sand px-2 font-mono text-[8.5px] opacity-60">
|
||||
{t('volunteers.name')}
|
||||
</div>
|
||||
{FESTIVAL_DAYS.map((day) =>
|
||||
DAY_PARTS.map((part) => (
|
||||
<div
|
||||
key={`${day.id}-${part.code}`}
|
||||
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
|
||||
>
|
||||
{part.label}
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
{lattice.columns.map((column) => (
|
||||
<div
|
||||
key={column.key}
|
||||
className="sticky top-[27px] z-[3] border-b-[3px] border-r border-ink bg-sand py-[5px] text-center font-mono text-[9.5px]"
|
||||
>
|
||||
{column.time}
|
||||
</div>
|
||||
))}
|
||||
<div className="sticky right-0 top-[27px] z-[7] flex items-center justify-center border-b-[3px] border-l-[3px] border-ink bg-yellow-soft text-center font-mono text-[8.5px]">
|
||||
{t('volunteers.cumulative')}
|
||||
</div>
|
||||
|
||||
{/* Lignes bénévoles */}
|
||||
{rows.map((row) => (
|
||||
<VolunteerRow key={row.volunteer.id} row={row} />
|
||||
<VolunteerRow
|
||||
key={row.volunteer.id}
|
||||
row={row}
|
||||
columns={lattice.columns}
|
||||
abbr={abbr}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -151,45 +153,44 @@ export function VolunteerViewPage() {
|
||||
</div>
|
||||
) : null}
|
||||
<div className="px-[14px] py-[10px] font-mono text-[9.5px] leading-relaxed opacity-60">
|
||||
{t('volunteers.footnote', {
|
||||
maxHours: state.config.maxDailyHours,
|
||||
restHours: state.config.minRestHours,
|
||||
})}
|
||||
{t('volunteers.footnote')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendItem({
|
||||
swatchClass,
|
||||
children,
|
||||
}: {
|
||||
swatchClass: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
function LegendItem({ swatch, children }: { swatch: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="flex items-center gap-[5px] font-display text-[10px] font-semibold">
|
||||
<span className={cx('h-4 w-[30px] border-[1.5px]', swatchClass)} />
|
||||
<span className={cx('h-4 w-[30px] border-[1.5px]', swatch)} />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type Row = {
|
||||
volunteer: import('@/domain/types').Volunteer;
|
||||
cells: Array<{ kind: 'confirmed' | 'proposed' | 'free' | 'none'; code: string; title: string }>;
|
||||
volunteer: VolunteerRead;
|
||||
cellByKey: Map<string, AssignmentRead & { shift: ShiftRead }>;
|
||||
total: number;
|
||||
over: boolean;
|
||||
empty: boolean;
|
||||
};
|
||||
|
||||
function VolunteerRow({ row }: { row: Row }) {
|
||||
function VolunteerRow({
|
||||
row,
|
||||
columns,
|
||||
abbr,
|
||||
}: {
|
||||
row: Row;
|
||||
columns: { key: string }[];
|
||||
abbr: (positionId: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="sticky left-0 z-[2] flex h-8 items-center gap-[6px] border-b border-r-[3px] border-ink bg-cream px-2">
|
||||
<span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
|
||||
{row.volunteer.fullName}
|
||||
{row.volunteer.first_name} {row.volunteer.last_name}
|
||||
</span>
|
||||
{row.over ? (
|
||||
<span className="flex-none border-[1.5px] border-ink bg-orange px-1 py-[2px] font-display text-[8px] font-extrabold">
|
||||
@@ -202,41 +203,29 @@ function VolunteerRow({ row }: { row: Row }) {
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{row.cells.map((cell, i) => {
|
||||
if (cell.kind === 'confirmed') {
|
||||
{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={i}
|
||||
title={cell.title}
|
||||
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"
|
||||
>
|
||||
{cell.code}
|
||||
{abbr(cell.shift.position_id)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (cell.kind === 'proposed') {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
title={cell.title}
|
||||
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">
|
||||
{cell.code}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (cell.kind === 'free') {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="h-8 border-b border-r border-ink bg-dots-sand"
|
||||
style={{ backgroundSize: '5px 5px' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div key={i} className="h-8 border-b border-r border-ink bg-sand" />;
|
||||
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(
|
||||
|
||||
@@ -1,69 +1,93 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { DAY_PARTS, FESTIVAL_DAYS, dayPartHours } from '@/domain/festival';
|
||||
import {
|
||||
useCreatePosition,
|
||||
useCreateShift,
|
||||
useDeleteShift,
|
||||
usePositions,
|
||||
useShifts,
|
||||
useUpdateShift,
|
||||
} from '@/api/hooks';
|
||||
import type { ShiftRead } from '@/api/schema';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { categoryLabel } from '@/lib/planning';
|
||||
import { datePart, hourRangeLabel, hoursBetween, longDayLabel } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
export function ConfigGrid() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const [positionId, setPositionId] = useState(state.positions[0]?.id ?? '');
|
||||
const position = state.positions.find((p) => p.id === positionId) ?? state.positions[0];
|
||||
const positions = usePositions();
|
||||
const allShifts = useShifts();
|
||||
const createPosition = useCreatePosition();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
const position =
|
||||
(positions.data ?? []).find((p) => p.id === selectedId) ?? (positions.data ?? [])[0];
|
||||
const positionShifts = useShifts(position?.id);
|
||||
const updateShift = useUpdateShift();
|
||||
const deleteShift = useDeleteShift();
|
||||
const createShift = useCreateShift();
|
||||
|
||||
const totals = useMemo(() => {
|
||||
let slots = 0;
|
||||
let min = 0;
|
||||
let ideal = 0;
|
||||
state.positions.forEach((p) =>
|
||||
p.needs.forEach((need) => {
|
||||
slots += 1;
|
||||
min += need.minStaff * dayPartHours(need.dayPartCode);
|
||||
ideal += need.idealStaff * dayPartHours(need.dayPartCode);
|
||||
}),
|
||||
);
|
||||
return { slots, min, ideal };
|
||||
}, [state.positions]);
|
||||
for (const s of allShifts.data ?? []) {
|
||||
const h = hoursBetween(s.starts_at, s.ends_at);
|
||||
min += s.min_staff * h;
|
||||
ideal += s.ideal_staff * h;
|
||||
}
|
||||
return { min: Math.round(min), ideal: Math.round(ideal), count: (allShifts.data ?? []).length };
|
||||
}, [allShifts.data]);
|
||||
|
||||
const positionMin = position.needs.reduce(
|
||||
(sum, need) => sum + need.minStaff * dayPartHours(need.dayPartCode),
|
||||
0,
|
||||
);
|
||||
const positionIdeal = position.needs.reduce(
|
||||
(sum, need) => sum + need.idealStaff * dayPartHours(need.dayPartCode),
|
||||
0,
|
||||
);
|
||||
if (positions.isLoading) return <Loading />;
|
||||
if (positions.error) return <ErrorNote error={positions.error} />;
|
||||
if (!position) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })}
|
||||
className="cursor-pointer border-[3px] border-ink bg-yellow px-4 py-3 font-display text-[12px] font-extrabold uppercase shadow-brutal"
|
||||
>
|
||||
{t('config.grid.addPosition')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const byDay = groupByDay(positionShifts.data ?? []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-stretch">
|
||||
<div className="max-h-[calc(100vh-118px)] w-[250px] flex-none overflow-auto border-r-[3px] border-ink">
|
||||
<div className="sticky top-0 z-[2] bg-ink px-[10px] py-[7px] font-display text-[10px] font-extrabold uppercase tracking-wider text-cream">
|
||||
{t('config.grid.positionsHeader', { count: totals.slots })}
|
||||
{t('config.grid.positionsHeader', { count: totals.count })}
|
||||
</div>
|
||||
{state.positions.map((entry) => {
|
||||
{(positions.data ?? []).map((entry) => {
|
||||
const active = entry.id === position.id;
|
||||
const n = (allShifts.data ?? []).filter((s) => s.position_id === entry.id).length;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => setPositionId(entry.id)}
|
||||
onClick={() => setSelectedId(entry.id)}
|
||||
className={cx(
|
||||
'block w-full cursor-pointer border-b border-ink px-[9px] py-[7px] text-left',
|
||||
active
|
||||
? 'border-l-[6px] bg-yellow'
|
||||
: 'border-l-[6px] border-l-transparent bg-paper hover:bg-sand',
|
||||
active ? 'border-l-[6px] bg-yellow' : 'border-l-[6px] border-l-transparent bg-paper hover:bg-sand',
|
||||
)}
|
||||
>
|
||||
<div className="font-display text-[12.5px] font-bold leading-tight">
|
||||
{entry.name} <span className="opacity-60">{entry.zone}</span>
|
||||
<div className="truncate font-display text-[12.5px] font-bold leading-tight">
|
||||
{entry.name}
|
||||
</div>
|
||||
<div className="font-mono text-[9px] opacity-60">
|
||||
{t('config.grid.slotsCount', { count: entry.needs.length })}
|
||||
{categoryLabel(entry.category)} · {t('config.grid.slotsCount', { count: n })}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => createPosition.mutate({ name: t('config.grid.newPositionName'), category: 'OTHER' })}
|
||||
className="w-full cursor-pointer border-t-[3px] border-ink bg-cream p-3 font-display text-[11px] font-extrabold uppercase tracking-wide hover:bg-yellow"
|
||||
>
|
||||
{t('config.grid.addPosition')}
|
||||
@@ -73,128 +97,89 @@ export function ConfigGrid() {
|
||||
<div className="max-h-[calc(100vh-118px)] min-w-0 flex-1 overflow-auto p-4">
|
||||
<div className="mb-[14px] flex flex-wrap items-end gap-[14px]">
|
||||
<div>
|
||||
<div className="font-display text-[26px] font-black tracking-tight">
|
||||
{position.name} — {position.zone}
|
||||
</div>
|
||||
<div className="font-display text-[24px] font-black tracking-tight">{position.name}</div>
|
||||
<div className="font-mono text-[10.5px] opacity-65">
|
||||
{t(`position.category.${position.category.toLowerCase()}`)} · {position.location}
|
||||
{categoryLabel(position.category)}
|
||||
{position.location ? ` · ${position.location}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<div className="border-[3px] border-ink bg-paper px-[11px] py-2 shadow-brutal-md">
|
||||
<div className="font-mono text-[20px] font-bold">
|
||||
{t('unit.hours', { count: positionMin })}
|
||||
</div>
|
||||
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||||
{t('config.grid.minTotal')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-[3px] border-ink bg-yellow px-[11px] py-2 shadow-brutal-md">
|
||||
<div className="font-mono text-[20px] font-bold">
|
||||
{t('unit.hours', { count: positionIdeal })}
|
||||
</div>
|
||||
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||||
{t('config.grid.idealTotal')}
|
||||
</div>
|
||||
</div>
|
||||
<MiniStat value={`${totals.min} h`} label={t('config.grid.minTotal')} />
|
||||
<MiniStat value={`${totals.ideal} h`} label={t('config.grid.idealTotal')} accent />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(280px,1fr))]">
|
||||
{FESTIVAL_DAYS.map((day) => (
|
||||
<div key={day.id} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="flex items-baseline gap-[7px] bg-ink px-[10px] py-[7px] text-cream">
|
||||
<span className="font-display text-[13px] font-black">{day.shortLabel}</span>
|
||||
<span className="font-mono text-[8.5px] text-yellow">{day.tag}</span>
|
||||
{positionShifts.isLoading ? <Loading /> : null}
|
||||
|
||||
<div className="grid gap-[14px] [grid-template-columns:repeat(auto-fit,minmax(300px,1fr))]">
|
||||
{byDay.map(([day, shifts]) => (
|
||||
<div key={day} className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div className="bg-ink px-[10px] py-[7px] font-display text-[13px] font-black text-cream">
|
||||
{longDayLabel(`${day}T12:00`)}
|
||||
</div>
|
||||
{DAY_PARTS.map((part) => {
|
||||
const need = position.needs.find(
|
||||
(n) => n.dayId === day.id && n.dayPartCode === part.code,
|
||||
);
|
||||
if (!need) {
|
||||
return (
|
||||
<div
|
||||
key={part.code}
|
||||
className="flex items-center gap-2 border-b border-sand-line bg-hatch-sand px-[9px] py-[7px]"
|
||||
>
|
||||
<span className="w-[52px] flex-none font-mono text-[11px] opacity-50">
|
||||
{part.label}
|
||||
</span>
|
||||
<span className="font-display text-[10px] font-semibold opacity-55">
|
||||
{t('config.grid.closed')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: 'toggleSlot',
|
||||
positionId: position.id,
|
||||
dayId: day.id,
|
||||
dayPartCode: part.code,
|
||||
})
|
||||
}
|
||||
className="ml-auto cursor-pointer border-2 border-ink bg-cream px-2 py-1 font-display text-[9.5px] font-extrabold hover:bg-yellow"
|
||||
>
|
||||
{t('config.grid.open')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={part.code}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
|
||||
{shifts.map((shift) => (
|
||||
<div
|
||||
key={shift.id}
|
||||
className="flex flex-wrap items-center gap-2 border-b border-sand-line px-[9px] py-[7px]"
|
||||
>
|
||||
<span className="w-[92px] flex-none font-mono text-[11px]">
|
||||
{hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
</span>
|
||||
<Stepper
|
||||
label={t('config.grid.min')}
|
||||
value={shift.min_staff}
|
||||
onChange={(delta) =>
|
||||
updateShift.mutate({
|
||||
id: shift.id,
|
||||
body: { min_staff: Math.max(0, shift.min_staff + delta) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Stepper
|
||||
label={t('config.grid.ideal')}
|
||||
accent
|
||||
value={shift.ideal_staff}
|
||||
onChange={(delta) =>
|
||||
updateShift.mutate({
|
||||
id: shift.id,
|
||||
body: { ideal_staff: Math.max(0, shift.ideal_staff + delta) },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => deleteShift.mutate(shift.id)}
|
||||
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
|
||||
aria-label={t('action.remove')}
|
||||
>
|
||||
<span className="w-[52px] flex-none font-mono text-[11px]">{part.label}</span>
|
||||
<NeedStepper
|
||||
label={t('config.grid.min')}
|
||||
value={need.minStaff}
|
||||
onChange={(delta) =>
|
||||
dispatch({
|
||||
type: 'setNeed',
|
||||
positionId: position.id,
|
||||
dayId: day.id,
|
||||
dayPartCode: part.code,
|
||||
which: 'min',
|
||||
delta,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<NeedStepper
|
||||
label={t('config.grid.ideal')}
|
||||
value={need.idealStaff}
|
||||
accent
|
||||
onChange={(delta) =>
|
||||
dispatch({
|
||||
type: 'setNeed',
|
||||
positionId: position.id,
|
||||
dayId: day.id,
|
||||
dayPartCode: part.code,
|
||||
which: 'ideal',
|
||||
delta,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: 'toggleSlot',
|
||||
positionId: position.id,
|
||||
dayId: day.id,
|
||||
dayPartCode: part.code,
|
||||
})
|
||||
}
|
||||
className="ml-auto h-[22px] w-[22px] cursor-pointer border-2 border-ink bg-paper font-display text-[10px] font-bold hover:bg-orange"
|
||||
aria-label={t('action.close')}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const start = `${day}T12:00:00`;
|
||||
const end = `${day}T17:00:00`;
|
||||
createShift.mutate({
|
||||
position_id: position.id,
|
||||
starts_at: start,
|
||||
ends_at: end,
|
||||
min_staff: 2,
|
||||
ideal_staff: 3,
|
||||
});
|
||||
}}
|
||||
className="w-full cursor-pointer bg-cream p-2 font-display text-[10px] font-extrabold uppercase tracking-wide hover:bg-yellow"
|
||||
>
|
||||
{t('config.grid.addShift')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{byDay.length === 0 ? (
|
||||
<div className="border-[3px] border-dashed border-ink p-4 font-mono text-[10.5px] leading-relaxed opacity-70">
|
||||
{t('config.grid.noShifts')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-[14px] max-w-[760px] border-[3px] border-dashed border-ink p-[11px] font-mono text-[10.5px] leading-relaxed opacity-75">
|
||||
@@ -205,7 +190,32 @@ export function ConfigGrid() {
|
||||
);
|
||||
}
|
||||
|
||||
function NeedStepper({
|
||||
function groupByDay(shifts: ShiftRead[]): Array<[string, ShiftRead[]]> {
|
||||
const map = new Map<string, ShiftRead[]>();
|
||||
for (const s of [...shifts].sort((a, b) => a.starts_at.localeCompare(b.starts_at))) {
|
||||
const day = datePart(s.starts_at);
|
||||
map.set(day, [...(map.get(day) ?? []), s]);
|
||||
}
|
||||
return [...map.entries()];
|
||||
}
|
||||
|
||||
function MiniStat({ value, label, accent = false }: { value: string; label: string; accent?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cx(
|
||||
'border-[3px] border-ink px-[11px] py-2 shadow-brutal-md',
|
||||
accent ? 'bg-yellow' : 'bg-paper',
|
||||
)}
|
||||
>
|
||||
<div className="font-mono text-[20px] font-bold">{value}</div>
|
||||
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stepper({
|
||||
label,
|
||||
value,
|
||||
accent = false,
|
||||
|
||||
@@ -1,112 +1,67 @@
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
const MAIL_VARIABLES = [
|
||||
'{prenom}',
|
||||
'{nom}',
|
||||
'{nb_creneaux}',
|
||||
'{nb_heures}',
|
||||
'{poste}',
|
||||
'{referent}',
|
||||
'{lien_magique}',
|
||||
];
|
||||
const MAIL_VARIABLES = ['{prenom}', '{nom}', '{nb_creneaux}', '{nb_heures}', '{poste}', '{referent}', '{lien_magique}'];
|
||||
|
||||
// Écran maquette : l'envoi d'emails et la publication versionnée sont hors
|
||||
// périmètre (CLAUDE.md §4). Les réglages ci-dessous ne sont pas persistés.
|
||||
export function ConfigMail() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const { config } = state;
|
||||
const setConfig = (patch: Partial<typeof config>) => dispatch({ type: 'setConfig', patch });
|
||||
const [from, setFrom] = useState('coordination@lessonsdautomne.fr');
|
||||
const [replyTo, setReplyTo] = useState('marie.bertin@lessonsdautomne.fr');
|
||||
const [subject, setSubject] = useState('Ton planning bénévole a été mis à jour');
|
||||
const [body, setBody] = useState(
|
||||
"Salut {prenom},\n\nTon planning vient d'être mis à jour. Tu as {nb_creneaux} créneaux, soit {nb_heures} heures.\n\nMerci de confirmer chaque créneau depuis ton planning personnel.",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid items-start gap-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.mail.templateTitle')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-[13px]">
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.mail.from')}</FieldLabel>
|
||||
<TextInput mono value={config.mailFrom} onChange={(v) => setConfig({ mailFrom: v })} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.mail.replyTo')}</FieldLabel>
|
||||
<TextInput
|
||||
mono
|
||||
value={config.mailReplyTo}
|
||||
onChange={(v) => setConfig({ mailReplyTo: v })}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.mail.subject')}</FieldLabel>
|
||||
<input
|
||||
value={config.mailSubject}
|
||||
onChange={(e) => setConfig({ mailSubject: e.target.value })}
|
||||
className="w-full border-2 border-ink bg-yellow p-[9px] font-display text-[13px] font-extrabold"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.mail.body')}</FieldLabel>
|
||||
<textarea
|
||||
rows={7}
|
||||
value={config.mailBody}
|
||||
onChange={(e) => setConfig({ mailBody: e.target.value })}
|
||||
className="w-full resize-y border-2 border-ink bg-cream p-[9px] font-display text-[12.5px] leading-relaxed"
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<FieldLabel>{t('config.mail.variables')}</FieldLabel>
|
||||
<div className="flex flex-wrap gap-[6px]">
|
||||
{MAIL_VARIABLES.map((variable) => (
|
||||
<span
|
||||
key={variable}
|
||||
className="border-2 border-ink bg-blue px-[7px] py-[5px] font-mono text-[10.5px]"
|
||||
>
|
||||
{variable}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="p-4">
|
||||
<div className="mb-4 border-[3px] border-dashed border-ink bg-yellow-soft p-3 font-display text-[12px] font-bold">
|
||||
{t('config.mail.draftBanner')}
|
||||
</div>
|
||||
<div className="grid items-start gap-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.mail.sendsTitle')}</PanelHeader>
|
||||
<div className="flex flex-col gap-[11px] p-[13px]">
|
||||
<ToggleRow
|
||||
label={t('config.mail.reminderToggle')}
|
||||
on={config.reminderEnabled}
|
||||
onLabel={t('config.mail.on')}
|
||||
offLabel={t('config.mail.off')}
|
||||
onToggle={() => setConfig({ reminderEnabled: !config.reminderEnabled })}
|
||||
/>
|
||||
<StepperRow
|
||||
label={t('config.mail.reminderDelay')}
|
||||
value={t('unit.hours', { count: config.reminderHours })}
|
||||
onDecrement={() =>
|
||||
setConfig({ reminderHours: Math.max(12, config.reminderHours - 12) })
|
||||
}
|
||||
onIncrement={() =>
|
||||
setConfig({ reminderHours: Math.min(120, config.reminderHours + 12) })
|
||||
}
|
||||
/>
|
||||
<StepperRow
|
||||
label={t('config.mail.magicLinkValidity')}
|
||||
value={t('unit.days', { count: config.magicLinkDays })}
|
||||
onDecrement={() =>
|
||||
setConfig({ magicLinkDays: Math.max(15, config.magicLinkDays - 15) })
|
||||
}
|
||||
onIncrement={() =>
|
||||
setConfig({ magicLinkDays: Math.min(90, config.magicLinkDays + 15) })
|
||||
}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t('config.mail.smsToggle')}
|
||||
hint={t('config.mail.smsCost')}
|
||||
on={config.smsEnabled}
|
||||
onLabel={t('config.mail.onM')}
|
||||
offLabel={t('config.mail.offM')}
|
||||
onToggle={() => setConfig({ smsEnabled: !config.smsEnabled })}
|
||||
/>
|
||||
<PanelHeader>{t('config.mail.templateTitle')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-[13px]">
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.mail.from')}</FieldLabel>
|
||||
<TextInput mono value={from} onChange={setFrom} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.mail.replyTo')}</FieldLabel>
|
||||
<TextInput mono value={replyTo} onChange={setReplyTo} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.mail.subject')}</FieldLabel>
|
||||
<input
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className="w-full border-2 border-ink bg-yellow p-[9px] font-display text-[13px] font-extrabold"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.mail.body')}</FieldLabel>
|
||||
<textarea
|
||||
rows={7}
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="w-full resize-y border-2 border-ink bg-cream p-[9px] font-display text-[12.5px] leading-relaxed"
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<FieldLabel>{t('config.mail.variables')}</FieldLabel>
|
||||
<div className="flex flex-wrap gap-[6px]">
|
||||
{MAIL_VARIABLES.map((variable) => (
|
||||
<span
|
||||
key={variable}
|
||||
className="border-2 border-ink bg-blue px-[7px] py-[5px] font-mono text-[10.5px]"
|
||||
>
|
||||
{variable}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -114,14 +69,12 @@ export function ConfigMail() {
|
||||
<PanelHeader hatched>{t('config.mail.previewTitle')}</PanelHeader>
|
||||
<div className="p-[13px]">
|
||||
<div className="font-mono text-[9.5px] leading-relaxed opacity-60">
|
||||
{t('config.mail.previewFrom', { from: config.mailFrom })}
|
||||
{t('config.mail.previewFrom', { from })}
|
||||
</div>
|
||||
<div className="my-2 border-t-2 border-ink pt-2 font-display text-[13.5px] font-extrabold leading-snug">
|
||||
{config.mailSubject}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap font-display text-[12.5px] leading-relaxed">
|
||||
{config.mailBody}
|
||||
{subject}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap font-display text-[12.5px] leading-relaxed">{body}</div>
|
||||
<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('config.mail.cta')}
|
||||
</div>
|
||||
@@ -131,73 +84,3 @@ export function ConfigMail() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
hint,
|
||||
on,
|
||||
onLabel,
|
||||
offLabel,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
on: boolean;
|
||||
onLabel: string;
|
||||
offLabel: string;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-[11px] border-t border-sand-line pt-[11px] first:border-t-0 first:pt-0">
|
||||
<span className="flex-1 font-display text-[13px] font-bold leading-snug">
|
||||
{label}
|
||||
{hint ? (
|
||||
<span className="block font-mono text-[9.5px] font-normal opacity-60">{hint}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className={cx(
|
||||
'flex-none cursor-pointer border-2 border-ink px-[11px] py-2 font-display text-[11px] font-extrabold uppercase',
|
||||
on ? 'bg-green text-white' : 'bg-cream',
|
||||
)}
|
||||
>
|
||||
{on ? onLabel : offLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StepperRow({
|
||||
label,
|
||||
value,
|
||||
onDecrement,
|
||||
onIncrement,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onDecrement: () => void;
|
||||
onIncrement: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-[11px] border-t border-sand-line pt-[11px]">
|
||||
<span className="flex-1 font-display text-[13px] font-bold leading-snug">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDecrement}
|
||||
className="h-7 w-7 cursor-pointer border-2 border-ink bg-cream font-display text-[14px] font-extrabold"
|
||||
>
|
||||
–
|
||||
</button>
|
||||
<span className="min-w-[54px] text-center font-mono text-[16px] font-bold">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onIncrement}
|
||||
className="h-7 w-7 cursor-pointer border-2 border-ink bg-yellow font-display text-[14px] font-extrabold"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,138 +1,82 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { FieldLabel, Panel, PanelHeader, TextInput } from '@/components/ui';
|
||||
import { indexPlanning, isOverloaded } from '@/domain/selectors';
|
||||
import { API_MODE, EDITION_ID } from '@/api';
|
||||
import { usePositions, useShifts, useVolunteers } from '@/api/hooks';
|
||||
import { Panel, PanelHeader } from '@/components/ui';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
export function ConfigRules() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const { config } = state;
|
||||
const setConfig = (patch: Partial<typeof config>) => dispatch({ type: 'setConfig', patch });
|
||||
const RULE_CODES = [
|
||||
'SHIFT_OVERLAP',
|
||||
'OUTSIDE_AVAILABILITY',
|
||||
'DAILY_HOURS_EXCEEDED',
|
||||
'INSUFFICIENT_REST',
|
||||
'MISSING_REQUIRED_SKILL',
|
||||
] as const;
|
||||
|
||||
const overCount = useMemo(() => {
|
||||
const index = indexPlanning(state.assignments, state.volunteers);
|
||||
return state.volunteers.filter((v) => isOverloaded(v, index, config)).length;
|
||||
}, [state.assignments, state.volunteers, config]);
|
||||
// Écran informatif : les seuils du moteur sont figés (CLAUDE.md §3) et il n'y a
|
||||
// pas encore d'endpoint de configuration d'édition.
|
||||
export function ConfigRules() {
|
||||
const volunteers = useVolunteers();
|
||||
const positions = usePositions();
|
||||
const shifts = useShifts();
|
||||
|
||||
return (
|
||||
<div className="grid items-start gap-4 p-4 [grid-template-columns:repeat(auto-fit,minmax(320px,1fr))]">
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.rules.editionTitle')}</PanelHeader>
|
||||
<div className="flex flex-col gap-3 p-[13px]">
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.rules.festivalName')}</FieldLabel>
|
||||
<input
|
||||
value={config.festivalName}
|
||||
onChange={(e) => setConfig({ festivalName: e.target.value })}
|
||||
className="w-full border-2 border-ink bg-yellow p-[9px] font-display text-[15px] font-black"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.rules.dates')}</FieldLabel>
|
||||
<TextInput value={config.datesLabel} onChange={(v) => setConfig({ datesLabel: v })} />
|
||||
</label>
|
||||
<div className="border-t-2 border-ink pt-[11px] font-mono text-[10px] leading-relaxed opacity-70">
|
||||
{t('config.rules.datesNote')}
|
||||
<div className="flex flex-col gap-2 p-[13px]">
|
||||
<InfoRow label={t('config.rules.editionId')} value={EDITION_ID} mono />
|
||||
<InfoRow label={t('config.rules.apiMode')} value={API_MODE.toUpperCase()} />
|
||||
<InfoRow label={t('config.rules.volunteersCount')} value={String(volunteers.data?.length ?? '…')} />
|
||||
<InfoRow label={t('config.rules.positionsCount')} value={String(positions.data?.length ?? '…')} />
|
||||
<InfoRow label={t('config.rules.shiftsCount')} value={String(shifts.data?.length ?? '…')} />
|
||||
<div className="mt-1 border-t-2 border-ink pt-2 font-mono text-[10px] leading-relaxed opacity-70">
|
||||
{t('config.rules.editionNote')}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.rules.thresholdsTitle')}</PanelHeader>
|
||||
<div className="flex flex-col gap-[11px] p-[13px]">
|
||||
<ThresholdRow
|
||||
label={t('config.rules.maxDaily')}
|
||||
hint={t('config.rules.maxDailyNote')}
|
||||
value={t('unit.hours', { count: config.maxDailyHours })}
|
||||
onDecrement={() => setConfig({ maxDailyHours: Math.max(4, config.maxDailyHours - 1) })}
|
||||
onIncrement={() => setConfig({ maxDailyHours: Math.min(14, config.maxDailyHours + 1) })}
|
||||
/>
|
||||
<ThresholdRow
|
||||
label={t('config.rules.minRest')}
|
||||
hint={t('config.rules.minRestNote')}
|
||||
value={t('unit.hours', { count: config.minRestHours })}
|
||||
onDecrement={() => setConfig({ minRestHours: Math.max(0, config.minRestHours - 1) })}
|
||||
onIncrement={() => setConfig({ minRestHours: Math.min(14, config.minRestHours + 1) })}
|
||||
/>
|
||||
<div className="flex items-center gap-[11px] border-t-2 border-ink pt-[11px]">
|
||||
<span className="flex h-[38px] w-[38px] flex-none items-center justify-center border-[3px] border-ink bg-orange font-display text-[15px] font-extrabold">
|
||||
▲
|
||||
</span>
|
||||
<span className="font-display text-[13px] font-bold leading-snug">
|
||||
{t('config.rules.overCount', { count: overCount })}
|
||||
</span>
|
||||
<div className="flex flex-col gap-2 p-[13px]">
|
||||
<InfoRow label={t('config.rules.maxDaily')} value="8 h" />
|
||||
<InfoRow label={t('config.rules.minRest')} value="8 h" />
|
||||
<div className="mt-1 font-mono text-[10px] leading-relaxed opacity-70">
|
||||
{t('config.rules.thresholdsNote')}
|
||||
</div>
|
||||
<div className="font-mono text-[10px] leading-relaxed opacity-70">
|
||||
{t('config.rules.overlapNote')}
|
||||
<div className="mt-2 border-t-2 border-ink pt-2">
|
||||
<div className="mb-1 font-display text-[9px] font-extrabold uppercase tracking-widest">
|
||||
{t('config.rules.rulesTitle')}
|
||||
</div>
|
||||
{RULE_CODES.map((code) => (
|
||||
<div key={code} className="flex items-baseline gap-2 py-[3px]">
|
||||
<span className="font-mono text-[9px] opacity-55">{code}</span>
|
||||
<span className="font-display text-[11px] font-semibold leading-snug">
|
||||
{t(`validation.${code}.rule`)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel shadow="lg">
|
||||
<PanelHeader>{t('config.rules.rolesTitle')}</PanelHeader>
|
||||
<PanelHeader>{t('config.rules.rolesTitle2')}</PanelHeader>
|
||||
<div className="flex flex-col gap-[9px] p-[13px]">
|
||||
<RoleCard
|
||||
tone="bg-yellow"
|
||||
title={t('config.rules.roleCoordinator')}
|
||||
desc={t('config.rules.roleCoordinatorDesc')}
|
||||
/>
|
||||
<RoleCard
|
||||
tone="bg-blue"
|
||||
title={t('config.rules.roleLead')}
|
||||
desc={t('config.rules.roleLeadDesc')}
|
||||
/>
|
||||
<RoleCard
|
||||
tone="bg-cream"
|
||||
title={t('config.rules.roleVolunteer', { count: state.volunteers.length })}
|
||||
desc={t('config.rules.roleVolunteerDesc')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-[3px] cursor-pointer border-[3px] border-ink bg-cream p-[11px] font-display text-[11.5px] font-extrabold uppercase tracking-wide shadow-brutal hover:bg-yellow"
|
||||
>
|
||||
{t('config.rules.designateLead')}
|
||||
</button>
|
||||
<RoleCard tone="bg-yellow" title={t('config.rules.roleCoordinator')} desc={t('config.rules.roleCoordinatorDesc')} />
|
||||
<RoleCard tone="bg-blue" title={t('config.rules.roleLead')} desc={t('config.rules.roleLeadDesc')} />
|
||||
<RoleCard tone="bg-cream" title={t('config.rules.roleVolunteer')} desc={t('config.rules.roleVolunteerDesc')} />
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThresholdRow({
|
||||
label,
|
||||
hint,
|
||||
value,
|
||||
onDecrement,
|
||||
onIncrement,
|
||||
}: {
|
||||
label: string;
|
||||
hint: string;
|
||||
value: string;
|
||||
onDecrement: () => void;
|
||||
onIncrement: () => void;
|
||||
}) {
|
||||
function InfoRow({ label, value, mono = false }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-[11px] border-t border-sand-line pt-[11px] first:border-t-0 first:pt-0">
|
||||
<span className="flex-1 font-display text-[13px] font-bold leading-snug">
|
||||
{label}
|
||||
<span className="block font-mono text-[9.5px] font-normal opacity-60">{hint}</span>
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="font-display text-[11px] font-bold">{label}</span>
|
||||
<span className={mono ? 'font-mono text-[10px] opacity-75' : 'font-display text-[12px] font-extrabold'}>
|
||||
{value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDecrement}
|
||||
className="h-7 w-7 cursor-pointer border-2 border-ink bg-cream font-display text-[14px] font-extrabold"
|
||||
>
|
||||
–
|
||||
</button>
|
||||
<span className="min-w-[48px] text-center font-mono text-[18px] font-bold">{value}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onIncrement}
|
||||
className="h-7 w-7 cursor-pointer border-2 border-ink bg-yellow font-display text-[14px] font-extrabold"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,35 +1,50 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import {
|
||||
useCreateVolunteer,
|
||||
useDeleteVolunteer,
|
||||
useUpdateVolunteer,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
import type { TshirtSize } from '@/api/schema';
|
||||
import { FieldLabel, TextInput } from '@/components/ui';
|
||||
import { DEMO_CATEGORIES, categoryLabelKey } from '@/domain/festival';
|
||||
import { indexPlanning } from '@/domain/selectors';
|
||||
import { ErrorNote, Loading } from '@/components/state';
|
||||
import { t } from '@/i18n';
|
||||
import { cx } from '@/lib/cx';
|
||||
|
||||
const TABLE_COLUMNS = 'minmax(0,2fr) minmax(0,1.3fr) minmax(0,1.6fr) minmax(0,1fr)';
|
||||
|
||||
function demoEmail(firstName: string, lastName: string): string {
|
||||
const normalize = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[^a-z]/g, '');
|
||||
return `${normalize(firstName)}.${normalize(lastName)}@mail.fr`;
|
||||
}
|
||||
const TSHIRT_SIZES: TshirtSize[] = ['XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'];
|
||||
const IMPORT_SAMPLE: Array<[string, string]> = [
|
||||
['Océane', 'Thibault'],
|
||||
['Samir', 'Amrani'],
|
||||
['Lucie', 'Charpentier'],
|
||||
['Noé', 'Delaunay'],
|
||||
];
|
||||
|
||||
export function ConfigVolunteers() {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const [selectedId, setSelectedId] = useState(state.volunteers[0]?.id ?? '');
|
||||
const selected = state.volunteers.find((v) => v.id === selectedId) ?? state.volunteers[0];
|
||||
const volunteers = useVolunteers();
|
||||
const updateVolunteer = useUpdateVolunteer();
|
||||
const deleteVolunteer = useDeleteVolunteer();
|
||||
const createVolunteer = useCreateVolunteer();
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [skillDraft, setSkillDraft] = useState('');
|
||||
|
||||
const index = useMemo(
|
||||
() => indexPlanning(state.assignments, state.volunteers),
|
||||
[state.assignments, state.volunteers],
|
||||
);
|
||||
const selected = useMemo(() => {
|
||||
const list = volunteers.data ?? [];
|
||||
return list.find((v) => v.id === selectedId) ?? list[0];
|
||||
}, [volunteers.data, selectedId]);
|
||||
|
||||
const rows = state.volunteers.slice(0, 26);
|
||||
const assignedHours = index.totalHours.get(selected.id) ?? 0;
|
||||
if (volunteers.isLoading) return <Loading />;
|
||||
if (volunteers.error) return <ErrorNote error={volunteers.error} />;
|
||||
|
||||
const rows = (volunteers.data ?? []).slice(0, 40);
|
||||
const patch = (body: Parameters<typeof updateVolunteer.mutate>[0]['body']) =>
|
||||
selected && updateVolunteer.mutate({ id: selected.id, body });
|
||||
|
||||
const runImport = () => {
|
||||
for (const [first_name, last_name] of IMPORT_SAMPLE) {
|
||||
createVolunteer.mutate({ first_name, last_name });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 items-stretch">
|
||||
@@ -49,40 +64,33 @@ export function ConfigVolunteers() {
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'runImport' })}
|
||||
className="flex-none cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white"
|
||||
onClick={runImport}
|
||||
disabled={createVolunteer.isPending}
|
||||
className="flex-none cursor-pointer border-[3px] border-ink bg-yellow px-[14px] py-3 font-display text-[12px] font-black uppercase tracking-wide shadow-brutal hover:bg-green hover:text-white disabled:opacity-60"
|
||||
>
|
||||
{t('config.vol.importButton')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-none border-[3px] border-ink bg-paper px-[14px] py-3 shadow-brutal-lg">
|
||||
<div className="font-mono text-[24px] font-bold">{state.volunteers.length}</div>
|
||||
<div className="font-mono text-[24px] font-bold">{volunteers.data?.length ?? 0}</div>
|
||||
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
|
||||
{t('config.vol.profiles')}
|
||||
</div>
|
||||
</div>
|
||||
{state.importedCount > 0 ? (
|
||||
<div className="flex-none border-[3px] border-ink bg-green px-[14px] py-3 text-white shadow-brutal-lg">
|
||||
<div className="font-mono text-[24px] font-bold">+{state.importedCount}</div>
|
||||
<div className="mt-1 font-display text-[9px] font-extrabold uppercase tracking-wide">
|
||||
{t('config.vol.imported')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="border-[3px] border-ink bg-paper shadow-brutal-lg">
|
||||
<div
|
||||
className="grid gap-[10px] bg-ink px-[10px] py-[7px] font-display text-[9.5px] font-extrabold uppercase tracking-wide text-cream"
|
||||
style={{ gridTemplateColumns: TABLE_COLUMNS }}
|
||||
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
|
||||
>
|
||||
<span>{t('config.vol.colName')}</span>
|
||||
<span>{t('config.vol.colPhone')}</span>
|
||||
<span>{t('config.vol.colWanted')}</span>
|
||||
<span>{t('config.vol.colAvailability')}</span>
|
||||
<span>{t('config.vol.colEmail')}</span>
|
||||
<span>{t('config.vol.colSkills')}</span>
|
||||
</div>
|
||||
{rows.map((volunteer) => {
|
||||
const active = volunteer.id === selected.id;
|
||||
const active = volunteer.id === selected?.id;
|
||||
return (
|
||||
<button
|
||||
key={volunteer.id}
|
||||
@@ -92,29 +100,23 @@ export function ConfigVolunteers() {
|
||||
'grid w-full items-center gap-[10px] border-b border-sand-line px-[10px] py-[7px] text-left',
|
||||
active ? 'bg-yellow' : 'hover:bg-cream',
|
||||
)}
|
||||
style={{ gridTemplateColumns: TABLE_COLUMNS }}
|
||||
style={{ gridTemplateColumns: 'minmax(0,2fr) minmax(0,1.4fr) minmax(0,2fr) minmax(0,1.4fr)' }}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-[7px]">
|
||||
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-paper font-display text-[9.5px] font-extrabold">
|
||||
{volunteer.initials}
|
||||
{volunteer.first_name[0]}
|
||||
{volunteer.last_name[0]}
|
||||
</span>
|
||||
<span className="truncate font-display text-[12.5px] font-bold">
|
||||
{volunteer.fullName}
|
||||
{volunteer.first_name} {volunteer.last_name}
|
||||
</span>
|
||||
{volunteer.imported ? (
|
||||
<span className="flex-none border-[1.5px] border-ink bg-green px-1 py-[2px] font-display text-[8px] font-extrabold text-white">
|
||||
{t('config.vol.new')}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] opacity-80">{volunteer.phone}</span>
|
||||
<span className="truncate font-display text-[11px] font-semibold opacity-80">
|
||||
{volunteer.preferredCategories
|
||||
.map((c) => t(categoryLabelKey(c)))
|
||||
.join(' · ')}
|
||||
<span className="truncate font-mono text-[11px] opacity-80">{volunteer.phone ?? '—'}</span>
|
||||
<span className="truncate font-mono text-[10.5px] opacity-70">
|
||||
{volunteer.email ?? '—'}
|
||||
</span>
|
||||
<span className="font-mono text-[10.5px] opacity-65">
|
||||
{t('config.vol.slotsDeclared', { count: volunteer.availability.size })}
|
||||
<span className="truncate font-mono text-[10px] opacity-70">
|
||||
{volunteer.skill_codes.join(' · ') || '—'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -122,95 +124,119 @@ export function ConfigVolunteers() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
||||
<div className="bg-ink px-[13px] py-[11px] text-cream">
|
||||
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
|
||||
<div className="mt-[7px] flex items-center gap-[9px]">
|
||||
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-2 border-cream bg-yellow font-display text-[13px] font-extrabold text-ink">
|
||||
{selected.initials}
|
||||
</span>
|
||||
<span className="font-display text-[19px] font-black tracking-tight">
|
||||
{selected.fullName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 p-[13px]">
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.vol.fullName')}</FieldLabel>
|
||||
<TextInput
|
||||
value={selected.fullName}
|
||||
onChange={(value) =>
|
||||
dispatch({ type: 'editVolunteer', id: selected.id, field: 'fullName', value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.vol.phone')}</FieldLabel>
|
||||
<TextInput
|
||||
mono
|
||||
value={selected.phone}
|
||||
onChange={(value) =>
|
||||
dispatch({ type: 'editVolunteer', id: selected.id, field: 'phone', value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<FieldLabel>{t('config.vol.email')}</FieldLabel>
|
||||
<div className="truncate border-2 border-ink bg-sand p-[9px] font-mono text-[12px]">
|
||||
{demoEmail(selected.firstName, selected.lastName)}
|
||||
{selected ? (
|
||||
<aside className="max-h-[calc(100vh-118px)] w-[360px] flex-none overflow-auto border-l-[3px] border-ink bg-paper">
|
||||
<div className="bg-ink px-[13px] py-[11px] text-cream">
|
||||
<div className="font-mono text-[10px] text-yellow">{t('config.vol.ficheKicker')}</div>
|
||||
<div className="mt-[7px] flex items-center gap-[9px]">
|
||||
<span className="flex h-[34px] w-[34px] flex-none items-center justify-center border-2 border-cream bg-yellow font-display text-[13px] font-extrabold text-ink">
|
||||
{selected.first_name[0]}
|
||||
{selected.last_name[0]}
|
||||
</span>
|
||||
<span className="font-display text-[18px] font-black tracking-tight">
|
||||
{selected.first_name} {selected.last_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>{t('config.vol.wanted')}</FieldLabel>
|
||||
<div className="flex flex-wrap gap-[6px]">
|
||||
{DEMO_CATEGORIES.map((category) => {
|
||||
const on = selected.preferredCategories.includes(category);
|
||||
return (
|
||||
|
||||
<div className="flex flex-col gap-3 p-[13px]">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.vol.firstName')}</FieldLabel>
|
||||
<TextInput value={selected.first_name} onChange={(v) => patch({ first_name: v })} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.vol.lastName')}</FieldLabel>
|
||||
<TextInput value={selected.last_name} onChange={(v) => patch({ last_name: v })} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.vol.phone')}</FieldLabel>
|
||||
<TextInput mono value={selected.phone ?? ''} onChange={(v) => patch({ phone: v || null })} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.vol.email')}</FieldLabel>
|
||||
<TextInput mono value={selected.email ?? ''} onChange={(v) => patch({ email: v || null })} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<FieldLabel>{t('config.vol.tshirt')}</FieldLabel>
|
||||
<select
|
||||
value={selected.tshirt_size ?? ''}
|
||||
onChange={(e) => patch({ tshirt_size: (e.target.value || null) as TshirtSize | null })}
|
||||
className="w-full border-2 border-ink bg-cream p-[9px] font-display text-[13px] font-bold"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{TSHIRT_SIZES.map((size) => (
|
||||
<option key={size} value={size}>
|
||||
{size}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<FieldLabel>{t('config.vol.skills')}</FieldLabel>
|
||||
<div className="mb-2 flex flex-wrap gap-[6px]">
|
||||
{selected.skill_codes.map((code) => (
|
||||
<button
|
||||
key={category}
|
||||
key={code}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
dispatch({ type: 'togglePreference', id: selected.id, category })
|
||||
patch({ skill_codes: selected.skill_codes.filter((c) => c !== code) })
|
||||
}
|
||||
className={cx(
|
||||
'cursor-pointer border-2 border-ink px-2 py-[6px] font-display text-[10.5px]',
|
||||
on ? 'bg-blue font-extrabold' : 'bg-cream font-semibold hover:bg-yellow',
|
||||
)}
|
||||
className="cursor-pointer border-2 border-ink bg-blue px-2 py-[5px] font-mono text-[10px] font-semibold hover:bg-orange"
|
||||
>
|
||||
{on ? '★ ' : ''}
|
||||
{t(categoryLabelKey(category))}
|
||||
{code} ✕
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-[9px] border-t-2 border-ink pt-[11px]">
|
||||
<div className="flex-1 border-2 border-ink bg-cream p-2">
|
||||
<div className="font-mono text-[17px] font-bold">
|
||||
{t('unit.hours', { count: assignedHours })}
|
||||
</div>
|
||||
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||||
{t('config.vol.hoursAssigned')}
|
||||
))}
|
||||
{selected.skill_codes.length === 0 ? (
|
||||
<span className="font-mono text-[10px] opacity-55">{t('config.vol.noSkills')}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const code = skillDraft.trim().toUpperCase();
|
||||
if (code && !selected.skill_codes.includes(code)) {
|
||||
patch({ skill_codes: [...selected.skill_codes, code] });
|
||||
}
|
||||
setSkillDraft('');
|
||||
}}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<input
|
||||
value={skillDraft}
|
||||
onChange={(e) => setSkillDraft(e.target.value)}
|
||||
placeholder={t('config.vol.skillPlaceholder')}
|
||||
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="cursor-pointer border-2 border-ink bg-yellow px-3 font-display text-[11px] font-extrabold uppercase hover:bg-green hover:text-white"
|
||||
>
|
||||
{t('action.add')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 border-2 border-ink bg-dots-sand p-2"
|
||||
style={{ backgroundSize: '5px 5px' }}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm(t('config.vol.deleteConfirm'))) {
|
||||
deleteVolunteer.mutate(selected.id);
|
||||
setSelectedId(null);
|
||||
}
|
||||
}}
|
||||
className="mt-2 cursor-pointer border-[3px] border-ink bg-cream p-[10px] font-display text-[11px] font-extrabold uppercase tracking-wide shadow-brutal hover:bg-orange"
|
||||
>
|
||||
<div className="font-mono text-[11px] leading-tight">
|
||||
{t('config.vol.availabilityValue', { count: selected.availability.size })}
|
||||
</div>
|
||||
<div className="mt-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
|
||||
{t('config.vol.availabilityDeclared')}
|
||||
</div>
|
||||
{t('config.vol.delete')}
|
||||
</button>
|
||||
<div className="font-mono text-[10px] leading-relaxed opacity-65">
|
||||
{t('config.vol.note')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono text-[10px] leading-relaxed opacity-65">
|
||||
{t('config.vol.note')}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,109 +1,103 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { usePlanning } from '@/app/planning-store';
|
||||
import { ApiError } from '@/api';
|
||||
import {
|
||||
useAssignments,
|
||||
useCreateAssignment,
|
||||
useDeleteAssignment,
|
||||
usePositions,
|
||||
useShifts,
|
||||
useUpdateAssignment,
|
||||
useValidateAssignment,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
import type { MatrixShift, PositionCategory, VolunteerRead } from '@/api/schema';
|
||||
import { Badge, Btn } from '@/components/ui';
|
||||
import { dayPartHours, festivalDay, dayPart } from '@/domain/festival';
|
||||
import { evaluateAssignment, hasBlocking, type Violation } from '@/domain/rules';
|
||||
import type { PlanningIndex } from '@/domain/selectors';
|
||||
import { Loading } from '@/components/state';
|
||||
import { categoryLabel, hoursByVolunteer, isActive, violationText } from '@/lib/planning';
|
||||
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
function violationText(violation: Violation): string {
|
||||
return t(`validation.${violation.code}`, violation.params);
|
||||
interface Props {
|
||||
positionName: string;
|
||||
positionLocation: string | null;
|
||||
positionCategory: PositionCategory;
|
||||
shift: MatrixShift;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AssignmentPanel({ slot, index }: { slot: string; index: PlanningIndex }) {
|
||||
const { state, dispatch } = usePlanning();
|
||||
const [positionId, dayId, dayPartCode] = slot.split('|');
|
||||
const MAX_CANDIDATES = 10;
|
||||
|
||||
const positionsById = useMemo(
|
||||
() => new Map(state.positions.map((p) => [p.id, p])),
|
||||
[state.positions],
|
||||
export function AssignmentPanel({
|
||||
positionName,
|
||||
positionLocation,
|
||||
positionCategory,
|
||||
shift,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const volunteers = useVolunteers();
|
||||
const allAssignments = useAssignments();
|
||||
const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
|
||||
const shifts = useShifts();
|
||||
const positions = usePositions();
|
||||
const deleteAssignment = useDeleteAssignment();
|
||||
const updateAssignment = useUpdateAssignment();
|
||||
|
||||
const shiftsById = useMemo(
|
||||
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
||||
[shifts.data],
|
||||
);
|
||||
const position = positionsById.get(positionId);
|
||||
const need = position?.needs.find((n) => n.dayId === dayId && n.dayPartCode === dayPartCode);
|
||||
const positionsById = useMemo(
|
||||
() => new Map((positions.data ?? []).map((p) => [p.id, p])),
|
||||
[positions.data],
|
||||
);
|
||||
const hours = useMemo(
|
||||
() => hoursByVolunteer(allAssignments.data ?? [], shiftsById),
|
||||
[allAssignments.data, shiftsById],
|
||||
);
|
||||
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);
|
||||
}
|
||||
return map;
|
||||
}, [allAssignments.data]);
|
||||
|
||||
const assignedList = useMemo(() => index.bySlot.get(slot) ?? [], [index, slot]);
|
||||
const durationHours = dayPartHours(dayPartCode);
|
||||
const resolvePosition = (shiftId: string): string | undefined => {
|
||||
const s = shiftsById.get(shiftId);
|
||||
const p = s ? positionsById.get(s.position_id) : undefined;
|
||||
return p?.name;
|
||||
};
|
||||
|
||||
const durationHours = hoursBetween(shift.starts_at, shift.ends_at);
|
||||
const assigned = useMemo(() => shiftAssignments.data ?? [], [shiftAssignments.data]);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
if (!position || !need) return [];
|
||||
return state.volunteers
|
||||
.filter((v) => !assignedList.some((a) => a.volunteerId === v.id))
|
||||
.map((volunteer) => {
|
||||
const currentAssignments = index.byVolunteer.get(volunteer.id) ?? [];
|
||||
const violations = evaluateAssignment({
|
||||
volunteer,
|
||||
target: { position, dayId, dayPartCode },
|
||||
currentAssignments,
|
||||
positionsById,
|
||||
thresholds: {
|
||||
maxDailyHours: state.config.maxDailyHours,
|
||||
minRestHours: state.config.minRestHours,
|
||||
},
|
||||
});
|
||||
const blocked = hasBlocking(violations);
|
||||
const blocking = violations.find((v) => v.severity === 'BLOCKING') ?? null;
|
||||
const warnings = violations.filter((v) => v.severity === 'WARNING');
|
||||
const available = volunteer.availability.has(`${dayId}.${dayPartCode}`);
|
||||
const wanted = volunteer.preferredCategories.includes(position.category);
|
||||
const hours = index.totalHours.get(volunteer.id) ?? 0;
|
||||
const score =
|
||||
(available ? 100 : 0) +
|
||||
(wanted ? 40 : 0) -
|
||||
warnings.length * 12 -
|
||||
hours * 2 -
|
||||
(blocked ? 1000 : 0);
|
||||
return {
|
||||
volunteer,
|
||||
blocked,
|
||||
blocking,
|
||||
warnings,
|
||||
available,
|
||||
wanted,
|
||||
hours,
|
||||
slots: currentAssignments.length,
|
||||
score,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 9);
|
||||
}, [
|
||||
position,
|
||||
need,
|
||||
state.volunteers,
|
||||
state.config.maxDailyHours,
|
||||
state.config.minRestHours,
|
||||
assignedList,
|
||||
index,
|
||||
positionsById,
|
||||
dayId,
|
||||
dayPartCode,
|
||||
]);
|
||||
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
|
||||
return (volunteers.data ?? [])
|
||||
.filter((v) => !assignedIds.has(v.id))
|
||||
.sort((a, b) => (hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0))
|
||||
.slice(0, MAX_CANDIDATES);
|
||||
}, [volunteers.data, assigned, hours]);
|
||||
|
||||
if (!position || !need) {
|
||||
return (
|
||||
<div className="px-[18px] py-[26px] font-mono text-[10.5px] opacity-65">
|
||||
{t('grille.panel.empty.body')}
|
||||
</div>
|
||||
);
|
||||
if (volunteers.isLoading || shiftAssignments.isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
const day = festivalDay(dayId);
|
||||
const part = dayPart(dayPartCode);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="sticky top-0 z-[5] bg-ink px-[13px] py-[11px] text-cream">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="font-display text-[16px] font-black tracking-tight">{position.name}</div>
|
||||
<div className="font-display text-[16px] font-black tracking-tight">{positionName}</div>
|
||||
<div className="font-mono text-[10px] text-yellow">
|
||||
{position.zone} · {position.location}
|
||||
{categoryLabel(positionCategory)}
|
||||
{positionLocation ? ` · ${positionLocation}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dispatch({ type: 'selectSlot', slot: null })}
|
||||
onClick={onClose}
|
||||
className="h-6 w-6 cursor-pointer border-2 border-cream bg-transparent font-display text-[12px] font-bold text-cream"
|
||||
aria-label={t('action.close')}
|
||||
>
|
||||
@@ -112,31 +106,34 @@ export function AssignmentPanel({ slot, index }: { slot: string; index: Planning
|
||||
</div>
|
||||
<div className="mt-[9px] flex items-stretch gap-2">
|
||||
<div className="bg-yellow px-2 py-[5px] font-display text-[11px] font-extrabold text-ink">
|
||||
{day.shortLabel} · {part.label}
|
||||
{shortDayLabel(shift.starts_at)} · {hourRangeLabel(shift.starts_at, shift.ends_at)}
|
||||
</div>
|
||||
<div className="border-2 border-cream px-2 py-1 font-mono text-[10px]">
|
||||
{t('grille.panel.slotDuration', { hours: durationHours })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-[9px] flex gap-[14px] font-mono text-[10px]">
|
||||
<div className="mt-[9px] flex flex-wrap gap-[14px] font-mono text-[10px]">
|
||||
<span>
|
||||
{t('grille.panel.assigned')}{' '}
|
||||
<b className="font-mono text-[15px] font-bold">{assignedList.length}</b>
|
||||
{t('grille.panel.confirmed')}{' '}
|
||||
<b className="text-[15px]">{shift.confirmed}</b>
|
||||
</span>
|
||||
<span className="opacity-70">{t('grille.panel.min', { count: need.minStaff })}</span>
|
||||
<span className="opacity-70">{t('grille.panel.ideal', { count: need.idealStaff })}</span>
|
||||
<span className="opacity-70">
|
||||
{t('grille.panel.proposedCount', { count: shift.proposed })}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-b-2 border-ink px-[13px] py-3">
|
||||
<div className="mb-2 font-display text-[10px] font-extrabold uppercase tracking-widest">
|
||||
{t('grille.panel.assignmentsTitle', { count: assignedList.length })}
|
||||
{t('grille.panel.assignmentsTitle', { count: assigned.length })}
|
||||
</div>
|
||||
{assignedList.length === 0 ? (
|
||||
{assigned.length === 0 ? (
|
||||
<div className="font-mono text-[10.5px] opacity-60">{t('grille.panel.noneAssigned')}</div>
|
||||
) : (
|
||||
assignedList.map((assignment) => {
|
||||
const volunteer = state.volunteers.find((v) => v.id === assignment.volunteerId);
|
||||
assigned.map((assignment) => {
|
||||
const volunteer = volunteers.data?.find((v) => v.id === assignment.volunteer_id);
|
||||
if (!volunteer) return null;
|
||||
return (
|
||||
<div
|
||||
@@ -144,24 +141,30 @@ export function AssignmentPanel({ slot, index }: { slot: string; index: Planning
|
||||
className="mb-[6px] flex items-center gap-2 border-2 border-ink bg-cream px-[7px] py-[6px]"
|
||||
>
|
||||
<span className="flex h-6 w-6 flex-none items-center justify-center border-2 border-ink bg-blue font-display text-[9.5px] font-extrabold">
|
||||
{volunteer.initials}
|
||||
{initials(volunteer)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 font-display text-[12px] font-bold">
|
||||
{volunteer.fullName}
|
||||
{volunteer.first_name} {volunteer.last_name}
|
||||
<span className="block font-mono text-[9px] opacity-60">
|
||||
{t('grille.panel.hoursTotal', {
|
||||
hours: index.totalHours.get(volunteer.id) ?? 0,
|
||||
})}
|
||||
{t('grille.panel.hoursTotal', { hours: round(hours.get(volunteer.id) ?? 0) })}
|
||||
</span>
|
||||
</span>
|
||||
{assignment.status === 'CONFIRMED' ? (
|
||||
<Badge tone="confirmed">{t('assignment.status.confirmed')}</Badge>
|
||||
) : (
|
||||
<Badge tone="proposed">{t('assignment.status.proposed')}</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={() => dispatch({ type: 'unassign', assignmentId: assignment.id })}
|
||||
onClick={() => deleteAssignment.mutate(assignment.id)}
|
||||
className="h-6 w-6 flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
|
||||
aria-label={t('action.remove')}
|
||||
>
|
||||
@@ -178,74 +181,126 @@ export function AssignmentPanel({ slot, index }: { slot: string; index: Planning
|
||||
<span className="font-display text-[10px] font-extrabold uppercase tracking-widest">
|
||||
{t('grille.panel.candidates')}
|
||||
</span>
|
||||
<span className="font-mono text-[9px] opacity-60">
|
||||
{t('grille.panel.candidatesHint')}
|
||||
</span>
|
||||
<span className="font-mono text-[9px] opacity-60">{t('grille.panel.candidatesHint')}</span>
|
||||
</div>
|
||||
{candidates.map(({ volunteer, blocked, blocking, warnings, available, wanted, hours, slots }) => (
|
||||
<div
|
||||
{candidates.map((volunteer) => (
|
||||
<CandidateRow
|
||||
key={volunteer.id}
|
||||
className="mb-[10px] border-2 border-ink bg-cream shadow-brutal"
|
||||
>
|
||||
<div className="flex items-center gap-2 p-[7px]">
|
||||
<span className="flex h-7 w-7 flex-none items-center justify-center border-2 border-ink bg-yellow font-display text-[10px] font-extrabold">
|
||||
{volunteer.initials}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
|
||||
{volunteer.fullName}
|
||||
<span className="block font-mono text-[9px] opacity-60">
|
||||
{t('grille.panel.hoursAssigned', { hours, slots })}
|
||||
</span>
|
||||
</span>
|
||||
{blocked ? (
|
||||
<span className="flex-none border-2 border-ink bg-sand-dark px-[10px] py-2 font-display text-[10.5px] font-extrabold uppercase tracking-wide opacity-60">
|
||||
{t('grille.panel.blocked')}
|
||||
</span>
|
||||
) : (
|
||||
<Btn onClick={() => dispatch({ type: 'assign', volunteerId: volunteer.id })}>
|
||||
{t('grille.panel.assign')}
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
{(available || wanted) && (
|
||||
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
|
||||
{available ? (
|
||||
<span className="border-[1.5px] border-ink bg-green/25 px-[6px] py-[3px] font-display text-[9.5px] font-semibold">
|
||||
{t('grille.panel.availableTag')}
|
||||
</span>
|
||||
) : null}
|
||||
{wanted ? (
|
||||
<span className="border-[1.5px] border-ink bg-blue px-[6px] py-[3px] font-display text-[9.5px] font-semibold">
|
||||
{t('grille.panel.wantedTag')}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{blocked && blocking ? (
|
||||
<div className="flex items-stretch border-t-2 border-ink">
|
||||
<span className="flex w-[26px] flex-none items-center justify-center border-r-2 border-ink bg-hatch-orange font-display text-[12px] font-bold">
|
||||
⛔
|
||||
</span>
|
||||
<span className="px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
|
||||
{t('grille.panel.blockingPrefix', { reason: violationText(blocking) })}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{!blocked && warnings.length > 0 ? (
|
||||
<div className="border-t-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px]">
|
||||
<div className="mb-1 font-display text-[8.5px] font-extrabold uppercase tracking-widest">
|
||||
{t('grille.panel.warningsTitle')}
|
||||
</div>
|
||||
{warnings.map((warning, i) => (
|
||||
<div key={i} className="font-display text-[10px] font-semibold leading-snug">
|
||||
· {violationText(warning)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
volunteer={volunteer}
|
||||
shiftId={shift.shift_id}
|
||||
hours={round(hours.get(volunteer.id) ?? 0)}
|
||||
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
|
||||
resolvePosition={resolvePosition}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidateRow({
|
||||
volunteer,
|
||||
shiftId,
|
||||
hours,
|
||||
slots,
|
||||
resolvePosition,
|
||||
}: {
|
||||
volunteer: VolunteerRead;
|
||||
shiftId: string;
|
||||
hours: number;
|
||||
slots: number;
|
||||
resolvePosition: (shiftId: string) => string | undefined;
|
||||
}) {
|
||||
const validation = useValidateAssignment(volunteer.id, shiftId);
|
||||
const createAssignment = useCreateAssignment();
|
||||
|
||||
const blocking = validation.data?.violations.find((v) => v.severity === 'BLOCKING') ?? null;
|
||||
const warnings = validation.data?.violations.filter((v) => v.severity === 'WARNING') ?? [];
|
||||
const isBlocked = validation.data?.blocking ?? false;
|
||||
|
||||
const assignError =
|
||||
createAssignment.error instanceof ApiError ? createAssignment.error : null;
|
||||
|
||||
return (
|
||||
<div className="mb-[10px] border-2 border-ink bg-cream shadow-brutal">
|
||||
<div className="flex items-center gap-2 p-[7px]">
|
||||
<span className="flex h-7 w-7 flex-none items-center justify-center border-2 border-ink bg-yellow font-display text-[10px] font-extrabold">
|
||||
{initials(volunteer)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 font-display text-[13px] font-bold">
|
||||
{volunteer.first_name} {volunteer.last_name}
|
||||
<span className="block font-mono text-[9px] opacity-60">
|
||||
{t('grille.panel.hoursAssigned', { hours, slots })}
|
||||
</span>
|
||||
</span>
|
||||
{validation.isLoading ? (
|
||||
<span className="h-4 w-4 flex-none animate-spin border-2 border-ink border-t-transparent" />
|
||||
) : isBlocked ? (
|
||||
<span className="flex-none border-2 border-ink bg-sand-dark px-[10px] py-2 font-display text-[10.5px] font-extrabold uppercase tracking-wide opacity-60">
|
||||
{t('grille.panel.blocked')}
|
||||
</span>
|
||||
) : (
|
||||
<Btn
|
||||
disabled={createAssignment.isPending}
|
||||
onClick={() =>
|
||||
createAssignment.mutate({ volunteer_id: volunteer.id, shift_id: shiftId })
|
||||
}
|
||||
>
|
||||
{t('grille.panel.assign')}
|
||||
</Btn>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{volunteer.skill_codes.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
|
||||
{volunteer.skill_codes.map((code) => (
|
||||
<span
|
||||
key={code}
|
||||
className="border-[1.5px] border-ink bg-blue px-[6px] py-[3px] font-mono text-[9px] font-semibold"
|
||||
>
|
||||
{code}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{blocking ? (
|
||||
<div className="flex items-stretch border-t-2 border-ink">
|
||||
<span className="flex w-[26px] flex-none items-center justify-center border-r-2 border-ink bg-hatch-orange font-display text-[12px] font-bold">
|
||||
⛔
|
||||
</span>
|
||||
<span className="px-2 py-[6px] font-display text-[10.5px] font-bold leading-snug">
|
||||
{t('grille.panel.blockingPrefix', { reason: violationText(blocking, resolvePosition) })}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isBlocked && warnings.length > 0 ? (
|
||||
<div className="border-t-2 border-dashed border-ink bg-yellow-soft px-2 py-[6px]">
|
||||
<div className="mb-1 font-display text-[8.5px] font-extrabold uppercase tracking-widest">
|
||||
{t('grille.panel.warningsTitle')}
|
||||
</div>
|
||||
{warnings.map((warning, i) => (
|
||||
<div key={i} className="font-display text-[10px] font-semibold leading-snug">
|
||||
· {violationText(warning, resolvePosition)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{assignError ? (
|
||||
<div className="border-t-2 border-ink bg-orange px-2 py-[6px] font-display text-[10px] font-bold">
|
||||
{t(assignError.code)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function initials(v: VolunteerRead): string {
|
||||
return `${v.first_name[0] ?? ''}${v.last_name[0] ?? ''}`;
|
||||
}
|
||||
|
||||
function round(n: number): number {
|
||||
return Math.round(n * 10) / 10;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user