From 36d108b47dac60c6330111fca9b8b5b7cb1a1b18 Mon Sep 17 00:00:00 2001 From: AdamThomas Date: Thu, 10 Sep 2026 23:01:59 +0200 Subject: [PATCH] =?UTF-8?q?Front=20:=20retrait=20des=20comp=C3=A9tences,?= =?UTF-8?q?=20disponibilit=C3=A9s=20en=20fen=C3=AAtres=20horaires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM --- frontend/src/api/hooks.ts | 33 ++ frontend/src/api/http-client.ts | 11 + frontend/src/api/mock/dataset.ts | 61 +-- frontend/src/api/mock/mock-client.ts | 194 ++++------ frontend/src/api/schema.ts | 63 ++- frontend/src/api/types.ts | 14 + frontend/src/i18n/locales/fr.json | 40 +- frontend/src/lib/planning.ts | 74 +++- frontend/src/pages/config/ConfigRules.tsx | 1 - .../src/pages/config/ConfigVolunteers.tsx | 363 ++++++++---------- frontend/src/pages/grille/AssignmentPanel.tsx | 72 +++- 11 files changed, 487 insertions(+), 439 deletions(-) diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index e047c6b..677f273 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -12,6 +12,7 @@ import type { AssignmentCreate, AssignmentUpdate, AssignmentValidateResult, + AvailabilitySet, DayPartCreate, DayPartUpdate, EditionUpdate, @@ -82,6 +83,23 @@ export function useShifts(positionId?: string) { }); } +/** Fenêtres de disponibilité d'un bénévole. */ +export function useVolunteerAvailability(volunteerId: string | null | undefined) { + return useQuery({ + queryKey: ['availability', edition, volunteerId ?? null], + queryFn: () => api.availability.list(edition, volunteerId as string), + enabled: Boolean(volunteerId), + }); +} + +/** Toutes les disponibilités de l'édition (grille des besoins). */ +export function useEditionAvailability() { + return useQuery({ + queryKey: ['availability', edition, 'all'], + queryFn: () => api.availability.listEdition(edition), + }); +} + export function useAssignments( params?: { volunteerId?: string; shiftId?: string }, options?: { enabled?: boolean }, @@ -117,6 +135,7 @@ function useInvalidate() { const client = useQueryClient(); return { volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }), + availability: () => client.invalidateQueries({ queryKey: ['availability', edition] }), artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }), positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }), shifts: () => client.invalidateQueries({ queryKey: ['shifts', edition] }), @@ -234,6 +253,20 @@ export function useDeleteVolunteer() { }); } +/** Remplace toutes les fenêtres de disponibilité d'un bénévole. */ +export function useReplaceAvailability() { + const invalidate = useInvalidate(); + return useMutation({ + mutationFn: ({ volunteerId, body }: { volunteerId: string; body: AvailabilitySet }) => + api.availability.replace(edition, volunteerId, body), + onSuccess: () => { + invalidate.availability(); + // Les avertissements « hors disponibilité » de la grille en dépendent. + invalidate.planning(); + }, + }); +} + /** Import de bénévoles — phase 1 : analyse du fichier. */ export function useAnalyzeImport() { return useMutation({ diff --git a/frontend/src/api/http-client.ts b/frontend/src/api/http-client.ts index 443aece..e6d1a35 100644 --- a/frontend/src/api/http-client.ts +++ b/frontend/src/api/http-client.ts @@ -4,6 +4,7 @@ import type { AssignmentCreate, AssignmentUpdate, AssignmentValidateRequest, + AvailabilitySet, DayPartCreate, DayPartUpdate, EditionUpdate, @@ -108,6 +109,16 @@ export function createHttpApi(baseUrl: string): PlanningApi { remove: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }), }, + availability: { + list: (editionId, volunteerId) => + request(`${base(editionId)}/volunteers/${volunteerId}/availability`), + replace: (editionId, volunteerId, body: AvailabilitySet) => + request(`${base(editionId)}/volunteers/${volunteerId}/availability`, { + method: 'PUT', + body: JSON.stringify(body), + }), + listEdition: (editionId) => request(`${base(editionId)}/availability`), + }, imports: { analyze: (editionId, file: File) => { const form = new FormData(); diff --git a/frontend/src/api/mock/dataset.ts b/frontend/src/api/mock/dataset.ts index 6490ce5..cba4340 100644 --- a/frontend/src/api/mock/dataset.ts +++ b/frontend/src/api/mock/dataset.ts @@ -4,6 +4,7 @@ import type { AccountRead, ArtistRead, AssignmentRead, + AvailabilityRead, DayPartRead, EditionRead, PositionCategory, @@ -30,14 +31,14 @@ export interface MockState { dayParts: DayPartRead[]; artists: ArtistRead[]; volunteers: VolunteerRead[]; + /** volunteerId -> fenêtres de disponibilité déclarées. */ + availability: Map; positions: PositionRead[]; shifts: ShiftRead[]; assignments: AssignmentRead[]; accounts: AccountRead[]; /** accountId -> mot de passe en clair (mock uniquement). */ passwords: Map; - /** volunteerId -> clés « YYYY-MM-DD|HH:MM » déclarées disponibles (privé au mock). */ - availability: Map>; seq: number; } @@ -88,7 +89,6 @@ const LAST_NAMES = [ 'Perrin', 'Lefèvre', 'Nguyen', 'Rossi', 'Garnier', 'Barbier', 'Vasseur', 'Colin', 'Prévost', 'Renard', 'Dupuy', ]; -const SKILL_POOL = ['CAISSE', 'CARISTE', 'REGIE', 'SECOURISME']; const ARTIST_POOL = [ 'La Battante', 'Orchestre Miroir', @@ -171,7 +171,6 @@ export function buildMockState(): MockState { ends_at: iso(dateStr, bounds[2]), min_staff, ideal_staff, - required_skill_codes: raw.requiredSkills ?? [], created_at: NOW, updated_at: NOW, }); @@ -193,26 +192,39 @@ export function buildMockState(): MockState { ); const volunteers: VolunteerRead[] = []; - const availability = new Map>(); + const availability = new Map(); + let availSeq = 0; + const window = ( + volunteerId: string, + date: string, + start_time: string, + end_time: string, + ): AvailabilityRead => ({ + id: `av${availSeq++}`, + edition_id, + volunteer_id: volunteerId, + date, + start_time, + end_time, + created_at: NOW, + updated_at: NOW, + }); + for (let i = 0; i < 64; i += 1) { const first_name = FIRST_NAMES[i % FIRST_NAMES.length]; const last_name = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length]; const id = `v${i}`; - const avail = new Set(); + // Pour ~75 % des jours : une fenêtre large 08:00 -> 02:00 le lendemain. + const windows: AvailabilityRead[] = []; for (const dateStr of DAY_DATES) { const edge = dateStr === DAY_DATES[0] || dateStr === DAY_DATES[3]; - for (const [, startHour] of PART_BOUNDS) { - if (rnd() < (edge ? 0.4 : 0.6)) { - avail.add(`${dateStr}|${String(startHour).padStart(2, '0')}:00`); - } + if (rnd() < (edge ? 0.55 : 0.8)) { + windows.push(window(id, dateStr, '08:00:00', '02:00:00')); } } - availability.set(id, avail); + availability.set(id, windows); const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2); - const skill_codes = rnd() < 0.3 ? [SKILL_POOL[Math.floor(rnd() * SKILL_POOL.length)]] : []; - const artist_ids = artists - .filter(() => rnd() < 0.25) - .map((a) => a.id); + const artist_ids = artists.filter(() => rnd() < 0.25).map((a) => a.id); const wished_position_ids = rnd() < 0.4 ? [positions[Math.floor(rnd() * positions.length)].id] : []; volunteers.push({ @@ -223,7 +235,6 @@ export function buildMockState(): MockState { email: rnd() < 0.85 ? demoEmail(first_name, last_name) : null, phone: `06 ${pair()} ${pair()} ${pair()} ${pair()}`, comment: null, - skill_codes, artist_ids, wished_position_ids, created_at: NOW, @@ -235,11 +246,7 @@ export function buildMockState(): MockState { me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo availability.set( me.id, - new Set( - DAY_DATES.flatMap((dateStr) => - PART_BOUNDS.map(([, h]) => `${dateStr}|${String(h).padStart(2, '0')}:00`), - ), - ), + DAY_DATES.map((dateStr) => window(me.id, dateStr, '08:00:00', '02:00:00')), ); NEVER_ASSIGNED.forEach((i) => { volunteers[i].comment = 'Inscrit, pas encore affecté'; @@ -262,7 +269,6 @@ export function buildMockState(): MockState { const duration = hoursBetween(shift.starts_at, shift.ends_at); const dateStr = shift.starts_at.slice(0, 10); - const availKey = `${dateStr}|${shift.starts_at.slice(11, 16)}`; const pool = volunteers .filter( @@ -274,13 +280,10 @@ export function buildMockState(): MockState { .sort((a, b) => score(b) - score(a)); function score(v: VolunteerRead): number { - const available = availability.get(v.id)?.has(availKey) ? 100 : 0; - const skilled = - shift.required_skill_codes.length > 0 && - shift.required_skill_codes.every((c) => v.skill_codes.includes(c)) - ? 40 - : 0; - return available + skilled - (totalHours.get(v.id) ?? 0) * 3; + const available = (availability.get(v.id) ?? []).some((w) => w.date === dateStr) + ? 100 + : 0; + return available - (totalHours.get(v.id) ?? 0) * 3; } for (const v of pool.slice(0, target)) { diff --git a/frontend/src/api/mock/mock-client.ts b/frontend/src/api/mock/mock-client.ts index 865fdbf..bf8e309 100644 --- a/frontend/src/api/mock/mock-client.ts +++ b/frontend/src/api/mock/mock-client.ts @@ -1,3 +1,4 @@ +import { uncoveredMinutes } from '@/lib/planning'; import { hoursBetween, naiveMinutes } from '@/lib/time'; import type { @@ -7,7 +8,8 @@ import type { ArtistUpdate, AssignmentRead, AssignmentValidateResult, - AvailabilityStatus, + AvailabilityRead, + AvailabilitySet, DayPartCreate, DayPartRead, DayPartUpdate, @@ -92,6 +94,17 @@ const slugCode = (label: string): string => .replace(/[^A-Z0-9]+/g, '_') .replace(/^_+|_+$/g, ''); +/** « HH:MM » ou « HH:MM:SS » -> « HH:MM:SS ». */ +const hms = (time: string): string => (time.length === 5 ? `${time}:00` : time); + +const sortWindows = (ws: AvailabilityRead[]): AvailabilityRead[] => + [...ws].sort( + (a, b) => + a.volunteer_id.localeCompare(b.volunteer_id) || + a.date.localeCompare(b.date) || + a.start_time.localeCompare(b.start_time), + ); + /** Vœux du bénévole : dé-doublonne, ordre conservé ; 422 si un id est étranger. */ function resolveWishes( pool: T[], @@ -131,7 +144,6 @@ function generateMissingShifts(positionId: string): number { ends_at, min_staff: 0, ideal_staff: 0, - required_skill_codes: [], created_at: now, updated_at: now, }); @@ -157,21 +169,8 @@ const IMPORT_HINTS: Record = { last_name: ['nom', 'last name', 'lastname', 'surname', 'famille'], phone: ['tel', 'phone', 'portable', 'mobile', 'gsm'], comment: ['commentaire', 'remarque', 'note', 'precision'], - skills: ['competence', 'skill', 'habilitation', 'brevet', 'diplome'], }; const IMPORT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; -const AVAIL_AVAILABLE = new Set([ - 'x', 'o', 'oui', 'y', 'yes', 'ok', '1', 'vrai', 'true', 'dispo', 'disponible', - 'present', 'presente', 'matin', 'aprem', 'apres-midi', 'apres midi', 'midi', - 'soir', 'soiree', 'journee', 'jour', -]); -const AVAIL_IF_NEEDED = new Set([ - 'si besoin', 'sib', 'peut-etre', 'peut etre', '?', '2', 'backup', 'reserve', 'au besoin', -]); -const AVAIL_UNAVAILABLE = new Set([ - 'non', 'n', 'no', '0', 'faux', 'false', 'indispo', 'indisponible', 'absent', '-', 'x-', -]); -const AVAIL_EMPTY = new Set(['', 'na', 'n/a', 'nc']); function normalizeText(value: string): string { return (value ?? '') @@ -182,15 +181,6 @@ function normalizeText(value: string): string { .replace(/\s+/g, ' '); } -function parseAvailabilityCell(value: string): [AvailabilityStatus | null, boolean] { - const n = normalizeText(value); - if (AVAIL_EMPTY.has(n)) return [null, true]; - if (AVAIL_AVAILABLE.has(n)) return ['AVAILABLE', true]; - if (AVAIL_IF_NEEDED.has(n)) return ['IF_NEEDED', true]; - if (AVAIL_UNAVAILABLE.has(n)) return ['UNAVAILABLE', true]; - return [null, false]; -} - function splitCsvLine(line: string, delimiter: string): string[] { const cells: string[] = []; let cur = ''; @@ -292,17 +282,20 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm } } - // OUTSIDE_AVAILABILITY (avertissement) — le mock ne connaît que AVAILABLE / non déclaré. - const declared = state.availability.get(volunteerId) ?? new Set(); - const availKey = `${candidate.starts_at.slice(0, 10)}|${candidate.starts_at.slice(11, 16)}`; - if (!declared.has(availKey)) { + // OUTSIDE_AVAILABILITY (avertissement) — créneau non couvert par l'union des + // fenêtres de disponibilité déclarées. + const gapMin = uncoveredMinutes( + state.availability.get(volunteerId) ?? [], + candidate.starts_at, + candidate.ends_at, + ); + if (gapMin > 0) { violations.push({ code: 'OUTSIDE_AVAILABILITY', severity: 'WARNING', params: { date: candidate.starts_at.slice(0, 10), - day_part_code: candidate.starts_at.slice(11, 16), - status: 'UNDECLARED', + uncovered_minutes: gapMin, }, }); } @@ -346,17 +339,6 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm } } - // MISSING_REQUIRED_SKILL (avertissement) - for (const code of [...candidate.required_skill_codes].sort()) { - if (!volunteer.skill_codes.includes(code)) { - violations.push({ - code: 'MISSING_REQUIRED_SKILL', - severity: 'WARNING', - params: { skill_code: code }, - }); - } - } - return violations; } @@ -537,7 +519,6 @@ export function createMockApi(): PlanningApi { email: body.email ?? null, phone: body.phone ?? null, comment: body.comment ?? null, - skill_codes: body.skill_codes ?? [], artist_ids: resolveWishes(state.artists, body.artist_ids ?? [], 'artist.unknown'), wished_position_ids: resolveWishes( state.positions, @@ -548,7 +529,7 @@ export function createMockApi(): PlanningApi { updated_at: now, }; state.volunteers.push(volunteer); - state.availability.set(volunteer.id, new Set()); + state.availability.set(volunteer.id, []); return delay(volunteer); }, update: (_edition, id, body) => { @@ -559,7 +540,6 @@ export function createMockApi(): PlanningApi { ...('email' in body ? { email: body.email ?? null } : {}), ...('phone' in body ? { phone: body.phone ?? null } : {}), ...('comment' in body ? { comment: body.comment ?? null } : {}), - ...(body.skill_codes != null ? { skill_codes: body.skill_codes } : {}), ...(body.artist_ids != null ? { artist_ids: resolveWishes(state.artists, body.artist_ids, 'artist.unknown') } : {}), @@ -584,6 +564,50 @@ export function createMockApi(): PlanningApi { }, }, + availability: { + list: (_edition, volunteerId) => { + if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound(); + return delay(sortWindows(state.availability.get(volunteerId) ?? [])); + }, + listEdition: () => + delay( + sortWindows( + [...state.availability.values()].flat().filter((w) => + state.volunteers.some((v) => v.id === w.volunteer_id), + ), + ), + ), + replace: (_edition, volunteerId, body: AvailabilitySet) => { + if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound(); + const seen = new Set(); + const windows: AvailabilityRead[] = []; + for (const w of body.windows) { + if (w.date < state.edition.start_date || w.date > state.edition.end_date) { + throw new ApiError(422, 'availability.date_outside_edition'); + } + const start = hms(w.start_time); + const end = hms(w.end_time); + if (start === end) throw new ApiError(422, 'availability.empty_window'); + const key = `${w.date}|${start}|${end}`; + if (seen.has(key)) continue; + seen.add(key); + const now = touch(); + windows.push({ + id: `av${state.seq++}`, + edition_id: state.edition.id, + volunteer_id: volunteerId, + date: w.date, + start_time: start, + end_time: end, + created_at: now, + updated_at: now, + }); + } + state.availability.set(volunteerId, windows); + return delay(sortWindows(windows)); + }, + }, + imports: { analyze: async (_edition, file: File) => { const { headers, rows } = await readImportTable(file); @@ -591,14 +615,6 @@ export function createMockApi(): PlanningApi { line: i + 2, values: Object.fromEntries(headers.map((h, c) => [h, cells[c] ?? ''])), })); - const editionDates: string[] = []; - for ( - let d = state.edition.start_date; - d <= state.edition.end_date; - d = addDays(d, 1) - ) { - editionDates.push(d); - } return delay({ filename: file.name || '', format: 'csv', @@ -606,16 +622,6 @@ export function createMockApi(): PlanningApi { row_count: rows.length, preview, suggested_mapping: suggestImportMapping(headers), - edition_dates: editionDates, - day_parts: [...state.dayParts] - .sort((a, b) => a.order - b.order) - .map((dp) => ({ - code: dp.code, - label: dp.label, - start_time: dp.start_time, - end_time: dp.end_time, - order: dp.order, - })), }); }, execute: async (_edition, file: File, mapping: ImportMapping) => { @@ -632,16 +638,11 @@ export function createMockApi(): PlanningApi { created: 0, updated: 0, skipped: 0, - availabilities_written: 0, - skills_created: [], rows: [], }; - const dpByCode = new Map(state.dayParts.map((d) => [d.code, d])); const emailSeen = new Map(); - const createdSkills = new Set(); // On travaille sur des copies : `state` n'est touché qu'en fin si !dry_run. - const working = state.volunteers.map((v) => ({ ...v, skill_codes: [...v.skill_codes] })); - const availWrites: Array<{ vid: string; key: string; on: boolean }> = []; + const working = state.volunteers.map((v) => ({ ...v })); rows.forEach((row, i) => { const line = i + 2; @@ -729,7 +730,6 @@ export function createMockApi(): PlanningApi { email: null, phone: null, comment: null, - skill_codes: [], artist_ids: [], wished_position_ids: [], created_at: now, @@ -744,48 +744,6 @@ export function createMockApi(): PlanningApi { if (has(cols.email)) vol.email = emailRaw || null; if (has(cols.phone)) vol.phone = cell(row, cols.phone) || null; if (has(cols.comment)) vol.comment = cell(row, cols.comment) || null; - if (has(cols.skills)) { - const tokens = cell(row, cols.skills) - .split(/[;,/\n]+/) - .map((s) => s.trim()) - .filter(Boolean); - for (const token of tokens) { - const code = slugCode(token); - if (!code) continue; - const known = working.some((v) => v.skill_codes.includes(code)); - if (!known && !createdSkills.has(code)) { - createdSkills.add(code); - entry.issues.push({ - code: 'import.row.skill_created', - severity: 'WARNING', - params: { code, label: token }, - }); - } - if (!vol.skill_codes.includes(code)) vol.skill_codes.push(code); - } - } - - for (const avCol of mapping.availability_columns) { - const dp = dpByCode.get(avCol.day_part_code); - if (!dp) continue; - const [status, recognized] = parseAvailabilityCell(cell(row, avCol.header)); - if (!recognized) { - entry.issues.push({ - code: 'import.row.unknown_availability_value', - severity: 'WARNING', - field: avCol.header, - params: { column: avCol.header, value: cell(row, avCol.header) }, - }); - continue; - } - if (status === null) continue; - availWrites.push({ - vid: vol.id, - key: `${avCol.date}|${dp.start_time.slice(0, 5)}`, - on: status !== 'UNAVAILABLE', - }); - report.availabilities_written += 1; - } entry.volunteer_id = vol.id; report.rows.push(entry); @@ -793,18 +751,10 @@ export function createMockApi(): PlanningApi { else report.updated += 1; }); - report.skills_created = [...createdSkills].sort(); - if (!mapping.dry_run) { state.volunteers = working; for (const v of working) { - if (!state.availability.has(v.id)) state.availability.set(v.id, new Set()); - } - for (const w of availWrites) { - const set = state.availability.get(w.vid) ?? new Set(); - if (w.on) set.add(w.key); - else set.delete(w.key); - state.availability.set(w.vid, set); + if (!state.availability.has(v.id)) state.availability.set(v.id, []); } } return delay(report); @@ -883,7 +833,6 @@ export function createMockApi(): PlanningApi { ends_at: body.ends_at, min_staff: body.min_staff ?? 0, ideal_staff: body.ideal_staff ?? 0, - required_skill_codes: body.required_skill_codes ?? [], created_at: now, updated_at: now, }; @@ -898,9 +847,6 @@ export function createMockApi(): PlanningApi { ...('ends_at' in body ? { ends_at: body.ends_at } : {}), ...('min_staff' in body ? { min_staff: body.min_staff } : {}), ...('ideal_staff' in body ? { ideal_staff: body.ideal_staff } : {}), - ...(body.required_skill_codes != null - ? { required_skill_codes: body.required_skill_codes } - : {}), updated_at: touch(), }); if (naiveMinutes(s.ends_at) <= naiveMinutes(s.starts_at)) { diff --git a/frontend/src/api/schema.ts b/frontend/src/api/schema.ts index be44355..b16602f 100644 --- a/frontend/src/api/schema.ts +++ b/frontend/src/api/schema.ts @@ -15,7 +15,6 @@ export type PositionCategory = // Depuis l'Étape 9 back-end : une affectation est valide dès sa création, // il n'y a plus de statut (proposée / confirmée…). La retirer = supprimer la ligne. export type AssignmentSource = 'SOLVER' | 'MANUAL'; -export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE'; export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER'; export type AuthMethod = 'password' | 'magic_link'; @@ -23,8 +22,7 @@ export type ViolationCode = | 'SHIFT_OVERLAP' | 'OUTSIDE_AVAILABILITY' | 'DAILY_HOURS_EXCEEDED' - | 'INSUFFICIENT_REST' - | 'MISSING_REQUIRED_SKILL'; + | 'INSUFFICIENT_REST'; export type ViolationSeverity = 'BLOCKING' | 'WARNING'; @@ -104,7 +102,6 @@ export interface VolunteerRead { email: string | null; phone: string | null; comment: string | null; - skill_codes: string[]; /** Vœux : artistes que le bénévole veut voir. */ artist_ids: string[]; /** Vœux : postes que le bénévole voudrait tenir. */ @@ -119,7 +116,6 @@ export interface VolunteerCreate { email?: string | null; phone?: string | null; comment?: string | null; - skill_codes?: string[]; artist_ids?: string[]; wished_position_ids?: string[]; } @@ -130,12 +126,37 @@ export interface VolunteerUpdate { email?: string | null; phone?: string | null; comment?: string | null; - /** null / absent = inchangé ; [] = vider. Idem artist_ids / wished_position_ids. */ - skill_codes?: string[] | null; + /** null / absent = inchangé ; [] = vider. */ artist_ids?: string[] | null; wished_position_ids?: string[] | null; } +// --------------------------------------------------------------------------- // +// Availability (disponibilités déclarées par le bénévole) // +// --------------------------------------------------------------------------- // +export interface AvailabilityWindowInput { + /** Date ISO (YYYY-MM-DD). */ + date: string; + /** Heure « HH:MM » ou « HH:MM:SS ». end <= start ⇒ se termine le lendemain. */ + start_time: string; + end_time: string; +} + +export interface AvailabilitySet { + windows: AvailabilityWindowInput[]; +} + +export interface AvailabilityRead { + id: string; + edition_id: string; + volunteer_id: string; + date: string; + start_time: string; + end_time: string; + created_at: string; + updated_at: string; +} + // --------------------------------------------------------------------------- // // Artist (programmation) // // --------------------------------------------------------------------------- // @@ -167,8 +188,7 @@ export type ImportFieldKey = | 'last_name' | 'email' | 'phone' - | 'comment' - | 'skills'; + | 'comment'; export interface ImportColumnMapping { first_name: string; @@ -176,20 +196,11 @@ export interface ImportColumnMapping { email?: string | null; phone?: string | null; comment?: string | null; - skills?: string | null; -} - -export interface ImportAvailabilityColumn { - header: string; - /** Date ISO (YYYY-MM-DD). */ - date: string; - day_part_code: string; } export interface ImportMapping { dry_run: boolean; columns: ImportColumnMapping; - availability_columns: ImportAvailabilityColumn[]; } export interface ImportPreviewRow { @@ -197,14 +208,6 @@ export interface ImportPreviewRow { values: Record; } -export interface ImportDayPartInfo { - code: string; - label: string; - start_time: string; - end_time: string; - order: number; -} - export interface ImportAnalyzeResponse { filename: string; format: 'csv' | 'xlsx'; @@ -212,9 +215,6 @@ export interface ImportAnalyzeResponse { row_count: number; preview: ImportPreviewRow[]; suggested_mapping: Partial>; - /** Dates ISO couvertes par l'édition. */ - edition_dates: string[]; - day_parts: ImportDayPartInfo[]; } export type ImportSeverity = 'ERROR' | 'WARNING'; @@ -240,8 +240,6 @@ export interface ImportReport { created: number; updated: number; skipped: number; - availabilities_written: number; - skills_created: string[]; rows: ImportRowReport[]; } @@ -287,7 +285,6 @@ export interface ShiftRead { ends_at: string; min_staff: number; ideal_staff: number; - required_skill_codes: string[]; created_at: string; updated_at: string; } @@ -298,7 +295,6 @@ export interface ShiftCreate { ends_at: string; min_staff?: number; ideal_staff?: number; - required_skill_codes?: string[]; } export interface ShiftUpdate { @@ -307,7 +303,6 @@ export interface ShiftUpdate { ends_at?: string; min_staff?: number; ideal_staff?: number; - required_skill_codes?: string[] | null; } // --------------------------------------------------------------------------- // diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index b19d755..7c9c2a9 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -9,6 +9,8 @@ import type { AssignmentValidateRequest, AssignmentValidateResult, AssignmentWriteResult, + AvailabilityRead, + AvailabilitySet, DayPartCreate, DayPartRead, DayPartUpdate, @@ -65,6 +67,18 @@ export interface PlanningApi { update(editionId: string, id: string, body: VolunteerUpdate): Promise; remove(editionId: string, id: string): Promise; }; + availability: { + /** Fenêtres déclarées par un bénévole. */ + list(editionId: string, volunteerId: string): Promise; + /** Remplace l'intégralité des fenêtres d'un bénévole (COORDINATION). */ + replace( + editionId: string, + volunteerId: string, + body: AvailabilitySet, + ): Promise; + /** Toutes les fenêtres de l'édition (grille des besoins). */ + listEdition(editionId: string): Promise; + }; imports: { /** Phase 1 : lit le fichier, propose un mapping des colonnes. */ analyze(editionId: string, file: File): Promise; diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index aadec8c..8875f0a 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -62,15 +62,12 @@ "validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau", "validation.SHIFT_OVERLAP.at": "Déjà affecté sur {position} au même moment", "validation.SHIFT_OVERLAP.rule": "Deux créneaux d'un même bénévole se chevauchent.", - "validation.OUTSIDE_AVAILABILITY": "Hors disponibilités déclarées", - "validation.OUTSIDE_AVAILABILITY.unavailable": "Déclaré indisponible sur ce créneau", - "validation.OUTSIDE_AVAILABILITY.rule": "Une demi-journée du créneau est indisponible ou sans déclaration.", + "validation.OUTSIDE_AVAILABILITY": "Hors des disponibilités déclarées ({gap} non couvertes).", + "validation.OUTSIDE_AVAILABILITY.rule": "Le créneau n'est pas entièrement couvert par les disponibilités déclarées.", "validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h sur la journée ({hours})", "validation.DAILY_HOURS_EXCEEDED.rule": "Plus de 8 h 00 d'affectations cumulées sur une même journée.", "validation.INSUFFICIENT_REST": "Moins de 8 h de repos entre deux créneaux ({rest})", "validation.INSUFFICIENT_REST.rule": "Moins de 8 h 00 entre la fin d'un créneau et le début du suivant.", - "validation.MISSING_REQUIRED_SKILL": "Compétence requise manquante : {skill}", - "validation.MISSING_REQUIRED_SKILL.rule": "Le bénévole n'a pas une compétence requise par le créneau.", "grille.filter.day": "JOUR", "grille.filter.position": "POSTE", "grille.legend.critical": "SOUS-EFFECTIF CRITIQUE", @@ -163,15 +160,11 @@ "config.vol.colName": "NOM", "config.vol.colPhone": "TÉLÉPHONE", "config.vol.colEmail": "EMAIL", - "config.vol.colSkills": "COMPÉTENCES", "config.vol.ficheKicker": "FICHE BÉNÉVOLE", "config.vol.firstName": "PRÉNOM", "config.vol.lastName": "NOM", "config.vol.phone": "TÉLÉPHONE", "config.vol.email": "EMAIL (LIEN MAGIQUE)", - "config.vol.skills": "COMPÉTENCES", - "config.vol.noSkills": "Aucune compétence déclarée", - "config.vol.skillPlaceholder": "Code (ex. CAISSE)", "config.vol.delete": "SUPPRIMER (RGPD — EFFACEMENT COMPLET)", "config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?", "config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade (RGPD).", @@ -231,7 +224,6 @@ "config.vol.field.email": "Email (lien magique)", "config.vol.field.phone": "Téléphone", "config.vol.field.comment": "Commentaire", - "config.vol.field.skills": "Compétences", "config.vol.addTitle": "NOUVEAU BÉNÉVOLE", "config.vol.addHint": "Le bénévole reçoit un accès par lien magique uniquement si un email est renseigné.", "config.vol.create": "CRÉER LE BÉNÉVOLE", @@ -248,12 +240,6 @@ "config.vol.wiz.mapTitle": "ASSOCIER LES COLONNES", "config.vol.wiz.mapHint": "« Prénom » et « Nom » sont obligatoires. Laissez « — » pour ignorer une colonne.", "config.vol.wiz.ignore": "—", - "config.vol.wiz.availTitle": "DISPONIBILITÉS (OPTIONNEL)", - "config.vol.wiz.availHint": "Associez une colonne du fichier à une date et une demi-journée pour importer les disponibilités déclarées.", - "config.vol.wiz.availAdd": "AJOUTER UNE COLONNE DE DISPONIBILITÉ", - "config.vol.wiz.availColumn": "Colonne", - "config.vol.wiz.availDate": "Date", - "config.vol.wiz.availPart": "Demi-journée", "config.vol.wiz.mapMissing": "Associez au moins les colonnes « Prénom » et « Nom ».", "config.vol.wiz.simulate": "SIMULER", "config.vol.wiz.runImport": "IMPORTER POUR DE VRAI", @@ -264,8 +250,6 @@ "config.vol.wiz.created": "{count} créé(s)", "config.vol.wiz.updated": "{count} mis à jour", "config.vol.wiz.skipped": "{count} ignoré(s)", - "config.vol.wiz.availWritten": "{count} disponibilité(s)", - "config.vol.wiz.skillsCreated": "Compétences créées : {list}", "config.vol.wiz.rowsWithIssues": "LIGNES AVEC REMARQUES", "config.vol.wiz.line": "Ligne {line}", "config.vol.wiz.noIssues": "Aucune remarque : tout est prêt à être importé.", @@ -278,17 +262,12 @@ "import.mapping.invalid": "Le paramétrage des colonnes est invalide.", "import.mapping.required_field_missing": "Le champ « {field} » doit être associé à une colonne.", "import.mapping.column_not_found": "La colonne « {column} » est absente du fichier.", - "import.mapping.unknown_day_part": "Demi-journée « {day_part_code} » inconnue (colonne « {column} »).", - "import.mapping.date_outside_edition": "La date {date} de la colonne « {column} » est hors des dates de l’édition.", - "import.mapping.duplicate_availability_column": "La colonne « {column} » est associée plusieurs fois.", "import.row.missing_required_field": "Champ obligatoire manquant : {field}.", "import.row.invalid_email": "Adresse email invalide : {value}.", "import.row.duplicate_email_in_file": "Email déjà présent à la ligne {first_line} du fichier.", "import.row.duplicate_name_in_file": "Nom et prénom déjà présents à la ligne {first_line} du fichier.", "import.row.updated_existing": "Bénévole existant mis à jour.", "import.row.possible_duplicate_name": "Un bénévole portant le même nom existe déjà dans l’édition.", - "import.row.skill_created": "Nouvelle compétence créée : {label}.", - "import.row.unknown_availability_value": "Valeur de disponibilité non comprise « {value} » (colonne « {column} »).", "config.tab.artists": "PROGRAMMATION", "config.artists.title": "PROGRAMMATION", "config.artists.hint": "La liste des artistes de l'édition, jour par jour. Les bénévoles y choisissent ceux qu'ils veulent voir depuis leur fiche.", @@ -301,5 +280,18 @@ "config.vol.noArtists": "Aucun artiste choisi", "config.vol.noPositions": "Aucun poste choisi", "config.vol.pickArtist": "Ajouter un artiste…", - "config.vol.pickPosition": "Ajouter un poste…" + "config.vol.pickPosition": "Ajouter un poste…", + "config.vol.colWishes": "VŒUX", + "config.vol.wishesCount": "{count} vœu(x)", + "config.vol.addAvailabilityHint": "Les disponibilités se renseignent sur la fiche du bénévole, une fois celui-ci créé.", + "config.vol.avail.title": "Disponibilités", + "config.vol.avail.hint": "Plages horaires où le bénévole peut venir. Une heure de fin ≤ début signifie que la plage se termine le lendemain.", + "config.vol.avail.none": "Aucune disponibilité déclarée", + "config.vol.avail.add": "Ajouter une plage", + "config.vol.avail.save": "Enregistrer les disponibilités", + "grille.panel.coverage.full": "Disponible", + "grille.panel.coverage.partial": "Partiellement dispo", + "grille.panel.coverage.none": "Dispo non déclarée", + "availability.date_outside_edition": "Cette date est hors des dates de l'édition.", + "availability.empty_window": "Une plage doit avoir un début et une fin différents." } diff --git a/frontend/src/lib/planning.ts b/frontend/src/lib/planning.ts index 6426784..43b3f62 100644 --- a/frontend/src/lib/planning.ts +++ b/frontend/src/lib/planning.ts @@ -1,5 +1,6 @@ import type { AssignmentRead, + AvailabilityRead, MatrixResponse, PositionCategory, ShiftRead, @@ -13,6 +14,71 @@ export function categoryLabel(category: PositionCategory): string { return t(`position.category.${category.toLowerCase()}`); } +// --------------------------------------------------------------------------- // +// Disponibilités : couverture d'un créneau par les fenêtres déclarées // +// (portage de `backend/app/validation/engine.py`). // +// --------------------------------------------------------------------------- // +const _dayNum = (iso: string): number => { + const [y, m, d] = iso.split('-').map(Number); + return Math.floor(Date.UTC(y, m - 1, d) / 86_400_000); +}; + +/** Minutes absolues (depuis une origine arbitraire) d'un datetime naïf ISO. */ +function absMinutes(iso: string): number { + const [date, time] = iso.split('T'); + const [h, min] = time.split(':').map(Number); + return _dayNum(date) * 1440 + h * 60 + min; +} + +function windowInterval(w: AvailabilityRead): [number, number] { + const [sh, sm] = w.start_time.split(':').map(Number); + const [eh, em] = w.end_time.split(':').map(Number); + const start = _dayNum(w.date) * 1440 + sh * 60 + sm; + let end = _dayNum(w.date) * 1440 + eh * 60 + em; + if (end <= start) end += 1440; // fenêtre à cheval sur minuit + return [start, end]; +} + +/** Minutes du créneau `[startIso, endIso)` hors de l'union des fenêtres. */ +export function uncoveredMinutes( + windows: AvailabilityRead[], + startIso: string, + endIso: string, +): number { + const shiftStart = absMinutes(startIso); + const shiftEnd = absMinutes(endIso); + const intervals = windows + .map(windowInterval) + .filter(([a, b]) => b > a) + .sort((a, b) => a[0] - b[0]); + let cursor = shiftStart; + let gap = 0; + for (const [s, e] of intervals) { + if (e <= cursor) continue; + if (s > shiftEnd) break; + if (s > cursor) gap += Math.min(s, shiftEnd) - cursor; + cursor = Math.max(cursor, e); + if (cursor >= shiftEnd) return gap; + } + if (cursor < shiftEnd) gap += shiftEnd - cursor; + return gap; +} + +export type Coverage = 'full' | 'partial' | 'none'; + +/** État de couverture d'un créneau par les fenêtres d'un bénévole. */ +export function availabilityCoverage( + windows: AvailabilityRead[], + startIso: string, + endIso: string, +): Coverage { + if (windows.length === 0) return 'none'; + const gap = uncoveredMinutes(windows, startIso, endIso); + if (gap === 0) return 'full'; + const total = absMinutes(endIso) - absMinutes(startIso); + return gap >= total ? 'none' : 'partial'; +} + /** Colonne de la grille : une (date, heure de début) distincte. */ export interface LatticeColumn { key: string; // `YYYY-MM-DD|HH:MM` @@ -114,9 +180,9 @@ export function violationText( : t('validation.SHIFT_OVERLAP'); } case 'OUTSIDE_AVAILABILITY': - return p.status === 'UNAVAILABLE' - ? t('validation.OUTSIDE_AVAILABILITY.unavailable') - : t('validation.OUTSIDE_AVAILABILITY'); + return t('validation.OUTSIDE_AVAILABILITY', { + gap: formatMinutes(Number(p.uncovered_minutes)), + }); case 'DAILY_HOURS_EXCEEDED': return t('validation.DAILY_HOURS_EXCEEDED', { hours: formatMinutes(Number(p.total_minutes)), @@ -125,8 +191,6 @@ export function violationText( return t('validation.INSUFFICIENT_REST', { rest: formatMinutes(Number(p.gap_minutes)), }); - case 'MISSING_REQUIRED_SKILL': - return t('validation.MISSING_REQUIRED_SKILL', { skill: String(p.skill_code) }); default: return violation.code; } diff --git a/frontend/src/pages/config/ConfigRules.tsx b/frontend/src/pages/config/ConfigRules.tsx index 0fd23a0..6ee03f6 100644 --- a/frontend/src/pages/config/ConfigRules.tsx +++ b/frontend/src/pages/config/ConfigRules.tsx @@ -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 diff --git a/frontend/src/pages/config/ConfigVolunteers.tsx b/frontend/src/pages/config/ConfigVolunteers.tsx index 77598ce..db37b66 100644 --- a/frontend/src/pages/config/ConfigVolunteers.tsx +++ b/frontend/src/pages/config/ConfigVolunteers.tsx @@ -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 = { @@ -42,7 +44,6 @@ const FIELD_LABEL: Record = { 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('list'); const [selectedId, setSelectedId] = useState(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() { {t('config.vol.colName')} {t('config.vol.colPhone')} {t('config.vol.colEmail')} - {t('config.vol.colSkills')} + {t('config.vol.colWishes')} {rows.map((volunteer) => { const active = volunteer.id === selected?.id; @@ -272,7 +272,9 @@ export function ConfigVolunteers() { {volunteer.email ?? '—'} - {volunteer.skill_codes.join(' · ') || '—'} + {t('config.vol.wishesCount', { + count: volunteer.artist_ids.length + volunteer.wished_position_ids.length, + })} ); @@ -356,50 +358,7 @@ export function ConfigVolunteers() { addLabel={t('config.vol.pickPosition')} /> -
- {t('config.vol.field.skills')} -
- {selected.skill_codes.map((code) => ( - - ))} - {selected.skill_codes.length === 0 ? ( - {t('config.vol.noSkills')} - ) : null} -
-
{ - 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" - > - 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]" - /> - -
-
+ - ))} - {skills.length === 0 ? ( - {t('config.vol.noSkills')} - ) : null} - -
- 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]" - /> - -
+
+ {t('config.vol.addAvailabilityHint')}
{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(null); const [analysis, setAnalysis] = useState(null); const [colMap, setColMap] = useState>>({}); - const [availRows, setAvailRows] = useState([]); const [report, setReport] = useState(null); const [mapError, setMapError] = useState(null); const fileInput = useRef(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 }) { -
- {t('config.vol.wiz.availTitle')} -
- {t('config.vol.wiz.availHint')} -
-
- {availRows.map((row, i) => ( -
- - - - -
- ))} - -
-
-
@@ -949,17 +763,8 @@ function ImportWizard({ onClose }: { onClose: () => void }) { {t('config.vol.wiz.skipped', { count: report.skipped })} - - {t('config.vol.wiz.availWritten', { count: report.availabilities_written })} - - {report.skills_created.length > 0 ? ( -
- {t('config.vol.wiz.skillsCreated', { list: report.skills_created.join(', ') })} -
- ) : null} - {(() => { const flagged = report.rows.filter((r) => r.issues.length > 0); if (flagged.length === 0) { @@ -1034,3 +839,149 @@ function ImportWizard({ onClose }: { onClose: () => void }) { ); } + +// --------------------------------------------------------------------------- // +// É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([]); + const [dirty, setDirty] = useState(false); + + const serverRows = useMemo( + () => + (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) => { + 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 ( +
+ {t('config.vol.avail.title')} +
+ {t('config.vol.avail.hint')} +
+
+ {rows.map((row, i) => ( +
+ + patch(i, { start: e.target.value })} + className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]" + /> + patch(i, { end: e.target.value })} + className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]" + /> + +
+ ))} + {rows.length === 0 ? ( + {t('config.vol.avail.none')} + ) : null} +
+ +
+ + +
+ {replace.isError ? : null} +
+ ); +} diff --git a/frontend/src/pages/grille/AssignmentPanel.tsx b/frontend/src/pages/grille/AssignmentPanel.tsx index 78fb31c..41cb907 100644 --- a/frontend/src/pages/grille/AssignmentPanel.tsx +++ b/frontend/src/pages/grille/AssignmentPanel.tsx @@ -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 = { 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(); + 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(); + 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 ; @@ -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 = { + 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({ )} - {volunteer.skill_codes.length > 0 ? ( -
- {volunteer.skill_codes.map((code) => ( - - {code} - - ))} -
- ) : null} +
+ + {t(COVERAGE_STYLE[coverage].key)} + +
{blocking ? (