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:
co-authored by
Claude Sonnet 5
parent
be51770b51
commit
36d108b47d
@@ -23,7 +23,6 @@ const RULE_CODES = [
|
||||
'OUTSIDE_AVAILABILITY',
|
||||
'DAILY_HOURS_EXCEEDED',
|
||||
'INSUFFICIENT_REST',
|
||||
'MISSING_REQUIRED_SKILL',
|
||||
] as const;
|
||||
|
||||
// Les seuils du moteur sont figés (CLAUDE.md §3). L'édition (nom, dates) est
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,18 +5,32 @@ import {
|
||||
useAssignments,
|
||||
useCreateAssignment,
|
||||
useDeleteAssignment,
|
||||
useEditionAvailability,
|
||||
usePositions,
|
||||
useShifts,
|
||||
useValidateAssignment,
|
||||
useVolunteers,
|
||||
} from '@/api/hooks';
|
||||
import type { MatrixShift, PositionCategory, VolunteerRead } from '@/api/schema';
|
||||
import type {
|
||||
AvailabilityRead,
|
||||
MatrixShift,
|
||||
PositionCategory,
|
||||
VolunteerRead,
|
||||
} from '@/api/schema';
|
||||
import { Btn } from '@/components/ui';
|
||||
import { Loading } from '@/components/state';
|
||||
import { categoryLabel, hoursByVolunteer, violationText } from '@/lib/planning';
|
||||
import {
|
||||
availabilityCoverage,
|
||||
categoryLabel,
|
||||
hoursByVolunteer,
|
||||
violationText,
|
||||
type Coverage,
|
||||
} from '@/lib/planning';
|
||||
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
|
||||
import { t } from '@/i18n';
|
||||
|
||||
const COVERAGE_ORDER: Record<Coverage, number> = { full: 0, partial: 1, none: 2 };
|
||||
|
||||
interface Props {
|
||||
positionName: string;
|
||||
positionLocation: string | null;
|
||||
@@ -39,8 +53,26 @@ export function AssignmentPanel({
|
||||
const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
|
||||
const shifts = useShifts();
|
||||
const positions = usePositions();
|
||||
const availability = useEditionAvailability();
|
||||
const deleteAssignment = useDeleteAssignment();
|
||||
|
||||
const coverageByVolunteer = useMemo(() => {
|
||||
const byVol = new Map<string, AvailabilityRead[]>();
|
||||
for (const w of availability.data ?? []) {
|
||||
const list = byVol.get(w.volunteer_id) ?? [];
|
||||
list.push(w);
|
||||
byVol.set(w.volunteer_id, list);
|
||||
}
|
||||
const map = new Map<string, Coverage>();
|
||||
for (const v of volunteers.data ?? []) {
|
||||
map.set(
|
||||
v.id,
|
||||
availabilityCoverage(byVol.get(v.id) ?? [], shift.starts_at, shift.ends_at),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [availability.data, volunteers.data, shift.starts_at, shift.ends_at]);
|
||||
|
||||
const shiftsById = useMemo(
|
||||
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
||||
[shifts.data],
|
||||
@@ -72,11 +104,15 @@ export function AssignmentPanel({
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
|
||||
const cov = (id: string) => COVERAGE_ORDER[coverageByVolunteer.get(id) ?? 'none'];
|
||||
return (volunteers.data ?? [])
|
||||
.filter((v) => !assignedIds.has(v.id))
|
||||
.sort((a, b) => (hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0))
|
||||
// D'abord les bénévoles disponibles sur le créneau, puis les moins chargés.
|
||||
.sort(
|
||||
(a, b) => cov(a.id) - cov(b.id) || (hours.get(a.id) ?? 0) - (hours.get(b.id) ?? 0),
|
||||
)
|
||||
.slice(0, MAX_CANDIDATES);
|
||||
}, [volunteers.data, assigned, hours]);
|
||||
}, [volunteers.data, assigned, hours, coverageByVolunteer]);
|
||||
|
||||
if (volunteers.isLoading || shiftAssignments.isLoading) {
|
||||
return <Loading />;
|
||||
@@ -171,6 +207,7 @@ export function AssignmentPanel({
|
||||
shiftId={shift.shift_id}
|
||||
hours={round(hours.get(volunteer.id) ?? 0)}
|
||||
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
|
||||
coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'}
|
||||
resolvePosition={resolvePosition}
|
||||
/>
|
||||
))}
|
||||
@@ -179,17 +216,25 @@ export function AssignmentPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const COVERAGE_STYLE: Record<Coverage, { cls: string; key: string }> = {
|
||||
full: { cls: 'bg-green text-white', key: 'grille.panel.coverage.full' },
|
||||
partial: { cls: 'bg-yellow-soft', key: 'grille.panel.coverage.partial' },
|
||||
none: { cls: 'bg-sand-dark', key: 'grille.panel.coverage.none' },
|
||||
};
|
||||
|
||||
function CandidateRow({
|
||||
volunteer,
|
||||
shiftId,
|
||||
hours,
|
||||
slots,
|
||||
coverage,
|
||||
resolvePosition,
|
||||
}: {
|
||||
volunteer: VolunteerRead;
|
||||
shiftId: string;
|
||||
hours: number;
|
||||
slots: number;
|
||||
coverage: Coverage;
|
||||
resolvePosition: (shiftId: string) => string | undefined;
|
||||
}) {
|
||||
const validation = useValidateAssignment(volunteer.id, shiftId);
|
||||
@@ -232,18 +277,13 @@ function CandidateRow({
|
||||
)}
|
||||
</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}
|
||||
<div className="px-[7px] pb-[7px]">
|
||||
<span
|
||||
className={`inline-block border-[1.5px] border-ink px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide ${COVERAGE_STYLE[coverage].cls}`}
|
||||
>
|
||||
{t(COVERAGE_STYLE[coverage].key)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{blocking ? (
|
||||
<div className="flex items-stretch border-t-2 border-ink">
|
||||
|
||||
Reference in New Issue
Block a user