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
@@ -12,6 +12,7 @@ import type {
|
|||||||
AssignmentCreate,
|
AssignmentCreate,
|
||||||
AssignmentUpdate,
|
AssignmentUpdate,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
|
AvailabilitySet,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionUpdate,
|
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(
|
export function useAssignments(
|
||||||
params?: { volunteerId?: string; shiftId?: string },
|
params?: { volunteerId?: string; shiftId?: string },
|
||||||
options?: { enabled?: boolean },
|
options?: { enabled?: boolean },
|
||||||
@@ -117,6 +135,7 @@ function useInvalidate() {
|
|||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
return {
|
return {
|
||||||
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
|
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
|
||||||
|
availability: () => client.invalidateQueries({ queryKey: ['availability', edition] }),
|
||||||
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
|
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
|
||||||
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
|
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
|
||||||
shifts: () => client.invalidateQueries({ queryKey: ['shifts', 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. */
|
/** Import de bénévoles — phase 1 : analyse du fichier. */
|
||||||
export function useAnalyzeImport() {
|
export function useAnalyzeImport() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AssignmentCreate,
|
AssignmentCreate,
|
||||||
AssignmentUpdate,
|
AssignmentUpdate,
|
||||||
AssignmentValidateRequest,
|
AssignmentValidateRequest,
|
||||||
|
AvailabilitySet,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
EditionUpdate,
|
EditionUpdate,
|
||||||
@@ -108,6 +109,16 @@ export function createHttpApi(baseUrl: string): PlanningApi {
|
|||||||
remove: (editionId, id) =>
|
remove: (editionId, id) =>
|
||||||
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
|
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: {
|
imports: {
|
||||||
analyze: (editionId, file: File) => {
|
analyze: (editionId, file: File) => {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AccountRead,
|
AccountRead,
|
||||||
ArtistRead,
|
ArtistRead,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
|
AvailabilityRead,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
EditionRead,
|
EditionRead,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
@@ -30,14 +31,14 @@ export interface MockState {
|
|||||||
dayParts: DayPartRead[];
|
dayParts: DayPartRead[];
|
||||||
artists: ArtistRead[];
|
artists: ArtistRead[];
|
||||||
volunteers: VolunteerRead[];
|
volunteers: VolunteerRead[];
|
||||||
|
/** volunteerId -> fenêtres de disponibilité déclarées. */
|
||||||
|
availability: Map<string, AvailabilityRead[]>;
|
||||||
positions: PositionRead[];
|
positions: PositionRead[];
|
||||||
shifts: ShiftRead[];
|
shifts: ShiftRead[];
|
||||||
assignments: AssignmentRead[];
|
assignments: AssignmentRead[];
|
||||||
accounts: AccountRead[];
|
accounts: AccountRead[];
|
||||||
/** accountId -> mot de passe en clair (mock uniquement). */
|
/** accountId -> mot de passe en clair (mock uniquement). */
|
||||||
passwords: Map<string, string>;
|
passwords: Map<string, string>;
|
||||||
/** volunteerId -> clés « YYYY-MM-DD|HH:MM » déclarées disponibles (privé au mock). */
|
|
||||||
availability: Map<string, Set<string>>;
|
|
||||||
seq: number;
|
seq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +89,6 @@ const LAST_NAMES = [
|
|||||||
'Perrin', 'Lefèvre', 'Nguyen', 'Rossi', 'Garnier', 'Barbier', 'Vasseur', 'Colin', 'Prévost',
|
'Perrin', 'Lefèvre', 'Nguyen', 'Rossi', 'Garnier', 'Barbier', 'Vasseur', 'Colin', 'Prévost',
|
||||||
'Renard', 'Dupuy',
|
'Renard', 'Dupuy',
|
||||||
];
|
];
|
||||||
const SKILL_POOL = ['CAISSE', 'CARISTE', 'REGIE', 'SECOURISME'];
|
|
||||||
const ARTIST_POOL = [
|
const ARTIST_POOL = [
|
||||||
'La Battante',
|
'La Battante',
|
||||||
'Orchestre Miroir',
|
'Orchestre Miroir',
|
||||||
@@ -171,7 +171,6 @@ export function buildMockState(): MockState {
|
|||||||
ends_at: iso(dateStr, bounds[2]),
|
ends_at: iso(dateStr, bounds[2]),
|
||||||
min_staff,
|
min_staff,
|
||||||
ideal_staff,
|
ideal_staff,
|
||||||
required_skill_codes: raw.requiredSkills ?? [],
|
|
||||||
created_at: NOW,
|
created_at: NOW,
|
||||||
updated_at: NOW,
|
updated_at: NOW,
|
||||||
});
|
});
|
||||||
@@ -193,26 +192,39 @@ export function buildMockState(): MockState {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const volunteers: VolunteerRead[] = [];
|
const volunteers: VolunteerRead[] = [];
|
||||||
const availability = new Map<string, Set<string>>();
|
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) {
|
for (let i = 0; i < 64; i += 1) {
|
||||||
const first_name = FIRST_NAMES[i % FIRST_NAMES.length];
|
const first_name = FIRST_NAMES[i % FIRST_NAMES.length];
|
||||||
const last_name = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length];
|
const last_name = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length];
|
||||||
const id = `v${i}`;
|
const id = `v${i}`;
|
||||||
const avail = new Set<string>();
|
// Pour ~75 % des jours : une fenêtre large 08:00 -> 02:00 le lendemain.
|
||||||
|
const windows: AvailabilityRead[] = [];
|
||||||
for (const dateStr of DAY_DATES) {
|
for (const dateStr of DAY_DATES) {
|
||||||
const edge = dateStr === DAY_DATES[0] || dateStr === DAY_DATES[3];
|
const edge = dateStr === DAY_DATES[0] || dateStr === DAY_DATES[3];
|
||||||
for (const [, startHour] of PART_BOUNDS) {
|
if (rnd() < (edge ? 0.55 : 0.8)) {
|
||||||
if (rnd() < (edge ? 0.4 : 0.6)) {
|
windows.push(window(id, dateStr, '08:00:00', '02:00:00'));
|
||||||
avail.add(`${dateStr}|${String(startHour).padStart(2, '0')}:00`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
availability.set(id, avail);
|
availability.set(id, windows);
|
||||||
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
|
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 =
|
const wished_position_ids =
|
||||||
rnd() < 0.4 ? [positions[Math.floor(rnd() * positions.length)].id] : [];
|
rnd() < 0.4 ? [positions[Math.floor(rnd() * positions.length)].id] : [];
|
||||||
volunteers.push({
|
volunteers.push({
|
||||||
@@ -223,7 +235,6 @@ export function buildMockState(): MockState {
|
|||||||
email: rnd() < 0.85 ? demoEmail(first_name, last_name) : null,
|
email: rnd() < 0.85 ? demoEmail(first_name, last_name) : null,
|
||||||
phone: `06 ${pair()} ${pair()} ${pair()} ${pair()}`,
|
phone: `06 ${pair()} ${pair()} ${pair()} ${pair()}`,
|
||||||
comment: null,
|
comment: null,
|
||||||
skill_codes,
|
|
||||||
artist_ids,
|
artist_ids,
|
||||||
wished_position_ids,
|
wished_position_ids,
|
||||||
created_at: NOW,
|
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
|
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
|
||||||
availability.set(
|
availability.set(
|
||||||
me.id,
|
me.id,
|
||||||
new Set(
|
DAY_DATES.map((dateStr) => window(me.id, dateStr, '08:00:00', '02:00:00')),
|
||||||
DAY_DATES.flatMap((dateStr) =>
|
|
||||||
PART_BOUNDS.map(([, h]) => `${dateStr}|${String(h).padStart(2, '0')}:00`),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
NEVER_ASSIGNED.forEach((i) => {
|
NEVER_ASSIGNED.forEach((i) => {
|
||||||
volunteers[i].comment = 'Inscrit, pas encore affecté';
|
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 duration = hoursBetween(shift.starts_at, shift.ends_at);
|
||||||
const dateStr = shift.starts_at.slice(0, 10);
|
const dateStr = shift.starts_at.slice(0, 10);
|
||||||
const availKey = `${dateStr}|${shift.starts_at.slice(11, 16)}`;
|
|
||||||
|
|
||||||
const pool = volunteers
|
const pool = volunteers
|
||||||
.filter(
|
.filter(
|
||||||
@@ -274,13 +280,10 @@ export function buildMockState(): MockState {
|
|||||||
.sort((a, b) => score(b) - score(a));
|
.sort((a, b) => score(b) - score(a));
|
||||||
|
|
||||||
function score(v: VolunteerRead): number {
|
function score(v: VolunteerRead): number {
|
||||||
const available = availability.get(v.id)?.has(availKey) ? 100 : 0;
|
const available = (availability.get(v.id) ?? []).some((w) => w.date === dateStr)
|
||||||
const skilled =
|
? 100
|
||||||
shift.required_skill_codes.length > 0 &&
|
: 0;
|
||||||
shift.required_skill_codes.every((c) => v.skill_codes.includes(c))
|
return available - (totalHours.get(v.id) ?? 0) * 3;
|
||||||
? 40
|
|
||||||
: 0;
|
|
||||||
return available + skilled - (totalHours.get(v.id) ?? 0) * 3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const v of pool.slice(0, target)) {
|
for (const v of pool.slice(0, target)) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { uncoveredMinutes } from '@/lib/planning';
|
||||||
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
import { hoursBetween, naiveMinutes } from '@/lib/time';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -7,7 +8,8 @@ import type {
|
|||||||
ArtistUpdate,
|
ArtistUpdate,
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
AvailabilityStatus,
|
AvailabilityRead,
|
||||||
|
AvailabilitySet,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
@@ -92,6 +94,17 @@ const slugCode = (label: string): string =>
|
|||||||
.replace(/[^A-Z0-9]+/g, '_')
|
.replace(/[^A-Z0-9]+/g, '_')
|
||||||
.replace(/^_+|_+$/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. */
|
/** Vœux du bénévole : dé-doublonne, ordre conservé ; 422 si un id est étranger. */
|
||||||
function resolveWishes<T extends { id: string }>(
|
function resolveWishes<T extends { id: string }>(
|
||||||
pool: T[],
|
pool: T[],
|
||||||
@@ -131,7 +144,6 @@ function generateMissingShifts(positionId: string): number {
|
|||||||
ends_at,
|
ends_at,
|
||||||
min_staff: 0,
|
min_staff: 0,
|
||||||
ideal_staff: 0,
|
ideal_staff: 0,
|
||||||
required_skill_codes: [],
|
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
});
|
});
|
||||||
@@ -157,21 +169,8 @@ const IMPORT_HINTS: Record<string, string[]> = {
|
|||||||
last_name: ['nom', 'last name', 'lastname', 'surname', 'famille'],
|
last_name: ['nom', 'last name', 'lastname', 'surname', 'famille'],
|
||||||
phone: ['tel', 'phone', 'portable', 'mobile', 'gsm'],
|
phone: ['tel', 'phone', 'portable', 'mobile', 'gsm'],
|
||||||
comment: ['commentaire', 'remarque', 'note', 'precision'],
|
comment: ['commentaire', 'remarque', 'note', 'precision'],
|
||||||
skills: ['competence', 'skill', 'habilitation', 'brevet', 'diplome'],
|
|
||||||
};
|
};
|
||||||
const IMPORT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
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 {
|
function normalizeText(value: string): string {
|
||||||
return (value ?? '')
|
return (value ?? '')
|
||||||
@@ -182,15 +181,6 @@ function normalizeText(value: string): string {
|
|||||||
.replace(/\s+/g, ' ');
|
.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[] {
|
function splitCsvLine(line: string, delimiter: string): string[] {
|
||||||
const cells: string[] = [];
|
const cells: string[] = [];
|
||||||
let cur = '';
|
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é.
|
// OUTSIDE_AVAILABILITY (avertissement) — créneau non couvert par l'union des
|
||||||
const declared = state.availability.get(volunteerId) ?? new Set<string>();
|
// fenêtres de disponibilité déclarées.
|
||||||
const availKey = `${candidate.starts_at.slice(0, 10)}|${candidate.starts_at.slice(11, 16)}`;
|
const gapMin = uncoveredMinutes(
|
||||||
if (!declared.has(availKey)) {
|
state.availability.get(volunteerId) ?? [],
|
||||||
|
candidate.starts_at,
|
||||||
|
candidate.ends_at,
|
||||||
|
);
|
||||||
|
if (gapMin > 0) {
|
||||||
violations.push({
|
violations.push({
|
||||||
code: 'OUTSIDE_AVAILABILITY',
|
code: 'OUTSIDE_AVAILABILITY',
|
||||||
severity: 'WARNING',
|
severity: 'WARNING',
|
||||||
params: {
|
params: {
|
||||||
date: candidate.starts_at.slice(0, 10),
|
date: candidate.starts_at.slice(0, 10),
|
||||||
day_part_code: candidate.starts_at.slice(11, 16),
|
uncovered_minutes: gapMin,
|
||||||
status: 'UNDECLARED',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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;
|
return violations;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +519,6 @@ export function createMockApi(): PlanningApi {
|
|||||||
email: body.email ?? null,
|
email: body.email ?? null,
|
||||||
phone: body.phone ?? null,
|
phone: body.phone ?? null,
|
||||||
comment: body.comment ?? null,
|
comment: body.comment ?? null,
|
||||||
skill_codes: body.skill_codes ?? [],
|
|
||||||
artist_ids: resolveWishes(state.artists, body.artist_ids ?? [], 'artist.unknown'),
|
artist_ids: resolveWishes(state.artists, body.artist_ids ?? [], 'artist.unknown'),
|
||||||
wished_position_ids: resolveWishes(
|
wished_position_ids: resolveWishes(
|
||||||
state.positions,
|
state.positions,
|
||||||
@@ -548,7 +529,7 @@ export function createMockApi(): PlanningApi {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
state.volunteers.push(volunteer);
|
state.volunteers.push(volunteer);
|
||||||
state.availability.set(volunteer.id, new Set());
|
state.availability.set(volunteer.id, []);
|
||||||
return delay(volunteer);
|
return delay(volunteer);
|
||||||
},
|
},
|
||||||
update: (_edition, id, body) => {
|
update: (_edition, id, body) => {
|
||||||
@@ -559,7 +540,6 @@ export function createMockApi(): PlanningApi {
|
|||||||
...('email' in body ? { email: body.email ?? null } : {}),
|
...('email' in body ? { email: body.email ?? null } : {}),
|
||||||
...('phone' in body ? { phone: body.phone ?? null } : {}),
|
...('phone' in body ? { phone: body.phone ?? null } : {}),
|
||||||
...('comment' in body ? { comment: body.comment ?? null } : {}),
|
...('comment' in body ? { comment: body.comment ?? null } : {}),
|
||||||
...(body.skill_codes != null ? { skill_codes: body.skill_codes } : {}),
|
|
||||||
...(body.artist_ids != null
|
...(body.artist_ids != null
|
||||||
? { artist_ids: resolveWishes(state.artists, body.artist_ids, 'artist.unknown') }
|
? { 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<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: {
|
imports: {
|
||||||
analyze: async (_edition, file: File) => {
|
analyze: async (_edition, file: File) => {
|
||||||
const { headers, rows } = await readImportTable(file);
|
const { headers, rows } = await readImportTable(file);
|
||||||
@@ -591,14 +615,6 @@ export function createMockApi(): PlanningApi {
|
|||||||
line: i + 2,
|
line: i + 2,
|
||||||
values: Object.fromEntries(headers.map((h, c) => [h, cells[c] ?? ''])),
|
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<ImportAnalyzeResponse>({
|
return delay<ImportAnalyzeResponse>({
|
||||||
filename: file.name || '',
|
filename: file.name || '',
|
||||||
format: 'csv',
|
format: 'csv',
|
||||||
@@ -606,16 +622,6 @@ export function createMockApi(): PlanningApi {
|
|||||||
row_count: rows.length,
|
row_count: rows.length,
|
||||||
preview,
|
preview,
|
||||||
suggested_mapping: suggestImportMapping(headers),
|
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) => {
|
execute: async (_edition, file: File, mapping: ImportMapping) => {
|
||||||
@@ -632,16 +638,11 @@ export function createMockApi(): PlanningApi {
|
|||||||
created: 0,
|
created: 0,
|
||||||
updated: 0,
|
updated: 0,
|
||||||
skipped: 0,
|
skipped: 0,
|
||||||
availabilities_written: 0,
|
|
||||||
skills_created: [],
|
|
||||||
rows: [],
|
rows: [],
|
||||||
};
|
};
|
||||||
const dpByCode = new Map(state.dayParts.map((d) => [d.code, d]));
|
|
||||||
const emailSeen = new Map<string, number>();
|
const emailSeen = new Map<string, number>();
|
||||||
const createdSkills = new Set<string>();
|
|
||||||
// On travaille sur des copies : `state` n'est touché qu'en fin si !dry_run.
|
// 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 working = state.volunteers.map((v) => ({ ...v }));
|
||||||
const availWrites: Array<{ vid: string; key: string; on: boolean }> = [];
|
|
||||||
|
|
||||||
rows.forEach((row, i) => {
|
rows.forEach((row, i) => {
|
||||||
const line = i + 2;
|
const line = i + 2;
|
||||||
@@ -729,7 +730,6 @@ export function createMockApi(): PlanningApi {
|
|||||||
email: null,
|
email: null,
|
||||||
phone: null,
|
phone: null,
|
||||||
comment: null,
|
comment: null,
|
||||||
skill_codes: [],
|
|
||||||
artist_ids: [],
|
artist_ids: [],
|
||||||
wished_position_ids: [],
|
wished_position_ids: [],
|
||||||
created_at: now,
|
created_at: now,
|
||||||
@@ -744,48 +744,6 @@ export function createMockApi(): PlanningApi {
|
|||||||
if (has(cols.email)) vol.email = emailRaw || null;
|
if (has(cols.email)) vol.email = emailRaw || null;
|
||||||
if (has(cols.phone)) vol.phone = cell(row, cols.phone) || 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.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;
|
entry.volunteer_id = vol.id;
|
||||||
report.rows.push(entry);
|
report.rows.push(entry);
|
||||||
@@ -793,18 +751,10 @@ export function createMockApi(): PlanningApi {
|
|||||||
else report.updated += 1;
|
else report.updated += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
report.skills_created = [...createdSkills].sort();
|
|
||||||
|
|
||||||
if (!mapping.dry_run) {
|
if (!mapping.dry_run) {
|
||||||
state.volunteers = working;
|
state.volunteers = working;
|
||||||
for (const v of working) {
|
for (const v of working) {
|
||||||
if (!state.availability.has(v.id)) state.availability.set(v.id, new Set());
|
if (!state.availability.has(v.id)) state.availability.set(v.id, []);
|
||||||
}
|
|
||||||
for (const w of availWrites) {
|
|
||||||
const set = state.availability.get(w.vid) ?? new Set<string>();
|
|
||||||
if (w.on) set.add(w.key);
|
|
||||||
else set.delete(w.key);
|
|
||||||
state.availability.set(w.vid, set);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return delay(report);
|
return delay(report);
|
||||||
@@ -883,7 +833,6 @@ export function createMockApi(): PlanningApi {
|
|||||||
ends_at: body.ends_at,
|
ends_at: body.ends_at,
|
||||||
min_staff: body.min_staff ?? 0,
|
min_staff: body.min_staff ?? 0,
|
||||||
ideal_staff: body.ideal_staff ?? 0,
|
ideal_staff: body.ideal_staff ?? 0,
|
||||||
required_skill_codes: body.required_skill_codes ?? [],
|
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -898,9 +847,6 @@ export function createMockApi(): PlanningApi {
|
|||||||
...('ends_at' in body ? { ends_at: body.ends_at } : {}),
|
...('ends_at' in body ? { ends_at: body.ends_at } : {}),
|
||||||
...('min_staff' in body ? { min_staff: body.min_staff } : {}),
|
...('min_staff' in body ? { min_staff: body.min_staff } : {}),
|
||||||
...('ideal_staff' in body ? { ideal_staff: body.ideal_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(),
|
updated_at: touch(),
|
||||||
});
|
});
|
||||||
if (naiveMinutes(s.ends_at) <= naiveMinutes(s.starts_at)) {
|
if (naiveMinutes(s.ends_at) <= naiveMinutes(s.starts_at)) {
|
||||||
|
|||||||
+29
-34
@@ -15,7 +15,6 @@ export type PositionCategory =
|
|||||||
// Depuis l'Étape 9 back-end : une affectation est valide dès sa création,
|
// 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.
|
// il n'y a plus de statut (proposée / confirmée…). La retirer = supprimer la ligne.
|
||||||
export type AssignmentSource = 'SOLVER' | 'MANUAL';
|
export type AssignmentSource = 'SOLVER' | 'MANUAL';
|
||||||
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
|
|
||||||
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
|
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
|
||||||
export type AuthMethod = 'password' | 'magic_link';
|
export type AuthMethod = 'password' | 'magic_link';
|
||||||
|
|
||||||
@@ -23,8 +22,7 @@ export type ViolationCode =
|
|||||||
| 'SHIFT_OVERLAP'
|
| 'SHIFT_OVERLAP'
|
||||||
| 'OUTSIDE_AVAILABILITY'
|
| 'OUTSIDE_AVAILABILITY'
|
||||||
| 'DAILY_HOURS_EXCEEDED'
|
| 'DAILY_HOURS_EXCEEDED'
|
||||||
| 'INSUFFICIENT_REST'
|
| 'INSUFFICIENT_REST';
|
||||||
| 'MISSING_REQUIRED_SKILL';
|
|
||||||
|
|
||||||
export type ViolationSeverity = 'BLOCKING' | 'WARNING';
|
export type ViolationSeverity = 'BLOCKING' | 'WARNING';
|
||||||
|
|
||||||
@@ -104,7 +102,6 @@ export interface VolunteerRead {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
comment: string | null;
|
comment: string | null;
|
||||||
skill_codes: string[];
|
|
||||||
/** Vœux : artistes que le bénévole veut voir. */
|
/** Vœux : artistes que le bénévole veut voir. */
|
||||||
artist_ids: string[];
|
artist_ids: string[];
|
||||||
/** Vœux : postes que le bénévole voudrait tenir. */
|
/** Vœux : postes que le bénévole voudrait tenir. */
|
||||||
@@ -119,7 +116,6 @@ export interface VolunteerCreate {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
skill_codes?: string[];
|
|
||||||
artist_ids?: string[];
|
artist_ids?: string[];
|
||||||
wished_position_ids?: string[];
|
wished_position_ids?: string[];
|
||||||
}
|
}
|
||||||
@@ -130,12 +126,37 @@ export interface VolunteerUpdate {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
/** null / absent = inchangé ; [] = vider. Idem artist_ids / wished_position_ids. */
|
/** null / absent = inchangé ; [] = vider. */
|
||||||
skill_codes?: string[] | null;
|
|
||||||
artist_ids?: string[] | null;
|
artist_ids?: string[] | null;
|
||||||
wished_position_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) //
|
// Artist (programmation) //
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
@@ -167,8 +188,7 @@ export type ImportFieldKey =
|
|||||||
| 'last_name'
|
| 'last_name'
|
||||||
| 'email'
|
| 'email'
|
||||||
| 'phone'
|
| 'phone'
|
||||||
| 'comment'
|
| 'comment';
|
||||||
| 'skills';
|
|
||||||
|
|
||||||
export interface ImportColumnMapping {
|
export interface ImportColumnMapping {
|
||||||
first_name: string;
|
first_name: string;
|
||||||
@@ -176,20 +196,11 @@ export interface ImportColumnMapping {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
comment?: 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 {
|
export interface ImportMapping {
|
||||||
dry_run: boolean;
|
dry_run: boolean;
|
||||||
columns: ImportColumnMapping;
|
columns: ImportColumnMapping;
|
||||||
availability_columns: ImportAvailabilityColumn[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportPreviewRow {
|
export interface ImportPreviewRow {
|
||||||
@@ -197,14 +208,6 @@ export interface ImportPreviewRow {
|
|||||||
values: Record<string, string>;
|
values: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportDayPartInfo {
|
|
||||||
code: string;
|
|
||||||
label: string;
|
|
||||||
start_time: string;
|
|
||||||
end_time: string;
|
|
||||||
order: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportAnalyzeResponse {
|
export interface ImportAnalyzeResponse {
|
||||||
filename: string;
|
filename: string;
|
||||||
format: 'csv' | 'xlsx';
|
format: 'csv' | 'xlsx';
|
||||||
@@ -212,9 +215,6 @@ export interface ImportAnalyzeResponse {
|
|||||||
row_count: number;
|
row_count: number;
|
||||||
preview: ImportPreviewRow[];
|
preview: ImportPreviewRow[];
|
||||||
suggested_mapping: Partial<Record<ImportFieldKey, string>>;
|
suggested_mapping: Partial<Record<ImportFieldKey, string>>;
|
||||||
/** Dates ISO couvertes par l'édition. */
|
|
||||||
edition_dates: string[];
|
|
||||||
day_parts: ImportDayPartInfo[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImportSeverity = 'ERROR' | 'WARNING';
|
export type ImportSeverity = 'ERROR' | 'WARNING';
|
||||||
@@ -240,8 +240,6 @@ export interface ImportReport {
|
|||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
availabilities_written: number;
|
|
||||||
skills_created: string[];
|
|
||||||
rows: ImportRowReport[];
|
rows: ImportRowReport[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +285,6 @@ export interface ShiftRead {
|
|||||||
ends_at: string;
|
ends_at: string;
|
||||||
min_staff: number;
|
min_staff: number;
|
||||||
ideal_staff: number;
|
ideal_staff: number;
|
||||||
required_skill_codes: string[];
|
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -298,7 +295,6 @@ export interface ShiftCreate {
|
|||||||
ends_at: string;
|
ends_at: string;
|
||||||
min_staff?: number;
|
min_staff?: number;
|
||||||
ideal_staff?: number;
|
ideal_staff?: number;
|
||||||
required_skill_codes?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShiftUpdate {
|
export interface ShiftUpdate {
|
||||||
@@ -307,7 +303,6 @@ export interface ShiftUpdate {
|
|||||||
ends_at?: string;
|
ends_at?: string;
|
||||||
min_staff?: number;
|
min_staff?: number;
|
||||||
ideal_staff?: number;
|
ideal_staff?: number;
|
||||||
required_skill_codes?: string[] | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import type {
|
|||||||
AssignmentValidateRequest,
|
AssignmentValidateRequest,
|
||||||
AssignmentValidateResult,
|
AssignmentValidateResult,
|
||||||
AssignmentWriteResult,
|
AssignmentWriteResult,
|
||||||
|
AvailabilityRead,
|
||||||
|
AvailabilitySet,
|
||||||
DayPartCreate,
|
DayPartCreate,
|
||||||
DayPartRead,
|
DayPartRead,
|
||||||
DayPartUpdate,
|
DayPartUpdate,
|
||||||
@@ -65,6 +67,18 @@ export interface PlanningApi {
|
|||||||
update(editionId: string, id: string, body: VolunteerUpdate): Promise<VolunteerRead>;
|
update(editionId: string, id: string, body: VolunteerUpdate): Promise<VolunteerRead>;
|
||||||
remove(editionId: string, id: string): Promise<void>;
|
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: {
|
imports: {
|
||||||
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */
|
/** Phase 1 : lit le fichier, propose un mapping des colonnes. */
|
||||||
analyze(editionId: string, file: File): Promise<ImportAnalyzeResponse>;
|
analyze(editionId: string, file: File): Promise<ImportAnalyzeResponse>;
|
||||||
|
|||||||
@@ -62,15 +62,12 @@
|
|||||||
"validation.SHIFT_OVERLAP": "Se chevauche avec un autre créneau",
|
"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.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.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": "Hors des disponibilités déclarées ({gap} non couvertes).",
|
||||||
"validation.OUTSIDE_AVAILABILITY.unavailable": "Déclaré indisponible sur ce créneau",
|
"validation.OUTSIDE_AVAILABILITY.rule": "Le créneau n'est pas entièrement couvert par les disponibilités déclarées.",
|
||||||
"validation.OUTSIDE_AVAILABILITY.rule": "Une demi-journée du créneau est indisponible ou sans déclaration.",
|
|
||||||
"validation.DAILY_HOURS_EXCEEDED": "Plus de 8 h sur la journée ({hours})",
|
"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.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": "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.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.day": "JOUR",
|
||||||
"grille.filter.position": "POSTE",
|
"grille.filter.position": "POSTE",
|
||||||
"grille.legend.critical": "SOUS-EFFECTIF CRITIQUE",
|
"grille.legend.critical": "SOUS-EFFECTIF CRITIQUE",
|
||||||
@@ -163,15 +160,11 @@
|
|||||||
"config.vol.colName": "NOM",
|
"config.vol.colName": "NOM",
|
||||||
"config.vol.colPhone": "TÉLÉPHONE",
|
"config.vol.colPhone": "TÉLÉPHONE",
|
||||||
"config.vol.colEmail": "EMAIL",
|
"config.vol.colEmail": "EMAIL",
|
||||||
"config.vol.colSkills": "COMPÉTENCES",
|
|
||||||
"config.vol.ficheKicker": "FICHE BÉNÉVOLE",
|
"config.vol.ficheKicker": "FICHE BÉNÉVOLE",
|
||||||
"config.vol.firstName": "PRÉNOM",
|
"config.vol.firstName": "PRÉNOM",
|
||||||
"config.vol.lastName": "NOM",
|
"config.vol.lastName": "NOM",
|
||||||
"config.vol.phone": "TÉLÉPHONE",
|
"config.vol.phone": "TÉLÉPHONE",
|
||||||
"config.vol.email": "EMAIL (LIEN MAGIQUE)",
|
"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.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.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).",
|
"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.email": "Email (lien magique)",
|
||||||
"config.vol.field.phone": "Téléphone",
|
"config.vol.field.phone": "Téléphone",
|
||||||
"config.vol.field.comment": "Commentaire",
|
"config.vol.field.comment": "Commentaire",
|
||||||
"config.vol.field.skills": "Compétences",
|
|
||||||
"config.vol.addTitle": "NOUVEAU BÉNÉVOLE",
|
"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.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",
|
"config.vol.create": "CRÉER LE BÉNÉVOLE",
|
||||||
@@ -248,12 +240,6 @@
|
|||||||
"config.vol.wiz.mapTitle": "ASSOCIER LES COLONNES",
|
"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.mapHint": "« Prénom » et « Nom » sont obligatoires. Laissez « — » pour ignorer une colonne.",
|
||||||
"config.vol.wiz.ignore": "—",
|
"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.mapMissing": "Associez au moins les colonnes « Prénom » et « Nom ».",
|
||||||
"config.vol.wiz.simulate": "SIMULER",
|
"config.vol.wiz.simulate": "SIMULER",
|
||||||
"config.vol.wiz.runImport": "IMPORTER POUR DE VRAI",
|
"config.vol.wiz.runImport": "IMPORTER POUR DE VRAI",
|
||||||
@@ -264,8 +250,6 @@
|
|||||||
"config.vol.wiz.created": "{count} créé(s)",
|
"config.vol.wiz.created": "{count} créé(s)",
|
||||||
"config.vol.wiz.updated": "{count} mis à jour",
|
"config.vol.wiz.updated": "{count} mis à jour",
|
||||||
"config.vol.wiz.skipped": "{count} ignoré(s)",
|
"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.rowsWithIssues": "LIGNES AVEC REMARQUES",
|
||||||
"config.vol.wiz.line": "Ligne {line}",
|
"config.vol.wiz.line": "Ligne {line}",
|
||||||
"config.vol.wiz.noIssues": "Aucune remarque : tout est prêt à être importé.",
|
"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.invalid": "Le paramétrage des colonnes est invalide.",
|
||||||
"import.mapping.required_field_missing": "Le champ « {field} » doit être associé à une colonne.",
|
"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.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.missing_required_field": "Champ obligatoire manquant : {field}.",
|
||||||
"import.row.invalid_email": "Adresse email invalide : {value}.",
|
"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_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.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.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.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.tab.artists": "PROGRAMMATION",
|
||||||
"config.artists.title": "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.",
|
"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.noArtists": "Aucun artiste choisi",
|
||||||
"config.vol.noPositions": "Aucun poste choisi",
|
"config.vol.noPositions": "Aucun poste choisi",
|
||||||
"config.vol.pickArtist": "Ajouter un artiste…",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AssignmentRead,
|
AssignmentRead,
|
||||||
|
AvailabilityRead,
|
||||||
MatrixResponse,
|
MatrixResponse,
|
||||||
PositionCategory,
|
PositionCategory,
|
||||||
ShiftRead,
|
ShiftRead,
|
||||||
@@ -13,6 +14,71 @@ export function categoryLabel(category: PositionCategory): string {
|
|||||||
return t(`position.category.${category.toLowerCase()}`);
|
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. */
|
/** Colonne de la grille : une (date, heure de début) distincte. */
|
||||||
export interface LatticeColumn {
|
export interface LatticeColumn {
|
||||||
key: string; // `YYYY-MM-DD|HH:MM`
|
key: string; // `YYYY-MM-DD|HH:MM`
|
||||||
@@ -114,9 +180,9 @@ export function violationText(
|
|||||||
: t('validation.SHIFT_OVERLAP');
|
: t('validation.SHIFT_OVERLAP');
|
||||||
}
|
}
|
||||||
case 'OUTSIDE_AVAILABILITY':
|
case 'OUTSIDE_AVAILABILITY':
|
||||||
return p.status === 'UNAVAILABLE'
|
return t('validation.OUTSIDE_AVAILABILITY', {
|
||||||
? t('validation.OUTSIDE_AVAILABILITY.unavailable')
|
gap: formatMinutes(Number(p.uncovered_minutes)),
|
||||||
: t('validation.OUTSIDE_AVAILABILITY');
|
});
|
||||||
case 'DAILY_HOURS_EXCEEDED':
|
case 'DAILY_HOURS_EXCEEDED':
|
||||||
return t('validation.DAILY_HOURS_EXCEEDED', {
|
return t('validation.DAILY_HOURS_EXCEEDED', {
|
||||||
hours: formatMinutes(Number(p.total_minutes)),
|
hours: formatMinutes(Number(p.total_minutes)),
|
||||||
@@ -125,8 +191,6 @@ export function violationText(
|
|||||||
return t('validation.INSUFFICIENT_REST', {
|
return t('validation.INSUFFICIENT_REST', {
|
||||||
rest: formatMinutes(Number(p.gap_minutes)),
|
rest: formatMinutes(Number(p.gap_minutes)),
|
||||||
});
|
});
|
||||||
case 'MISSING_REQUIRED_SKILL':
|
|
||||||
return t('validation.MISSING_REQUIRED_SKILL', { skill: String(p.skill_code) });
|
|
||||||
default:
|
default:
|
||||||
return violation.code;
|
return violation.code;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ const RULE_CODES = [
|
|||||||
'OUTSIDE_AVAILABILITY',
|
'OUTSIDE_AVAILABILITY',
|
||||||
'DAILY_HOURS_EXCEEDED',
|
'DAILY_HOURS_EXCEEDED',
|
||||||
'INSUFFICIENT_REST',
|
'INSUFFICIENT_REST',
|
||||||
'MISSING_REQUIRED_SKILL',
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Les seuils du moteur sont figés (CLAUDE.md §3). L'édition (nom, dates) est
|
// Les seuils du moteur sont figés (CLAUDE.md §3). L'édition (nom, dates) est
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { ApiError } from '@/api';
|
import { ApiError } from '@/api';
|
||||||
import {
|
import {
|
||||||
@@ -6,9 +6,12 @@ import {
|
|||||||
useArtists,
|
useArtists,
|
||||||
useCreateVolunteer,
|
useCreateVolunteer,
|
||||||
useDeleteVolunteer,
|
useDeleteVolunteer,
|
||||||
|
useEdition,
|
||||||
useExecuteImport,
|
useExecuteImport,
|
||||||
usePositions,
|
usePositions,
|
||||||
|
useReplaceAvailability,
|
||||||
useUpdateVolunteer,
|
useUpdateVolunteer,
|
||||||
|
useVolunteerAvailability,
|
||||||
useVolunteers,
|
useVolunteers,
|
||||||
} from '@/api/hooks';
|
} from '@/api/hooks';
|
||||||
import type {
|
import type {
|
||||||
@@ -33,7 +36,6 @@ const MAP_FIELDS: Array<{ key: ImportFieldKey; required: boolean }> = [
|
|||||||
{ key: 'email', required: false },
|
{ key: 'email', required: false },
|
||||||
{ key: 'phone', required: false },
|
{ key: 'phone', required: false },
|
||||||
{ key: 'comment', required: false },
|
{ key: 'comment', required: false },
|
||||||
{ key: 'skills', required: false },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const FIELD_LABEL: Record<ImportFieldKey, string> = {
|
const FIELD_LABEL: Record<ImportFieldKey, string> = {
|
||||||
@@ -42,7 +44,6 @@ const FIELD_LABEL: Record<ImportFieldKey, string> = {
|
|||||||
email: 'config.vol.field.email',
|
email: 'config.vol.field.email',
|
||||||
phone: 'config.vol.field.phone',
|
phone: 'config.vol.field.phone',
|
||||||
comment: 'config.vol.field.comment',
|
comment: 'config.vol.field.comment',
|
||||||
skills: 'config.vol.field.skills',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type WishOption = { id: string; label: string; hint?: string };
|
type WishOption = { id: string; label: string; hint?: string };
|
||||||
@@ -143,7 +144,6 @@ export function ConfigVolunteers() {
|
|||||||
const [mode, setMode] = useState<Mode>('list');
|
const [mode, setMode] = useState<Mode>('list');
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [skillDraft, setSkillDraft] = useState('');
|
|
||||||
|
|
||||||
const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]);
|
const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]);
|
||||||
const positionOpts = useMemo(() => positionOptions(positions.data), [positions.data]);
|
const positionOpts = useMemo(() => positionOptions(positions.data), [positions.data]);
|
||||||
@@ -241,7 +241,7 @@ export function ConfigVolunteers() {
|
|||||||
<span>{t('config.vol.colName')}</span>
|
<span>{t('config.vol.colName')}</span>
|
||||||
<span>{t('config.vol.colPhone')}</span>
|
<span>{t('config.vol.colPhone')}</span>
|
||||||
<span>{t('config.vol.colEmail')}</span>
|
<span>{t('config.vol.colEmail')}</span>
|
||||||
<span>{t('config.vol.colSkills')}</span>
|
<span>{t('config.vol.colWishes')}</span>
|
||||||
</div>
|
</div>
|
||||||
{rows.map((volunteer) => {
|
{rows.map((volunteer) => {
|
||||||
const active = volunteer.id === selected?.id;
|
const active = volunteer.id === selected?.id;
|
||||||
@@ -272,7 +272,9 @@ export function ConfigVolunteers() {
|
|||||||
{volunteer.email ?? '—'}
|
{volunteer.email ?? '—'}
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate font-mono text-[10px] opacity-70">
|
<span className="truncate font-mono text-[10px] opacity-70">
|
||||||
{volunteer.skill_codes.join(' · ') || '—'}
|
{t('config.vol.wishesCount', {
|
||||||
|
count: volunteer.artist_ids.length + volunteer.wished_position_ids.length,
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -356,50 +358,7 @@ export function ConfigVolunteers() {
|
|||||||
addLabel={t('config.vol.pickPosition')}
|
addLabel={t('config.vol.pickPosition')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<AvailabilityEditor volunteerId={selected.id} />
|
||||||
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
|
|
||||||
<div className="mb-2 flex flex-wrap gap-[6px]">
|
|
||||||
{selected.skill_codes.map((code) => (
|
|
||||||
<button
|
|
||||||
key={code}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
patch({ skill_codes: selected.skill_codes.filter((c) => c !== code) })
|
|
||||||
}
|
|
||||||
className="cursor-pointer border-2 border-ink bg-blue px-2 py-[5px] font-mono text-[10px] font-semibold hover:bg-orange"
|
|
||||||
>
|
|
||||||
{code} ✕
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{selected.skill_codes.length === 0 ? (
|
|
||||||
<span className="font-mono text-[10px] opacity-55">{t('config.vol.noSkills')}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<form
|
|
||||||
onSubmit={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const code = skillDraft.trim().toUpperCase();
|
|
||||||
if (code && !selected.skill_codes.includes(code)) {
|
|
||||||
patch({ skill_codes: [...selected.skill_codes, code] });
|
|
||||||
}
|
|
||||||
setSkillDraft('');
|
|
||||||
}}
|
|
||||||
className="flex gap-2"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
value={skillDraft}
|
|
||||||
onChange={(e) => setSkillDraft(e.target.value)}
|
|
||||||
placeholder={t('config.vol.skillPlaceholder')}
|
|
||||||
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="cursor-pointer border-2 border-ink bg-yellow px-3 font-display text-[11px] font-extrabold uppercase hover:bg-green hover:text-white"
|
|
||||||
>
|
|
||||||
{t('action.add')}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -431,10 +390,8 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
|||||||
const artists = useArtists();
|
const artists = useArtists();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
const [form, setForm] = useState<VolunteerCreate>({ first_name: '', last_name: '' });
|
const [form, setForm] = useState<VolunteerCreate>({ first_name: '', last_name: '' });
|
||||||
const [skills, setSkills] = useState<string[]>([]);
|
|
||||||
const [artistIds, setArtistIds] = useState<string[]>([]);
|
const [artistIds, setArtistIds] = useState<string[]>([]);
|
||||||
const [wishedPositionIds, setWishedPositionIds] = useState<string[]>([]);
|
const [wishedPositionIds, setWishedPositionIds] = useState<string[]>([]);
|
||||||
const [skillDraft, setSkillDraft] = useState('');
|
|
||||||
const [localError, setLocalError] = useState<string | null>(null);
|
const [localError, setLocalError] = useState<string | null>(null);
|
||||||
|
|
||||||
const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]);
|
const artistOpts = useMemo(() => artistOptions(artists.data), [artists.data]);
|
||||||
@@ -456,7 +413,6 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
|||||||
email: form.email?.trim() || null,
|
email: form.email?.trim() || null,
|
||||||
phone: form.phone?.trim() || null,
|
phone: form.phone?.trim() || null,
|
||||||
comment: form.comment?.trim() || null,
|
comment: form.comment?.trim() || null,
|
||||||
skill_codes: skills,
|
|
||||||
artist_ids: artistIds,
|
artist_ids: artistIds,
|
||||||
wished_position_ids: wishedPositionIds,
|
wished_position_ids: wishedPositionIds,
|
||||||
});
|
});
|
||||||
@@ -534,50 +490,8 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
|||||||
addLabel={t('config.vol.pickPosition')}
|
addLabel={t('config.vol.pickPosition')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div>
|
<div className="font-mono text-[10px] leading-relaxed opacity-60">
|
||||||
<FieldLabel>{t('config.vol.field.skills')}</FieldLabel>
|
{t('config.vol.addAvailabilityHint')}
|
||||||
<div className="mb-2 flex flex-wrap gap-[6px]">
|
|
||||||
{skills.map((code) => (
|
|
||||||
<button
|
|
||||||
key={code}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSkills((s) => s.filter((c) => c !== code))}
|
|
||||||
className="cursor-pointer border-2 border-ink bg-blue px-2 py-[5px] font-mono text-[10px] font-semibold hover:bg-orange"
|
|
||||||
>
|
|
||||||
{code} ✕
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{skills.length === 0 ? (
|
|
||||||
<span className="font-mono text-[10px] opacity-55">{t('config.vol.noSkills')}</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
value={skillDraft}
|
|
||||||
onChange={(e) => setSkillDraft(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
e.preventDefault();
|
|
||||||
const code = skillDraft.trim().toUpperCase();
|
|
||||||
if (code && !skills.includes(code)) setSkills((s) => [...s, code]);
|
|
||||||
setSkillDraft('');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={t('config.vol.skillPlaceholder')}
|
|
||||||
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const code = skillDraft.trim().toUpperCase();
|
|
||||||
if (code && !skills.includes(code)) setSkills((s) => [...s, code]);
|
|
||||||
setSkillDraft('');
|
|
||||||
}}
|
|
||||||
className="cursor-pointer border-2 border-ink bg-yellow px-3 font-display text-[11px] font-extrabold uppercase hover:bg-green hover:text-white"
|
|
||||||
>
|
|
||||||
{t('action.add')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{localError ? (
|
{localError ? (
|
||||||
@@ -611,8 +525,6 @@ function AddVolunteerForm({ onClose }: { onClose: () => void }) {
|
|||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
// Import de fichier (assistant en 3 étapes) //
|
// Import de fichier (assistant en 3 étapes) //
|
||||||
// --------------------------------------------------------------------------- //
|
// --------------------------------------------------------------------------- //
|
||||||
type AvailRow = { header: string; date: string; day_part_code: string };
|
|
||||||
|
|
||||||
function ImportWizard({ onClose }: { onClose: () => void }) {
|
function ImportWizard({ onClose }: { onClose: () => void }) {
|
||||||
const analyze = useAnalyzeImport();
|
const analyze = useAnalyzeImport();
|
||||||
const execute = useExecuteImport();
|
const execute = useExecuteImport();
|
||||||
@@ -620,7 +532,6 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
|
|||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [analysis, setAnalysis] = useState<ImportAnalyzeResponse | null>(null);
|
const [analysis, setAnalysis] = useState<ImportAnalyzeResponse | null>(null);
|
||||||
const [colMap, setColMap] = useState<Partial<Record<ImportFieldKey, string>>>({});
|
const [colMap, setColMap] = useState<Partial<Record<ImportFieldKey, string>>>({});
|
||||||
const [availRows, setAvailRows] = useState<AvailRow[]>([]);
|
|
||||||
const [report, setReport] = useState<ImportReport | null>(null);
|
const [report, setReport] = useState<ImportReport | null>(null);
|
||||||
const [mapError, setMapError] = useState<string | null>(null);
|
const [mapError, setMapError] = useState<string | null>(null);
|
||||||
const fileInput = useRef<HTMLInputElement>(null);
|
const fileInput = useRef<HTMLInputElement>(null);
|
||||||
@@ -636,7 +547,6 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
|
|||||||
const res = await analyze.mutateAsync(f);
|
const res = await analyze.mutateAsync(f);
|
||||||
setAnalysis(res);
|
setAnalysis(res);
|
||||||
setColMap({ ...res.suggested_mapping });
|
setColMap({ ...res.suggested_mapping });
|
||||||
setAvailRows([]);
|
|
||||||
} catch {
|
} catch {
|
||||||
/* l'erreur est portée par analyze.error */
|
/* l'erreur est portée par analyze.error */
|
||||||
}
|
}
|
||||||
@@ -650,9 +560,7 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
|
|||||||
email: colMap.email || null,
|
email: colMap.email || null,
|
||||||
phone: colMap.phone || null,
|
phone: colMap.phone || null,
|
||||||
comment: colMap.comment || 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) => {
|
const run = async (dryRun: boolean) => {
|
||||||
@@ -778,100 +686,6 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<FieldLabel>{t('config.vol.wiz.availTitle')}</FieldLabel>
|
|
||||||
<div className="mb-1 font-mono text-[10px] leading-relaxed opacity-65">
|
|
||||||
{t('config.vol.wiz.availHint')}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{availRows.map((row, i) => (
|
|
||||||
<div key={i} className="flex flex-wrap items-end gap-2">
|
|
||||||
<label className="min-w-[140px] flex-1">
|
|
||||||
<span className="mb-[4px] block font-display text-[9px] font-bold uppercase">
|
|
||||||
{t('config.vol.wiz.availColumn')}
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={row.header}
|
|
||||||
onChange={(e) =>
|
|
||||||
setAvailRows((rs) =>
|
|
||||||
rs.map((r, j) => (j === i ? { ...r, header: e.target.value } : r)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
|
|
||||||
>
|
|
||||||
<option value="">{t('config.vol.wiz.ignore')}</option>
|
|
||||||
{analysis.headers.map((h) => (
|
|
||||||
<option key={h} value={h}>
|
|
||||||
{h}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="min-w-[130px] flex-1">
|
|
||||||
<span className="mb-[4px] block font-display text-[9px] font-bold uppercase">
|
|
||||||
{t('config.vol.wiz.availDate')}
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={row.date}
|
|
||||||
onChange={(e) =>
|
|
||||||
setAvailRows((rs) =>
|
|
||||||
rs.map((r, j) => (j === i ? { ...r, date: e.target.value } : r)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
|
|
||||||
>
|
|
||||||
<option value="">{t('config.vol.wiz.ignore')}</option>
|
|
||||||
{analysis.edition_dates.map((d) => (
|
|
||||||
<option key={d} value={d}>
|
|
||||||
{shortDayLabel(`${d}T12:00`)} ({d})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="min-w-[130px] flex-1">
|
|
||||||
<span className="mb-[4px] block font-display text-[9px] font-bold uppercase">
|
|
||||||
{t('config.vol.wiz.availPart')}
|
|
||||||
</span>
|
|
||||||
<select
|
|
||||||
value={row.day_part_code}
|
|
||||||
onChange={(e) =>
|
|
||||||
setAvailRows((rs) =>
|
|
||||||
rs.map((r, j) =>
|
|
||||||
j === i ? { ...r, day_part_code: e.target.value } : r,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="w-full border-2 border-ink bg-cream p-[7px] font-mono text-[11px]"
|
|
||||||
>
|
|
||||||
<option value="">{t('config.vol.wiz.ignore')}</option>
|
|
||||||
{analysis.day_parts.map((dp) => (
|
|
||||||
<option key={dp.code} value={dp.code}>
|
|
||||||
{dp.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setAvailRows((rs) => rs.filter((_, j) => j !== i))}
|
|
||||||
className="cursor-pointer border-2 border-ink bg-cream px-3 py-[7px] font-display text-[10px] font-extrabold uppercase hover:bg-orange"
|
|
||||||
>
|
|
||||||
{t('action.remove')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setAvailRows((rs) => [...rs, { header: '', date: '', day_part_code: '' }])
|
|
||||||
}
|
|
||||||
className="cursor-pointer self-start border-2 border-ink bg-cream px-3 py-[7px] font-display text-[10px] font-extrabold uppercase hover:bg-yellow"
|
|
||||||
>
|
|
||||||
+ {t('config.vol.wiz.availAdd')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto border-2 border-ink">
|
<div className="overflow-x-auto border-2 border-ink">
|
||||||
<table className="w-full border-collapse font-mono text-[10px]">
|
<table className="w-full border-collapse font-mono text-[10px]">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -949,17 +763,8 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
|
|||||||
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
|
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
|
||||||
{t('config.vol.wiz.skipped', { count: report.skipped })}
|
{t('config.vol.wiz.skipped', { count: report.skipped })}
|
||||||
</span>
|
</span>
|
||||||
<span className="border-2 border-ink bg-paper px-[8px] py-[4px]">
|
|
||||||
{t('config.vol.wiz.availWritten', { count: report.availabilities_written })}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{report.skills_created.length > 0 ? (
|
|
||||||
<div className="font-mono text-[11px]">
|
|
||||||
{t('config.vol.wiz.skillsCreated', { list: report.skills_created.join(', ') })}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const flagged = report.rows.filter((r) => r.issues.length > 0);
|
const flagged = report.rows.filter((r) => r.issues.length > 0);
|
||||||
if (flagged.length === 0) {
|
if (flagged.length === 0) {
|
||||||
@@ -1034,3 +839,149 @@ function ImportWizard({ onClose }: { onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
// Éditeur de disponibilités (fiche bénévole) //
|
||||||
|
// --------------------------------------------------------------------------- //
|
||||||
|
type WindowRow = { date: string; start: string; end: string };
|
||||||
|
|
||||||
|
function editionDays(start: string, end: string): string[] {
|
||||||
|
const out: string[] = [];
|
||||||
|
for (
|
||||||
|
let d = Date.parse(`${start}T00:00:00Z`);
|
||||||
|
d <= Date.parse(`${end}T00:00:00Z`);
|
||||||
|
d += 86_400_000
|
||||||
|
) {
|
||||||
|
out.push(new Date(d).toISOString().slice(0, 10));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvailabilityEditor({ volunteerId }: { volunteerId: string }) {
|
||||||
|
const edition = useEdition();
|
||||||
|
const windows = useVolunteerAvailability(volunteerId);
|
||||||
|
const replace = useReplaceAvailability();
|
||||||
|
|
||||||
|
const [rows, setRows] = useState<WindowRow[]>([]);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
const serverRows = useMemo<WindowRow[]>(
|
||||||
|
() =>
|
||||||
|
(windows.data ?? []).map((w) => ({
|
||||||
|
date: w.date,
|
||||||
|
start: w.start_time.slice(0, 5),
|
||||||
|
end: w.end_time.slice(0, 5),
|
||||||
|
})),
|
||||||
|
[windows.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recharge depuis le serveur tant que l'utilisateur n'a rien modifié.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) setRows(serverRows);
|
||||||
|
}, [serverRows, dirty]);
|
||||||
|
|
||||||
|
// Repart de zéro quand on change de bénévole.
|
||||||
|
useEffect(() => {
|
||||||
|
setDirty(false);
|
||||||
|
}, [volunteerId]);
|
||||||
|
|
||||||
|
const days = edition.data
|
||||||
|
? editionDays(edition.data.start_date, edition.data.end_date)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const patch = (i: number, next: Partial<WindowRow>) => {
|
||||||
|
setDirty(true);
|
||||||
|
setRows((rs) => rs.map((r, j) => (j === i ? { ...r, ...next } : r)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
replace.mutate(
|
||||||
|
{
|
||||||
|
volunteerId,
|
||||||
|
body: {
|
||||||
|
windows: rows.map((r) => ({
|
||||||
|
date: r.date,
|
||||||
|
start_time: `${r.start}:00`,
|
||||||
|
end_time: `${r.end}:00`,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ onSuccess: () => setDirty(false) },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>{t('config.vol.avail.title')}</FieldLabel>
|
||||||
|
<div className="mb-2 font-mono text-[10px] leading-relaxed opacity-60">
|
||||||
|
{t('config.vol.avail.hint')}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-[6px]">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={i} className="flex flex-wrap items-center gap-1">
|
||||||
|
<select
|
||||||
|
value={row.date}
|
||||||
|
onChange={(e) => patch(i, { date: e.target.value })}
|
||||||
|
className="min-w-0 flex-1 border-2 border-ink bg-cream p-[6px] font-mono text-[11px]"
|
||||||
|
>
|
||||||
|
{days.map((d) => (
|
||||||
|
<option key={d} value={d}>
|
||||||
|
{shortDayLabel(`${d}T12:00`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={row.start}
|
||||||
|
onChange={(e) => patch(i, { start: e.target.value })}
|
||||||
|
className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={row.end}
|
||||||
|
onChange={(e) => patch(i, { end: e.target.value })}
|
||||||
|
className="border-2 border-ink bg-cream p-[5px] font-mono text-[11px]"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setDirty(true);
|
||||||
|
setRows((rs) => rs.filter((_, j) => j !== i));
|
||||||
|
}}
|
||||||
|
aria-label={t('action.remove')}
|
||||||
|
className="h-[26px] w-[26px] flex-none cursor-pointer border-2 border-ink bg-cream font-display text-[11px] font-bold hover:bg-orange"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<span className="font-mono text-[10px] opacity-55">{t('config.vol.avail.none')}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={days.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setDirty(true);
|
||||||
|
setRows((rs) => [...rs, { date: days[0], start: '08:00', end: '18:00' }]);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-cream px-3 py-[6px] font-display text-[10px] font-extrabold uppercase hover:bg-yellow disabled:opacity-50"
|
||||||
|
>
|
||||||
|
+ {t('config.vol.avail.add')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!dirty || replace.isPending}
|
||||||
|
onClick={save}
|
||||||
|
className="cursor-pointer border-2 border-ink bg-yellow px-3 py-[6px] font-display text-[10px] font-extrabold uppercase hover:bg-green hover:text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('config.vol.avail.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{replace.isError ? <ErrorNote error={replace.error} /> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,18 +5,32 @@ import {
|
|||||||
useAssignments,
|
useAssignments,
|
||||||
useCreateAssignment,
|
useCreateAssignment,
|
||||||
useDeleteAssignment,
|
useDeleteAssignment,
|
||||||
|
useEditionAvailability,
|
||||||
usePositions,
|
usePositions,
|
||||||
useShifts,
|
useShifts,
|
||||||
useValidateAssignment,
|
useValidateAssignment,
|
||||||
useVolunteers,
|
useVolunteers,
|
||||||
} from '@/api/hooks';
|
} 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 { Btn } from '@/components/ui';
|
||||||
import { Loading } from '@/components/state';
|
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 { hourRangeLabel, hoursBetween, shortDayLabel } from '@/lib/time';
|
||||||
import { t } from '@/i18n';
|
import { t } from '@/i18n';
|
||||||
|
|
||||||
|
const COVERAGE_ORDER: Record<Coverage, number> = { full: 0, partial: 1, none: 2 };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
positionName: string;
|
positionName: string;
|
||||||
positionLocation: string | null;
|
positionLocation: string | null;
|
||||||
@@ -39,8 +53,26 @@ export function AssignmentPanel({
|
|||||||
const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
|
const shiftAssignments = useAssignments({ shiftId: shift.shift_id });
|
||||||
const shifts = useShifts();
|
const shifts = useShifts();
|
||||||
const positions = usePositions();
|
const positions = usePositions();
|
||||||
|
const availability = useEditionAvailability();
|
||||||
const deleteAssignment = useDeleteAssignment();
|
const deleteAssignment = useDeleteAssignment();
|
||||||
|
|
||||||
|
const coverageByVolunteer = useMemo(() => {
|
||||||
|
const byVol = new Map<string, AvailabilityRead[]>();
|
||||||
|
for (const w of availability.data ?? []) {
|
||||||
|
const list = byVol.get(w.volunteer_id) ?? [];
|
||||||
|
list.push(w);
|
||||||
|
byVol.set(w.volunteer_id, list);
|
||||||
|
}
|
||||||
|
const map = new Map<string, Coverage>();
|
||||||
|
for (const v of volunteers.data ?? []) {
|
||||||
|
map.set(
|
||||||
|
v.id,
|
||||||
|
availabilityCoverage(byVol.get(v.id) ?? [], shift.starts_at, shift.ends_at),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [availability.data, volunteers.data, shift.starts_at, shift.ends_at]);
|
||||||
|
|
||||||
const shiftsById = useMemo(
|
const shiftsById = useMemo(
|
||||||
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
() => new Map((shifts.data ?? []).map((s) => [s.id, s])),
|
||||||
[shifts.data],
|
[shifts.data],
|
||||||
@@ -72,11 +104,15 @@ export function AssignmentPanel({
|
|||||||
|
|
||||||
const candidates = useMemo(() => {
|
const candidates = useMemo(() => {
|
||||||
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
|
const assignedIds = new Set(assigned.map((a) => a.volunteer_id));
|
||||||
|
const cov = (id: string) => COVERAGE_ORDER[coverageByVolunteer.get(id) ?? 'none'];
|
||||||
return (volunteers.data ?? [])
|
return (volunteers.data ?? [])
|
||||||
.filter((v) => !assignedIds.has(v.id))
|
.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);
|
.slice(0, MAX_CANDIDATES);
|
||||||
}, [volunteers.data, assigned, hours]);
|
}, [volunteers.data, assigned, hours, coverageByVolunteer]);
|
||||||
|
|
||||||
if (volunteers.isLoading || shiftAssignments.isLoading) {
|
if (volunteers.isLoading || shiftAssignments.isLoading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
@@ -171,6 +207,7 @@ export function AssignmentPanel({
|
|||||||
shiftId={shift.shift_id}
|
shiftId={shift.shift_id}
|
||||||
hours={round(hours.get(volunteer.id) ?? 0)}
|
hours={round(hours.get(volunteer.id) ?? 0)}
|
||||||
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
|
slots={slotsByVolunteer.get(volunteer.id) ?? 0}
|
||||||
|
coverage={coverageByVolunteer.get(volunteer.id) ?? 'none'}
|
||||||
resolvePosition={resolvePosition}
|
resolvePosition={resolvePosition}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -179,17 +216,25 @@ export function AssignmentPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COVERAGE_STYLE: Record<Coverage, { cls: string; key: string }> = {
|
||||||
|
full: { cls: 'bg-green text-white', key: 'grille.panel.coverage.full' },
|
||||||
|
partial: { cls: 'bg-yellow-soft', key: 'grille.panel.coverage.partial' },
|
||||||
|
none: { cls: 'bg-sand-dark', key: 'grille.panel.coverage.none' },
|
||||||
|
};
|
||||||
|
|
||||||
function CandidateRow({
|
function CandidateRow({
|
||||||
volunteer,
|
volunteer,
|
||||||
shiftId,
|
shiftId,
|
||||||
hours,
|
hours,
|
||||||
slots,
|
slots,
|
||||||
|
coverage,
|
||||||
resolvePosition,
|
resolvePosition,
|
||||||
}: {
|
}: {
|
||||||
volunteer: VolunteerRead;
|
volunteer: VolunteerRead;
|
||||||
shiftId: string;
|
shiftId: string;
|
||||||
hours: number;
|
hours: number;
|
||||||
slots: number;
|
slots: number;
|
||||||
|
coverage: Coverage;
|
||||||
resolvePosition: (shiftId: string) => string | undefined;
|
resolvePosition: (shiftId: string) => string | undefined;
|
||||||
}) {
|
}) {
|
||||||
const validation = useValidateAssignment(volunteer.id, shiftId);
|
const validation = useValidateAssignment(volunteer.id, shiftId);
|
||||||
@@ -232,18 +277,13 @@ function CandidateRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{volunteer.skill_codes.length > 0 ? (
|
<div className="px-[7px] pb-[7px]">
|
||||||
<div className="flex flex-wrap gap-[5px] px-[7px] pb-[7px]">
|
<span
|
||||||
{volunteer.skill_codes.map((code) => (
|
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}`}
|
||||||
<span
|
>
|
||||||
key={code}
|
{t(COVERAGE_STYLE[coverage].key)}
|
||||||
className="border-[1.5px] border-ink bg-blue px-[6px] py-[3px] font-mono text-[9px] font-semibold"
|
</span>
|
||||||
>
|
</div>
|
||||||
{code}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{blocking ? (
|
{blocking ? (
|
||||||
<div className="flex items-stretch border-t-2 border-ink">
|
<div className="flex items-stretch border-t-2 border-ink">
|
||||||
|
|||||||
Reference in New Issue
Block a user