Files
benevoles/frontend/src/api/hooks.ts
T
AdamThomasandClaude Sonnet 5 096b7cd26a
ci-cd / build-backend (push) Successful in 1m12s
ci-cd / build-frontend (push) Successful in 18s
ci-cd / deploy (push) Successful in 12s
Lien d'inscription protégé par jeton, reçus fiscaux mécénat, infobulles et revue de sécurité
- Lien d'inscription protégé par un jeton (FormTemplate.access_token,
  /inscription/<token>), avec régénération depuis l'écran de configuration.
- Reçus fiscaux mécénat (cerfa 11580*05) : un PDF par bénévole ayant payé en
  mécénat, donateur/montant (chiffres et toutes lettres en français,
  app/mecenat/number_words.py)/date complétés par-dessus le modèle officiel.
- Jeu de démonstration : bénévoles avec cotisation classique ou mécénat payée.
- Infobulles (HelpTip et détail de cellule de la grille des besoins)
  repositionnées en position: fixed via JS, bornées à l'écran.
- Revue de sécurité : logo des emails en pièce jointe inline au lieu d'une
  URL (fuyait le jeton d'inscription), sujet retiré du log de remise des
  emails (pouvait porter un nom de bénévole), photo du formulaire public
  revalidée par décodage réel avant stockage, export Excel protégé contre
  l'injection de formule, sondage du statut de paiement limité en
  fréquence, pyjwt mis à jour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 07:47:17 +02:00

781 lines
24 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,
AvailabilitySet,
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),
});
}
/** 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 },
) {
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) });
}
/** 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] }),
availability: () => client.invalidateQueries({ queryKey: ['availability', 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),
});
}
/** 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({
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),
});
}