Retrait des disponibilités et des limites de temps dans la journée
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s

Disponibilités déclarées supprimées (table `availability`, migration 0022,
API, éditeur de la fiche bénévole, badge de couverture du panneau
d'affectation, colonnes des statistiques) ainsi que les règles
OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus
de disponibilité et de repos du solveur et le filtre « Surchargés » de la
vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis
disponible » du formulaire public est conservée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
AdamThomas
2026-10-05 21:29:58 +02:00
co-authored by Claude Opus 5.5
parent f671bb85bf
commit d722a81a87
40 changed files with 213 additions and 1845 deletions
-33
View File
@@ -16,7 +16,6 @@ import type {
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateResult,
AvailabilitySet,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeSizeUpdate,
@@ -135,23 +134,6 @@ 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 },
@@ -247,7 +229,6 @@ function useInvalidate() {
const client = useQueryClient();
return {
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
availability: () => client.invalidateQueries({ queryKey: ['availability', edition] }),
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
accounts: () => client.invalidateQueries({ queryKey: ['accounts', edition] }),
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
@@ -408,20 +389,6 @@ export function useExportMecenatReceipts() {
});
}
/** 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({
-11
View File
@@ -6,7 +6,6 @@ import type {
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateRequest,
AvailabilitySet,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeSizeUpdate,
@@ -172,16 +171,6 @@ export function createHttpApi(baseUrl: string): PlanningApi {
remove: (editionId, id) =>
request(`${base(editionId)}/accounts/${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();
+1 -38
View File
@@ -4,7 +4,6 @@ import type {
AccountRead,
ArtistRead,
AssignmentRead,
AvailabilityRead,
BadgeFieldRead,
DayPartRead,
EditionRead,
@@ -67,7 +66,6 @@ export interface MockState {
artists: ArtistRead[];
volunteers: VolunteerRead[];
/** volunteerId -> fenêtres de disponibilité déclarées. */
availability: Map<string, AvailabilityRead[]>;
positions: PositionRead[];
shifts: ShiftRead[];
assignments: AssignmentRead[];
@@ -266,37 +264,10 @@ export function buildMockState(): MockState {
);
const volunteers: VolunteerRead[] = [];
const availability = new Map<string, AvailabilityRead[]>();
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}`;
// 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];
if (rnd() < (edge ? 0.55 : 0.8)) {
windows.push(window(id, dateStr, '08:00:00', '02:00:00'));
}
}
availability.set(id, windows);
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
const artist_ids = artists.filter(() => rnd() < 0.25).map((a) => a.id);
const wished_position_ids =
@@ -354,10 +325,6 @@ export function buildMockState(): MockState {
const me = volunteers[0];
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
availability.set(
me.id,
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é';
});
@@ -390,10 +357,7 @@ export function buildMockState(): MockState {
.sort((a, b) => score(b) - score(a));
function score(v: VolunteerRead): number {
const available = (availability.get(v.id) ?? []).some((w) => w.date === dateStr)
? 100
: 0;
return available - (totalHours.get(v.id) ?? 0) * 3;
return -(totalHours.get(v.id) ?? 0) * 3;
}
for (const v of pool.slice(0, target)) {
@@ -510,7 +474,6 @@ export function buildMockState(): MockState {
assignments,
accounts,
passwords,
availability,
badgeTemplate: {
frontImage: null,
frontImageType: null,
+3 -156
View File
@@ -1,4 +1,4 @@
import { dayBoundaryFromDayParts, festivalDay, uncoveredMinutes } from '@/lib/planning';
import { dayBoundaryFromDayParts, festivalDay } from '@/lib/planning';
import { hoursBetween, naiveMinutes } from '@/lib/time';
import type {
@@ -8,8 +8,6 @@ import type {
ArtistUpdate,
AssignmentRead,
AssignmentValidateResult,
AvailabilityRead,
AvailabilitySet,
BadgeFieldRead,
BadgeFieldsSet,
BadgeGenerateRequest,
@@ -54,7 +52,6 @@ import { buildMockState, type MockPendingSubmission, type MockState } from './da
// comportement (chargements, invalidations) proche du back-end réel.
const LATENCY_MS = 120;
const EIGHT_HOURS_MIN = 8 * 60;
const MAGIC_LINK_TTL_MS = 15 * 60 * 1000;
let state: MockState = buildMockState();
@@ -199,14 +196,6 @@ const slugCode = (label: string): string =>
/** « 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<T extends { id: string }>(
pool: T[],
@@ -260,7 +249,6 @@ function promotePending(pending: MockPendingSubmission): VolunteerRead {
};
if (pending.photo) state.photos.set(volunteer.id, pending.photo);
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
pending.promotedVolunteerId = volunteer.id;
pending.status = 'PAID';
@@ -471,63 +459,6 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
}
}
// 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),
uncovered_minutes: gapMin,
},
});
}
// DAILY_HOURS_EXCEEDED (avertissement) — journée = date de début du créneau.
const dayKey = candidate.starts_at.slice(0, 10);
let totalMin = (naiveMinutes(candidate.ends_at) - naiveMinutes(candidate.starts_at));
for (const other of existing) {
if (other.starts_at.slice(0, 10) === dayKey) {
totalMin += naiveMinutes(other.ends_at) - naiveMinutes(other.starts_at);
}
}
if (totalMin > EIGHT_HOURS_MIN) {
violations.push({
code: 'DAILY_HOURS_EXCEEDED',
severity: 'WARNING',
params: { date: dayKey, total_minutes: totalMin },
});
}
// INSUFFICIENT_REST (avertissement) — < 8 h entre deux créneaux (strict).
for (const other of existing) {
if (overlaps(candidate, other)) continue;
let gap: number;
let direction: 'BEFORE' | 'AFTER';
if (naiveMinutes(other.ends_at) <= naiveMinutes(candidate.starts_at)) {
gap = naiveMinutes(candidate.starts_at) - naiveMinutes(other.ends_at);
direction = 'BEFORE';
} else if (naiveMinutes(candidate.ends_at) <= naiveMinutes(other.starts_at)) {
gap = naiveMinutes(other.starts_at) - naiveMinutes(candidate.ends_at);
direction = 'AFTER';
} else {
continue;
}
if (gap < EIGHT_HOURS_MIN) {
violations.push({
code: 'INSUFFICIENT_REST',
severity: 'WARNING',
params: { other_shift_id: other.id, direction, gap_minutes: gap },
});
}
}
return violations;
}
@@ -567,10 +498,10 @@ function buildStats(): EditionStats {
const sum = (list: VolunteerRead[]) => round(list.reduce((t, v) => t + (v.fee_amount ?? 0), 0));
const boundary = dayBoundaryFromDayParts(state.dayParts);
type Row = { shifts: number; min: number; ideal: number; avail: number; who: Set<string>; assigned: number };
type Row = { shifts: number; min: number; ideal: number; assigned: number };
const rows = new Map<string, Row>();
const row = (d: string) => {
if (!rows.has(d)) rows.set(d, { shifts: 0, min: 0, ideal: 0, avail: 0, who: new Set(), assigned: 0 });
if (!rows.has(d)) rows.set(d, { shifts: 0, min: 0, ideal: 0, assigned: 0 });
return rows.get(d) as Row;
};
const shiftsById = new Map(state.shifts.map((s) => [s.id, s]));
@@ -585,17 +516,6 @@ function buildStats(): EditionStats {
const s = shiftsById.get(a.shift_id);
if (s) row(festivalDay(s.starts_at, boundary)).assigned += hoursBetween(s.starts_at, s.ends_at);
}
const withAvailability = new Set<string>();
for (const [vid, windows] of state.availability) {
for (const w of windows) {
const start = `${w.date}T${w.start_time.slice(0, 5)}:00`;
const endDate = w.end_time > w.start_time ? w.date : addDay(w.date);
const r = row(festivalDay(start, boundary));
r.avail += hoursBetween(start, `${endDate}T${w.end_time.slice(0, 5)}:00`);
r.who.add(vid);
withAvailability.add(vid);
}
}
const assignedByShift = new Map<string, number>();
for (const a of state.assignments) assignedByShift.set(a.shift_id, (assignedByShift.get(a.shift_id) ?? 0) + 1);
@@ -650,8 +570,6 @@ function buildStats(): EditionStats {
shifts: r.shifts,
min_hours: round(r.min),
ideal_hours: round(r.ideal),
available_hours: round(r.avail),
available_volunteers: r.who.size,
assigned_hours: round(r.assigned),
})),
by_position: [...state.positions]
@@ -669,7 +587,6 @@ function buildStats(): EditionStats {
wishes: vols.filter((v) => v.wished_position_ids.includes(p.id)).length,
};
}),
volunteers_with_availability: withAvailability.size,
volunteers_with_assignment: new Set(state.assignments.map((a) => a.volunteer_id)).size,
},
};
@@ -714,8 +631,6 @@ function buildMatrix(): MatrixResponse {
const SOLVER_URGENT_BASE = 1000;
const SOLVER_NORMAL_BASE = 20;
const SOLVER_WISH_BONUS = 30;
const SOLVER_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const SOLVER_REST_PENALTY = 60;
const SOLVER_ARTIST_CONFLICT_PENALTY = 80;
const SOLVER_FAIRNESS_PENALTY_PER_HOUR = 2;
// Le bénévole a déjà tenu le créneau immédiatement précédent (même jour,
@@ -829,24 +744,6 @@ function solverScore(
}
score += SOLVER_COMPANION_BONUS_PER_MATCH * companionsPresent;
const windows = state.availability.get(volunteer.id) ?? [];
const missed = uncoveredMinutes(windows, shift.starts_at, shift.ends_at);
score -= Math.floor(missed / 30) * SOLVER_AVAIL_PENALTY_PER_HALF_HOUR;
for (const other of existing) {
if (overlaps(shift, other)) continue;
let gap: number | null = null;
if (naiveMinutes(other.ends_at) <= naiveMinutes(shift.starts_at)) {
gap = naiveMinutes(shift.starts_at) - naiveMinutes(other.ends_at);
} else if (naiveMinutes(shift.ends_at) <= naiveMinutes(other.starts_at)) {
gap = naiveMinutes(other.starts_at) - naiveMinutes(shift.ends_at);
}
if (gap !== null && gap < EIGHT_HOURS_MIN) {
score -= SOLVER_REST_PENALTY;
break;
}
}
const existingMinutes = existing.reduce(
(sum, s) => sum + (naiveMinutes(s.ends_at) - naiveMinutes(s.starts_at)),
0,
@@ -1112,7 +1009,6 @@ export function createMockApi(): PlanningApi {
updated_at: now,
};
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
return delay(volunteer);
},
update: (_edition, id, body) => {
@@ -1154,7 +1050,6 @@ export function createMockApi(): PlanningApi {
remove: (_edition, id) => {
state.volunteers = state.volunteers.filter((x) => x.id !== id);
state.assignments = state.assignments.filter((a) => a.volunteer_id !== id);
state.availability.delete(id);
state.photos.delete(id);
// RGPD : effacement complet, y compris le compte de connexion éventuel
// et les vœux de compagnon dont il était la cible.
@@ -1256,50 +1151,6 @@ 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<string>();
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);
@@ -1480,9 +1331,6 @@ export function createMockApi(): PlanningApi {
if (!mapping.dry_run) {
state.volunteers = working;
for (const v of working) {
if (!state.availability.has(v.id)) state.availability.set(v.id, []);
}
}
return delay(report);
},
@@ -1935,7 +1783,6 @@ export function createMockApi(): PlanningApi {
};
if (photo) state.photos.set(volunteer.id, photo);
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, []);
return delay<FormSubmissionResult>({
volunteer_id: volunteer.id,
pending_token: null,
+1 -34
View File
@@ -18,11 +18,7 @@ export type AssignmentSource = 'SOLVER' | 'MANUAL';
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
export type AuthMethod = 'password' | 'magic_link';
export type ViolationCode =
| 'SHIFT_OVERLAP'
| 'OUTSIDE_AVAILABILITY'
| 'DAILY_HOURS_EXCEEDED'
| 'INSUFFICIENT_REST';
export type ViolationCode = 'SHIFT_OVERLAP';
export type ViolationSeverity = 'BLOCKING' | 'WARNING';
@@ -177,32 +173,6 @@ export interface VolunteerPlanningEntry {
referent_phone: 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) //
// --------------------------------------------------------------------------- //
@@ -766,8 +736,6 @@ export interface StatsNeedDay {
shifts: number;
min_hours: number;
ideal_hours: number;
available_hours: number;
available_volunteers: number;
assigned_hours: number;
}
@@ -801,7 +769,6 @@ export interface EditionStats {
needs: {
by_day: StatsNeedDay[];
by_position: StatsNeedPosition[];
volunteers_with_availability: number;
volunteers_with_assignment: number;
};
}
-14
View File
@@ -11,8 +11,6 @@ import type {
AssignmentValidateRequest,
AssignmentValidateResult,
AssignmentWriteResult,
AvailabilityRead,
AvailabilitySet,
BadgeFieldRead,
BadgeFieldsSet,
BadgeGenerateRequest,
@@ -106,18 +104,6 @@ export interface PlanningApi {
update(editionId: string, id: string, body: AccountUpdate): Promise<AccountRead>;
remove(editionId: string, id: string): Promise<void>;
};
availability: {
/** Fenêtres déclarées par un bénévole. */
list(editionId: string, volunteerId: string): Promise<AvailabilityRead[]>;
/** Remplace l'intégralité des fenêtres d'un bénévole (COORDINATION). */
replace(
editionId: string,
volunteerId: string,
body: AvailabilitySet,
): Promise<AvailabilityRead[]>;
/** Toutes les fenêtres de l'édition (grille des besoins). */
listEdition(editionId: string): Promise<AvailabilityRead[]>;
};
imports: {
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */
analyze(editionId: string, file: File): Promise<ImportAnalyzeResponse>;
+6 -29
View File
@@ -70,12 +70,6 @@
"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 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.",
"grille.filter.day": "JOUR",
"grille.filter.position": "POSTE",
"grille.legend.critical": "SOUS-EFFECTIF CRITIQUE",
@@ -107,18 +101,16 @@
"grille.panel.warningsTitle": "⚠ AVERTISSEMENTS — AFFECTATION POSSIBLE",
"volunteers.filter.show": "AFFICHER",
"volunteers.filter.all": "TOUS",
"volunteers.filter.overloaded": "SURCHARGÉS",
"volunteers.filter.unassigned": "SANS AFFECTATION",
"volunteers.legend.assigned": "AFFECTATION",
"volunteers.count": "{count} BÉNÉVOLES",
"volunteers.totalHours": "TOTAL H",
"volunteers.name": "NOM",
"volunteers.cumulative": "CUMUL",
"volunteers.badge.overloaded": "▲ SURCHARGE",
"volunteers.badge.unassigned": "○ OUBLIÉ",
"volunteers.empty": "Aucun bénévole ne correspond à ce filtre — bonne nouvelle. Repassez sur « TOUS » pour revoir l'ensemble de l'équipe.",
"volunteers.noData": "Aucun bénévole pour le moment. Ajoutez-les dans Configuration → « Bénévoles & import ».",
"volunteers.footnote": "Surcharge = plus de 8 h sur une même journée ou moins de 8 h entre deux créneaux. Oublié = aucune affectation.",
"volunteers.footnote": "Oublié = aucune affectation.",
"mobileVol.kicker": "MON PLANNING BÉNÉVOLE",
"mobileVol.greeting": "SALUT {name}",
"mobileVol.slots": "{count} CRÉNEAUX",
@@ -174,7 +166,7 @@
"config.vol.phone": "TÉLÉPHONE",
"config.vol.email": "EMAIL (LIEN MAGIQUE)",
"config.vol.delete": "SUPPRIMER",
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (disponibilités, affectations) ?",
"config.vol.deleteConfirm": "Supprimer définitivement ce bénévole et toutes ses données (affectations, vœux) ?",
"config.vol.note": "Modifications enregistrées immédiatement via l'API. La suppression est un effacement complet en cascade.",
"config.mail.smtpBanner": "Envoie un récapitulatif de planning à chaque bénévole. L'email part si le serveur SMTP est configuré (EMAIL_ENABLED) ; sinon il est seulement journalisé.",
"config.mail.templateTitle": "MODÈLE DE MESSAGE",
@@ -209,9 +201,7 @@
"config.rules.slotAdd": "AJOUTER",
"config.rules.slotsNote": "Ces créneaux sont ajoutés à besoin 0/0 sur chaque jour à la création d'un poste. Après un ajout ici (ou une extension des dates), utilisez « Générer les créneaux » sur le poste.",
"config.rules.thresholdsTitle": "MOTEUR DE VALIDATION",
"config.rules.maxDaily": "Maximum d'heures par journée",
"config.rules.minRest": "Repos minimum entre deux créneaux",
"config.rules.thresholdsNote": "Seuils figés côté back-end (CLAUDE.md §3) : non paramétrables. Le double emploi au même créneau est toujours bloquant.",
"config.rules.thresholdsNote": "Seule règle appliquée : le double emploi au même moment est toujours bloquant. Aucune limite d'heures par journée ni de temps de repos.",
"config.rules.rulesTitle": "RÈGLES APPLIQUÉES",
"config.rules.rolesTitle2": "RÔLES ET ACCÈS",
"config.rules.roleCoordinator": "Coordination",
@@ -325,17 +315,6 @@
"config.vol.fee.short.failed": "échouée",
"config.vol.fee.short.expired": "expirée",
"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.",
"grille.solver.run": "SUGGESTIONS (SOLVEUR)",
"grille.solver.running": "CALCUL EN COURS…",
"grille.solver.result": "{count} suggestion(s)",
@@ -392,7 +371,7 @@
"stats.kpi.registered": "Bénévoles inscrits · +{count} en 7 jours",
"stats.kpi.collected": "Cotisations encaissées en ligne",
"stats.kpi.coverage": "Places pourvues · {assigned} / {ideal}",
"stats.kpi.withAvailability": "Ont déclaré des disponibilités · sur {total}",
"stats.kpi.withAssignment": "Ont au moins une affectation · sur {total}",
"stats.empty": "Aucun bénévole inscrit pour l'instant.",
"stats.showTable": "Voir le tableau",
"stats.unit.hours": "h",
@@ -424,13 +403,12 @@
"stats.payments.mecenat": "Dont mécénat : {count} don(s), {amount} (reçus fiscaux).",
"stats.payments.unfinished": "{count} formulaire(s) rempli(s) sans paiement abouti : ces personnes ne sont pas comptées parmi les bénévoles (paiement refusé, abandonné ou expiré).",
"stats.needs.title": "Besoins et bénévoles",
"stats.needs.help": "Heures × personnes demandées par les créneaux, face aux heures de disponibilité déclarées et aux heures déjà affectées — par journée de festival (une soirée qui passe minuit compte pour le jour où elle commence).",
"stats.needs.help": "Heures × personnes demandées par les créneaux, face aux heures déjà affectées — par journée de festival (une soirée qui passe minuit compte pour le jour où elle commence).",
"stats.needs.empty": "Aucun créneau défini pour l'instant.",
"stats.needs.byDay": "Par jour (heures × personnes)",
"stats.needs.byPosition": "Par poste (places = personnes × créneaux)",
"stats.needs.min": "Besoin minimal",
"stats.needs.ideal": "Besoin idéal",
"stats.needs.available": "Disponibilités déclarées",
"stats.needs.assigned": "Déjà affecté",
"stats.needs.filledWithExtra": "{filled} / {ideal} (+{extra} en sureffectif)",
"stats.needs.wishesHint": "Pourvues : places occupées, plafonnées à l'effectif idéal de chaque créneau (« +2 » = bénévoles en sureffectif, non comptés). Vœux / place : nombre de bénévoles qui ont demandé le poste, rapporté aux places idéales. En dessous de 1, le poste aura besoin de bénévoles qui ne l'ont pas choisi.",
@@ -443,7 +421,6 @@
"stats.col.paymentType": "Type de paiement",
"stats.col.amount": "Montant",
"stats.col.shifts": "Créneaux",
"stats.col.availableVolunteers": "Bénévoles dispo.",
"stats.col.position": "Poste",
"stats.col.minSlots": "Places min.",
"stats.col.idealSlots": "Places idéales",
@@ -459,7 +436,7 @@
"form.field.companionWish": "Collègue apprécié",
"form.field.companionWish.help": "Texte libre : la personne visée n'est pas forcément déjà inscrite. À résoudre ensuite depuis sa fiche.",
"form.field.available": "Case « je suis disponible »",
"form.field.available.help": "Purement informatif — n'a aucun lien avec les disponibilités détaillées de la fiche bénévole.",
"form.field.available.help": "Purement informatif — simple case à cocher, sans effet sur les affectations.",
"form.field.willingToPayFee": "Case « je veux bien payer une cotisation »",
"config.vol.feeStatus.pending": "Cotisation : paiement en attente",
"config.vol.feeStatus.paid": "Cotisation payée ✓",
+4 -117
View File
@@ -1,7 +1,6 @@
import type {
ArtistRead,
AssignmentRead,
AvailabilityRead,
DayPartRead,
MatrixResponse,
PositionCategory,
@@ -25,71 +24,6 @@ export function foldAccents(value: string): string {
.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';
}
// --------------------------------------------------------------------------- //
// Score de chance d'un candidat pour un créneau — portage simplifié du //
// solveur (`backend/app/solver/engine.py`) : sert à trier la liste des //
@@ -145,28 +79,25 @@ export function buildEffectiveCompanionIds(
}
const CHANCE_WISH_POSITION_BONUS = 30;
const CHANCE_AVAIL_PENALTY_PER_HALF_HOUR = 5;
const CHANCE_ARTIST_CONFLICT_PENALTY = 80;
const CHANCE_FAIRNESS_PENALTY_PER_HOUR = 2;
const CHANCE_COMPANION_BONUS_PER_MATCH = 25;
/**
* Score de chance d'un bénévole pour un créneau : vœu de poste, disponibilité
* (y compris le conflit avec un artiste qu'il veut voir), compagnons
* souhaités déjà affectés au créneau, charge déjà assignée. Plus haut =
* Score de chance d'un bénévole pour un créneau : vœu de poste, conflit avec
* un artiste qu'il veut voir, compagnons souhaités déjà affectés au créneau,
* charge déjà assignée. Plus haut =
* meilleur candidat. Sert uniquement à trier la liste affichée ; la
* validation (blocage / avertissements) reste seule juge.
*/
export function candidateChanceScore(params: {
wishesPosition: boolean;
uncoveredMinutes: number;
conflictsWishedArtist: boolean;
companionMatches: number;
assignedHours: number;
}): number {
let score = 0;
if (params.wishesPosition) score += CHANCE_WISH_POSITION_BONUS;
score -= Math.floor(params.uncoveredMinutes / 30) * CHANCE_AVAIL_PENALTY_PER_HALF_HOUR;
if (params.conflictsWishedArtist) score -= CHANCE_ARTIST_CONFLICT_PENALTY;
score += CHANCE_COMPANION_BONUS_PER_MATCH * params.companionMatches;
score -= CHANCE_FAIRNESS_PENALTY_PER_HOUR * params.assignedHours;
@@ -239,7 +170,7 @@ export function dayBoundaryFromDayParts(
* « journée de festival » plutôt que par date calendaire brute : un créneau
* commençant avant cette heure (ex. 02:00, suite d'une soirée qui a passé
* minuit) est rattaché au jour précédent et trié après 23:00, pas avant
* 19:00 — voir CLAUDE.md § 3 (« journée » pour le cumul des 8 h). */
* 19:00 — voir CLAUDE.md § 3. */
export function buildLattice(
starts: Array<{ starts_at: string; ends_at: string }>,
dayBoundary = '00:00',
@@ -460,32 +391,6 @@ export function hoursByVolunteer(
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`).
@@ -502,25 +407,7 @@ export function violationText(
? 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')}`;
}
+3 -8
View File
@@ -57,8 +57,8 @@ export function StatsPage() {
label={t('stats.kpi.coverage', { assigned: fr(filled), ideal: fr(idealSlots) })}
/>
<StatCard
value={fr(s.needs.volunteers_with_availability)}
label={t('stats.kpi.withAvailability', { total: fr(s.registrations.total) })}
value={fr(s.needs.volunteers_with_assignment)}
label={t('stats.kpi.withAssignment', { total: fr(s.registrations.total) })}
/>
</div>
@@ -230,7 +230,6 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
}
const series = [
{ label: t('stats.needs.ideal'), color: SERIES.blue },
{ label: t('stats.needs.available'), color: SERIES.orange },
{ label: t('stats.needs.assigned'), color: SERIES.aqua },
];
return (
@@ -243,7 +242,7 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
groups={n.by_day.map((d) => ({
key: d.date,
label: shortDayLabel(d.date),
values: [d.ideal_hours, d.available_hours, d.assigned_hours],
values: [d.ideal_hours, d.assigned_hours],
}))}
/>
<TableToggle label={t('stats.showTable')}>
@@ -253,8 +252,6 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
t('stats.col.shifts'),
t('stats.needs.min'),
t('stats.needs.ideal'),
t('stats.needs.available'),
t('stats.col.availableVolunteers'),
t('stats.needs.assigned'),
]}
rows={n.by_day.map((d) => [
@@ -262,8 +259,6 @@ function NeedsSection({ stats }: { stats: EditionStats }) {
fr(d.shifts),
fr(d.min_hours, 1),
fr(d.ideal_hours, 1),
fr(d.available_hours, 1),
fr(d.available_volunteers),
fr(d.assigned_hours, 1),
])}
/>
+3 -20
View File
@@ -4,12 +4,12 @@ import { useAssignments, useDayParts, usePositions, useShifts, useVolunteers } f
import type { AssignmentRead, ShiftRead, VolunteerRead } from '@/api/schema';
import { Chip, HelpTip } from '@/components/ui';
import { EmptyNote, ErrorNote, Loading } from '@/components/state';
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer, isOverloaded } from '@/lib/planning';
import { buildLattice, dayBoundaryFromDayParts, hoursByVolunteer } from '@/lib/planning';
import { datePart, shortDayLabel, timePart } from '@/lib/time';
import { t } from '@/i18n';
import { cx } from '@/lib/cx';
type Filter = 'all' | 'overloaded' | 'unassigned';
type Filter = 'all' | 'unassigned';
export function VolunteerViewPage() {
const volunteers = useVolunteers();
@@ -48,7 +48,6 @@ export function VolunteerViewPage() {
return (volunteers.data ?? [])
.filter((v) => {
const own = byVolunteer.get(v.id) ?? [];
if (filter === 'overloaded') return isOverloaded(v.id, all, shiftsById);
if (filter === 'unassigned') return own.length === 0;
return true;
})
@@ -67,7 +66,6 @@ export function VolunteerViewPage() {
volunteer: v,
cellByKey,
total: Math.round((hours.get(v.id) ?? 0) * 10) / 10,
over: isOverloaded(v.id, all, shiftsById),
empty: own.length === 0,
};
});
@@ -102,9 +100,6 @@ export function VolunteerViewPage() {
<Chip active={filter === 'all'} onClick={() => setFilter('all')}>
{t('volunteers.filter.all')}
</Chip>
<Chip active={filter === 'overloaded'} onClick={() => setFilter('overloaded')}>
{t('volunteers.filter.overloaded')}
</Chip>
<Chip active={filter === 'unassigned'} onClick={() => setFilter('unassigned')}>
{t('volunteers.filter.unassigned')}
</Chip>
@@ -182,7 +177,6 @@ type Row = {
volunteer: VolunteerRead;
cellByKey: Map<string, AssignmentRead & { shift: ShiftRead }>;
total: number;
over: boolean;
empty: boolean;
};
@@ -201,11 +195,6 @@ function VolunteerRow({
<span className="min-w-0 flex-1 truncate font-display text-[11.5px] font-bold">
{row.volunteer.first_name} {row.volunteer.last_name}
</span>
{row.over ? (
<span className="flex-none border-[1.5px] border-ink bg-orange px-1 py-[2px] font-display text-[8px] font-extrabold">
{t('volunteers.badge.overloaded')}
</span>
) : null}
{row.empty ? (
<span className="flex-none border-[1.5px] border-ink bg-blue px-1 py-[2px] font-display text-[8px] font-extrabold">
{t('volunteers.badge.unassigned')}
@@ -224,14 +213,8 @@ function VolunteerRow({
</div>
);
})}
<div
className={cx(
'sticky right-0 z-[2] flex items-center justify-center gap-[3px] border-b border-l-[3px] border-ink font-mono text-[13px] font-bold',
row.over ? 'bg-orange' : 'bg-paper',
)}
>
<div className="sticky right-0 z-[2] flex items-center justify-center border-b border-l-[3px] border-ink bg-paper font-mono text-[13px] font-bold">
{row.total}
{row.over ? <span className="font-display text-[9px] font-bold">▲</span> : null}
</div>
</>
);
+3 -10
View File
@@ -18,14 +18,9 @@ import { t } from '@/i18n';
const hm = (time: string) => time.slice(0, 5); // "HH:MM:SS" -> "HH:MM"
const RULE_CODES = [
'SHIFT_OVERLAP',
'OUTSIDE_AVAILABILITY',
'DAILY_HOURS_EXCEEDED',
'INSUFFICIENT_REST',
] as const;
const RULE_CODES = ['SHIFT_OVERLAP'] as const;
// Les seuils du moteur sont figés (CLAUDE.md §3). L'édition (nom, dates) est
// Le moteur de validation n'a qu'une règle, figée (CLAUDE.md §3). L'édition (nom, dates) est
// éditable — c'est là qu'on ajoute ou retire des jours.
export function ConfigRules() {
const volunteers = useVolunteers();
@@ -62,9 +57,7 @@ export function ConfigRules() {
<HelpTip text={t('config.rules.thresholdsNote')} side="bottom" />
</PanelHeader>
<div className="flex flex-col gap-2 p-[13px]">
<InfoRow label={t('config.rules.maxDaily')} value="8 h" />
<InfoRow label={t('config.rules.minRest')} value="8 h" />
<div className="mt-2 border-t-2 border-ink pt-2">
<div>
<div className="mb-1 font-display text-[9px] font-extrabold uppercase tracking-widest">
{t('config.rules.rulesTitle')}
</div>
+1 -152
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useMemo, useRef, useState, type ReactNode } from 'react';
import { ApiError } from '@/api';
import { roleLabelKey } from '@/auth/roles';
@@ -10,16 +10,13 @@ import {
useCreateVolunteer,
useDeleteAccount,
useDeleteVolunteer,
useEdition,
useExecuteImport,
useExportMecenatReceipts,
useExportVolunteers,
usePositions,
useReplaceAvailability,
useUpdateAccount,
useUpdatePosition,
useUpdateVolunteer,
useVolunteerAvailability,
useVolunteerPhotoUrl,
useVolunteers,
} from '@/api/hooks';
@@ -870,8 +867,6 @@ export function ConfigVolunteers() {
<PublicFormAnswers volunteer={selected} patch={patch} />
<AvailabilityEditor volunteerId={selected.id} />
<button
type="button"
onClick={() => {
@@ -941,7 +936,6 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
<div className="flex items-center justify-between bg-ink px-[14px] py-[11px] text-cream">
<span className="flex items-center gap-[7px] font-display text-[14px] font-black">
{t('config.vol.addTitle')}
<HelpTip text={t('config.vol.addAvailabilityHint')} side="bottom" />
</span>
<button
type="button"
@@ -1361,148 +1355,3 @@ 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')} <HelpTip text={t('config.vol.avail.hint')} />
</FieldLabel>
<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>
);
}
+3 -54
View File
@@ -6,33 +6,24 @@ import {
useAssignments,
useCreateAssignment,
useDeleteAssignment,
useEditionAvailability,
usePositions,
useShifts,
useSolverSuggestions,
useValidateAssignment,
useVolunteers,
} from '@/api/hooks';
import type {
AvailabilityRead,
MatrixShift,
PositionCategory,
VolunteerRead,
} from '@/api/schema';
import type { MatrixShift, PositionCategory, VolunteerRead } from '@/api/schema';
import { Btn } from '@/components/ui';
import { Loading } from '@/components/state';
import {
artistPerformanceWindow,
availabilityCoverage,
buildEffectiveCompanionIds,
candidateChanceScore,
categoryLabel,
foldAccents,
hoursByVolunteer,
uncoveredMinutes,
violationText,
windowsOverlap,
type Coverage,
} from '@/lib/planning';
import { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
import { t } from '@/i18n';
@@ -63,7 +54,6 @@ export function AssignmentPanel({
const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
const shifts = useShifts();
const positions = usePositions();
const availability = useEditionAvailability();
const artists = useArtists();
const solverSuggestions = useSolverSuggestions();
const deleteAssignment = useDeleteAssignment();
@@ -75,27 +65,6 @@ export function AssignmentPanel({
return entry?.volunteer_ids ?? [];
}, [solverSuggestions.data, shift.shift_id]);
const windowsByVolunteer = 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);
}
return byVol;
}, [availability.data]);
const coverageByVolunteer = useMemo(() => {
const map = new Map<string, Coverage>();
for (const v of volunteers.data ?? []) {
map.set(
v.id,
availabilityCoverage(windowsByVolunteer.get(v.id) ?? [], shift.starts_at, shift.ends_at),
);
}
return map;
}, [windowsByVolunteer, volunteers.data, shift.starts_at, shift.ends_at]);
const artistsById = useMemo(
() => new Map((artists.data ?? []).map((a) => [a.id, a])),
[artists.data],
@@ -166,8 +135,7 @@ export function AssignmentPanel({
}, [volunteers.data, artistsById, shift, positionId, assigned, effectiveCompanionIds]);
// Score de chance de chaque candidat pour CE créneau : vœu de poste,
// disponibilité (dont conflit avec un artiste qu'il veut voir), compagnons
// souhaités déjà présents, charge déjà assignée — c'est ce qui classe la
// conflit avec un artiste qu'il veut voir, compagnons souhaités déjà présents, charge déjà assignée — c'est ce qui classe la
// liste, pas juste « le moins chargé ».
const scoreByVolunteer = useMemo(() => {
const map = new Map<string, number>();
@@ -177,11 +145,6 @@ export function AssignmentPanel({
v.id,
candidateChanceScore({
wishesPosition: details?.wishesPosition ?? false,
uncoveredMinutes: uncoveredMinutes(
windowsByVolunteer.get(v.id) ?? [],
shift.starts_at,
shift.ends_at,
),
conflictsWishedArtist: details?.conflictsWishedArtist ?? false,
companionMatches: details?.companionMatches ?? 0,
assignedHours: hours.get(v.id) ?? 0,
@@ -189,7 +152,7 @@ export function AssignmentPanel({
);
}
return map;
}, [volunteers.data, detailsByVolunteer, windowsByVolunteer, hours, shift]);
}, [volunteers.data, detailsByVolunteer, hours]);
const needle = foldAccents(search.trim());
@@ -319,7 +282,6 @@ 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'}
wishesPosition={detailsByVolunteer.get(volunteer.id)?.wishesPosition ?? false}
conflictsWishedArtist={
detailsByVolunteer.get(volunteer.id)?.conflictsWishedArtist ?? false
@@ -334,18 +296,11 @@ 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,
wishesPosition,
conflictsWishedArtist,
companionMatches,
@@ -356,7 +311,6 @@ function CandidateRow({
shiftId: string;
hours: number;
slots: number;
coverage: Coverage;
wishesPosition: boolean;
conflictsWishedArtist: boolean;
companionMatches: number;
@@ -411,11 +365,6 @@ function CandidateRow({
</div>
<div className="flex flex-wrap gap-[5px] 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>
{wishesPosition ? (
<span className="inline-block border-[1.5px] border-ink bg-yellow px-[6px] py-[3px] font-display text-[8.5px] font-extrabold uppercase tracking-wide">
{t('grille.panel.wishesPosition')}