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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user