diff --git a/frontend/src/components/admin/AdminGuestsList.tsx b/frontend/src/components/admin/AdminGuestsList.tsx index 53344802..d21c1968 100644 --- a/frontend/src/components/admin/AdminGuestsList.tsx +++ b/frontend/src/components/admin/AdminGuestsList.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react'; import { Card, Button, Loading } from '../common'; @@ -9,6 +9,7 @@ import { GuestSelectionsAggregate } from './GuestSelectionsAggregate'; import { GuestInviteDialog } from './GuestInviteDialog'; import { toast } from 'react-toastify'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { useMutationWithToast, useModal } from '../../hooks'; interface AdminGuestsListProps { eventId: number; @@ -20,37 +21,34 @@ type View = 'list' | 'aggregate'; export const AdminGuestsList: React.FC = ({ eventId, eventName }) => { const { t } = useTranslation(); const { format: fmtDate } = useLocalizedDate(); - const queryClient = useQueryClient(); const [view, setView] = useState('list'); const [selectedGuest, setSelectedGuest] = useState(null); const [mergeMode, setMergeMode] = useState(false); const [mergeSelection, setMergeSelection] = useState([]); - const [inviteDialogOpen, setInviteDialogOpen] = useState(false); + const inviteModal = useModal(); const { data, isLoading, refetch } = useQuery({ queryKey: ['admin-guests', eventId], queryFn: () => guestsService.getEventGuests(eventId), }); - const deleteMutation = useMutation({ + const deleteMutation = useMutationWithToast({ mutationFn: (guestId: number) => guestsService.deleteGuest(eventId, guestId), - onSuccess: () => { - toast.success(t('admin.guests.deletedToast', 'Guest removed')); - queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] }); - }, - onError: () => toast.error(t('admin.guests.deletedError', 'Failed to remove guest')), + successMessage: t('admin.guests.deletedToast', 'Guest removed'), + invalidateKeys: [['admin-guests', eventId]], + errorMessage: () => t('admin.guests.deletedError', 'Failed to remove guest'), }); - const mergeMutation = useMutation({ + const mergeMutation = useMutationWithToast({ mutationFn: ({ keepId, mergeIds }: { keepId: number; mergeIds: number[] }) => guestsService.mergeGuests(eventId, keepId, mergeIds), + successMessage: t('admin.guests.mergedToast', 'Guests merged'), + invalidateKeys: [['admin-guests', eventId]], onSuccess: () => { - toast.success(t('admin.guests.mergedToast', 'Guests merged')); setMergeMode(false); setMergeSelection([]); - queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] }); }, - onError: () => toast.error(t('admin.guests.mergedError', 'Failed to merge guests')), + errorMessage: () => t('admin.guests.mergedError', 'Failed to merge guests'), }); const handleDelete = (guest: AdminGuest) => { @@ -160,7 +158,7 @@ export const AdminGuestsList: React.FC = ({ eventId, event variant="outline" size="sm" leftIcon={} - onClick={() => setInviteDialogOpen(true)} + onClick={inviteModal.open} > {t('admin.guests.createInvite', 'Create invite')} @@ -331,12 +329,12 @@ export const AdminGuestsList: React.FC = ({ eventId, event /> )} - {inviteDialogOpen && ( + {inviteModal.isOpen && ( { - setInviteDialogOpen(false); + inviteModal.close(); refetch(); }} /> diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index bf2402b5..0f4c22c5 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -9,6 +9,7 @@ import { useAdminAuth } from '../../contexts'; import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext'; import { useOnClickOutside } from '../../hooks/useOnClickOutside'; import { usePublicSettings } from '../../hooks/usePublicSettings'; +import { useModal } from '../../hooks'; import { PasswordChangeModal } from './PasswordChangeModal'; import { LanguageSelector, SUPPORTED_LANGUAGES } from '../common'; import { notificationsService } from '../../services/notifications.service'; @@ -25,10 +26,10 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode(); const { t, i18n } = useTranslation(); const { format, formatDistanceToNow } = useLocalizedDate(); - const [showUserMenu, setShowUserMenu] = useState(false); - const [showUserMenuLangSection, setShowUserMenuLangSection] = useState(false); - const [showNotifications, setShowNotifications] = useState(false); - const [showPasswordModal, setShowPasswordModal] = useState(false); + const userMenuModal = useModal(); + const userMenuLangSectionModal = useModal(); + const notificationsModal = useModal(); + const passwordModal = useModal(); const queryClient = useQueryClient(); const { data: brandingSettings, isLoading: brandingLoading } = usePublicSettings(); @@ -138,8 +139,8 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { // outer dropdown closes, so re-opening it doesn't surprise the user // with the language list already expanded from the previous session. const closeUserMenu = () => { - setShowUserMenu(false); - setShowUserMenuLangSection(false); + userMenuModal.close(); + userMenuLangSectionModal.close(); }; const handleUserMenuLangSelect = (languageCode: string) => { i18n.changeLanguage(languageCode); @@ -150,7 +151,7 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { const notificationRef = useRef(null); useOnClickOutside(userMenuRef, closeUserMenu); - useOnClickOutside(notificationRef, () => setShowNotifications(false)); + useOnClickOutside(notificationRef, notificationsModal.close); const handleLogout = () => { logout(); @@ -159,8 +160,8 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { // Fetch notifications const { data: notificationsData } = useQuery({ - queryKey: ['notifications', showNotifications], - queryFn: () => notificationsService.getNotifications(showNotifications, 20), + queryKey: ['notifications', notificationsModal.isOpen], + queryFn: () => notificationsService.getNotifications(notificationsModal.isOpen, 20), refetchInterval: 60000, // Refetch every minute }); @@ -297,7 +298,7 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { {/* Notifications */}
{/* Notifications dropdown */} - {showNotifications && ( + {notificationsModal.isOpen && (

{t('admin.notifications')}

@@ -368,7 +369,7 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { {notifications.length > 0 && (
{/* User dropdown */} - {showUserMenu && ( + {userMenuModal.isOpen && (

{user?.username}

@@ -408,16 +409,16 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { so the menu isn't 8 rows taller by default. */}
- {showUserMenuLangSection && ( + {userMenuLangSectionModal.isOpen && (
{SUPPORTED_LANGUAGES.map((language) => (
{/* Password Change Modal */} - setShowPasswordModal(false)} + ); diff --git a/frontend/src/components/admin/AdminPhotoViewer.tsx b/frontend/src/components/admin/AdminPhotoViewer.tsx index 00cbfc2a..e36b8ba8 100644 --- a/frontend/src/components/admin/AdminPhotoViewer.tsx +++ b/frontend/src/components/admin/AdminPhotoViewer.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { X, ChevronLeft, ChevronRight, Download, Trash2, Tag, Calendar, HardDrive, Eye, MousePointer, MessageSquare, Star, Heart, CheckCircle, XCircle, AlertCircle } from 'lucide-react'; import { toast } from 'react-toastify'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { AdminPhoto } from '../../services/photos.service'; import { photosService } from '../../services/photos.service'; @@ -10,6 +10,7 @@ import { Button } from '../common'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { useMutationWithToast, useModal } from '../../hooks'; type AdminFeedbackResponse = { feedback: PhotoFeedback[]; @@ -35,8 +36,8 @@ export const AdminPhotoViewer: React.FC = ({ }) => { const [currentIndex, setCurrentIndex] = useState(initialIndex); const [isDeleting, setIsDeleting] = useState(false); - const [showCategoryMenu, setShowCategoryMenu] = useState(false); - const [expandedComments, setExpandedComments] = useState(false); + const categoryMenuModal = useModal(); + const commentsModal = useModal(); const queryClient = useQueryClient(); const { formatDateTime: fmtDateTime } = useLocalizedDate(); @@ -115,7 +116,7 @@ export const AdminPhotoViewer: React.FC = ({ try { await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId); toast.success('Category updated'); - setShowCategoryMenu(false); + categoryMenuModal.close(); // Invalidate photos query to refresh data await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId.toString()] }); await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId] }); @@ -127,27 +128,19 @@ export const AdminPhotoViewer: React.FC = ({ }; // Mutations for feedback moderation - const moderateFeedbackMutation = useMutation({ + const moderateFeedbackMutation = useMutationWithToast({ mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) => feedbackService.moderateFeedback(feedbackId, action), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id] }); - toast.success('Feedback moderated successfully'); - }, - onError: () => { - toast.error('Failed to moderate feedback'); - } + invalidateKeys: [['admin-photo-feedback', eventId, currentPhoto?.id]], + successMessage: 'Feedback moderated successfully', + errorMessage: () => 'Failed to moderate feedback' }); - const deleteFeedbackMutation = useMutation({ + const deleteFeedbackMutation = useMutationWithToast({ mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id] }); - toast.success('Feedback deleted successfully'); - }, - onError: () => { - toast.error('Failed to delete feedback'); - } + invalidateKeys: [['admin-photo-feedback', eventId, currentPhoto?.id]], + successMessage: 'Feedback deleted successfully', + errorMessage: () => 'Failed to delete feedback' }); React.useEffect(() => { @@ -266,7 +259,7 @@ export const AdminPhotoViewer: React.FC = ({ Category - - {expandedComments && ( + + {commentsModal.isOpen && (
{comments.map((comment) => (
diff --git a/frontend/src/components/admin/BackupHistory.jsx b/frontend/src/components/admin/BackupHistory.jsx index 08cc1aab..f220241f 100644 --- a/frontend/src/components/admin/BackupHistory.jsx +++ b/frontend/src/components/admin/BackupHistory.jsx @@ -20,10 +20,10 @@ import { RefreshCw, Loader2 } from 'lucide-react'; -import { toast } from 'react-toastify'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { Button, Card, Input, Loading } from '../common'; import { api } from '../../config/api'; +import { useMutationWithToast } from '../../hooks'; // Per [[feedback_respect_general_format_settings]]: route every displayed // date/time through useLocalizedDate so the admin's general_date_format + // general_time_format settings apply uniformly. Previously the backup @@ -53,7 +53,6 @@ export const BackupHistory = () => { const [searchTerm, setSearchTerm] = useState(''); const [filterStatus, setFilterStatus] = useState('all'); const [currentPage, setCurrentPage] = useState(1); - const queryClient = useQueryClient(); // Locale-aware formatters that respect admin's general_date_format + // general_time_format settings. See useLocalizedDate.ts for the full // contract; formatTime gives "HH:mm" (24h) or "h:mm a" (12h) based on @@ -77,18 +76,14 @@ export const BackupHistory = () => { }); // Delete backup mutation - const deleteMutation = useMutation({ + const deleteMutation = useMutationWithToast({ mutationFn: async (backupId) => { const response = await api.delete(`/admin/backup/runs/${backupId}`); return response.data; }, - onSuccess: () => { - toast.success('Backup deleted successfully'); - queryClient.invalidateQueries({ queryKey: ['backup-history'] }); - }, - onError: (error) => { - toast.error(error.response?.data?.error || 'Failed to delete backup'); - } + successMessage: 'Backup deleted successfully', + invalidateKeys: [['backup-history']], + errorMessage: 'Failed to delete backup' }); const toggleRowExpansion = (id) => { diff --git a/frontend/src/components/admin/CategoryManager.tsx b/frontend/src/components/admin/CategoryManager.tsx index 3b928ec0..efc1ee2e 100644 --- a/frontend/src/components/admin/CategoryManager.tsx +++ b/frontend/src/components/admin/CategoryManager.tsx @@ -1,15 +1,14 @@ import React, { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react'; -import { toast } from 'react-toastify'; import { useTranslation } from 'react-i18next'; import { categoriesService, type PhotoCategory } from '../../services/categories.service'; import { Button } from '../common'; +import { useMutationWithToast, useModal } from '../../hooks'; export const CategoryManager: React.FC = () => { const { t } = useTranslation(); - const queryClient = useQueryClient(); - const [isAdding, setIsAdding] = useState(false); + const addingModal = useModal(); const [editingId, setEditingId] = useState(null); const [newCategoryName, setNewCategoryName] = useState(''); const [editingName, setEditingName] = useState(''); @@ -21,45 +20,37 @@ export const CategoryManager: React.FC = () => { }); // Create category mutation - const createMutation = useMutation({ - mutationFn: (name: string) => + const createMutation = useMutationWithToast({ + mutationFn: (name: string) => categoriesService.createCategory({ name, is_global: true }), + invalidateKeys: [['global-categories']], + successMessage: t('categories.categoryCreatedSuccess'), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['global-categories'] }); - toast.success(t('categories.categoryCreatedSuccess')); setNewCategoryName(''); - setIsAdding(false); - }, - onError: (error: any) => { - toast.error(error.response?.data?.error || t('categories.failedToCreateCategory')); + addingModal.close(); }, + errorMessage: t('categories.failedToCreateCategory'), }); // Update category mutation - const updateMutation = useMutation({ - mutationFn: ({ id, name }: { id: number; name: string }) => + const updateMutation = useMutationWithToast({ + mutationFn: ({ id, name }: { id: number; name: string }) => categoriesService.updateCategory(id, name), + invalidateKeys: [['global-categories']], + successMessage: t('toast.categoryUpdated'), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['global-categories'] }); - toast.success(t('toast.categoryUpdated')); setEditingId(null); setEditingName(''); }, - onError: (error: any) => { - toast.error(error.response?.data?.error || t('toast.saveError')); - }, + errorMessage: t('toast.saveError'), }); // Delete category mutation - const deleteMutation = useMutation({ + const deleteMutation = useMutationWithToast({ mutationFn: categoriesService.deleteCategory, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['global-categories'] }); - toast.success(t('categories.categoryDeletedSuccess')); - }, - onError: (error: any) => { - toast.error(error.response?.data?.error || t('categories.failedToDeleteCategory')); - }, + invalidateKeys: [['global-categories']], + successMessage: t('categories.categoryDeletedSuccess'), + errorMessage: t('categories.failedToDeleteCategory'), }); const handleCreate = () => { @@ -102,11 +93,11 @@ export const CategoryManager: React.FC = () => {

{t('categories.title')}

- {!isAdding && ( + {!addingModal.isOpen && (
{/* Add new category form */} - {isAdding && ( + {addingModal.isOpen && (
{ variant="secondary" size="sm" onClick={() => { - setIsAdding(false); + addingModal.close(); setNewCategoryName(''); }} > diff --git a/frontend/src/components/admin/ChartOfAccountsManager.tsx b/frontend/src/components/admin/ChartOfAccountsManager.tsx index 562ee617..d011a492 100644 --- a/frontend/src/components/admin/ChartOfAccountsManager.tsx +++ b/frontend/src/components/admin/ChartOfAccountsManager.tsx @@ -13,15 +13,15 @@ * VatCodesManager. Scoping each patch keeps the two from reverting each other. */ import React, { useEffect, useMemo, useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { toast } from 'react-toastify'; import { X, Plus, Pencil, Trash2, AlertCircle } from 'lucide-react'; import { Button, Card, CardContent, Input, Loading } from '../common'; import { ledgerService, type LedgerAccount, type AccountType, type LedgerSettings, } from '../../services/ledger.service'; import { categoryLabel } from '../../services/accounting.service'; +import { useMutationWithToast } from '../../hooks'; const ACCOUNT_TYPES: AccountType[] = ['asset', 'liability', 'equity', 'revenue', 'expense']; const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1'; @@ -39,10 +39,11 @@ const AccountModal: React.FC<{ account?: LedgerAccount; onClose: () => void; onD const [number, setNumber] = useState(account?.number ?? ''); const [name, setName] = useState(account?.name ?? ''); const [type, setType] = useState(account?.type ?? 'expense'); - const save = useMutation({ + const save = useMutationWithToast({ mutationFn: () => isEdit ? ledgerService.updateAccount(account!.id, { number, name, type }) : ledgerService.createAccount({ number, name, type }), - onSuccess: () => { toast.success(t('common.saved', 'Saved.')); onDone(); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + successMessage: t('common.saved', 'Saved.'), + onSuccess: () => onDone(), + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', }); return (
@@ -85,27 +86,29 @@ export const ChartOfAccountsManager: React.FC = () => { const refetchAll = () => { qc.invalidateQueries({ queryKey: ['ledger-accounts'] }); qc.invalidateQueries({ queryKey: ['ledger-vat-codes'] }); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); }; - const delAccount = useMutation({ + const delAccount = useMutationWithToast({ mutationFn: (id: number) => ledgerService.deleteAccount(id), - onSuccess: () => { toast.success(t('common.deleted', 'Deleted.')); refetchAll(); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + successMessage: t('common.deleted', 'Deleted.'), + onSuccess: () => refetchAll(), + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', }); - const setCat = useMutation({ + const setCat = useMutationWithToast({ mutationFn: ({ id, accId }: { id: number; accId: number | null }) => ledgerService.setCategoryAccount(id, accId), - onSuccess: () => { qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + invalidateKeys: [['ledger-mappings']], + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', }); // Save ONLY the account keys — the VAT maps are owned by VatCodesManager and // updateSettings is a partial merge, so scoping the patch here prevents a // stale full-settings save from reverting the maps. - const saveSettings = useMutation({ + const saveSettings = useMutationWithToast({ mutationFn: () => { const patch: Partial = {}; for (const k of SETTING_ACCOUNT_KEYS) patch[k] = settings[k]; return ledgerService.updateSettings(patch); }, - onSuccess: () => { toast.success(t('ledger.settingsSaved', 'Mappings saved.')); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + successMessage: t('ledger.settingsSaved', 'Mappings saved.'), + invalidateKeys: [['ledger-mappings']], + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', }); const setAcctSetting = (key: keyof LedgerSettings, value: string) => setSettings((s) => ({ ...s, [key]: value })); diff --git a/frontend/src/components/admin/CssTemplateEditor.tsx b/frontend/src/components/admin/CssTemplateEditor.tsx index 47d1c613..f2f23089 100644 --- a/frontend/src/components/admin/CssTemplateEditor.tsx +++ b/frontend/src/components/admin/CssTemplateEditor.tsx @@ -6,6 +6,7 @@ import { Save, RotateCcw, Code, AlertTriangle, Check } from 'lucide-react'; import { Button, Card, Loading } from '../common'; import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { useMutationWithToast } from '../../hooks'; export const CssTemplateEditor: React.FC = () => { const { t } = useTranslation(); @@ -57,15 +58,11 @@ export const CssTemplateEditor: React.FC = () => { }); // Reset mutation - const resetMutation = useMutation({ + const resetMutation = useMutationWithToast({ mutationFn: () => cssTemplatesService.resetToDefault(), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['css-templates'] }); - toast.success(t('cssTemplates.reset', 'Template reset to default')); - }, - onError: (error: Error) => { - toast.error(error.message || t('cssTemplates.resetFailed', 'Failed to reset template')); - } + invalidateKeys: [['css-templates']], + successMessage: t('cssTemplates.reset', 'Template reset to default'), + errorMessage: (error: Error) => error.message || t('cssTemplates.resetFailed', 'Failed to reset template') }); const activeTemplate = localTemplates.find(t => t.slot_number === activeSlot); diff --git a/frontend/src/components/admin/CustomerDashboardBrandingCard.tsx b/frontend/src/components/admin/CustomerDashboardBrandingCard.tsx index a9f51d71..cb70b8af 100644 --- a/frontend/src/components/admin/CustomerDashboardBrandingCard.tsx +++ b/frontend/src/components/admin/CustomerDashboardBrandingCard.tsx @@ -14,11 +14,11 @@ */ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'react-toastify'; +import { useQuery } from '@tanstack/react-query'; import { Save, Image as ImageIcon, Type, UserCog } from 'lucide-react'; import { Button, Card, Loading } from '../common'; import { api } from '../../config/api'; +import { useMutationWithToast } from '../../hooks'; interface CustomerSurfaceSettings { customer_show_logo: boolean; @@ -74,7 +74,6 @@ const Toggle: React.FC = ({ enabled, onChange, label, hint, icon: I export const CustomerDashboardBrandingCard: React.FC = () => { const { t } = useTranslation(); - const qc = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: ['admin-settings-customer-surface'], @@ -87,17 +86,14 @@ export const CustomerDashboardBrandingCard: React.FC = () => { const [form, setForm] = useState(DEFAULTS); useEffect(() => { if (data) setForm(data); }, [data]); - const saveMutation = useMutation({ + const saveMutation = useMutationWithToast({ mutationFn: () => api.put('/admin/settings/customer-surface', form), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['admin-settings-customer-surface'] }); - // The customer-side session response (/api/customer/auth/session) - // also bundles these as branding flags — invalidate so a customer - // tab refresh picks up the new visibility on the next focus. - qc.invalidateQueries({ queryKey: ['public-settings'] }); - toast.success(t('settings.customerSurface.saved', 'Customer dashboard branding saved')); - }, - onError: () => toast.error(t('settings.customerSurface.error', 'Could not save settings')), + // The customer-side session response (/api/customer/auth/session) + // also bundles these as branding flags — invalidate public-settings so + // a customer tab refresh picks up the new visibility on the next focus. + invalidateKeys: [['admin-settings-customer-surface'], ['public-settings']], + successMessage: t('settings.customerSurface.saved', 'Customer dashboard branding saved'), + errorMessage: () => t('settings.customerSurface.error', 'Could not save settings'), }); const toggle = (key: keyof CustomerSurfaceSettings) => { diff --git a/frontend/src/components/admin/EventCategoryManager.tsx b/frontend/src/components/admin/EventCategoryManager.tsx index 72845927..80562f1e 100644 --- a/frontend/src/components/admin/EventCategoryManager.tsx +++ b/frontend/src/components/admin/EventCategoryManager.tsx @@ -1,20 +1,19 @@ import React, { useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud } from 'lucide-react'; -import { toast } from 'react-toastify'; import { categoriesService, type PhotoCategory } from '../../services/categories.service'; import { photosService } from '../../services/photos.service'; import { Button, Card, AuthenticatedImage } from '../common'; import { useTranslation } from 'react-i18next'; +import { useMutationWithToast, useModal } from '../../hooks'; interface EventCategoryManagerProps { eventId: number; } export const EventCategoryManager: React.FC = ({ eventId }) => { - const queryClient = useQueryClient(); const { t } = useTranslation(); - const [isAdding, setIsAdding] = useState(false); + const addingModal = useModal(); const [newCategoryName, setNewCategoryName] = useState(''); const [heroPickerCategoryId, setHeroPickerCategoryId] = useState(null); @@ -35,67 +34,55 @@ export const EventCategoryManager: React.FC = ({ even const eventCategories = categories.filter(cat => !cat.is_global); // Create category mutation - const createMutation = useMutation({ + const createMutation = useMutationWithToast({ mutationFn: (name: string) => categoriesService.createCategory({ name, is_global: false, event_id: eventId }), + invalidateKeys: [['event-categories', eventId]], + successMessage: t('categories.categoryCreatedSuccess'), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] }); - toast.success(t('categories.categoryCreatedSuccess')); setNewCategoryName(''); - setIsAdding(false); - }, - onError: (error: any) => { - toast.error(error.response?.data?.error || t('categories.failedToCreateCategory')); + addingModal.close(); }, + errorMessage: t('categories.failedToCreateCategory'), }); // Delete category mutation - const deleteMutation = useMutation({ + const deleteMutation = useMutationWithToast({ mutationFn: categoriesService.deleteCategory, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] }); - toast.success(t('categories.categoryDeletedSuccess')); - }, - onError: (error: any) => { - toast.error(error.response?.data?.error || t('categories.failedToDeleteCategory')); - }, + invalidateKeys: [['event-categories', eventId]], + successMessage: t('categories.categoryDeletedSuccess'), + errorMessage: t('categories.failedToDeleteCategory'), }); // Set hero photo mutation - const heroMutation = useMutation({ + const heroMutation = useMutationWithToast({ mutationFn: ({ categoryId, photoId }: { categoryId: number; photoId: number | null }) => categoriesService.setCategoryHeroPhoto(categoryId, photoId), - onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] }); + invalidateKeys: [['event-categories', eventId]], + successMessage: (_data, variables) => + variables.photoId ? t('categories.coverPhotoSet') : t('categories.coverPhotoRemoved'), + onSuccess: () => { setHeroPickerCategoryId(null); - toast.success(variables.photoId ? t('categories.coverPhotoSet') : t('categories.coverPhotoRemoved')); - }, - onError: (error: any) => { - toast.error(error.response?.data?.error || t('categories.failedToSetCoverPhoto')); }, + errorMessage: t('categories.failedToSetCoverPhoto'), }); // Toggle per-category download permission (#640). The backend AND's this // with the event-level `allow_downloads`, so disabling at either level // blocks downloads for this category's photos. - const downloadToggleMutation = useMutation({ + const downloadToggleMutation = useMutationWithToast({ mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) => categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }), - onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] }); - toast.success( - variables.allow - ? t('categories.downloadsEnabled', 'Downloads enabled for this category') - : t('categories.downloadsDisabled', 'Downloads disabled for this category') - ); - }, - onError: (error: any) => { - toast.error(error.response?.data?.error || t('categories.failedToToggleDownloads', 'Failed to update download permission')); - }, + invalidateKeys: [['event-categories', eventId]], + successMessage: (_data, variables) => + variables.allow + ? t('categories.downloadsEnabled', 'Downloads enabled for this category') + : t('categories.downloadsDisabled', 'Downloads disabled for this category'), + errorMessage: t('categories.failedToToggleDownloads', 'Failed to update download permission'), }); const handleCreate = () => { @@ -130,11 +117,11 @@ export const EventCategoryManager: React.FC = ({ even

{t('categories.eventSpecificCategories')}

- {!isAdding && ( + {!addingModal.isOpen && (
diff --git a/frontend/src/components/admin/PhotoExportMenu.tsx b/frontend/src/components/admin/PhotoExportMenu.tsx index 1448edc0..bb1bd267 100644 --- a/frontend/src/components/admin/PhotoExportMenu.tsx +++ b/frontend/src/components/admin/PhotoExportMenu.tsx @@ -5,6 +5,7 @@ import { useMutation } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import { photosService, ExportOptions, FeedbackFilters } from '../../services/photos.service'; import { ExportPreviewModal } from './ExportPreviewModal'; +import { useMutationWithToast, useModal } from '../../hooks'; // TXT + CSV render through the preview modal (with copy-to-clipboard and a // fallback download button). XMP is a ZIP archive — no textarea preview makes @@ -53,22 +54,20 @@ export const PhotoExportMenu: React.FC = ({ disabled = false }) => { const { t } = useTranslation(); - const [isOpen, setIsOpen] = useState(false); + const menuModal = useModal(); const [preview, setPreview] = useState<{ format: 'txt' | 'csv'; content: string; filename: string; } | null>(null); - const exportMutation = useMutation({ + const exportMutation = useMutationWithToast({ mutationFn: (options: ExportOptions) => photosService.exportPhotos(eventId, options), + successMessage: t('export.success', 'Export downloaded successfully'), onSuccess: () => { - toast.success(t('export.success', 'Export downloaded successfully')); - setIsOpen(false); + menuModal.close(); }, - onError: (error: Error) => { - toast.error(t('export.error', 'Export failed: ') + error.message); - } + errorMessage: (error: Error) => t('export.error', 'Export failed: ') + error.message }); const previewMutation = useMutation({ @@ -79,7 +78,7 @@ export const PhotoExportMenu: React.FC = ({ })), onSuccess: (result) => { setPreview(result); - setIsOpen(false); + menuModal.close(); }, onError: (error: Error) => { toast.error(t('export.error', 'Export failed: ') + error.message); @@ -145,7 +144,7 @@ export const PhotoExportMenu: React.FC = ({
- {isOpen && !isDisabled && ( + {menuModal.isOpen && !isDisabled && ( <> {/* Backdrop */}
setIsOpen(false)} + onClick={menuModal.close} /> {/* Dropdown Menu */} diff --git a/frontend/src/components/admin/VatCodesManager.tsx b/frontend/src/components/admin/VatCodesManager.tsx index 2c544dc2..d6b32c24 100644 --- a/frontend/src/components/admin/VatCodesManager.tsx +++ b/frontend/src/components/admin/VatCodesManager.tsx @@ -8,14 +8,14 @@ * keys, and the two never overwrite each other. */ import React, { useEffect, useMemo, useState } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { toast } from 'react-toastify'; import { X, Plus, Pencil, Trash2 } from 'lucide-react'; import { Button, Card, CardContent, Input, Loading } from '../common'; import { ledgerService, type LedgerAccount, type VatCode, type VatDirection, type LedgerSettings, } from '../../services/ledger.service'; +import { useMutationWithToast } from '../../hooks'; const labelCls = 'block text-xs font-medium text-neutral-700 dark:text-neutral-300 mb-1'; const selectCls = 'w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 px-3 py-2 text-sm'; @@ -39,13 +39,14 @@ const VatModal: React.FC<{ vat?: VatCode; accounts: LedgerAccount[]; onClose: () const [rate, setRate] = useState(vat ? String(vat.rate) : '8.1'); const [direction, setDirection] = useState(vat?.direction ?? 'input'); const [accountId, setAccountId] = useState(vat?.account_id ?? ''); - const save = useMutation({ + const save = useMutationWithToast({ mutationFn: () => { const payload = { code, name, rate: Number(rate) || 0, direction, accountId: accountId === '' ? null : Number(accountId) }; return isEdit ? ledgerService.updateVatCode(vat!.id, payload) : ledgerService.createVatCode(payload); }, - onSuccess: () => { toast.success(t('common.saved', 'Saved.')); onDone(); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + successMessage: t('common.saved', 'Saved.'), + onSuccess: () => onDone(), + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', }); return (
@@ -122,19 +123,21 @@ export const VatCodesManager: React.FC = () => { const refetch = () => { qc.invalidateQueries({ queryKey: ['ledger-vat-codes'] }); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); }; - const delVat = useMutation({ + const delVat = useMutationWithToast({ mutationFn: (id: number) => ledgerService.deleteVatCode(id), - onSuccess: () => { toast.success(t('common.deleted', 'Deleted.')); refetch(); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + successMessage: t('common.deleted', 'Deleted.'), + onSuccess: () => refetch(), + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', }); // PARTIAL save — only the two map keys, never the account keys. - const saveMaps = useMutation({ + const saveMaps = useMutationWithToast({ mutationFn: () => ledgerService.updateSettings({ ledger_vat_map: maps.ledger_vat_map || {}, ledger_output_vat_map: maps.ledger_output_vat_map || {}, }), - onSuccess: () => { toast.success(t('ledger.settingsSaved', 'Mappings saved.')); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + successMessage: t('ledger.settingsSaved', 'Mappings saved.'), + invalidateKeys: [['ledger-mappings']], + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', }); const setVatMap = (tt: string, code: string) => setMaps((s) => ({ ...s, ledger_vat_map: { ...(s.ledger_vat_map || {}), [tt]: code } })); diff --git a/frontend/src/components/admin/WhatsNewBanner.tsx b/frontend/src/components/admin/WhatsNewBanner.tsx index 7c59bd00..5d5100b9 100644 --- a/frontend/src/components/admin/WhatsNewBanner.tsx +++ b/frontend/src/components/admin/WhatsNewBanner.tsx @@ -14,11 +14,12 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Sparkles, X, ExternalLink, ChevronRight } from 'lucide-react'; import { adminService } from '../../services/admin.service'; +import { useModal } from '../../hooks'; export const WhatsNewBanner: React.FC = () => { const { t } = useTranslation(); const qc = useQueryClient(); - const [open, setOpen] = useState(false); + const detailsModal = useModal(); const [hidden, setHidden] = useState(false); const { data } = useQuery({ @@ -31,7 +32,7 @@ export const WhatsNewBanner: React.FC = () => { mutationFn: () => adminService.markWhatsNewSeen(), onSuccess: () => { setHidden(true); - setOpen(false); + detailsModal.close(); qc.invalidateQueries({ queryKey: ['whatsnew'] }); }, }); @@ -56,7 +57,7 @@ export const WhatsNewBanner: React.FC = () => {
- {open && ( + {detailsModal.isOpen && (
setOpen(false)} + onClick={detailsModal.close} >
{ {t('admin.whatsnew.modalTitle', "What's new")} -
diff --git a/frontend/src/components/admin/WordFilterManager.tsx b/frontend/src/components/admin/WordFilterManager.tsx index 3780339a..f6d6aadf 100644 --- a/frontend/src/components/admin/WordFilterManager.tsx +++ b/frontend/src/components/admin/WordFilterManager.tsx @@ -15,6 +15,7 @@ import { import { toast } from 'react-toastify'; import { Card, Button, Input, Loading } from '../common'; import { feedbackService } from '../../services/feedback.service'; +import { useMutationWithToast } from '../../hooks'; interface WordFilter { id: number; @@ -62,29 +63,23 @@ export const WordFilterManager: React.FC = () => { }); // Update word filter mutation - const updateMutation = useMutation({ + const updateMutation = useMutationWithToast({ mutationFn: ({ id, updates }: { id: number; updates: Partial }) => feedbackService.updateWordFilter(id, updates), + invalidateKeys: [['word-filters']], + successMessage: t('settings.moderation.filterUpdated', 'Word filter updated successfully'), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['word-filters'] }); - toast.success(t('settings.moderation.filterUpdated', 'Word filter updated successfully')); setEditingId(null); }, - onError: () => { - toast.error(t('settings.moderation.updateError', 'Failed to update word filter')); - } + errorMessage: () => t('settings.moderation.updateError', 'Failed to update word filter') }); // Delete word filter mutation - const deleteMutation = useMutation({ + const deleteMutation = useMutationWithToast({ mutationFn: (id: number) => feedbackService.deleteWordFilter(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['word-filters'] }); - toast.success(t('settings.moderation.filterDeleted', 'Word filter deleted successfully')); - }, - onError: () => { - toast.error(t('settings.moderation.deleteError', 'Failed to delete word filter')); - } + invalidateKeys: [['word-filters']], + successMessage: t('settings.moderation.filterDeleted', 'Word filter deleted successfully'), + errorMessage: () => t('settings.moderation.deleteError', 'Failed to delete word filter') }); const handleAdd = () => { diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 9a8ed8f1..7221ab0a 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -2,4 +2,6 @@ export * from './useSessionTimeout'; export * from './useOnClickOutside'; export * from './useLocalizedDate'; export * from './usePermission'; -export * from './usePublicSettings'; \ No newline at end of file +export * from './usePublicSettings'; +export * from './useMutationWithToast'; +export * from './useModal'; \ No newline at end of file diff --git a/frontend/src/hooks/useModal.ts b/frontend/src/hooks/useModal.ts new file mode 100644 index 00000000..17d9a356 --- /dev/null +++ b/frontend/src/hooks/useModal.ts @@ -0,0 +1,20 @@ +import { useCallback, useState } from 'react'; + +export interface UseModalResult { + isOpen: boolean; + open: () => void; + close: () => void; + toggle: () => void; +} + +/** + * Small helper for the ubiquitous `const [showX, setShowX] = useState(false)` + * modal open/close flag. + */ +export function useModal(initialOpen = false): UseModalResult { + const [isOpen, setIsOpen] = useState(initialOpen); + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + const toggle = useCallback(() => setIsOpen((prev) => !prev), []); + return { isOpen, open, close, toggle }; +} diff --git a/frontend/src/hooks/useMutationWithToast.ts b/frontend/src/hooks/useMutationWithToast.ts new file mode 100644 index 00000000..67bd9408 --- /dev/null +++ b/frontend/src/hooks/useMutationWithToast.ts @@ -0,0 +1,73 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type { QueryKey, UseMutationOptions, UseMutationResult } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; + +/** + * Extracts the server-provided error message from an axios error response, + * matching the `error.response?.data?.error` pattern used across admin pages. + */ +const extractServerError = (error: unknown): string | undefined => { + const serverError = (error as { response?: { data?: { error?: unknown; message?: unknown } } }) + ?.response?.data; + if (typeof serverError?.error === 'string') return serverError.error; + if (typeof serverError?.message === 'string') return serverError.message; + return undefined; +}; + +export interface UseMutationWithToastOptions + extends UseMutationOptions { + /** Toast shown on success. Omit to show no success toast. */ + successMessage?: string | ((data: TData, variables: TVariables) => string); + /** + * Fallback toast shown on error when the server response carries no error + * message. Pass a function to take full control of the error text. + */ + errorMessage?: string | ((error: TError) => string); + /** Query keys invalidated on success, before the passthrough `onSuccess` runs. */ + invalidateKeys?: QueryKey[]; +} + +/** + * `useMutation` wrapper for the common admin mutation shape: + * invalidate queries + success toast on success, error toast (server message + * first, then `errorMessage` fallback) on error. Passthrough `onSuccess` / + * `onError` still run after the built-in handling. + */ +export function useMutationWithToast< + TData = unknown, + TError = Error, + TVariables = void, + TContext = unknown, +>( + options: UseMutationWithToastOptions +): UseMutationResult { + const queryClient = useQueryClient(); + const { successMessage, errorMessage, invalidateKeys, onSuccess, onError, ...mutationOptions } = + options; + + return useMutation({ + ...mutationOptions, + onSuccess: (data, variables, context) => { + invalidateKeys?.forEach((queryKey) => { + queryClient.invalidateQueries({ queryKey }); + }); + if (successMessage) { + toast.success( + typeof successMessage === 'function' ? successMessage(data, variables) : successMessage + ); + } + onSuccess?.(data, variables, context); + }, + onError: (error, variables, context) => { + const message = + typeof errorMessage === 'function' + ? errorMessage(error) + : extractServerError(error) || + errorMessage || + (error instanceof Error ? error.message : undefined) || + 'An unexpected error occurred'; + toast.error(message); + onError?.(error, variables, context); + }, + }); +} diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index 49def4ab..8ca892bc 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -17,14 +17,14 @@ import { } from 'lucide-react'; import { differenceInDays, parseISO } from 'date-fns'; import { useTranslation } from 'react-i18next'; -import { toast } from 'react-toastify'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { useMutationWithToast } from '../../hooks'; import { Button, Card, Loading } from '../../components/common'; import { UpdateNotification } from '../../components/admin/UpdateNotification'; import { WhatsNewBanner } from '../../components/admin/WhatsNewBanner'; import { CrmOverviewSection } from '../../components/admin/CrmOverviewSection'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { adminService, ActivityType } from '../../services/admin.service'; import { workflowsService } from '../../services/workflows.service'; @@ -82,19 +82,16 @@ export const AdminDashboard: React.FC = () => { // Pending workflow approvals — only when the workflow engine is live. These // are the human-in-the-loop gates (e.g. "review invoice before sending"). const { flags } = useFeatureFlags(); - const qc = useQueryClient(); const { data: pendingApprovals } = useQuery({ queryKey: ['workflow-approvals'], queryFn: () => workflowsService.approvals(), enabled: !!flags.workflows, }); - const approvalMutation = useMutation({ + const approvalMutation = useMutationWithToast({ mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['workflow-approvals'] }); - toast.success(t('workflows.approvals.acted', 'Done') as string); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)), + invalidateKeys: [['workflow-approvals']], + successMessage: t('workflows.approvals.acted', 'Done') as string, + errorMessage: t('common.error', 'Something went wrong') as string, }); // Admin detail route for an approval's run entity, so clicking opens the diff --git a/frontend/src/pages/admin/ArchivesPage.tsx b/frontend/src/pages/admin/ArchivesPage.tsx index 9ff55597..4f80f6f6 100644 --- a/frontend/src/pages/admin/ArchivesPage.tsx +++ b/frontend/src/pages/admin/ArchivesPage.tsx @@ -16,10 +16,11 @@ import { format, parseISO, isValid } from 'date-fns'; import { toast } from 'react-toastify'; import { Button, Input, Card, Loading } from '../../components/common'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { archiveService } from '../../services/archive.service'; import { useTranslation } from 'react-i18next'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { useMutationWithToast } from '../../hooks'; // import { useNavigate } from 'react-router-dom'; export const ArchivesPage: React.FC = () => { @@ -30,7 +31,6 @@ export const ArchivesPage: React.FC = () => { const [sortBy, setSortBy] = useState<'date' | 'size' | 'name'>('date'); const [currentPage, setCurrentPage] = useState(1); // const navigate = useNavigate(); - const queryClient = useQueryClient(); // Helper function to safely format dates const formatDate = (dateString: string | null | undefined, formatStr: string): string => { @@ -79,26 +79,18 @@ export const ArchivesPage: React.FC = () => { }; // Mutations - const restoreMutation = useMutation({ + const restoreMutation = useMutationWithToast({ mutationFn: (id: number) => archiveService.restoreArchive(id), - onSuccess: () => { - toast.success(t('archives.restoreSuccess')); - queryClient.invalidateQueries({ queryKey: ['admin-archives'] }); - }, - onError: () => { - toast.error(t('errors.somethingWentWrong')); - } + successMessage: t('archives.restoreSuccess'), + errorMessage: () => t('errors.somethingWentWrong'), + invalidateKeys: [['admin-archives']], }); - const deleteMutation = useMutation({ + const deleteMutation = useMutationWithToast({ mutationFn: (id: number) => archiveService.deleteArchive(id), - onSuccess: () => { - toast.success(t('archives.deleteSuccess')); - queryClient.invalidateQueries({ queryKey: ['admin-archives'] }); - }, - onError: () => { - toast.error(t('errors.somethingWentWrong')); - } + successMessage: t('archives.deleteSuccess'), + errorMessage: () => t('errors.somethingWentWrong'), + invalidateKeys: [['admin-archives']], }); const handleDownload = async (archive: typeof archives[0]) => { diff --git a/frontend/src/pages/admin/BackupManagement.tsx b/frontend/src/pages/admin/BackupManagement.tsx index 1e6532e8..d8074859 100644 --- a/frontend/src/pages/admin/BackupManagement.tsx +++ b/frontend/src/pages/admin/BackupManagement.tsx @@ -13,12 +13,12 @@ import { ShieldCheck, FolderTree, } from 'lucide-react'; -import { toast } from 'react-toastify'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Button, Card, Loading } from '../../components/common'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { useMutationWithToast } from '../../hooks'; import { BackupDashboard } from '../../components/admin/BackupDashboard'; import { BackupConfiguration } from '../../components/admin/BackupConfiguration'; import { BackupHistory } from '../../components/admin/BackupHistory'; @@ -31,7 +31,6 @@ type TabId = 'dashboard' | 'configuration' | 'history' | 'restore' | 'integrity' export const BackupManagement: React.FC = () => { const [activeTab, setActiveTab] = useState('dashboard'); - const queryClient = useQueryClient(); const { t } = useTranslation(); const { formatDateTime: fmtDateTime } = useLocalizedDate(); @@ -61,34 +60,24 @@ export const BackupManagement: React.FC = () => { }, }); - const manualBackupMutation = useMutation({ + const manualBackupMutation = useMutationWithToast({ mutationFn: async () => { const response = await api.post('/admin/backup/run'); return response.data; }, - onSuccess: () => { - toast.success(t('backup.messages.backupStarted')); - queryClient.invalidateQueries({ queryKey: ['backup-status'] }); - }, - onError: (error: any) => { - const message = error.response?.data?.error || t('backup.messages.backupFailed'); - toast.error(message); - }, + successMessage: t('backup.messages.backupStarted'), + errorMessage: t('backup.messages.backupFailed'), + invalidateKeys: [['backup-status']], }); - const updateConfigMutation = useMutation({ + const updateConfigMutation = useMutationWithToast({ mutationFn: async (config: unknown) => { const response = await api.put('/admin/backup/config', config); return response.data; }, - onSuccess: () => { - toast.success(t('backup.messages.configUpdated')); - queryClient.invalidateQueries({ queryKey: ['backup-config'] }); - }, - onError: (error: any) => { - const message = error.response?.data?.error || t('backup.messages.configUpdateFailed'); - toast.error(message); - }, + successMessage: t('backup.messages.configUpdated'), + errorMessage: t('backup.messages.configUpdateFailed'), + invalidateKeys: [['backup-config']], }); if (statusLoading || configLoading) { diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index dc3e0371..dfd646bf 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -4,7 +4,7 @@ import { toast } from 'react-toastify'; import { Button, Card, Input, ErrorBoundary, Loading, MarkdownContent } from '../../components/common'; import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin'; import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { settingsService, type BrandingSettings } from '../../services/settings.service'; import { businessProfileService } from '../../services/businessProfile.service'; import { useTranslation } from 'react-i18next'; @@ -13,6 +13,7 @@ import { useFeatureEnabled, useFeatureFlags } from '../../contexts/FeatureFlagsC import { CustomerDashboardBrandingCard } from '../../components/admin/CustomerDashboardBrandingCard'; import { PdfTypographyCard } from '../../components/admin/PdfTypographyCard'; import { usePublicSettings } from '../../hooks/usePublicSettings'; +import { useMutationWithToast } from '../../hooks'; export const BrandingPage: React.FC = () => { const { t } = useTranslation(); @@ -88,33 +89,23 @@ export const BrandingPage: React.FC = () => { // Update branding mutation const queryClient = useQueryClient(); - const brandingMutation = useMutation({ + const brandingMutation = useMutationWithToast({ mutationFn: settingsService.updateBranding, - onSuccess: () => { - toast.success(t('toast.brandingUpdated')); - // Invalidate all settings queries to refresh data - queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); - queryClient.invalidateQueries({ queryKey: ['public-settings'] }); - }, - onError: () => { - toast.error(t('toast.saveError')); - }, + successMessage: t('toast.brandingUpdated'), + errorMessage: () => t('toast.saveError'), + // Invalidate all settings queries to refresh data + invalidateKeys: [['admin-settings'], ['public-settings']], }); // Update theme mutation - const themeMutation = useMutation({ + const themeMutation = useMutationWithToast({ mutationFn: settingsService.updateTheme, - onSuccess: () => { - toast.success(t('toast.themeUpdated')); - // Refresh both the admin settings cache (which the page reads from) and - // the public-settings cache (which the gallery reads from) so the saved - // theme is reflected without a manual reload (#317). - queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); - queryClient.invalidateQueries({ queryKey: ['public-settings'] }); - }, - onError: () => { - toast.error(t('toast.saveError')); - }, + successMessage: t('toast.themeUpdated'), + errorMessage: () => t('toast.saveError'), + // Refresh both the admin settings cache (which the page reads from) and + // the public-settings cache (which the gallery reads from) so the saved + // theme is reflected without a manual reload (#317). + invalidateKeys: [['admin-settings'], ['public-settings']], }); // Initialize settings from database diff --git a/frontend/src/pages/admin/CMSPage.tsx b/frontend/src/pages/admin/CMSPage.tsx index b51116eb..618e64a0 100644 --- a/frontend/src/pages/admin/CMSPage.tsx +++ b/frontend/src/pages/admin/CMSPage.tsx @@ -13,6 +13,7 @@ import type { CMSPage as CMSPageType } from '../../services/cms.service'; import { settingsService, PublicSiteBranding } from '../../services/settings.service'; import { buildResourceUrl } from '../../utils/url'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { useMutationWithToast } from '../../hooks'; export const CMSPage: React.FC = () => { const { t } = useTranslation(); @@ -221,14 +222,14 @@ export const CMSPage: React.FC = () => { }, onError: () => toast.error(t('toast.uploadError')), }); - const clearLogoMutation = useMutation({ + const clearLogoMutation = useMutationWithToast({ mutationFn: async () => cmsService.clearPageLogo(selectedPage), + successMessage: t('cms.logoCleared', 'Logo cleared'), + errorMessage: () => t('toast.saveError'), + invalidateKeys: [['cms-pages']], onSuccess: () => { setEditForm(prev => ({ ...prev, logo_url: null })); - queryClient.invalidateQueries({ queryKey: ['cms-pages'] }); - toast.success(t('cms.logoCleared', 'Logo cleared')); }, - onError: () => toast.error(t('toast.saveError')), }); // Warn before leaving with unsaved changes diff --git a/frontend/src/pages/admin/CustomerDetailPage.tsx b/frontend/src/pages/admin/CustomerDetailPage.tsx index b91bb492..a9d0c678 100644 --- a/frontend/src/pages/admin/CustomerDetailPage.tsx +++ b/frontend/src/pages/admin/CustomerDetailPage.tsx @@ -32,6 +32,7 @@ import { CustomerCrmPanels } from '../../components/admin/CustomerCrmPanels'; import { HoursSection } from '../../components/admin/HoursSection'; import { formatMoney } from '../../components/admin/LineItemsTable'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; +import { useMutationWithToast, useModal } from '../../hooks'; import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; type EditableFields = @@ -101,13 +102,13 @@ export const CustomerDetailPage: React.FC = () => { }; const [form, setForm] = useState>>({}); - const [confirmDeactivate, setConfirmDeactivate] = useState(false); - const [confirmErase, setConfirmErase] = useState(false); + const deactivateModal = useModal(); + const eraseModal = useModal(); // Drives the "Manage galleries" modal launched from the Assigned // events card. We hold open-state here (rather than inside the // dialog) so the parent decides when to mount/unmount and the // dialog can hard-reset its internal state per open. - const [assignedDialogOpen, setAssignedDialogOpen] = useState(false); + const assignedDialog = useModal(); // Hydrate the form from the fetched record once. We deliberately do NOT // re-sync on every refetch so an admin's in-progress edits aren't blown @@ -187,10 +188,10 @@ export const CustomerDetailPage: React.FC = () => { * Confirm dialog ahead of the click is surfaced via the same modal * pattern as deactivate. */ - const passwordResetMutation = useMutation({ + const passwordResetMutation = useMutationWithToast({ mutationFn: () => customerAdminService.sendPasswordReset(customerId), - onSuccess: () => toast.success(t('customers.detail.passwordReset.success', 'Password reset email sent')), - onError: () => toast.error(t('customers.detail.passwordReset.error', 'Could not send password reset')), + successMessage: t('customers.detail.passwordReset.success', 'Password reset email sent'), + errorMessage: () => t('customers.detail.passwordReset.error', 'Could not send password reset'), }); // Promote a passive customer to active by firing the standard @@ -219,25 +220,21 @@ export const CustomerDetailPage: React.FC = () => { // before the configured day. Surfaces backend errors verbatim so // admin sees "No pending monthly bill" / "Draft is empty" when the // queue isn't ready. - const triggerMonthlyBillMutation = useMutation({ + const triggerMonthlyBillMutation = useMutationWithToast({ mutationFn: () => customerAdminService.triggerMonthlyBill(customerId), - onSuccess: (result) => { - queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] }); - queryClient.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] }); + invalidateKeys: [ + ['admin-customer', customerId], + ['admin-customer-hour-entries', customerId], // Clear the draft preview so the list collapses to empty // immediately after the trigger ships — a new draft is minted // on the next createInvoice / hour-entry append. - queryClient.invalidateQueries({ queryKey: ['admin-customer-monthly-draft', customerId] }); - toast.success( - t('customers.billing.triggered', - 'Monthly bill issued: {{number}}', - { number: result.invoiceNumber }), - ); - }, - onError: (err: any) => { - toast.error(err?.response?.data?.error - || t('customers.billing.triggerError', 'Could not trigger the monthly bill.')); - }, + ['admin-customer-monthly-draft', customerId], + ], + successMessage: (result) => + t('customers.billing.triggered', + 'Monthly bill issued: {{number}}', + { number: result.invoiceNumber }), + errorMessage: t('customers.billing.triggerError', 'Could not trigger the monthly bill.'), }); const deactivateMutation = useMutation({ @@ -252,14 +249,11 @@ export const CustomerDetailPage: React.FC = () => { }); /** Re-enable login for a deactivated customer. */ - const reactivateMutation = useMutation({ + const reactivateMutation = useMutationWithToast({ mutationFn: () => customerAdminService.reactivate(customerId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] }); - queryClient.invalidateQueries({ queryKey: ['admin-customers'] }); - toast.success(t('customers.reactivate.success', 'Customer reactivated')); - }, - onError: () => toast.error(t('customers.reactivate.error', 'Could not reactivate customer')), + invalidateKeys: [['admin-customer', customerId], ['admin-customers']], + successMessage: t('customers.reactivate.success', 'Customer reactivated'), + errorMessage: () => t('customers.reactivate.error', 'Could not reactivate customer'), }); /** @@ -466,7 +460,7 @@ export const CustomerDetailPage: React.FC = () => { variant="outline" size="sm" leftIcon={} - onClick={() => setAssignedDialogOpen(true)} + onClick={() => assignedDialog.open()} disabled={!customer.isActive} > {t('customers.detail.manageEvents', 'Manage galleries')} @@ -495,13 +489,13 @@ export const CustomerDetailPage: React.FC = () => { ({ id: ev.id, eventName: ev.eventName, eventDate: ev.eventDate || null, }))} - onClose={() => setAssignedDialogOpen(false)} + onClose={() => assignedDialog.close()} onSaved={() => { // Parent refetch is handled by the dialog's invalidateQueries. }} @@ -960,7 +954,7 @@ export const CustomerDetailPage: React.FC = () => { @@ -981,7 +975,7 @@ export const CustomerDetailPage: React.FC = () => {
- {confirmDeactivate && ( + {deactivateModal.isOpen && (
@@ -1017,13 +1011,13 @@ export const CustomerDetailPage: React.FC = () => {
- @@ -1037,7 +1031,7 @@ export const CustomerDetailPage: React.FC = () => { "irreversible" copy + red Confirm button so the click feels deliberate. The action anonymizes PII in place; assignments and audit-log references are preserved. */} - {confirmErase && ( + {eraseModal.isOpen && (
@@ -1054,14 +1048,14 @@ export const CustomerDetailPage: React.FC = () => {
- @@ -274,9 +267,9 @@ export const EventTypesPage: React.FC = () => {
{/* Create Modal */} - {showCreateModal && ( + {createModal.isOpen && ( setShowCreateModal(false)} + onClose={createModal.close} onSubmit={(data) => createMutation.mutate(data as CreateEventTypeData)} isLoading={createMutation.isPending} /> diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx index 57379a6a..f8dd6bd0 100644 --- a/frontend/src/pages/admin/EventsListPage.tsx +++ b/frontend/src/pages/admin/EventsListPage.tsx @@ -20,6 +20,7 @@ import { } from 'lucide-react'; import { parseISO, differenceInDays } from 'date-fns'; import { toast } from 'react-toastify'; +import { useModal, useMutationWithToast } from '../../hooks'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common'; @@ -46,8 +47,8 @@ export const EventsListPage: React.FC = () => { // const [showFilters, setShowFilters] = useState(false); const [activeDropdown, setActiveDropdown] = useState(null); const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null); - const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false); - const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false); + const bulkArchiveModal = useModal(); + const bulkDeleteModal = useModal(); const [copiedEventId, setCopiedEventId] = useState(null); const copyShareLink = async (event: Event) => { @@ -169,29 +170,19 @@ export const EventsListPage: React.FC = () => { }); // Archive mutation - const archiveMutation = useMutation({ + const archiveMutation = useMutationWithToast({ mutationFn: eventsService.archiveEvent, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-events'] }); - queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] }); - toast.success(t('toast.eventArchived')); - }, - onError: () => { - toast.error(t('toast.saveError')); - }, + invalidateKeys: [['admin-events'], ['admin-dashboard-stats']], + successMessage: t('toast.eventArchived'), + errorMessage: () => t('toast.saveError'), }); // Delete mutation - const deleteMutation = useMutation({ + const deleteMutation = useMutationWithToast({ mutationFn: eventsService.deleteEvent, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['admin-events'] }); - queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] }); - toast.success(t('toast.deleteSuccess')); - }, - onError: () => { - toast.error(t('toast.deleteError')); - }, + invalidateKeys: [['admin-events'], ['admin-dashboard-stats']], + successMessage: t('toast.deleteSuccess'), + errorMessage: () => t('toast.deleteError'), }); // Bulk archive mutation @@ -201,7 +192,7 @@ export const EventsListPage: React.FC = () => { queryClient.invalidateQueries({ queryKey: ['admin-events'] }); queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] }); setSelectedEvents([]); - setShowBulkArchiveModal(false); + bulkArchiveModal.close(); if (data.results.failed.length === 0) { toast.success(t('events.bulkArchiveSuccess', { count: data.results.successful.length })); @@ -222,7 +213,7 @@ export const EventsListPage: React.FC = () => { queryClient.invalidateQueries({ queryKey: ['admin-events'] }); queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] }); setSelectedEvents([]); - setShowBulkDeleteModal(false); + bulkDeleteModal.close(); if (data.results.failed.length === 0) { toast.success(t('events.bulkDelete.successAll', { count: data.results.successful.length })); @@ -232,7 +223,7 @@ export const EventsListPage: React.FC = () => { }, onError: () => { toast.error(t('events.bulkDelete.errorGeneric')); - setShowBulkDeleteModal(false); + bulkDeleteModal.close(); }, }); @@ -439,14 +430,14 @@ export const EventsListPage: React.FC = () => { @@ -983,8 +969,8 @@ export const UserManagementPage: React.FC = () => { {/* Create Invitation Modal */} setShowCreateInvitationModal(false)} + isOpen={createInvitationModal.isOpen} + onClose={createInvitationModal.close} onSubmit={handleCreateInvitation} roles={roles || []} isLoading={createInvitationMutation.isPending} @@ -992,9 +978,9 @@ export const UserManagementPage: React.FC = () => { {/* Edit User Modal */} { - setShowEditUserModal(false); + editUserModal.close(); setSelectedUser(null); }} onSubmit={handleUpdateUser} diff --git a/frontend/src/pages/admin/WebhookDeliveriesPage.tsx b/frontend/src/pages/admin/WebhookDeliveriesPage.tsx index 08307c74..02384bca 100644 --- a/frontend/src/pages/admin/WebhookDeliveriesPage.tsx +++ b/frontend/src/pages/admin/WebhookDeliveriesPage.tsx @@ -1,10 +1,10 @@ import React, { useState } from 'react'; import { useParams, Link } from 'react-router-dom'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'react-toastify'; +import { useQuery } from '@tanstack/react-query'; import { ArrowLeft, RefreshCw, RotateCw, Send, X, AlertCircle, CheckCircle2, Clock } from 'lucide-react'; import { Button, Card, Loading } from '../../components/common'; import { api } from '../../config/api'; +import { useModal, useMutationWithToast } from '../../hooks'; import { useLocalizedDate } from '../../hooks/useLocalizedDate'; const WEBHOOK_EVENT_TYPES = [ @@ -64,12 +64,11 @@ function statusBadge(status: string) { export const WebhookDeliveriesPage: React.FC = () => { const { id } = useParams<{ id: string }>(); const webhookId = parseInt(id || '', 10); - const queryClient = useQueryClient(); const { formatDateTime: fmtDateTime } = useLocalizedDate(); const [filter, setFilter] = useState('all'); const [openDeliveryId, setOpenDeliveryId] = useState(null); - const [showTestDialog, setShowTestDialog] = useState(false); + const testDialog = useModal(); const [testEventType, setTestEventType] = useState('event.published'); const { data: webhook, isLoading: loadingWebhook } = useQuery({ @@ -108,24 +107,22 @@ export const WebhookDeliveriesPage: React.FC = () => { enabled: Number.isFinite(webhookId) && openDeliveryId !== null, }); - const replayMutation = useMutation({ + const replayMutation = useMutationWithToast({ mutationFn: async (deliveryId: number) => api.post(`/admin/webhooks/${webhookId}/deliveries/${deliveryId}/replay`), - onSuccess: () => { - toast.success('Replay enqueued'); - queryClient.invalidateQueries({ queryKey: ['admin-webhook-deliveries', webhookId] }); - }, - onError: () => toast.error('Failed to replay'), + invalidateKeys: [['admin-webhook-deliveries', webhookId]], + successMessage: 'Replay enqueued', + errorMessage: () => 'Failed to replay', }); - const testMutation = useMutation({ + const testMutation = useMutationWithToast({ mutationFn: async () => api.post(`/admin/webhooks/${webhookId}/test`, { event_type: testEventType }), + invalidateKeys: [['admin-webhook-deliveries', webhookId]], + successMessage: 'Test event enqueued', + errorMessage: 'Failed to send test', onSuccess: () => { - toast.success('Test event enqueued'); - setShowTestDialog(false); - queryClient.invalidateQueries({ queryKey: ['admin-webhook-deliveries', webhookId] }); + testDialog.close(); }, - onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed to send test'), }); if (loadingWebhook) { @@ -174,7 +171,7 @@ export const WebhookDeliveriesPage: React.FC = () => { variant="outline" size="sm" leftIcon={} - onClick={() => setShowTestDialog(true)} + onClick={() => testDialog.open()} > Send test event @@ -338,8 +335,8 @@ export const WebhookDeliveriesPage: React.FC = () => { )} {/* Test event dialog */} - {showTestDialog && ( -
setShowTestDialog(false)}> + {testDialog.isOpen && ( +
testDialog.close()}>
e.stopPropagation()}>

Send test event

@@ -354,7 +351,7 @@ export const WebhookDeliveriesPage: React.FC = () => { {WEBHOOK_EVENT_TYPES.map((e) => )}

- + diff --git a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx index 58d34cdc..63e1dbe0 100644 --- a/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx +++ b/frontend/src/pages/admin/accounting/AccountingInboxPage.tsx @@ -18,6 +18,7 @@ import { EventBookingSelect } from '../../../components/admin/EventBookingSelect import { formatMoneyMinor } from '../../../utils/money'; import { sortedCountryOptions } from '../../../constants/countries'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; +import { useMutationWithToast } from '../../../hooks'; import { accountingService, categoryLabel, type InboundDocument, type Disposition, type MarkupType, type PaymentMethod, type ExpenseCategory, @@ -89,10 +90,11 @@ const PayModal: React.FC<{ doc: InboundDocument; onClose: () => void; onDone: () const [paidAt, setPaidAt] = useState(''); const [method, setMethod] = useState('bank_transfer'); const [reference, setReference] = useState(doc.paymentReference || ''); - const save = useMutation({ + const save = useMutationWithToast({ mutationFn: () => accountingService.markInboundPaid(doc.id, { paid: true, paidAt: paidAt || undefined, paymentMethod: method, paymentReference: reference || undefined }), - onSuccess: () => { toast.success(t('accounting.incoming.paidToast', 'Marked as paid.')); onDone(); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + successMessage: t('accounting.incoming.paidToast', 'Marked as paid.'), + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', + onSuccess: () => onDone(), }); return (
@@ -205,7 +207,7 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[ // `pay` decides whether to also mark the supplier invoice paid in the same // step (#5/#1). When true we mark it paid directly (using the reference // entered) — no second dialog — so "Save & mark paid" actually pays. - const save = useMutation({ + const save = useMutationWithToast({ mutationFn: async (pay: boolean) => { await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null, note: note || null, supplierCountry: supplierCountry || null }); await accountingService.categorizeInbound(doc.id, { @@ -221,11 +223,9 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[ await accountingService.markInboundPaid(doc.id, { paid: true, paymentReference: reference || undefined }); } }, - onSuccess: (_data, pay) => { - toast.success(pay ? t('accounting.incoming.categorizedPaidToast', 'Categorized and marked paid.') : t('accounting.incoming.categorizedToast', 'Categorized.')); - onDone(); - }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + successMessage: (_data, pay) => pay ? t('accounting.incoming.categorizedPaidToast', 'Categorized and marked paid.') : t('accounting.incoming.categorizedToast', 'Categorized.'), + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', + onSuccess: () => onDone(), }); const rebillNeedsCustomer = disposition === 'rebill' && !customer[0]; @@ -342,10 +342,10 @@ export const AccountingInboxPage: React.FC = () => { onSuccess: (doc) => { toast.success(t('accounting.inbox.capturedToast', 'Document captured.')); qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); if (doc.status === 'unsorted') setTriageDoc(doc); }, onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Upload failed'), }); - const unpay = useMutation({ + const unpay = useMutationWithToast({ mutationFn: (id: number) => accountingService.markInboundPaid(id, { paid: false }), - onSuccess: () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + invalidateKeys: [['accounting-inbound']], + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', }); const billPending = useMutation({ mutationFn: (customerAccountId: number) => accountingService.billPendingRebills(customerAccountId), diff --git a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx index 4bc8ab83..67e6021d 100644 --- a/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx +++ b/frontend/src/pages/admin/accounting/ExpensesLedgerPage.tsx @@ -8,7 +8,7 @@ */ import React, { useState, useMemo } from 'react'; import { Link } from 'react-router-dom'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { toast } from 'react-toastify'; import { X, Plus, Paperclip, Car, CalendarDays, Coins, Pencil, FileText, CheckCircle2, Circle, Lock } from 'lucide-react'; @@ -18,6 +18,7 @@ import { EventBookingSelect } from '../../../components/admin/EventBookingSelect import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker'; import { formatMoneyMinor } from '../../../utils/money'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; +import { useMutationWithToast, useModal } from '../../../hooks'; import { accountingService, categoryLabel, type Expense, type ExpenseKind, type ExpenseCategory, type MarkupType, type PaymentMethod, @@ -68,12 +69,13 @@ const ExpenseFormModal: React.FC<{ categories: ExpenseCategory[]; expense?: Expe description: description || null, }); - const save = useMutation({ + const save = useMutationWithToast({ mutationFn: () => isEdit ? accountingService.updateExpense(expense!.id, payload(), file) : accountingService.createExpense(payload(), file), - onSuccess: () => { toast.success(isEdit ? t('accounting.ledger.updatedToast', 'Expense updated.') : t('accounting.ledger.createdToast', 'Expense added.')); onDone(); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + successMessage: isEdit ? t('accounting.ledger.updatedToast', 'Expense updated.') : t('accounting.ledger.createdToast', 'Expense added.'), + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', + onSuccess: () => onDone(), }); const qtyLabel = kind === 'mileage' ? t('accounting.expense.km', 'Kilometres') : t('accounting.expense.days', 'Days'); @@ -143,10 +145,11 @@ const ExpensePaidModal: React.FC<{ expense: Expense; onClose: () => void; onDone const [paidAt, setPaidAt] = useState(''); const [method, setMethod] = useState('bank_transfer'); const [reference, setReference] = useState(''); - const save = useMutation({ + const save = useMutationWithToast({ mutationFn: () => accountingService.markExpensePaid(expense.id, { paid: true, paidAt: paidAt || undefined, paymentMethod: method, paymentReference: reference || undefined }), - onSuccess: () => { toast.success(t('accounting.ledger.paidToast', 'Marked as paid.')); onDone(); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + successMessage: t('accounting.ledger.paidToast', 'Marked as paid.'), + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', + onSuccess: () => onDone(), }); return (
@@ -182,15 +185,16 @@ const InvoiceExpenseModal: React.FC<{ expense: Expense; onClose: () => void; onD const [customer, setCustomer] = useState([]); const [markupType, setMarkupType] = useState('none'); const [markupValue, setMarkupValue] = useState(NaN); - const save = useMutation({ + const save = useMutationWithToast({ mutationFn: () => accountingService.invoiceExpense(expense.id, { customerAccountId: customer[0]!.id, markupType, markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null, }), - onSuccess: () => { toast.success(t('accounting.ledger.invoicedToast', 'Added to a client invoice.')); onDone(); }, - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + successMessage: t('accounting.ledger.invoicedToast', 'Added to a client invoice.'), + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', + onSuccess: () => onDone(), }); return (
@@ -230,15 +234,15 @@ export const ExpensesLedgerPage: React.FC = () => { const qc = useQueryClient(); const { format } = useLocalizedDate(); const [kind, setKind] = useState(''); - const [showAdd, setShowAdd] = useState(false); + const addModal = useModal(); const [editExpense, setEditExpense] = useState(null); const [paidExpense, setPaidExpense] = useState(null); const [invoiceExpense, setInvoiceExpense] = useState(null); - const unpay = useMutation({ + const unpay = useMutationWithToast({ mutationFn: (id: number) => accountingService.markExpensePaid(id, { paid: false }), - onSuccess: () => qc.invalidateQueries({ queryKey: ['accounting-expenses'] }), - onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), + invalidateKeys: [['accounting-expenses']], + errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed', }); const { data, isLoading } = useQuery({ @@ -262,7 +266,7 @@ export const ExpensesLedgerPage: React.FC = () => { {KINDS.map((k) => )} - +
{isLoading ? : items.length === 0 ? ( @@ -318,7 +322,7 @@ export const ExpensesLedgerPage: React.FC = () => {
)} - {showAdd && setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />} + {addModal.isOpen && addModal.close()} onDone={() => { addModal.close(); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />} {editExpense && setEditExpense(null)} onDone={() => { setEditExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />} {paidExpense && setPaidExpense(null)} onDone={() => { setPaidExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />} {invoiceExpense && setInvoiceExpense(null)} onDone={() => { setInvoiceExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />} diff --git a/frontend/src/pages/admin/contracts/BlockLibraryPage.tsx b/frontend/src/pages/admin/contracts/BlockLibraryPage.tsx index c851b0f2..ede1a4c2 100644 --- a/frontend/src/pages/admin/contracts/BlockLibraryPage.tsx +++ b/frontend/src/pages/admin/contracts/BlockLibraryPage.tsx @@ -27,6 +27,7 @@ import { toast } from 'react-toastify'; import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react'; import { Button, Card, Loading } from '../../../components/common'; import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector'; +import { useMutationWithToast } from '../../../hooks'; import { contractsService, type ContractBlock, @@ -147,14 +148,12 @@ export const BlockLibraryPage: React.FC = () => { onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.blocks.updateError', 'Update failed') as string), }); - const deleteMutation = useMutation({ + const deleteMutation = useMutationWithToast({ mutationFn: (id: number) => contractsService.deleteBlock(id), - onSuccess: () => { - toast.success(t('contracts.blocks.deletedToast', 'Block deleted.') as string); - queryClient.invalidateQueries({ queryKey: ['contracts', 'blocks'] }); - setSelection(null); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.blocks.deleteError', 'Delete failed') as string), + successMessage: t('contracts.blocks.deletedToast', 'Block deleted.') as string, + invalidateKeys: [['contracts', 'blocks']], + errorMessage: t('contracts.blocks.deleteError', 'Delete failed') as string, + onSuccess: () => setSelection(null), }); // Group blocks by section for sidebar rendering. Empty sections are diff --git a/frontend/src/pages/admin/contracts/ContractDetailPage.tsx b/frontend/src/pages/admin/contracts/ContractDetailPage.tsx index 78bf3947..870ffdb7 100644 --- a/frontend/src/pages/admin/contracts/ContractDetailPage.tsx +++ b/frontend/src/pages/admin/contracts/ContractDetailPage.tsx @@ -15,7 +15,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext'; import { Link, useNavigate, useParams } from 'react-router-dom'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import { billsService } from '../../../services/bills.service'; import { quotesService } from '../../../services/quotes.service'; @@ -32,6 +32,7 @@ import { type ContractStatus, } from '../../../services/contracts.service'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; +import { useMutationWithToast } from '../../../hooks'; function statusBadgeClass(status: ContractStatus): string { return status === 'fully_signed' ? 'bg-green-100 text-green-800' @@ -90,25 +91,21 @@ export const ContractDetailPage: React.FC = () => { select: (res) => res?.invoices?.filter((i) => i.sourceContractId === numericId) || [], }); - const sendMutation = useMutation({ + const sendMutation = useMutationWithToast({ mutationFn: () => contractsService.send(numericId as number), - onSuccess: () => { - toast.success(t('contracts.detail.sentToast', 'Contract sent.') as string); - queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.sendError', 'Send failed') as string), + successMessage: t('contracts.detail.sentToast', 'Contract sent.') as string, + invalidateKeys: [['contract', numericId]], + errorMessage: t('contracts.detail.sendError', 'Send failed') as string, }); - const cancelMutation = useMutation({ + const cancelMutation = useMutationWithToast({ mutationFn: () => contractsService.cancel(numericId as number), - onSuccess: () => { - toast.success(t('contracts.detail.cancelledToast', 'Contract cancelled.') as string); - queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.cancelError', 'Cancel failed') as string), + successMessage: t('contracts.detail.cancelledToast', 'Contract cancelled.') as string, + invalidateKeys: [['contract', numericId]], + errorMessage: t('contracts.detail.cancelError', 'Cancel failed') as string, }); - const countersignMutation = useMutation({ + const countersignMutation = useMutationWithToast({ mutationFn: () => { // Capture the canvas signature (if drawn) at submit time so we // send a fresh data URL, not a stale one from an earlier mount. @@ -119,55 +116,45 @@ export const ContractDetailPage: React.FC = () => { signatureDataUrl, }); }, + successMessage: t('contracts.detail.countersignedToast', 'Counter-signed.') as string, + invalidateKeys: [['contract', numericId]], + errorMessage: t('contracts.detail.countersignError', 'Counter-sign failed') as string, onSuccess: () => { - toast.success(t('contracts.detail.countersignedToast', 'Counter-signed.') as string); setCountersignName(''); countersignPadRef.current?.clear(); - queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); }, - onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.countersignError', 'Counter-sign failed') as string), }); - const uploadMutation = useMutation({ + const uploadMutation = useMutationWithToast({ mutationFn: (file: File) => contractsService.uploadSignedPdf(numericId as number, file), - onSuccess: () => { - toast.success(t('contracts.detail.uploadedToast', 'Signed PDF uploaded.') as string); - queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.uploadError', 'Upload failed') as string), + successMessage: t('contracts.detail.uploadedToast', 'Signed PDF uploaded.') as string, + invalidateKeys: [['contract', numericId]], + errorMessage: t('contracts.detail.uploadError', 'Upload failed') as string, }); - const resendSignedMutation = useMutation({ + const resendSignedMutation = useMutationWithToast({ mutationFn: () => contractsService.resendSigned(numericId as number), - onSuccess: () => { - toast.success(t('contracts.detail.resentSignedToast', - 'Signed contract re-sent to both parties.') as string); - queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); - }, - onError: (err: any) => toast.error(err?.response?.data?.error - || t('contracts.detail.resendError', 'Resend failed') as string), + successMessage: t('contracts.detail.resentSignedToast', + 'Signed contract re-sent to both parties.') as string, + invalidateKeys: [['contract', numericId]], + errorMessage: t('contracts.detail.resendError', 'Resend failed') as string, }); - const convertToEventMutation = useMutation({ + const convertToEventMutation = useMutationWithToast({ mutationFn: () => contractsService.convertToEvent(numericId as number), - onSuccess: (result) => { - toast.success(result.alreadyConverted - ? (t('contracts.detail.alreadyEventToast', 'Already linked to an event.') as string) - : (t('contracts.detail.convertedToEventToast', 'Contract converted to event #{{id}}', { id: result.eventId }) as string)); - queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.convertError', 'Convert failed') as string), + successMessage: (result) => result.alreadyConverted + ? (t('contracts.detail.alreadyEventToast', 'Already linked to an event.') as string) + : (t('contracts.detail.convertedToEventToast', 'Contract converted to event #{{id}}', { id: result.eventId }) as string), + invalidateKeys: [['contract', numericId]], + errorMessage: t('contracts.detail.convertError', 'Convert failed') as string, }); - const convertToInvoiceMutation = useMutation({ + const convertToInvoiceMutation = useMutationWithToast({ mutationFn: () => contractsService.convertToInvoice(numericId as number), - onSuccess: (result) => { - toast.success(t('contracts.detail.convertedToInvoiceToast', - '{{count}} invoice(s) created from this contract', { count: result.installmentsCreated }) as string); - queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); - queryClient.invalidateQueries({ queryKey: ['invoices'] }); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.convertError', 'Convert failed') as string), + successMessage: (result) => t('contracts.detail.convertedToInvoiceToast', + '{{count}} invoice(s) created from this contract', { count: result.installmentsCreated }) as string, + invalidateKeys: [['contract', numericId], ['invoices']], + errorMessage: t('contracts.detail.convertError', 'Convert failed') as string, }); if (isLoading) return ; @@ -1003,7 +990,7 @@ const RestampSignaturesCard: React.FC = ({ contract, onSuccess return () => { cleanupCustomer(); cleanupAdmin(); }; }, []); - const mutation = useMutation({ + const mutation = useMutationWithToast({ mutationFn: () => { const customerPad = customerPadRef.current; const adminPad = adminPadRef.current; @@ -1017,16 +1004,16 @@ const RestampSignaturesCard: React.FC = ({ contract, onSuccess adminSignatureDataUrl, }); }, + successMessage: t('contracts.detail.restampedToast', + 'Signatures re-stamped and PDF re-rendered.') as string, + errorMessage: (err: any) => err?.response?.data?.error + || err?.message + || t('contracts.detail.restampError', 'Re-stamp failed') as string, onSuccess: () => { - toast.success(t('contracts.detail.restampedToast', - 'Signatures re-stamped and PDF re-rendered.') as string); customerPadRef.current?.clear(); adminPadRef.current?.clear(); onSuccess(); }, - onError: (err: any) => toast.error(err?.response?.data?.error - || err?.message - || t('contracts.detail.restampError', 'Re-stamp failed') as string), }); const missingCustomer = !contract.signedCustomerSignaturePath && contract.signedByCustomerAt; diff --git a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx index 76727642..1ad7ee73 100644 --- a/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx +++ b/frontend/src/pages/admin/projects/ProjectCockpitPage.tsx @@ -24,6 +24,7 @@ import { } from '../../../services/projects.service'; import { eventsService } from '../../../services/events.service'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; +import { useMutationWithToast } from '../../../hooks'; import { formatMoneyMinor } from '../../../utils/money'; import { useFeatureFlags, type FeatureKey } from '../../../contexts/FeatureFlagsContext'; @@ -121,29 +122,24 @@ export const ProjectCockpitPage: React.FC = () => { enabled: projectId !== null, }); - const renameMutation = useMutation({ + const renameMutation = useMutationWithToast({ mutationFn: (name: string) => projectsService.update(projectId as number, { name }), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['project-overview', projectId] }); - qc.invalidateQueries({ queryKey: ['projects'] }); - setEditName(null); - toast.success(t('projects.toast.saved', 'Project saved') as string); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.toast.saveFailed', 'Save failed') as string)), + successMessage: t('projects.toast.saved', 'Project saved') as string, + invalidateKeys: [['project-overview', projectId], ['projects']], + errorMessage: t('projects.toast.saveFailed', 'Save failed') as string, + onSuccess: () => setEditName(null), }); - const emailActionMutation = useMutation({ + const emailActionMutation = useMutationWithToast({ mutationFn: ({ action, emailId }: { action: 'resend' | 'cancel' | 'retry' | 'sendNow'; emailId: number }) => { if (action === 'resend') return projectsService.resendEmail(emailId); if (action === 'cancel') return projectsService.cancelEmail(emailId); if (action === 'retry') return projectsService.retryEmail(emailId); return projectsService.sendEmailNow(emailId); }, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['project-overview', projectId] }); - toast.success(t('projects.toast.emailAction', 'Done') as string); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.toast.emailActionFailed', 'Action failed') as string)), + successMessage: t('projects.toast.emailAction', 'Done') as string, + invalidateKeys: [['project-overview', projectId]], + errorMessage: t('projects.toast.emailActionFailed', 'Action failed') as string, }); // Event search for the "attach event" control (results exclude events diff --git a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx index 3201a03a..01d19c55 100644 --- a/frontend/src/pages/admin/settings/CrmSettingsPage.tsx +++ b/frontend/src/pages/admin/settings/CrmSettingsPage.tsx @@ -9,13 +9,13 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { Save as SaveIcon, Workflow as WorkflowIcon } from 'lucide-react'; import { Button, Card, Loading, Input } from '../../../components/common'; import { settingsService } from '../../../services/settings.service'; import { quotesService } from '../../../services/quotes.service'; import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext'; -import { toast } from 'react-toastify'; +import { useMutationWithToast } from '../../../hooks'; const SETTING_KEYS = [ 'crm_quotes_pdf_attachment_enabled', @@ -76,7 +76,6 @@ const SETTING_KEYS = [ export const CrmSettingsPage: React.FC = () => { const { t } = useTranslation(); - const qc = useQueryClient(); const { flags } = useFeatureFlags(); // Show each section only when the corresponding master flag is on — // configuring Skonto on quotes is pointless when quotes itself is @@ -116,7 +115,7 @@ export const CrmSettingsPage: React.FC = () => { const [values, setValues] = useState>({}); useEffect(() => { if (data) setValues(data); }, [data]); - const saveAll = useMutation({ + const saveAll = useMutationWithToast({ mutationFn: async () => { const changed: Record = {}; for (const key of SETTING_KEYS) { @@ -126,11 +125,9 @@ export const CrmSettingsPage: React.FC = () => { await settingsService.updateSettings(changed); } }, - onSuccess: () => { - toast.success(t('crmSettings.savedToast', 'CRM settings saved.')); - qc.invalidateQueries({ queryKey: ['settings', 'crm'] }); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || 'Save failed'), + successMessage: t('crmSettings.savedToast', 'CRM settings saved.'), + invalidateKeys: [['settings', 'crm']], + errorMessage: 'Save failed', }); if (isLoading) return ; diff --git a/frontend/src/pages/admin/settings/ReminderTemplatesPage.tsx b/frontend/src/pages/admin/settings/ReminderTemplatesPage.tsx index f2adb168..0d53e55e 100644 --- a/frontend/src/pages/admin/settings/ReminderTemplatesPage.tsx +++ b/frontend/src/pages/admin/settings/ReminderTemplatesPage.tsx @@ -31,8 +31,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router-dom'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'react-toastify'; +import { useQuery } from '@tanstack/react-query'; import { ArrowLeft, Save, AlertTriangle, Workflow as WorkflowIcon } from 'lucide-react'; import { Button, Card, Loading, Input } from '../../../components/common'; import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector'; @@ -41,6 +40,7 @@ import { eventTypesService } from '../../../services/eventTypes.service'; import { emailService, type EmailTemplateTranslation } from '../../../services/email.service'; import { settingsService } from '../../../services/settings.service'; import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext'; +import { useMutationWithToast } from '../../../hooks'; const TEMPLATE_KEY_DEFAULT = 'event_reminder_default'; const TEMPLATE_KEY_PREFIX = 'event_reminder_'; @@ -60,7 +60,6 @@ interface SidebarRow { export const ReminderTemplatesPage: React.FC = () => { const { t } = useTranslation(); - const queryClient = useQueryClient(); // Global on/off + lead time: owned by the "Pre-event reminder" workflow when // the engine is live; otherwise the legacy crm_event_reminders_* settings drive @@ -86,16 +85,14 @@ export const ReminderTemplatesPage: React.FC = () => { const d = Number(settings.crm_event_reminders_days_before); setDaysBefore(Number.isFinite(d) ? d : 2); }, [settings]); - const saveSettingsMutation = useMutation({ + const saveSettingsMutation = useMutationWithToast({ mutationFn: () => settingsService.updateSettings({ crm_event_reminders_enabled: enabled, crm_event_reminders_days_before: daysBefore, }), - onSuccess: () => { - toast.success(t('reminderTemplates.settingsSaved', 'Reminder settings saved.')); - queryClient.invalidateQueries({ queryKey: ['reminder-settings'] }); - }, - onError: () => toast.error(t('reminderTemplates.settingsSaveError', 'Could not save reminder settings.')), + successMessage: t('reminderTemplates.settingsSaved', 'Reminder settings saved.'), + invalidateKeys: [['reminder-settings']], + errorMessage: () => t('reminderTemplates.settingsSaveError', 'Could not save reminder settings.'), }); // ---- Event types catalog --------------------------------------------- @@ -195,7 +192,7 @@ export const ReminderTemplatesPage: React.FC = () => { }, [selectedKey, selectedTemplate, defaultTemplate]); // ---- Save ------------------------------------------------------------- - const saveMutation = useMutation({ + const saveMutation = useMutationWithToast({ mutationFn: async () => { // Only send non-empty translations so we don't clobber DB rows // for locales the admin hasn't touched. @@ -220,15 +217,9 @@ export const ReminderTemplatesPage: React.FC = () => { }); } }, - onSuccess: () => { - toast.success(t('reminderTemplates.saved', 'Template saved.')); - queryClient.invalidateQueries({ queryKey: ['email-templates'] }); - queryClient.invalidateQueries({ queryKey: ['email-template', selectedKey] }); - }, - onError: (err: unknown) => { - const e = err as { response?: { data?: { error?: string } } }; - toast.error(e?.response?.data?.error || t('reminderTemplates.saveError', 'Could not save template.')); - }, + successMessage: t('reminderTemplates.saved', 'Template saved.'), + invalidateKeys: [['email-templates'], ['email-template', selectedKey]], + errorMessage: t('reminderTemplates.saveError', 'Could not save template.'), }); // Translation completeness pill for the sidebar — matches the email diff --git a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx index b8d188e1..54f4eacd 100644 --- a/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx +++ b/frontend/src/pages/admin/settings/SettingsBusinessProfilePage.tsx @@ -20,6 +20,7 @@ import { import { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../../components/common'; import { toast } from 'react-toastify'; import { currencyOptions, normalizeCurrency } from '../../../constants/currencies'; +import { useMutationWithToast } from '../../../hooks'; // Full IANA timezone list for the picker. `Intl.supportedValuesOf` is ES2022 // (all current browsers); fall back to a small CH/LI-relevant set on the rare @@ -35,7 +36,6 @@ const IANA_TIMEZONES: string[] = (() => { export const SettingsBusinessProfilePage: React.FC = () => { const { t } = useTranslation(); - const qc = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: ['business-profile'], queryFn: () => businessProfileService.get(), @@ -44,7 +44,7 @@ export const SettingsBusinessProfilePage: React.FC = () => { const [profile, setProfile] = useState(null); useEffect(() => { if (data?.profile) setProfile(data.profile); }, [data]); - const saveProfile = useMutation({ + const saveProfile = useMutationWithToast({ // vatLabel + defaultHourlyRateMinor now live on Settings → Accounting, and // vatRateDefault is retired (the rates are the Accounting VAT codes). Strip // them from this save so an open Business-profile page can't clobber an edit @@ -55,11 +55,9 @@ export const SettingsBusinessProfilePage: React.FC = () => { void vatLabel; void defaultHourlyRateMinor; void vatRateDefault; return businessProfileService.update(rest); }, - onSuccess: () => { - toast.success(t('businessProfile.savedToast', 'Business profile saved.')); - qc.invalidateQueries({ queryKey: ['business-profile'] }); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || 'Save failed'), + successMessage: t('businessProfile.savedToast', 'Business profile saved.'), + invalidateKeys: [['business-profile']], + errorMessage: 'Save failed', }); if (isLoading || !profile) return ; @@ -614,24 +612,20 @@ const BankAccountsSection: React.FC = ({ accounts }) = }; const closeForm = () => { setOpenForm(null); setDraft(EMPTY_DRAFT); }; - const create = useMutation({ + const create = useMutationWithToast({ mutationFn: () => businessProfileService.createBankAccount(draft), - onSuccess: () => { - toast.success(t('businessProfile.bankCreatedToast', 'Bank account added.')); - qc.invalidateQueries({ queryKey: ['business-profile'] }); - closeForm(); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed'), + successMessage: t('businessProfile.bankCreatedToast', 'Bank account added.'), + invalidateKeys: [['business-profile']], + errorMessage: 'Failed', + onSuccess: () => closeForm(), }); - const update = useMutation({ + const update = useMutationWithToast({ mutationFn: (id: number) => businessProfileService.updateBankAccount(id, draft), - onSuccess: () => { - toast.success(t('businessProfile.bankUpdatedToast', 'Bank account updated.')); - qc.invalidateQueries({ queryKey: ['business-profile'] }); - closeForm(); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed'), + successMessage: t('businessProfile.bankUpdatedToast', 'Bank account updated.'), + invalidateKeys: [['business-profile']], + errorMessage: 'Failed', + onSuccess: () => closeForm(), }); const setDefault = useMutation({ diff --git a/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx b/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx index b036363b..389ae910 100644 --- a/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx +++ b/frontend/src/pages/admin/workflows/WorkflowApprovalsPage.tsx @@ -7,17 +7,16 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'react-toastify'; +import { useQuery } from '@tanstack/react-query'; import { ArrowLeft, Check, X } from 'lucide-react'; import { Button, Card, Loading } from '../../../components/common'; import { workflowsService, type WorkflowApproval } from '../../../services/workflows.service'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; +import { useMutationWithToast } from '../../../hooks'; export const WorkflowApprovalsPage: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const qc = useQueryClient(); const { formatDateTime } = useLocalizedDate(); const { data: approvals, isLoading } = useQuery({ @@ -25,13 +24,11 @@ export const WorkflowApprovalsPage: React.FC = () => { queryFn: () => workflowsService.approvals(), }); - const actMutation = useMutation({ + const actMutation = useMutationWithToast({ mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['workflow-approvals'] }); - toast.success(t('workflows.approvals.recorded', 'Response recorded') as string); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)), + successMessage: t('workflows.approvals.recorded', 'Response recorded') as string, + invalidateKeys: [['workflow-approvals']], + errorMessage: t('common.error', 'Something went wrong') as string, }); const promptOf = (a: WorkflowApproval) => (a.payload && (a.payload.prompt as string)) || t('workflows.approvals.defaultPrompt', 'A workflow needs your confirmation.'); diff --git a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx index bf4394cf..26ade706 100644 --- a/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx +++ b/frontend/src/pages/admin/workflows/WorkflowEditorPage.tsx @@ -10,7 +10,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import { ReactFlow, Background, Controls, MiniMap, addEdge, useNodesState, useEdgesState, @@ -22,6 +22,7 @@ import { ArrowLeft, Save, Trash2, Wand2, Code } from 'lucide-react'; import { Button, Loading } from '../../../components/common'; import { api } from '../../../config/api'; import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext'; +import { useMutationWithToast } from '../../../hooks'; import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service'; import { NodeConfigPanel } from './NodeConfigPanel'; @@ -117,7 +118,6 @@ function layoutGraph(nodes: Node[], edges: Edge[]): Node[] { export const WorkflowEditorPage: React.FC = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const qc = useQueryClient(); const { isDark } = useAdminDarkMode(); const { id } = useParams<{ id: string }>(); const workflowId = Number(id); @@ -245,7 +245,7 @@ export const WorkflowEditorPage: React.FC = () => { setSelectedId(null); }; - const saveMutation = useMutation({ + const saveMutation = useMutationWithToast({ mutationFn: () => workflowsService.update(workflowId, { name: name.trim() || 'Untitled', trigger_type: triggerType, @@ -257,12 +257,9 @@ export const WorkflowEditorPage: React.FC = () => { })), edges: edges.map((e) => ({ from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target })), }), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['workflow', workflowId] }); - qc.invalidateQueries({ queryKey: ['workflows'] }); - toast.success(t('workflows.editor.saved', 'Workflow saved') as string); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.editor.saveFailed', 'Could not save') as string)), + successMessage: t('workflows.editor.saved', 'Workflow saved') as string, + invalidateKeys: [['workflow', workflowId], ['workflows']], + errorMessage: t('workflows.editor.saveFailed', 'Could not save') as string, }); if (isLoading) return
; diff --git a/frontend/src/pages/admin/workflows/WorkflowsListPage.tsx b/frontend/src/pages/admin/workflows/WorkflowsListPage.tsx index 06078501..da30bb07 100644 --- a/frontend/src/pages/admin/workflows/WorkflowsListPage.tsx +++ b/frontend/src/pages/admin/workflows/WorkflowsListPage.tsx @@ -11,6 +11,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil, FlaskConical } from 'lucide-react'; import { Button, Card, Loading } from '../../../components/common'; +import { useMutationWithToast } from '../../../hooks'; import { workflowsService, type WorkflowSummary, type WorkflowSavePayload, type WorkflowTestResult } from '../../../services/workflows.service'; const NEW_WORKFLOW: WorkflowSavePayload = { @@ -55,19 +56,17 @@ export const WorkflowsListPage: React.FC = () => { onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.test.failed', 'Test run failed') as string)), }); - const toggleMutation = useMutation({ + const toggleMutation = useMutationWithToast({ mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => workflowsService.setEnabled(id, enabled), - onSuccess: () => qc.invalidateQueries({ queryKey: ['workflows'] }), - onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)), + invalidateKeys: [['workflows']], + errorMessage: t('common.error', 'Something went wrong') as string, }); - const deleteMutation = useMutation({ + const deleteMutation = useMutationWithToast({ mutationFn: (id: number) => workflowsService.remove(id), - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['workflows'] }); - toast.success(t('workflows.toast.deleted', 'Workflow deleted') as string); - }, - onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.toast.deleteFailed', 'Could not delete workflow') as string)), + successMessage: t('workflows.toast.deleted', 'Workflow deleted') as string, + invalidateKeys: [['workflows']], + errorMessage: t('workflows.toast.deleteFailed', 'Could not delete workflow') as string, }); const isEnabled = (w: WorkflowSummary) => w.enabled === true || w.enabled === 1;