Files
benevoles/frontend/src/api/http-client.ts
T
AdamThomasandClaude Sonnet 5 f58ec00e30 Créneaux types : paramétrage + génération auto des créneaux
Le paramétrage des créneaux se fait dans « Règles & édition » : la coordination
définit des créneaux types (DayPart). À la création d'un poste, un créneau à
besoin 0/0 est généré pour chaque jour de l'édition et chaque créneau type ;
les coordinateurs ajustent ensuite min/idéal.

Back-end :
- DayPart CRUD (GET/POST/PATCH/DELETE /editions/{id}/day-parts). Code figé
  après création (slugifié depuis le libellé si absent), unique par édition.
- Shift.day_part_id (nullable, SET NULL) + migration 0004.
- create_position génère les créneaux ; POST .../positions/{pid}/generate-shifts
  recrée les manquants (idempotent, ne touche pas aux besoins déjà réglés).
- 13 tests (day-parts, génération, extension d'édition, ajout de type).

Front-end :
- api/hooks/mock : dayParts CRUD + positions.generateShifts.
- ConfigRules : panneau « Créneaux types » (libellé + horaires éditables,
  ajout, suppression).
- ConfigGrid : bouton « Générer les créneaux » sur l'en-tête du poste.
- GrillePage : case « à définir » (gris, glyphe ·) quand min=idéal=0, avec
  entrée de légende.

155 tests back OK ; typecheck + lint + build + smoke front OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017uUsRg7XzopkCPWimwg9ZM
2026-09-10 20:57:51 +02:00

160 lines
6.5 KiB
TypeScript

import type {
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateRequest,
DayPartCreate,
DayPartUpdate,
EditionUpdate,
LoginRequest,
MagicLinkRequest,
PlanningNotificationRequest,
PositionCreate,
PositionUpdate,
ShiftCreate,
ShiftUpdate,
VerifyRequest,
VolunteerCreate,
VolunteerUpdate,
} from './schema';
import { getAuthToken, notifyUnauthorized } from './session';
import { ApiError, type PlanningApi } from './types';
// Implémentation HTTP réelle. En dev, Vite proxifie `/api` vers le back-end.
export function createHttpApi(baseUrl: string): PlanningApi {
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getAuthToken();
const res = await fetch(`${baseUrl}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...init?.headers,
},
credentials: 'include',
});
if (res.status === 204) return undefined as T;
const isJson = res.headers.get('content-type')?.includes('application/json');
const body: unknown = isJson ? await res.json() : await res.text();
if (!res.ok) {
const detail =
body && typeof body === 'object' && 'detail' in body
? (body as { detail: unknown }).detail
: body;
const code =
detail && typeof detail === 'object' && 'code' in detail
? String((detail as { code: unknown }).code)
: 'error.generic';
const violations =
detail && typeof detail === 'object' && 'violations' in detail
? ((detail as { violations: [] }).violations ?? [])
: [];
if (res.status === 401 && !path.includes('/auth/')) notifyUnauthorized();
throw new ApiError(res.status, code, violations);
}
return body as T;
}
const query = (params: Record<string, string | undefined>) => {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value != null) search.set(key, value);
}
const str = search.toString();
return str ? `?${str}` : '';
};
const base = (editionId: string) => `/editions/${editionId}`;
return {
editions: {
get: (editionId) => request(base(editionId)),
update: (editionId, body: EditionUpdate) =>
request(base(editionId), { method: 'PATCH', body: JSON.stringify(body) }),
},
dayParts: {
list: (editionId) => request(`${base(editionId)}/day-parts`),
create: (editionId, body: DayPartCreate) =>
request(`${base(editionId)}/day-parts`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: DayPartUpdate) =>
request(`${base(editionId)}/day-parts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/day-parts/${id}`, { method: 'DELETE' }),
},
volunteers: {
list: (editionId) => request(`${base(editionId)}/volunteers`),
get: (editionId, id) => request(`${base(editionId)}/volunteers/${id}`),
create: (editionId, body: VolunteerCreate) =>
request(`${base(editionId)}/volunteers`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: VolunteerUpdate) =>
request(`${base(editionId)}/volunteers/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/volunteers/${id}`, { method: 'DELETE' }),
},
positions: {
list: (editionId) => request(`${base(editionId)}/positions`),
get: (editionId, id) => request(`${base(editionId)}/positions/${id}`),
create: (editionId, body: PositionCreate) =>
request(`${base(editionId)}/positions`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: PositionUpdate) =>
request(`${base(editionId)}/positions/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/positions/${id}`, { method: 'DELETE' }),
generateShifts: (editionId, id) =>
request(`${base(editionId)}/positions/${id}/generate-shifts`, { method: 'POST' }),
},
shifts: {
list: (editionId, params) =>
request(`${base(editionId)}/shifts${query({ position_id: params?.positionId })}`),
get: (editionId, id) => request(`${base(editionId)}/shifts/${id}`),
create: (editionId, body: ShiftCreate) =>
request(`${base(editionId)}/shifts`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: ShiftUpdate) =>
request(`${base(editionId)}/shifts/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) => request(`${base(editionId)}/shifts/${id}`, { method: 'DELETE' }),
},
assignments: {
list: (editionId, params) =>
request(
`${base(editionId)}/assignments${query({
volunteer_id: params?.volunteerId,
shift_id: params?.shiftId,
})}`,
),
get: (editionId, id) => request(`${base(editionId)}/assignments/${id}`),
create: (editionId, body: AssignmentCreate) =>
request(`${base(editionId)}/assignments`, { method: 'POST', body: JSON.stringify(body) }),
update: (editionId, id, body: AssignmentUpdate) =>
request(`${base(editionId)}/assignments/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
remove: (editionId, id) =>
request(`${base(editionId)}/assignments/${id}`, { method: 'DELETE' }),
validate: (editionId, body: AssignmentValidateRequest) =>
request(`${base(editionId)}/assignments/validate`, {
method: 'POST',
body: JSON.stringify(body),
}),
},
matrix: {
get: (editionId) => request(`${base(editionId)}/matrix`),
},
notifications: {
sendPlanning: (editionId, body: PlanningNotificationRequest) =>
request(`${base(editionId)}/notifications/planning`, {
method: 'POST',
body: JSON.stringify(body),
}),
},
auth: {
login: (editionId, body: LoginRequest) =>
request(`${base(editionId)}/auth/login`, { method: 'POST', body: JSON.stringify(body) }),
requestMagicLink: (editionId, body: MagicLinkRequest) =>
request(`${base(editionId)}/auth/magic-link`, { method: 'POST', body: JSON.stringify(body) }),
verifyMagicLink: (editionId, body: VerifyRequest) =>
request(`${base(editionId)}/auth/verify`, { method: 'POST', body: JSON.stringify(body) }),
me: () => request(`/auth/me`),
},
};
}