Front : retrait des compétences, disponibilités en fenêtres horaires

Aligne le frontend sur le back-end (étape 14).

- Types : suppression de `skill_codes` / `required_skill_codes` /
  `AvailabilityStatus` / `MISSING_REQUIRED_SKILL` ; ajout de
  `AvailabilityRead` / `AvailabilitySet` et de la surface API
  `availability` (list / replace / listEdition) + hooks associés.
- Fiche bénévole : bloc « compétences » remplacé par un éditeur de
  disponibilités (plages date + heures libres, `PUT` en bloc).
- Assistant d'import : plus de colonnes compétences ni disponibilités
  (mapping réduit à l'identité) ; rapport simplifié.
- Grille des besoins : chaque candidat porte un badge de couverture
  (disponible / partiel / non déclaré) calculé sur ses fenêtres, et la
  liste est triée « disponibles d'abord ». Nouvel util
  `availabilityCoverage` / `uncoveredMinutes` dans `lib/planning`,
  portage du moteur back-end.
- Mock : `state.availability` passe à des fenêtres `AvailabilityRead[]`,
  validation `OUTSIDE_AVAILABILITY` recalculée sur l'union des fenêtres,
  jeu de démo et import CSV adaptés.

typecheck / lint / build / smoke au vert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
This commit is contained in:
AdamThomas
2026-09-10 23:01:59 +02:00
co-authored by Claude Sonnet 5
parent be51770b51
commit 36d108b47d
11 changed files with 487 additions and 439 deletions
+157 -206
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ApiError } from '@/api';
import {
@@ -6,9 +6,12 @@ import {
useArtists,
useCreateVolunteer,
useDeleteVolunteer,
useEdition,
useExecuteImport,
usePositions,
useReplaceAvailability,
useUpdateVolunteer,
useVolunteerAvailability,
useVolunteers,
} from '@/api/hooks';
import type {
@@ -33,7 +36,6 @@ const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
{ key: 'email', required: false },
{ key: 'phone', required: false },
{ key: 'comment', required: false },
{ key: 'skills', required: false },
];
const FIELD_LABEL: Record<ImportFieldKey, string> = {
@@ -42,7 +44,6 @@ const FIELD_LABEL: Record<ImportFieldKey, string> = {
email: 'config.vol.field.email',
phone: 'config.vol.field.phone',
comment: 'config.vol.field.comment',
skills: 'config.vol.field.skills',
};
type WishOption = { id: string; label: string; hint?: string };
@@ -143,7 +144,6 @@ export function ConfigVolunteers() {
const [mode, setMode] = useState<Mode>('list');
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [skillDraft, setSkillDraft] = useState('');
const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]);
const positionOpts = useMemo(() => positionOptions(positions.data), [positions.data]);
@@ -241,7 +241,7 @@ export function ConfigVolunteers() {
<span>{t('config.vol.colName')}</span>
<span>{t('config.vol.colPhone')}</span>
<span>{t('config.vol.colEmail')}</span>
<span>{t('config.vol.colSkills')}</span>
<span>{t('config.vol.colWishes')}</span>
</div>
{rows.map((volunteer) => {
const active = volunteer.id === selected?.id;
@@ -272,7 +272,9 @@ export function ConfigVolunteers() {
{volunteer.email ?? '—'}
</span>
<span className="truncate font-mono text-[10px] opacity-70">
{volunteer.skill_codes.join(' · ') || '—'}
{t('config.vol.wishesCount', {
count: volunteer.artist_ids.length + volunteer.wished_position_ids.length,
})}
</span>
</button>
);
@@ -356,50 +358,7 @@ export function ConfigVolunteers() {
addLabel={t('config.vol.pickPosition')}
/>
<div>
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
<div className="mb-2 flex flex-wrap gap-[6px]">
{selected.skill_codes.map((code) => (
<button
key={code}
type="button"
onClick={() =>
patch({ skill_codes: selected.skill_codes.filter((c) => c !== code) })
}
className="cursor-pointer border-2 border-ink bg-blue px-2 py-[5px] font-mono text-[10px] font-semibold hover:bg-orange"
>
{code} ✕
</button>
))}
{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>
<AvailabilityEditor volunteerId={selected.id} />
<button
type="button"
@@ -431,10 +390,8 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
const artists = useArtists();
const positions = usePositions();
const [form, setForm] = useState<VolunteerCreate>({ first_name: '', last_name: '' });
const [skills, setSkills] = useState<string[]>([]);
const [artistIds, setArtistIds] = useState<string[]>([]);
const [wishedPositionIds, setWishedPositionIds] = useState<string[]>([]);
const [skillDraft, setSkillDraft] = useState('');
const [localError, setLocalError] = useState<string | null>(null);
const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]);
@@ -456,7 +413,6 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
email: form.email?.trim() || null,
phone: form.phone?.trim() || null,
comment: form.comment?.trim() || null,
skill_codes: skills,
artist_ids: artistIds,
wished_position_ids: wishedPositionIds,
});
@@ -534,50 +490,8 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
addLabel={t('config.vol.pickPosition')}
/>
<div>
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
<div className="mb-2 flex flex-wrap gap-[6px]">
{skills.map((code) => (
<button
key={code}
type="button"
onClick={() => setSkills((s) => s.filter((c) => c !== code))}
className="cursor-pointer border-2 border-ink bg-blue px-2 py-[5px] font-mono text-[10px] font-semibold hover:bg-orange"
>
{code} ✕
</button>
))}
{skills.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{t('config.vol.noSkills')}</span>
) : null}
</div>
<div className="flex gap-2">
<input
value={skillDraft}
onChange={(e) => setSkillDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const code = skillDraft.trim().toUpperCase();
if (code && !skills.includes(code)) setSkills((s) => [...s, code]);
setSkillDraft('');
}
}}
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="button"
onClick={() => {
const code = skillDraft.trim().toUpperCase();
if (code && !skills.includes(code)) setSkills((s) => [...s, code]);
setSkillDraft('');
}}
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>
</div>
<div className="font-mono text-[10px] leading-relaxed opacity-60">
{t('config.vol.addAvailabilityHint')}
</div>
{localError ? (
@@ -611,8 +525,6 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
// --------------------------------------------------------------------------- //
// Import de fichier (assistant en 3 étapes) //
// --------------------------------------------------------------------------- //
type AvailRow = { header: string; date: string; day_part_code: string };
function ImportWizard({ onClose }: { onClose: () => void }) {
const analyze = useAnalyzeImport();
const execute = useExecuteImport();
@@ -620,7 +532,6 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
const [file, setFile] = useState<File | null>(null);
const [analysis, setAnalysis] = useState<ImportAnalyzeResponse | null>(null);
const [colMap, setColMap] = useState<Partial<Record<ImportFieldKey, string>>>({});
const [availRows, setAvailRows] = useState<AvailRow[]>([]);
const [report, setReport] = useState<ImportReport | null>(null);
const [mapError, setMapError] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
@@ -636,7 +547,6 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
const res = await analyze.mutateAsync(f);
setAnalysis(res);
setColMap({ ...res.suggested_mapping });
setAvailRows([]);
} catch {
/* l'erreur est portée par analyze.error */
}
@@ -650,9 +560,7 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
email: colMap.email || null,
phone: colMap.phone || null,
comment: colMap.comment || null,
skills: colMap.skills || null,
},
availability_columns: availRows.filter((r) => r.header && r.date && r.day_part_code),
});
const run = async (dryRun: boolean) => {
@@ -778,100 +686,6 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
</div>
</div>
<div>
<FieldLabel>{t('config.vol.wiz.availTitle')}</FieldLabel>
<div className="mb-1 font-mono text-[10px] leading-relaxed opacity-65">
{t('config.vol.wiz.availHint')}
</div>
<div className="flex flex-col gap-2">
{availRows.map((row, i) => (
<div key={i} className="flex flex-wrap items-end gap-2">
<label className="min-w-[140px] flex-1">
<span className="mb-[4px] block font-display text-[9px] font-bold uppercase">
{t('config.vol.wiz.availColumn')}
</span>
<select
value={row.header}
onChange={(e) =>
setAvailRows((rs) =>
rs.map((r, j) => (j === i ? { ...r, header: e.target.value } : r)),
)
}
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
>
<option value="">{t('config.vol.wiz.ignore')}</option>
{analysis.headers.map((h) => (
<option key={h} value={h}>
{h}
</option>
))}
</select>
</label>
<label className="min-w-[130px] flex-1">
<span className="mb-[4px] block font-display text-[9px] font-bold uppercase">
{t('config.vol.wiz.availDate')}
</span>
<select
value={row.date}
onChange={(e) =>
setAvailRows((rs) =>
rs.map((r, j) => (j === i ? { ...r, date: e.target.value } : r)),
)
}
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
>
<option value="">{t('config.vol.wiz.ignore')}</option>
{analysis.edition_dates.map((d) => (
<option key={d} value={d}>
{shortDayLabel(`${d}T12:00`)} ({d})
</option>
))}
</select>
</label>
<label className="min-w-[130px] flex-1">
<span className="mb-[4px] block font-display text-[9px] font-bold uppercase">
{t('config.vol.wiz.availPart')}
</span>
<select
value={row.day_part_code}
onChange={(e) =>
setAvailRows((rs) =>
rs.map((r, j) =>
j === i ? { ...r, day_part_code: e.target.value } : r,
),
)
}
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
>
<option value="">{t('config.vol.wiz.ignore')}</option>
{analysis.day_parts.map((dp) => (
<option key={dp.code} value={dp.code}>
{dp.label}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => setAvailRows((rs) => rs.filter((_, j) => j !== i))}
className="cursor-pointer border-2 border-ink bg-cream px-3 py-[7px] font-display text-[10px] font-extrabold uppercase hover:bg-orange"
>
{t('action.remove')}
</button>
</div>
))}
<button
type="button"
onClick={() =>
setAvailRows((rs) => [...rs, { header: '', date: '', day_part_code: '' }])
}
className="cursor-pointer self-start border-2 border-ink bg-cream px-3 py-[7px] font-display text-[10px] font-extrabold uppercase hover:bg-yellow"
>
+ {t('config.vol.wiz.availAdd')}
</button>
</div>
</div>
<div className="overflow-x-auto border-2 border-ink">
<table className="w-full border-collapse font-mono text-[10px]">
<thead>
@@ -949,17 +763,8 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
{t('config.vol.wiz.skipped', { count: report.skipped })}
</span>
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
{t('config.vol.wiz.availWritten', { count: report.availabilities_written })}
</span>
</div>
{report.skills_created.length > 0 ? (
<div className="font-mono text-[11px]">
{t('config.vol.wiz.skillsCreated', { list: report.skills_created.join(', ') })}
</div>
) : null}
{(() => {
const flagged = report.rows.filter((r) => r.issues.length > 0);
if (flagged.length === 0) {
@@ -1034,3 +839,149 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
</div>
);
}
// --------------------------------------------------------------------------- //
// Éditeur de disponibilités (fiche bénévole) //
// --------------------------------------------------------------------------- //
type WindowRow = { date: string; start: string; end: string };
function editionDays(start: string, end: string): string[] {
const out: string[] = [];
for (
let d = Date.parse(`${start}T00:00:00Z`);
d <= Date.parse(`${end}T00:00:00Z`);
d += 86_400_000
) {
out.push(new Date(d).toISOString().slice(0, 10));
}
return out;
}
function AvailabilityEditor({ volunteerId }: { volunteerId: string }) {
const edition = useEdition();
const windows = useVolunteerAvailability(volunteerId);
const replace = useReplaceAvailability();
const [rows, setRows] = useState<WindowRow[]>([]);
const [dirty, setDirty] = useState(false);
const serverRows = useMemo<WindowRow[]>(
() =>
(windows.data ?? []).map((w) => ({
date: w.date,
start: w.start_time.slice(0, 5),
end: w.end_time.slice(0, 5),
})),
[windows.data],
);
// Recharge depuis le serveur tant que l'utilisateur n'a rien modifié.
useEffect(() => {
if (!dirty) setRows(serverRows);
}, [serverRows, dirty]);
// Repart de zéro quand on change de bénévole.
useEffect(() => {
setDirty(false);
}, [volunteerId]);
const days = edition.data
? editionDays(edition.data.start_date, edition.data.end_date)
: [];
const patch = (i: number, next: Partial<WindowRow>) => {
setDirty(true);
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...next } : r)));
};
const save = () => {
replace.mutate(
{
volunteerId,
body: {
windows: rows.map((r) => ({
date: r.date,
start_time: `${r.start}:00`,
end_time: `${r.end}:00`,
})),
},
},
{ onSuccess: () => setDirty(false) },
);
};
return (
<div>
<FieldLabel>{t('config.vol.avail.title')}</FieldLabel>
<div className="mb-2 font-mono text-[10px] leading-relaxed opacity-60">
{t('config.vol.avail.hint')}
</div>
<div className="flex flex-col gap-[6px]">
{rows.map((row, i) => (
<div key={i} className="flex flex-wrap items-center gap-1">
<select
value={row.date}
onChange={(e) => patch(i, { date: e.target.value })}
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[6px] font-mono text-[11px]"
>
{days.map((d) => (
<option key={d} value={d}>
{shortDayLabel(`${d}T12:00`)}
</option>
))}
</select>
<input
type="time"
value={row.start}
onChange={(e) => patch(i, { start: e.target.value })}
className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]"
/>
<input
type="time"
value={row.end}
onChange={(e) => patch(i, { end: e.target.value })}
className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]"
/>
<button
type="button"
onClick={() => {
setDirty(true);
setRows((rs) => rs.filter((_, j) => j !== i));
}}
aria-label={t('action.remove')}
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
>
✕
</button>
</div>
))}
{rows.length === 0 ? (
<span className="font-mono text-[10px] opacity-55">{t('config.vol.avail.none')}</span>
) : null}
</div>
<div className="mt-2 flex gap-2">
<button
type="button"
disabled={days.length === 0}
onClick={() => {
setDirty(true);
setRows((rs) => [...rs, { date: days[0], start: '08:00', end: '18:00' }]);
}}
className="cursor-pointer border-2 border-ink bg-cream px-3 py-[6px] font-display text-[10px] font-extrabold uppercase hover:bg-yellow disabled:opacity-50"
>
+ {t('config.vol.avail.add')}
</button>
<button
type="button"
disabled={!dirty || replace.isPending}
onClick={save}
className="cursor-pointer border-2 border-ink bg-yellow px-3 py-[6px] font-display text-[10px] font-extrabold uppercase hover:bg-green hover:text-white disabled:opacity-50"
>
{t('config.vol.avail.save')}
</button>
</div>
{replace.isError ? <ErrorNote error={replace.error} /> : null}
</div>
);
}