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(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(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>, ) { 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(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), }); }