Files
benevoles/frontend/src/api/hooks.ts
T
AdamThomasandClaude Opus 5.5 d722a81a87
ci-cd / build-backend (push) Successful in 31s
ci-cd / build-frontend (push) Successful in 19s
ci-cd / deploy (push) Successful in 12s
Retrait des disponibilités et des limites de temps dans la journée
Disponibilités déclarées supprimées (table `availability`, migration 0022,
API, éditeur de la fiche bénévole, badge de couverture du panneau
d'affectation, colonnes des statistiques) ainsi que les règles
OUTSIDE_AVAILABILITY, DAILY_HOURS_EXCEEDED et INSUFFICIENT_REST, les malus
de disponibilité et de repos du solveur et le filtre « Surchargés » de la
vue bénévoles. Seul le chevauchement reste contrôlé. La case « je suis
disponible » du formulaire public est conservée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 21:29:58 +02:00

754 lines
23 KiB
TypeScript

import { useEffect, useState } from 'react';
import {
useMutation,
useQuery,
useQueryClient,
type UseQueryOptions,
} from '@tanstack/react-query';
import { EDITION_ID, api } from './index';
import type {
AccountCreate,
AccountUpdate,
ArtistCreate,
ArtistUpdate,
AssignmentCreate,
AssignmentUpdate,
AssignmentValidateResult,
BadgeFieldsSet,
BadgeGenerateRequest,
BadgeSizeUpdate,
DayPartCreate,
DayPartUpdate,
EditionUpdate,
FormFieldsSet,
FormPaymentOptionCreate,
FormPaymentOptionUpdate,
FormTemplateUpdate,
ImportMapping,
MecenatInfoSubmit,
PlanningNotificationRequest,
PositionCreate,
PositionSheetsRequest,
PositionUpdate,
PublicFormSubmission,
ShiftCreate,
ShiftUpdate,
VolunteerCreate,
VolunteerUpdate,
} from './schema';
const edition = EDITION_ID;
export const queryKeys = {
edition: ['edition', edition] as const,
volunteers: ['volunteers', edition] as const,
positions: ['positions', edition] as const,
shifts: (positionId?: string) => ['shifts', edition, positionId ?? null] as const,
assignments: (params?: { volunteerId?: string; shiftId?: string }) =>
['assignments', edition, params?.volunteerId ?? null, params?.shiftId ?? null] as const,
matrix: ['matrix', edition] as const,
solver: ['solver', edition] as const,
validate: (volunteerId: string, shiftId: string) =>
['validate', edition, volunteerId, shiftId] as const,
};
// --------------------------------------------------------------------------- //
// Lectures //
// --------------------------------------------------------------------------- //
export function useEdition() {
return useQuery({ queryKey: queryKeys.edition, queryFn: () => api.editions.get(edition) });
}
export function useDayParts() {
return useQuery({ queryKey: ['dayParts', edition], queryFn: () => api.dayParts.list(edition) });
}
export function useArtists() {
return useQuery({ queryKey: ['artists', edition], queryFn: () => api.artists.list(edition) });
}
export function useVolunteers(options?: { enabled?: boolean }) {
return useQuery({
queryKey: queryKeys.volunteers,
queryFn: () => api.volunteers.list(edition),
enabled: options?.enabled ?? true,
});
}
export function usePositions() {
return useQuery({ queryKey: queryKeys.positions, queryFn: () => api.positions.list(edition) });
}
export function useAccounts() {
return useQuery({ queryKey: ['accounts', edition], queryFn: () => api.accounts.list(edition) });
}
/** Fiche d'un bénévole (un bénévole connecté peut lire la sienne). */
export function useVolunteer(id: string | null | undefined) {
return useQuery({
queryKey: ['volunteer', edition, id ?? null],
queryFn: () => api.volunteers.get(edition, id as string),
enabled: Boolean(id),
});
}
/** Planning enrichi (poste, lieu, référent) d'un bénévole — écran bénévole. */
export function useVolunteerPlanning(volunteerId: string | null | undefined) {
return useQuery({
queryKey: ['volunteer-planning', edition, volunteerId ?? null],
queryFn: () => api.volunteers.planning(edition, volunteerId as string),
enabled: Boolean(volunteerId),
});
}
/** URL objet (aperçu) de la photo d'un bénévole (formulaire public), ou
* `null` si absente. */
export function useVolunteerPhotoUrl(volunteerId: string | null | undefined, hasPhoto: boolean) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let objectUrl: string | null = null;
let cancelled = false;
if (!hasPhoto || !volunteerId) {
setUrl(null);
return;
}
api.volunteers.photoBlob(edition, volunteerId).then((blob) => {
if (cancelled || !blob) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [volunteerId, hasPhoto]);
return url;
}
export function useShifts(positionId?: string) {
return useQuery({
queryKey: queryKeys.shifts(positionId),
queryFn: () => api.shifts.list(edition, positionId ? { positionId } : undefined),
});
}
export function useAssignments(
params?: { volunteerId?: string; shiftId?: string },
options?: { enabled?: boolean },
) {
return useQuery({
queryKey: queryKeys.assignments(params),
queryFn: () => api.assignments.list(edition, params),
enabled: options?.enabled ?? true,
});
}
export function useMatrix() {
return useQuery({ queryKey: queryKeys.matrix, queryFn: () => api.matrix.get(edition) });
}
/** Statistiques de l'édition (inscriptions, réponses, paiements, besoins) —
* recalculées côté serveur à chaque appel. */
export function useStats() {
return useQuery({ queryKey: ['stats', edition] as const, queryFn: () => api.stats.get(edition) });
}
/** Configuration du recto/verso des badges (images, champs). */
export function useBadgeTemplate() {
return useQuery({
queryKey: ['badgeTemplate', edition],
queryFn: () => api.badges.getTemplate(edition),
});
}
/**
* URL objet (aperçu) de l'image recto/verso, ou `null` si non définie.
* `refreshKey` (ex. `dataUpdatedAt` de `useBadgeTemplate`) force un nouveau
* fetch après un remplacement d'image — un simple `hasImage` ne changerait
* pas de valeur entre deux images successives.
*/
export function useBadgeImageUrl(
kind: 'front' | 'back',
hasImage: boolean,
refreshKey: number | string,
) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let objectUrl: string | null = null;
let cancelled = false;
if (!hasImage) {
setUrl(null);
return;
}
const fetchBlob = kind === 'front' ? api.badges.frontImageBlob : api.badges.backImageBlob;
fetchBlob(edition).then((blob) => {
if (cancelled || !blob) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [kind, hasImage, refreshKey]);
return url;
}
/**
* Dernières suggestions du solveur (lecture seule du cache posé par
* `useRunSolver`). Ne déclenche jamais de calcul elle-même.
*/
export function useSolverSuggestions() {
return useQuery({
queryKey: queryKeys.solver,
queryFn: () => api.solver.run(edition),
enabled: false,
staleTime: Infinity,
});
}
export function useValidateAssignment(
volunteerId: string,
shiftId: string,
options?: Partial<UseQueryOptions<AssignmentValidateResult>>,
) {
return useQuery({
queryKey: queryKeys.validate(volunteerId, shiftId),
queryFn: () => api.assignments.validate(edition, { volunteer_id: volunteerId, shift_id: shiftId }),
staleTime: 60_000,
...options,
});
}
// --------------------------------------------------------------------------- //
// Écritures //
// --------------------------------------------------------------------------- //
function useInvalidate() {
const client = useQueryClient();
return {
volunteers: () => client.invalidateQueries({ queryKey: ['volunteers', edition] }),
artists: () => client.invalidateQueries({ queryKey: ['artists', edition] }),
accounts: () => client.invalidateQueries({ queryKey: ['accounts', edition] }),
positions: () => client.invalidateQueries({ queryKey: ['positions', edition] }),
shifts: () => client.invalidateQueries({ queryKey: ['shifts', edition] }),
planning: () => {
client.invalidateQueries({ queryKey: ['assignments', edition] });
client.invalidateQueries({ queryKey: ['matrix', edition] });
client.invalidateQueries({ queryKey: ['validate', edition] });
},
};
}
export function useUpdateEdition() {
const client = useQueryClient();
return useMutation({
mutationFn: (body: EditionUpdate) => api.editions.update(edition, body),
onSuccess: () => client.invalidateQueries({ queryKey: ['edition', edition] }),
});
}
export function useCreateDayPart() {
const client = useQueryClient();
return useMutation({
mutationFn: (body: DayPartCreate) => api.dayParts.create(edition, body),
onSuccess: () => client.invalidateQueries({ queryKey: ['dayParts', edition] }),
});
}
export function useUpdateDayPart() {
const client = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: DayPartUpdate }) =>
api.dayParts.update(edition, id, body),
onSuccess: () => client.invalidateQueries({ queryKey: ['dayParts', edition] }),
});
}
export function useDeleteDayPart() {
const client = useQueryClient();
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.dayParts.remove(edition, id),
onSuccess: () => {
client.invalidateQueries({ queryKey: ['dayParts', edition] });
invalidate.shifts();
invalidate.planning();
},
});
}
export function useGenerateShifts() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (positionId: string) => api.positions.generateShifts(edition, positionId),
onSuccess: () => {
invalidate.shifts();
invalidate.planning();
},
});
}
export function useCreateArtist() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (body: ArtistCreate) => api.artists.create(edition, body),
onSuccess: invalidate.artists,
});
}
export function useUpdateArtist() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: ArtistUpdate }) =>
api.artists.update(edition, id, body),
onSuccess: invalidate.artists,
});
}
export function useDeleteArtist() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.artists.remove(edition, id),
onSuccess: () => {
invalidate.artists();
// La suppression retire l'artiste des vœux des bénévoles.
invalidate.volunteers();
},
});
}
export function useCreateAccount() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (body: AccountCreate) => api.accounts.create(edition, body),
onSuccess: invalidate.accounts,
});
}
export function useUpdateAccount() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: AccountUpdate }) =>
api.accounts.update(edition, id, body),
onSuccess: invalidate.accounts,
});
}
export function useDeleteAccount() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.accounts.remove(edition, id),
onSuccess: invalidate.accounts,
});
}
export function useCreateVolunteer() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (body: VolunteerCreate) => api.volunteers.create(edition, body),
onSuccess: invalidate.volunteers,
});
}
export function useUpdateVolunteer() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: VolunteerUpdate }) =>
api.volunteers.update(edition, id, body),
onSuccess: invalidate.volunteers,
});
}
export function useDeleteVolunteer() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.volunteers.remove(edition, id),
onSuccess: () => {
invalidate.volunteers();
invalidate.planning();
},
});
}
/** Export Excel de la liste des bénévoles ; ne fait que retourner le
* fichier (le composant appelant déclenche le téléchargement, voir
* `useGenerateBadges`). */
export function useExportVolunteers() {
return useMutation({
mutationFn: () => api.volunteers.exportXlsx(edition),
});
}
/** Reçus fiscaux (cerfa) des bénévoles ayant payé en mécénat, un PDF pour
* tous les reçus ; même mécanique que `useExportVolunteers`. */
export function useExportMecenatReceipts() {
return useMutation({
mutationFn: () => api.volunteers.exportMecenatReceipts(edition),
});
}
/** Import de bénévoles — phase 1 : analyse du fichier. */
export function useAnalyzeImport() {
return useMutation({
mutationFn: (file: File) => api.imports.analyze(edition, file),
});
}
/** Import de bénévoles — phase 2 : simulation ou exécution réelle. */
export function useExecuteImport() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ file, mapping }: { file: File; mapping: ImportMapping }) =>
api.imports.execute(edition, file, mapping),
onSuccess: (report) => {
if (!report.dry_run) {
invalidate.volunteers();
invalidate.planning();
}
},
});
}
export function useCreatePosition() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (body: PositionCreate) => api.positions.create(edition, body),
onSuccess: () => {
invalidate.positions();
invalidate.shifts();
invalidate.planning();
},
});
}
export function useUpdatePosition() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: PositionUpdate }) =>
api.positions.update(edition, id, body),
onSuccess: () => {
invalidate.positions();
invalidate.planning();
},
});
}
export function useDeletePosition() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.positions.remove(edition, id),
onSuccess: () => {
invalidate.positions();
invalidate.shifts();
invalidate.planning();
// Le poste disparaît des vœux des bénévoles.
invalidate.volunteers();
},
});
}
export function useCreateShift() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (body: ShiftCreate) => api.shifts.create(edition, body),
onSuccess: () => {
invalidate.shifts();
invalidate.planning();
},
});
}
export function useUpdateShift() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: ShiftUpdate }) =>
api.shifts.update(edition, id, body),
onSuccess: () => {
invalidate.shifts();
invalidate.planning();
},
});
}
export function useDeleteShift() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.shifts.remove(edition, id),
onSuccess: () => {
invalidate.shifts();
invalidate.planning();
},
});
}
export function useCreateAssignment() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (body: AssignmentCreate) => api.assignments.create(edition, body),
onSuccess: invalidate.planning,
});
}
export function useUpdateAssignment() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: AssignmentUpdate }) =>
api.assignments.update(edition, id, body),
onSuccess: invalidate.planning,
});
}
export function useDeleteAssignment() {
const invalidate = useInvalidate();
return useMutation({
mutationFn: (id: string) => api.assignments.remove(edition, id),
onSuccess: invalidate.planning,
});
}
/**
* Lance le solveur (CP-SAT) : calcule des suggestions, n'affecte jamais
* personne. Le résultat alimente le cache lu par `useSolverSuggestions`.
*/
export function useRunSolver() {
const client = useQueryClient();
return useMutation({
mutationFn: () => api.solver.run(edition),
onSuccess: (data) => client.setQueryData(queryKeys.solver, data),
});
}
/** Feuilles de poste PDF (une page par poste et par jour, créneaux +
* bénévoles affectés + téléphone) ; ne fait que retourner le fichier (le
* composant appelant déclenche le téléchargement). */
export function useGeneratePositionSheets() {
return useMutation({
mutationFn: (body: PositionSheetsRequest) => api.positionSheets.generate(edition, body),
});
}
export function useSendPlanningNotification() {
return useMutation({
mutationFn: (body: PlanningNotificationRequest) =>
api.notifications.sendPlanning(edition, body),
});
}
function useInvalidateBadgeTemplate() {
const client = useQueryClient();
return () => client.invalidateQueries({ queryKey: ['badgeTemplate', edition] });
}
export function useUploadBadgeFrontImage() {
const invalidate = useInvalidateBadgeTemplate();
return useMutation({
mutationFn: (file: File) => api.badges.uploadFrontImage(edition, file),
onSuccess: invalidate,
});
}
export function useUploadBadgeBackImage() {
const invalidate = useInvalidateBadgeTemplate();
return useMutation({
mutationFn: (file: File) => api.badges.uploadBackImage(edition, file),
onSuccess: invalidate,
});
}
/** Remplace l'intégralité des champs du recto (positions, activation). */
export function useSetBadgeFields() {
const invalidate = useInvalidateBadgeTemplate();
return useMutation({
mutationFn: (body: BadgeFieldsSet) => api.badges.setFields(edition, body),
onSuccess: invalidate,
});
}
/** Redimensionne le badge indépendamment de l'image (agrandissement
* proportionnel d'une image importée trop petite). */
export function useSetBadgeSize() {
const invalidate = useInvalidateBadgeTemplate();
return useMutation({
mutationFn: (body: BadgeSizeUpdate) => api.badges.setSize(edition, body),
onSuccess: invalidate,
});
}
/** Génère la planche PDF ; ne fait que retourner le fichier (le composant
* appelant déclenche le téléchargement). */
export function useGenerateBadges() {
return useMutation({
mutationFn: (body: BadgeGenerateRequest) => api.badges.generate(edition, body),
});
}
// --------------------------------------------------------------------------- //
// Formulaire public d'inscription //
// --------------------------------------------------------------------------- //
/** Configuration du formulaire (COORDINATION). */
export function useFormTemplate() {
return useQuery({
queryKey: ['formTemplate', edition],
queryFn: () => api.forms.getTemplate(edition),
});
}
function useInvalidateFormTemplate() {
const client = useQueryClient();
return () => client.invalidateQueries({ queryKey: ['formTemplate', edition] });
}
export function useSetFormFields() {
const invalidate = useInvalidateFormTemplate();
return useMutation({
mutationFn: (body: FormFieldsSet) => api.forms.setFields(edition, body),
onSuccess: invalidate,
});
}
/** `PATCH /form` : `is_open` et/ou `description`, chacun optionnel
* (absent = inchangé — voir `FormTemplateUpdate`). */
export function useUpdateFormTemplate() {
const invalidate = useInvalidateFormTemplate();
return useMutation({
mutationFn: (body: FormTemplateUpdate) => api.forms.setOpen(edition, body),
onSuccess: invalidate,
});
}
export function useUploadFormLogo() {
const invalidate = useInvalidateFormTemplate();
return useMutation({
mutationFn: (file: File) => api.forms.uploadLogo(edition, file),
onSuccess: invalidate,
});
}
/** Révoque le lien d'inscription partagé jusqu'ici (nouveau `access_token`)
* — voir `FormTemplateRead.access_token`. */
export function useRegenerateFormToken() {
const invalidate = useInvalidateFormTemplate();
return useMutation({
mutationFn: () => api.forms.regenerateToken(edition),
onSuccess: invalidate,
});
}
/** Types de paiement de la cotisation (COORDINATION) — voir `FormPaymentOptionRead`. */
export function useFormPaymentOptions() {
return useQuery({
queryKey: ['formPaymentOptions', edition],
queryFn: () => api.forms.paymentOptions.list(edition),
});
}
function useInvalidateFormPaymentOptions() {
const client = useQueryClient();
return () => client.invalidateQueries({ queryKey: ['formPaymentOptions', edition] });
}
export function useCreateFormPaymentOption() {
const invalidate = useInvalidateFormPaymentOptions();
return useMutation({
mutationFn: (body: FormPaymentOptionCreate) => api.forms.paymentOptions.create(edition, body),
onSuccess: invalidate,
});
}
export function useUpdateFormPaymentOption() {
const invalidate = useInvalidateFormPaymentOptions();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: FormPaymentOptionUpdate }) =>
api.forms.paymentOptions.update(edition, id, body),
onSuccess: invalidate,
});
}
export function useDeleteFormPaymentOption() {
const invalidate = useInvalidateFormPaymentOptions();
return useMutation({
mutationFn: (id: string) => api.forms.paymentOptions.remove(edition, id),
onSuccess: invalidate,
});
}
/** URL objet (aperçu) du logo du formulaire public, ou `null` si absent.
* `GET .../form/logo` exige `token` (voir `_require_access_token` côté
* back-end) — celui de l'édition pour l'écran de configuration
* (COORDINATION, `template.access_token`), celui de l'URL pour la page
* publique (`/inscription/:token`) ; `null` tant qu'il n'est pas connu.
* `refreshKey` (ex. `dataUpdatedAt` de `useFormTemplate`) force un nouveau
* fetch après un changement de logo. */
export function useFormLogoUrl(
hasLogo: boolean,
refreshKey: number | string,
token: string | null,
) {
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let objectUrl: string | null = null;
let cancelled = false;
if (!hasLogo || !token) {
setUrl(null);
return;
}
api.forms.logoBlob(edition, token).then((blob) => {
if (cancelled || !blob) return;
objectUrl = URL.createObjectURL(blob);
setUrl(objectUrl);
});
return () => {
cancelled = true;
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [hasLogo, refreshKey, token]);
return url;
}
/** Configuration publique du formulaire — utilisable sans compte connecté
* (écran `/inscription/:token`, hors de `RequireAuth`). `token` vient de
* l'URL (voir `PublicSignupPage`) ; requis par le back-end en plus de
* l'édition (`_require_access_token`). */
export function usePublicFormConfig(token: string) {
return useQuery({
queryKey: ['publicFormConfig', edition, token],
queryFn: () => api.forms.getPublicConfig(edition, token),
});
}
export function useSubmitPublicForm(token: string) {
return useMutation({
mutationFn: (body: PublicFormSubmission) => api.forms.submitPublic(edition, token, body),
});
}
export function useSubmitMecenatInfo() {
return useMutation({
mutationFn: ({ token, body }: { token: string; body: MecenatInfoSubmit }) =>
api.forms.submitMecenatInfo(edition, token, body),
});
}
/** Statut d'une inscription en attente de paiement en ligne — interrogé en
* direct (voir CLAUDE.md § 2, Paiement de la cotisation) : la page de
* confirmation reste en attente tant que `status` vaut `PENDING`. */
export function usePendingStatus(token: string | null) {
return useQuery({
queryKey: ['pendingStatus', edition, token],
queryFn: () => api.forms.getPendingStatus(edition, token as string),
enabled: token !== null,
// Arrête la vérification automatique après ~2 min (40 x 3 s) : au-delà,
// un bouton manuel dans PaymentConfirmationView permet de réessayer.
refetchInterval: (query) =>
query.state.data?.status === 'PENDING' && query.state.dataUpdateCount < 40 ? 3000 : false,
});
}
export function useRetryPendingPayment() {
return useMutation({
mutationFn: (token: string) => api.forms.retryPendingPayment(edition, token),
});
}