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
204 lines
6.5 KiB
TypeScript
204 lines
6.5 KiB
TypeScript
import type {
|
|
AssignmentRead,
|
|
AvailabilityRead,
|
|
MatrixResponse,
|
|
PositionCategory,
|
|
ShiftRead,
|
|
Violation,
|
|
} from '@/api/schema';
|
|
import { t } from '@/i18n';
|
|
|
|
import { datePart, hoursBetween, naiveMinutes, timePart } from './time';
|
|
|
|
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`
|
|
date: string;
|
|
time: string;
|
|
}
|
|
|
|
export interface LatticeDay {
|
|
date: string;
|
|
columns: LatticeColumn[];
|
|
}
|
|
|
|
/** Construit le treillis de colonnes à partir de créneaux (grille alignée). */
|
|
export function buildLattice(starts: Array<{ starts_at: string }>): {
|
|
days: LatticeDay[];
|
|
columns: LatticeColumn[];
|
|
} {
|
|
const seen = new Map<string, LatticeColumn>();
|
|
for (const s of starts) {
|
|
const date = datePart(s.starts_at);
|
|
const time = timePart(s.starts_at);
|
|
const key = `${date}|${time}`;
|
|
if (!seen.has(key)) seen.set(key, { key, date, time });
|
|
}
|
|
const columns = [...seen.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
const days: LatticeDay[] = [];
|
|
for (const column of columns) {
|
|
let day = days.find((d) => d.date === column.date);
|
|
if (!day) {
|
|
day = { date: column.date, columns: [] };
|
|
days.push(day);
|
|
}
|
|
day.columns.push(column);
|
|
}
|
|
return { days, columns };
|
|
}
|
|
|
|
export function latticeFromMatrix(matrix: MatrixResponse) {
|
|
return buildLattice(matrix.positions.flatMap((p) => p.shifts.map((s) => ({ starts_at: s.starts_at }))));
|
|
}
|
|
|
|
/** Heures cumulées par bénévole. */
|
|
export function hoursByVolunteer(
|
|
assignments: AssignmentRead[],
|
|
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
|
|
): Map<string, number> {
|
|
const totals = new Map<string, number>();
|
|
for (const a of assignments) {
|
|
const shift = shiftsById.get(a.shift_id);
|
|
if (!shift) continue;
|
|
totals.set(
|
|
a.volunteer_id,
|
|
(totals.get(a.volunteer_id) ?? 0) + hoursBetween(shift.starts_at, shift.ends_at),
|
|
);
|
|
}
|
|
return totals;
|
|
}
|
|
|
|
/** Surcharge : > 8 h sur une journée civile ou < 8 h de repos entre créneaux. */
|
|
export function isOverloaded(
|
|
volunteerId: string,
|
|
assignments: AssignmentRead[],
|
|
shiftsById: Map<string, Pick<ShiftRead, 'starts_at' | 'ends_at'>>,
|
|
): boolean {
|
|
const shifts = assignments
|
|
.filter((a) => a.volunteer_id === volunteerId)
|
|
.map((a) => shiftsById.get(a.shift_id))
|
|
.filter((s): s is ShiftRead => Boolean(s))
|
|
.sort((a, b) => a.starts_at.localeCompare(b.starts_at));
|
|
|
|
const perDay = new Map<string, number>();
|
|
for (const s of shifts) {
|
|
const day = datePart(s.starts_at);
|
|
perDay.set(day, (perDay.get(day) ?? 0) + hoursBetween(s.starts_at, s.ends_at));
|
|
}
|
|
for (const hours of perDay.values()) if (hours > 8) return true;
|
|
|
|
for (let i = 1; i < shifts.length; i += 1) {
|
|
const gap = naiveMinutes(shifts[i].starts_at) - naiveMinutes(shifts[i - 1].ends_at);
|
|
if (gap > 0 && gap < 8 * 60) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Rend une violation en texte lisible. Les `params` suivent exactement le
|
|
* contrat du moteur back-end (`backend/app/validation/engine.py`).
|
|
*/
|
|
export function violationText(
|
|
violation: Violation,
|
|
resolvePosition?: (shiftId: string) => string | undefined,
|
|
): string {
|
|
const p = violation.params;
|
|
switch (violation.code) {
|
|
case 'SHIFT_OVERLAP': {
|
|
const where = resolvePosition?.(String(p.other_shift_id));
|
|
return where
|
|
? t('validation.SHIFT_OVERLAP.at', { position: where })
|
|
: t('validation.SHIFT_OVERLAP');
|
|
}
|
|
case '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)),
|
|
});
|
|
case 'INSUFFICIENT_REST':
|
|
return t('validation.INSUFFICIENT_REST', {
|
|
rest: formatMinutes(Number(p.gap_minutes)),
|
|
});
|
|
default:
|
|
return violation.code;
|
|
}
|
|
}
|
|
|
|
function formatMinutes(total: number): string {
|
|
const h = Math.floor(total / 60);
|
|
const m = total % 60;
|
|
return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')}`;
|
|
}
|