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
160 lines
6.5 KiB
TypeScript
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`),
|
|
},
|
|
};
|
|
}
|