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:
AdamThomas
2026-09-10 23:01:59 +02:00
co-authored by Claude Sonnet 5
parent be51770b51
commit 36d108b47d
11 changed files with 487 additions and 439 deletions
+33
View File
@@ -12,6 +12,7 @@ import type {
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateResult,
AvailabilitySet,
DayPartCreate,
DayPartUpdate,
EditionUpdate,
@@ -82,6 +83,23 @@ export function useShifts(positionId?: string) {
});
}
/** Fenêtres de disponibilité d'un bénévole. */
export function useVolunteerAvailability(volunteerId: string | null | undefined) {
return useQuery({
queryKey: ['availability', edition, volunteerId ?? null],
queryFn: () => api.availability.list(edition, volunteerId as string),
enabled: Boolean(volunteerId),
});
}
/** Toutes les disponibilités de l'édition (grille des besoins). */
export function useEditionAvailability() {
return useQuery({
queryKey: ['availability', edition, 'all'],
queryFn: () => api.availability.listEdition(edition),
});
}
export function useAssignments(
params?: { volunteerId?: string; shiftId?: string },
options?: { enabled?: boolean },
@@ -117,6 +135,7 @@ function useInvalidate() {
const client = useQueryClient();
return {
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
availability: () => client.invalidateQueries({ queryKey: ['availability', edition] }),
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
shifts: () => client.invalidateQueries({ queryKey: ['shifts', edition] }),
@@ -234,6 +253,20 @@ export function useDeleteVolunteer() {
});
}
/** Remplace toutes les fenêtres de disponibilité d'un bénévole. */
export function useReplaceAvailability() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ volunteerId, body }: { volunteerId: string; body: AvailabilitySet }) =>
api.availability.replace(edition, volunteerId, body),
onSuccess: () => {
invalidate.availability();
// Les avertissements « hors disponibilité » de la grille en dépendent.
invalidate.planning();
},
});
}
/** Import de bénévoles — phase 1 : analyse du fichier. */
export function useAnalyzeImport() {
return useMutation({
+11
View File
@@ -4,6 +4,7 @@ import type {
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateRequest,
AvailabilitySet,
DayPartCreate,
DayPartUpdate,
EditionUpdate,
@@ -108,6 +109,16 @@ export function createHttpApi(baseUrl: string): PlanningApi {
remove: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
},
availability: {
list: (editionId, volunteerId) =>
request(`${base(editionId)}/volunteers/${volunteerId}/availability`),
replace: (editionId, volunteerId, body: AvailabilitySet) =>
request(`${base(editionId)}/volunteers/${volunteerId}/availability`, {
method: 'PUT',
body: JSON.stringify(body),
}),
listEdition: (editionId) => request(`${base(editionId)}/availability`),
},
imports: {
analyze: (editionId, file: File) => {
const form = new FormData();
+32 -29
View File
@@ -4,6 +4,7 @@ import type {
AccountRead,
ArtistRead,
AssignmentRead,
AvailabilityRead,
DayPartRead,
EditionRead,
PositionCategory,
@@ -30,14 +31,14 @@ export interface MockState {
dayParts: DayPartRead[];
artists: ArtistRead[];
volunteers: VolunteerRead[];
/** volunteerId -> fenêtres de disponibilité déclarées. */
availability: Map<string, AvailabilityRead[]>;
positions: PositionRead[];
shifts: ShiftRead[];
assignments: AssignmentRead[];
accounts: AccountRead[];
/** accountId -> mot de passe en clair (mock uniquement). */
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;
}
@@ -88,7 +89,6 @@ const LAST_NAMES = [
'Perrin', 'Lefèvre', 'Nguyen', 'Rossi', 'Garnier', 'Barbier', 'Vasseur', 'Colin', 'Prévost',
'Renard', 'Dupuy',
];
const SKILL_POOL = ['CAISSE', 'CARISTE', 'REGIE', 'SECOURISME'];
const ARTIST_POOL = [
'La Battante',
'Orchestre Miroir',
@@ -171,7 +171,6 @@ export function buildMockState(): MockState {
ends_at: iso(dateStr, bounds[2]),
min_staff,
ideal_staff,
required_skill_codes: raw.requiredSkills ?? [],
created_at: NOW,
updated_at: NOW,
});
@@ -193,26 +192,39 @@ export function buildMockState(): MockState {
);
const volunteers: VolunteerRead[] = [];
const availability = new Map<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) {
const first_name = FIRST_NAMES[i % FIRST_NAMES.length];
const last_name = LAST_NAMES[(i * 7 + 3) % LAST_NAMES.length];
const id = `v${i}`;
const avail = new Set<string>();
// Pour ~75 % des jours : une fenêtre large 08:00 -> 02:00 le lendemain.
const windows: AvailabilityRead[] = [];
for (const dateStr of DAY_DATES) {
const edge = dateStr === DAY_DATES[0] || dateStr === DAY_DATES[3];
for (const [, startHour] of PART_BOUNDS) {
if (rnd() < (edge ? 0.4 : 0.6)) {
avail.add(`${dateStr}|${String(startHour).padStart(2, '0')}:00`);
}
if (rnd() < (edge ? 0.55 : 0.8)) {
windows.push(window(id, dateStr, '08:00:00', '02:00:00'));
}
}
availability.set(id, avail);
availability.set(id, windows);
const pair = () => `0${Math.floor(rnd() * 90 + 10)}`.slice(-2);
const skill_codes = rnd() < 0.3 ? [SKILL_POOL[Math.floor(rnd() * SKILL_POOL.length)]] : [];
const artist_ids = artists
.filter(() => rnd() < 0.25)
.map((a) => a.id);
const artist_ids = artists.filter(() => rnd() < 0.25).map((a) => a.id);
const wished_position_ids =
rnd() < 0.4 ? [positions[Math.floor(rnd() * positions.length)].id] : [];
volunteers.push({
@@ -223,7 +235,6 @@ export function buildMockState(): MockState {
email: rnd() < 0.85 ? demoEmail(first_name, last_name) : null,
phone: `06 ${pair()} ${pair()} ${pair()} ${pair()}`,
comment: null,
skill_codes,
artist_ids,
wished_position_ids,
created_at: NOW,
@@ -235,11 +246,7 @@ export function buildMockState(): MockState {
me.email = demoEmail(me.first_name, me.last_name); // toujours un compte pour la démo
availability.set(
me.id,
new Set(
DAY_DATES.flatMap((dateStr) =>
PART_BOUNDS.map(([, h]) => `${dateStr}|${String(h).padStart(2, '0')}:00`),
),
),
DAY_DATES.map((dateStr) => window(me.id, dateStr, '08:00:00', '02:00:00')),
);
NEVER_ASSIGNED.forEach((i) => {
volunteers[i].comment = 'Inscrit, pas encore affecté';
@@ -262,7 +269,6 @@ export function buildMockState(): MockState {
const duration = hoursBetween(shift.starts_at, shift.ends_at);
const dateStr = shift.starts_at.slice(0, 10);
const availKey = `${dateStr}|${shift.starts_at.slice(11, 16)}`;
const pool = volunteers
.filter(
@@ -274,13 +280,10 @@ export function buildMockState(): MockState {
.sort((a, b) => score(b) - score(a));
function score(v: VolunteerRead): number {
const available = availability.get(v.id)?.has(availKey) ? 100 : 0;
const skilled =
shift.required_skill_codes.length > 0 &&
shift.required_skill_codes.every((c) => v.skill_codes.includes(c))
? 40
: 0;
return available + skilled - (totalHours.get(v.id) ?? 0) * 3;
const available = (availability.get(v.id) ?? []).some((w) => w.date === dateStr)
? 100
: 0;
return available - (totalHours.get(v.id) ?? 0) * 3;
}
for (const v of pool.slice(0, target)) {
+70 -124
View File
@@ -1,3 +1,4 @@
import { uncoveredMinutes } from '@/lib/planning';
import { hoursBetween, naiveMinutes } from '@/lib/time';
import type {
@@ -7,7 +8,8 @@ import type {
ArtistUpdate,
AssignmentRead,
AssignmentValidateResult,
AvailabilityStatus,
AvailabilityRead,
AvailabilitySet,
DayPartCreate,
DayPartRead,
DayPartUpdate,
@@ -92,6 +94,17 @@ const slugCode = (label: string): string =>
.replace(/[^A-Z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
/** « HH:MM » ou « HH:MM:SS » -> « HH:MM:SS ». */
const hms = (time: string): string => (time.length === 5 ? `${time}:00` : time);
const sortWindows = (ws: AvailabilityRead[]): AvailabilityRead[] =>
[...ws].sort(
(a, b) =>
a.volunteer_id.localeCompare(b.volunteer_id) ||
a.date.localeCompare(b.date) ||
a.start_time.localeCompare(b.start_time),
);
/** Vœux du bénévole : dé-doublonne, ordre conservé ; 422 si un id est étranger. */
function resolveWishes<T extends { id: string }>(
pool: T[],
@@ -131,7 +144,6 @@ function generateMissingShifts(positionId: string): number {
ends_at,
min_staff: 0,
ideal_staff: 0,
required_skill_codes: [],
created_at: now,
updated_at: now,
});
@@ -157,21 +169,8 @@ const IMPORT_HINTS: Record<string, string[]> = {
last_name: ['nom', 'last name', 'lastname', 'surname', 'famille'],
phone: ['tel', 'phone', 'portable', 'mobile', 'gsm'],
comment: ['commentaire', 'remarque', 'note', 'precision'],
skills: ['competence', 'skill', 'habilitation', 'brevet', 'diplome'],
};
const IMPORT_EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
const AVAIL_AVAILABLE = new Set([
'x', 'o', 'oui', 'y', 'yes', 'ok', '1', 'vrai', 'true', 'dispo', 'disponible',
'present', 'presente', 'matin', 'aprem', 'apres-midi', 'apres midi', 'midi',
'soir', 'soiree', 'journee', 'jour',
]);
const AVAIL_IF_NEEDED = new Set([
'si besoin', 'sib', 'peut-etre', 'peut etre', '?', '2', 'backup', 'reserve', 'au besoin',
]);
const AVAIL_UNAVAILABLE = new Set([
'non', 'n', 'no', '0', 'faux', 'false', 'indispo', 'indisponible', 'absent', '-', 'x-',
]);
const AVAIL_EMPTY = new Set(['', 'na', 'n/a', 'nc']);
function normalizeText(value: string): string {
return (value ?? '')
@@ -182,15 +181,6 @@ function normalizeText(value: string): string {
.replace(/\s+/g, ' ');
}
function parseAvailabilityCell(value: string): [AvailabilityStatus | null, boolean] {
const n = normalizeText(value);
if (AVAIL_EMPTY.has(n)) return [null, true];
if (AVAIL_AVAILABLE.has(n)) return ['AVAILABLE', true];
if (AVAIL_IF_NEEDED.has(n)) return ['IF_NEEDED', true];
if (AVAIL_UNAVAILABLE.has(n)) return ['UNAVAILABLE', true];
return [null, false];
}
function splitCsvLine(line: string, delimiter: string): string[] {
const cells: string[] = [];
let cur = '';
@@ -292,17 +282,20 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
}
}
// OUTSIDE_AVAILABILITY (avertissement) — le mock ne connaît que AVAILABLE / non déclaré.
const declared = state.availability.get(volunteerId) ?? new Set<string>();
const availKey = `${candidate.starts_at.slice(0, 10)}|${candidate.starts_at.slice(11, 16)}`;
if (!declared.has(availKey)) {
// OUTSIDE_AVAILABILITY (avertissement) — créneau non couvert par l'union des
// fenêtres de disponibilité déclarées.
const gapMin = uncoveredMinutes(
state.availability.get(volunteerId) ?? [],
candidate.starts_at,
candidate.ends_at,
);
if (gapMin > 0) {
violations.push({
code: 'OUTSIDE_AVAILABILITY',
severity: 'WARNING',
params: {
date: candidate.starts_at.slice(0, 10),
day_part_code: candidate.starts_at.slice(11, 16),
status: 'UNDECLARED',
uncovered_minutes: gapMin,
},
});
}
@@ -346,17 +339,6 @@ function runValidation(volunteerId: string, candidate: ShiftRead, excludeAssignm
}
}
// MISSING_REQUIRED_SKILL (avertissement)
for (const code of [...candidate.required_skill_codes].sort()) {
if (!volunteer.skill_codes.includes(code)) {
violations.push({
code: 'MISSING_REQUIRED_SKILL',
severity: 'WARNING',
params: { skill_code: code },
});
}
}
return violations;
}
@@ -537,7 +519,6 @@ export function createMockApi(): PlanningApi {
email: body.email ?? null,
phone: body.phone ?? null,
comment: body.comment ?? null,
skill_codes: body.skill_codes ?? [],
artist_ids: resolveWishes(state.artists, body.artist_ids ?? [], 'artist.unknown'),
wished_position_ids: resolveWishes(
state.positions,
@@ -548,7 +529,7 @@ export function createMockApi(): PlanningApi {
updated_at: now,
};
state.volunteers.push(volunteer);
state.availability.set(volunteer.id, new Set());
state.availability.set(volunteer.id, []);
return delay(volunteer);
},
update: (_edition, id, body) => {
@@ -559,7 +540,6 @@ export function createMockApi(): PlanningApi {
...('email' in body ? { email: body.email ?? null } : {}),
...('phone' in body ? { phone: body.phone ?? null } : {}),
...('comment' in body ? { comment: body.comment ?? null } : {}),
...(body.skill_codes != null ? { skill_codes: body.skill_codes } : {}),
...(body.artist_ids != null
? { artist_ids: resolveWishes(state.artists, body.artist_ids, 'artist.unknown') }
: {}),
@@ -584,6 +564,50 @@ export function createMockApi(): PlanningApi {
},
},
availability: {
list: (_edition, volunteerId) => {
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
return delay(sortWindows(state.availability.get(volunteerId) ?? []));
},
listEdition: () =>
delay(
sortWindows(
[...state.availability.values()].flat().filter((w) =>
state.volunteers.some((v) => v.id === w.volunteer_id),
),
),
),
replace: (_edition, volunteerId, body: AvailabilitySet) => {
if (!state.volunteers.some((v) => v.id === volunteerId)) return notFound();
const seen = new Set<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);
@@ -591,14 +615,6 @@ export function createMockApi(): PlanningApi {
line: i + 2,
values: Object.fromEntries(headers.map((h, c) => [h, cells[c] ?? ''])),
}));
const editionDates: string[] = [];
for (
let d = state.edition.start_date;
d <= state.edition.end_date;
d = addDays(d, 1)
) {
editionDates.push(d);
}
return delay<ImportAnalyzeResponse>({
filename: file.name || '',
format: 'csv',
@@ -606,16 +622,6 @@ export function createMockApi(): PlanningApi {
row_count: rows.length,
preview,
suggested_mapping: suggestImportMapping(headers),
edition_dates: editionDates,
day_parts: [...state.dayParts]
.sort((a, b) => a.order - b.order)
.map((dp) => ({
code: dp.code,
label: dp.label,
start_time: dp.start_time,
end_time: dp.end_time,
order: dp.order,
})),
});
},
execute: async (_edition, file: File, mapping: ImportMapping) => {
@@ -632,16 +638,11 @@ export function createMockApi(): PlanningApi {
created: 0,
updated: 0,
skipped: 0,
availabilities_written: 0,
skills_created: [],
rows: [],
};
const dpByCode = new Map(state.dayParts.map((d) => [d.code, d]));
const emailSeen = new Map<string, number>();
const createdSkills = new Set<string>();
// On travaille sur des copies : `state` n'est touché qu'en fin si !dry_run.
const working = state.volunteers.map((v) => ({ ...v, skill_codes: [...v.skill_codes] }));
const availWrites: Array<{ vid: string; key: string; on: boolean }> = [];
const working = state.volunteers.map((v) => ({ ...v }));
rows.forEach((row, i) => {
const line = i + 2;
@@ -729,7 +730,6 @@ export function createMockApi(): PlanningApi {
email: null,
phone: null,
comment: null,
skill_codes: [],
artist_ids: [],
wished_position_ids: [],
created_at: now,
@@ -744,48 +744,6 @@ export function createMockApi(): PlanningApi {
if (has(cols.email)) vol.email = emailRaw || null;
if (has(cols.phone)) vol.phone = cell(row, cols.phone) || null;
if (has(cols.comment)) vol.comment = cell(row, cols.comment) || null;
if (has(cols.skills)) {
const tokens = cell(row, cols.skills)
.split(/[;,/\n]+/)
.map((s) => s.trim())
.filter(Boolean);
for (const token of tokens) {
const code = slugCode(token);
if (!code) continue;
const known = working.some((v) => v.skill_codes.includes(code));
if (!known && !createdSkills.has(code)) {
createdSkills.add(code);
entry.issues.push({
code: 'import.row.skill_created',
severity: 'WARNING',
params: { code, label: token },
});
}
if (!vol.skill_codes.includes(code)) vol.skill_codes.push(code);
}
}
for (const avCol of mapping.availability_columns) {
const dp = dpByCode.get(avCol.day_part_code);
if (!dp) continue;
const [status, recognized] = parseAvailabilityCell(cell(row, avCol.header));
if (!recognized) {
entry.issues.push({
code: 'import.row.unknown_availability_value',
severity: 'WARNING',
field: avCol.header,
params: { column: avCol.header, value: cell(row, avCol.header) },
});
continue;
}
if (status === null) continue;
availWrites.push({
vid: vol.id,
key: `${avCol.date}|${dp.start_time.slice(0, 5)}`,
on: status !== 'UNAVAILABLE',
});
report.availabilities_written += 1;
}
entry.volunteer_id = vol.id;
report.rows.push(entry);
@@ -793,18 +751,10 @@ export function createMockApi(): PlanningApi {
else report.updated += 1;
});
report.skills_created = [...createdSkills].sort();
if (!mapping.dry_run) {
state.volunteers = working;
for (const v of working) {
if (!state.availability.has(v.id)) state.availability.set(v.id, new Set());
}
for (const w of availWrites) {
const set = state.availability.get(w.vid) ?? new Set<string>();
if (w.on) set.add(w.key);
else set.delete(w.key);
state.availability.set(w.vid, set);
if (!state.availability.has(v.id)) state.availability.set(v.id, []);
}
}
return delay(report);
@@ -883,7 +833,6 @@ export function createMockApi(): PlanningApi {
ends_at: body.ends_at,
min_staff: body.min_staff ?? 0,
ideal_staff: body.ideal_staff ?? 0,
required_skill_codes: body.required_skill_codes ?? [],
created_at: now,
updated_at: now,
};
@@ -898,9 +847,6 @@ export function createMockApi(): PlanningApi {
...('ends_at' in body ? { ends_at: body.ends_at } : {}),
...('min_staff' in body ? { min_staff: body.min_staff } : {}),
...('ideal_staff' in body ? { ideal_staff: body.ideal_staff } : {}),
...(body.required_skill_codes != null
? { required_skill_codes: body.required_skill_codes }
: {}),
updated_at: touch(),
});
if (naiveMinutes(s.ends_at) <= naiveMinutes(s.starts_at)) {
+29 -34
View File
@@ -15,7 +15,6 @@ export type PositionCategory =
// Depuis l'Étape 9 back-end : une affectation est valide dès sa création,
// il n'y a plus de statut (proposée / confirmée…). La retirer = supprimer la ligne.
export type AssignmentSource = 'SOLVER' | 'MANUAL';
export type AvailabilityStatus = 'AVAILABLE' | 'IF_NEEDED' | 'UNAVAILABLE';
export type Role = 'COORDINATOR' | 'LEAD' | 'VOLUNTEER';
export type AuthMethod = 'password' | 'magic_link';
@@ -23,8 +22,7 @@ export type ViolationCode =
| 'SHIFT_OVERLAP'
| 'OUTSIDE_AVAILABILITY'
| 'DAILY_HOURS_EXCEEDED'
| 'INSUFFICIENT_REST'
| 'MISSING_REQUIRED_SKILL';
| 'INSUFFICIENT_REST';
export type ViolationSeverity = 'BLOCKING' | 'WARNING';
@@ -104,7 +102,6 @@ export interface VolunteerRead {
email: string | null;
phone: string | null;
comment: string | null;
skill_codes: string[];
/** Vœux : artistes que le bénévole veut voir. */
artist_ids: string[];
/** Vœux : postes que le bénévole voudrait tenir. */
@@ -119,7 +116,6 @@ export interface VolunteerCreate {
email?: string | null;
phone?: string | null;
comment?: string | null;
skill_codes?: string[];
artist_ids?: string[];
wished_position_ids?: string[];
}
@@ -130,12 +126,37 @@ export interface VolunteerUpdate {
email?: string | null;
phone?: string | null;
comment?: string | null;
/** null / absent = inchangé ; [] = vider. Idem artist_ids / wished_position_ids. */
skill_codes?: string[] | null;
/** null / absent = inchangé ; [] = vider. */
artist_ids?: string[] | null;
wished_position_ids?: string[] | null;
}
// --------------------------------------------------------------------------- //
// Availability (disponibilités déclarées par le bénévole) //
// --------------------------------------------------------------------------- //
export interface AvailabilityWindowInput {
/** Date ISO (YYYY-MM-DD). */
date: string;
/** Heure « HH:MM » ou « HH:MM:SS ». end <= start ⇒ se termine le lendemain. */
start_time: string;
end_time: string;
}
export interface AvailabilitySet {
windows: AvailabilityWindowInput[];
}
export interface AvailabilityRead {
id: string;
edition_id: string;
volunteer_id: string;
date: string;
start_time: string;
end_time: string;
created_at: string;
updated_at: string;
}
// --------------------------------------------------------------------------- //
// Artist (programmation) //
// --------------------------------------------------------------------------- //
@@ -167,8 +188,7 @@ export type ImportFieldKey =
| 'last_name'
| 'email'
| 'phone'
| 'comment'
| 'skills';
| 'comment';
export interface ImportColumnMapping {
first_name: string;
@@ -176,20 +196,11 @@ export interface ImportColumnMapping {
email?: string | null;
phone?: string | null;
comment?: string | null;
skills?: string | null;
}
export interface ImportAvailabilityColumn {
header: string;
/** Date ISO (YYYY-MM-DD). */
date: string;
day_part_code: string;
}
export interface ImportMapping {
dry_run: boolean;
columns: ImportColumnMapping;
availability_columns: ImportAvailabilityColumn[];
}
export interface ImportPreviewRow {
@@ -197,14 +208,6 @@ export interface ImportPreviewRow {
values: Record<string, string>;
}
export interface ImportDayPartInfo {
code: string;
label: string;
start_time: string;
end_time: string;
order: number;
}
export interface ImportAnalyzeResponse {
filename: string;
format: 'csv' | 'xlsx';
@@ -212,9 +215,6 @@ export interface ImportAnalyzeResponse {
row_count: number;
preview: ImportPreviewRow[];
suggested_mapping: Partial<Record<ImportFieldKey, string>>;
/** Dates ISO couvertes par l'édition. */
edition_dates: string[];
day_parts: ImportDayPartInfo[];
}
export type ImportSeverity = 'ERROR' | 'WARNING';
@@ -240,8 +240,6 @@ export interface ImportReport {
created: number;
updated: number;
skipped: number;
availabilities_written: number;
skills_created: string[];
rows: ImportRowReport[];
}
@@ -287,7 +285,6 @@ export interface ShiftRead {
ends_at: string;
min_staff: number;
ideal_staff: number;
required_skill_codes: string[];
created_at: string;
updated_at: string;
}
@@ -298,7 +295,6 @@ export interface ShiftCreate {
ends_at: string;
min_staff?: number;
ideal_staff?: number;
required_skill_codes?: string[];
}
export interface ShiftUpdate {
@@ -307,7 +303,6 @@ export interface ShiftUpdate {
ends_at?: string;
min_staff?: number;
ideal_staff?: number;
required_skill_codes?: string[] | null;
}
// --------------------------------------------------------------------------- //
+14
View File
@@ -9,6 +9,8 @@ import type {
AssignmentValidateRequest,
AssignmentValidateResult,
AssignmentWriteResult,
AvailabilityRead,
AvailabilitySet,
DayPartCreate,
DayPartRead,
DayPartUpdate,
@@ -65,6 +67,18 @@ export interface PlanningApi {
update(editionId: string, id: string, body: VolunteerUpdate): Promise<VolunteerRead>;
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>;