refactor(frontend): useMutationWithToast + useModal hooks, migrate admin surfaces

- 92 mutations across 40 files moved to useMutationWithToast
  (success/error toast + invalidateKeys); complex flows left as-is
- 24 boolean modal flags moved to useModal
- Mutations without an original onError intentionally not migrated
  to avoid introducing new error toasts
This commit is contained in:
Paul Nothaft
2026-07-03 07:49:45 +02:00
parent 6eb46d8c31
commit 0f230f53fb
46 changed files with 680 additions and 768 deletions
@@ -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<AdminGuestsListProps> = ({ eventId, eventName }) => {
const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate();
const queryClient = useQueryClient();
const [view, setView] = useState<View>('list');
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
const [mergeMode, setMergeMode] = useState(false);
const [mergeSelection, setMergeSelection] = useState<number[]>([]);
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<AdminGuestsListProps> = ({ eventId, event
variant="outline"
size="sm"
leftIcon={<UserPlus className="w-4 h-4" />}
onClick={() => setInviteDialogOpen(true)}
onClick={inviteModal.open}
>
{t('admin.guests.createInvite', 'Create invite')}
</Button>
@@ -331,12 +329,12 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
/>
)}
{inviteDialogOpen && (
{inviteModal.isOpen && (
<GuestInviteDialog
eventId={eventId}
eventName={eventName}
onClose={() => {
setInviteDialogOpen(false);
inviteModal.close();
refetch();
}}
/>
+23 -22
View File
@@ -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<AdminHeaderProps> = ({ 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<AdminHeaderProps> = ({ 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<AdminHeaderProps> = ({ onMenuClick }) => {
const notificationRef = useRef<HTMLDivElement>(null);
useOnClickOutside(userMenuRef, closeUserMenu);
useOnClickOutside(notificationRef, () => setShowNotifications(false));
useOnClickOutside(notificationRef, notificationsModal.close);
const handleLogout = () => {
logout();
@@ -159,8 +160,8 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ 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<AdminHeaderProps> = ({ onMenuClick }) => {
{/* Notifications */}
<div className="relative" ref={notificationRef}>
<button
onClick={() => setShowNotifications(!showNotifications)}
onClick={notificationsModal.toggle}
className="relative p-2 text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
>
<Bell className="w-5 h-5" />
@@ -307,7 +308,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
</button>
{/* Notifications dropdown */}
{showNotifications && (
{notificationsModal.isOpen && (
<div className="absolute right-0 mt-2 w-96 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700">
<div className="px-4 py-3 border-b border-neutral-100 dark:border-neutral-700 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">{t('admin.notifications')}</h3>
@@ -368,7 +369,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{notifications.length > 0 && (
<div className="px-4 py-2 border-t border-neutral-100 dark:border-neutral-700 text-center">
<button
onClick={() => setShowNotifications(false)}
onClick={notificationsModal.close}
className="text-sm text-accent hover:opacity-80"
>
{t('admin.close')}
@@ -382,7 +383,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{/* User menu */}
<div className="relative" ref={userMenuRef}>
<button
onClick={() => setShowUserMenu(!showUserMenu)}
onClick={userMenuModal.toggle}
className="flex items-center gap-3 p-2 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-lg transition-colors"
>
<div className="text-right hidden sm:block">
@@ -395,7 +396,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
</button>
{/* User dropdown */}
{showUserMenu && (
{userMenuModal.isOpen && (
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 py-1">
<div className="px-4 py-2 border-b border-neutral-100 dark:border-neutral-700 sm:hidden">
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{user?.username}</p>
@@ -408,16 +409,16 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
so the menu isn't 8 rows taller by default. */}
<div className="sm:hidden border-b border-neutral-100 dark:border-neutral-700">
<button
onClick={() => setShowUserMenuLangSection(!showUserMenuLangSection)}
onClick={userMenuLangSectionModal.toggle}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
aria-expanded={showUserMenuLangSection}
aria-expanded={userMenuLangSectionModal.isOpen}
>
<Globe className="w-4 h-4" />
<span className="flex-1">{t('common.language', 'Language')}</span>
<currentLanguage.Flag className="w-4 h-4" />
<ChevronDown className={`w-4 h-4 transition-transform ${showUserMenuLangSection ? 'rotate-180' : ''}`} />
<ChevronDown className={`w-4 h-4 transition-transform ${userMenuLangSectionModal.isOpen ? 'rotate-180' : ''}`} />
</button>
{showUserMenuLangSection && (
{userMenuLangSectionModal.isOpen && (
<div className="bg-neutral-50 dark:bg-neutral-900 py-1">
{SUPPORTED_LANGUAGES.map((language) => (
<button
@@ -449,7 +450,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<button
onClick={() => {
closeUserMenu();
setShowPasswordModal(true);
passwordModal.open();
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
>
@@ -471,9 +472,9 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
</div>
{/* Password Change Modal */}
<PasswordChangeModal
isOpen={showPasswordModal}
onClose={() => setShowPasswordModal(false)}
<PasswordChangeModal
isOpen={passwordModal.isOpen}
onClose={passwordModal.close}
/>
</header>
);
@@ -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<AdminPhotoViewerProps> = ({
}) => {
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<AdminPhotoViewerProps> = ({
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<AdminPhotoViewerProps> = ({
};
// 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<AdminPhotoViewerProps> = ({
Category
</span>
<button
onClick={() => setShowCategoryMenu(!showCategoryMenu)}
onClick={categoryMenuModal.toggle}
className="text-xs text-accent hover:text-accent-dark"
>
Change
@@ -276,7 +269,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
{currentPhoto.category_name || 'Uncategorized'}
</p>
{showCategoryMenu && (
{categoryMenuModal.isOpen && (
<div className="mt-2 bg-neutral-800 rounded-lg p-2">
<button
onClick={() => handleCategoryChange(null)}
@@ -393,13 +386,13 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
{comments.length > 0 && (
<div className="space-y-2">
<button
onClick={() => setExpandedComments(!expandedComments)}
onClick={commentsModal.toggle}
className="text-xs text-accent hover:text-accent-dark mb-2"
>
{expandedComments ? 'Hide' : 'Show'} Comments ({comments.length})
{commentsModal.isOpen ? 'Hide' : 'Show'} Comments ({comments.length})
</button>
{expandedComments && (
{commentsModal.isOpen && (
<div className="space-y-3 max-h-64 overflow-y-auto">
{comments.map((comment) => (
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
@@ -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) => {
@@ -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<number | null>(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 = () => {
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('categories.title')}</h3>
{!isAdding && (
{!addingModal.isOpen && (
<Button
variant="primary"
size="sm"
onClick={() => setIsAdding(true)}
onClick={addingModal.open}
leftIcon={<Plus className="w-4 h-4" />}
>
{t('categories.addCategory')}
@@ -115,7 +106,7 @@ export const CategoryManager: React.FC = () => {
</div>
{/* Add new category form */}
{isAdding && (
{addingModal.isOpen && (
<div className="flex gap-2 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<input
type="text"
@@ -142,7 +133,7 @@ export const CategoryManager: React.FC = () => {
variant="secondary"
size="sm"
onClick={() => {
setIsAdding(false);
addingModal.close();
setNewCategoryName('');
}}
>
@@ -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<AccountType>(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 (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
@@ -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<LedgerSettings> = {};
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 }));
@@ -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);
@@ -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<ToggleProps> = ({ 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<CustomerSurfaceSettings>(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) => {
@@ -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<EventCategoryManagerProps> = ({ eventId }) => {
const queryClient = useQueryClient();
const { t } = useTranslation();
const [isAdding, setIsAdding] = useState(false);
const addingModal = useModal();
const [newCategoryName, setNewCategoryName] = useState('');
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null);
@@ -35,67 +34,55 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ 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<EventCategoryManagerProps> = ({ even
<div className="space-y-3">
<div className="flex justify-between items-center">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3>
{!isAdding && (
{!addingModal.isOpen && (
<Button
variant="outline"
size="sm"
onClick={() => setIsAdding(true)}
onClick={addingModal.open}
leftIcon={<Plus className="w-3 h-3" />}
>
{t('common.add')}
@@ -148,7 +135,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
</p>
{/* Add new category form */}
{isAdding && (
{addingModal.isOpen && (
<div className="flex gap-2">
<input
type="text"
@@ -175,7 +162,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
variant="outline"
size="sm"
onClick={() => {
setIsAdding(false);
addingModal.close();
setNewCategoryName('');
}}
>
@@ -25,11 +25,10 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Bell, BellOff, Save } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { api } from '../../config/api';
import { useMutationWithToast } from '../../hooks';
export interface EventReminderOverrideCardProps {
eventId: number;
@@ -48,7 +47,6 @@ export const EventReminderOverrideCard: React.FC<EventReminderOverrideCardProps>
eventId, initial, onSaved,
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [disabled, setDisabled] = useState<boolean>(!!initial.event_reminder_disabled);
const [offsetDays, setOffsetDays] = useState<string>(
@@ -64,7 +62,7 @@ export const EventReminderOverrideCard: React.FC<EventReminderOverrideCardProps>
setBodyOverride(initial.event_reminder_body_override || '');
}, [initial.event_reminder_disabled, initial.event_reminder_offset_days, initial.event_reminder_body_override]);
const save = useMutation({
const save = useMutationWithToast({
mutationFn: async () => {
const payload: Record<string, unknown> = {
event_reminder_disabled: disabled,
@@ -83,18 +81,17 @@ export const EventReminderOverrideCard: React.FC<EventReminderOverrideCardProps>
payload.event_reminder_body_override = bodyOverride.trim() === '' ? null : bodyOverride;
await api.put(`/admin/events/${eventId}`, payload);
},
successMessage: t('eventReminderOverride.saved', 'Reminder override saved.'),
invalidateKeys: [['admin-event', eventId], ['adminEvent', eventId]],
onSuccess: () => {
toast.success(t('eventReminderOverride.saved', 'Reminder override saved.'));
queryClient.invalidateQueries({ queryKey: ['admin-event', eventId] });
queryClient.invalidateQueries({ queryKey: ['adminEvent', eventId] });
onSaved?.();
},
onError: (err: unknown) => {
errorMessage: (err: unknown) => {
const e = err as { message?: string; response?: { data?: { error?: string } } };
toast.error(
return (
e?.response?.data?.error
|| e?.message
|| t('eventReminderOverride.saveError', 'Could not save reminder override.'),
|| t('eventReminderOverride.saveError', 'Could not save reminder override.')
);
},
});
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
@@ -8,12 +8,12 @@ import {
CheckCircle,
User
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Card, Loading, Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { feedbackService, type FeedbackResponse, type PhotoFeedback } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useModal } from '../../hooks';
interface FeedbackModerationPanelProps {
eventId: number;
@@ -31,7 +31,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
const { t } = useTranslation();
const { formatDateTime } = useLocalizedDate();
const queryClient = useQueryClient();
const [showAll, setShowAll] = useState(false);
const showAllModal = useModal();
// Fetch pending feedback
const { data: feedbackData, isLoading } = useQuery<FeedbackResponse>({
@@ -39,7 +39,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
type: 'comment',
status: 'pending',
limit: showAll ? 100 : maxItems
limit: showAllModal.isOpen ? 100 : maxItems
}),
refetchInterval: 30000 // Refresh every 30 seconds
});
@@ -97,7 +97,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
</div>
) : (
<div className="space-y-3">
{pendingComments.slice(0, showAll ? undefined : maxItems).map((item) => (
{pendingComments.slice(0, showAllModal.isOpen ? undefined : maxItems).map((item) => (
<div key={item.id} className="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 hover:bg-neutral-50 dark:hover:bg-neutral-800">
<div className="flex items-start gap-3">
<div className="flex-shrink-0">
@@ -182,9 +182,9 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
</div>
))}
{pendingComments.length > maxItems && !showAll && (
{pendingComments.length > maxItems && !showAllModal.isOpen && (
<button
onClick={() => setShowAll(true)}
onClick={showAllModal.open}
className="w-full text-center py-2 text-sm text-accent hover:opacity-80 font-medium"
>
{t('feedback.showAll', 'Show all {{count}} pending comments', { count: pendingComments.length })}
@@ -1,10 +1,10 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Copy, Check, Trash2 } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { Button, Input, Loading } from '../common';
import { guestsService, GuestInvite } from '../../services/guests.service';
import { toast } from 'react-toastify';
import { useMutationWithToast } from '../../hooks';
interface GuestInviteDialogProps {
eventId: number;
@@ -19,7 +19,6 @@ interface GuestInviteDialogProps {
*/
export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, onClose }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [copiedId, setCopiedId] = useState<number | null>(null);
@@ -29,25 +28,22 @@ export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, o
queryFn: () => guestsService.listInvites(eventId),
});
const createMutation = useMutation({
const createMutation = useMutationWithToast({
mutationFn: () => guestsService.createInvite(eventId, { name, email: email || undefined }),
successMessage: t('admin.guests.inviteCreated', 'Invite created'),
invalidateKeys: [['admin-guest-invites', eventId], ['admin-guests', eventId]],
onSuccess: () => {
setName('');
setEmail('');
toast.success(t('admin.guests.inviteCreated', 'Invite created'));
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
},
onError: () => toast.error(t('admin.guests.inviteCreateError', 'Failed to create invite')),
errorMessage: () => t('admin.guests.inviteCreateError', 'Failed to create invite'),
});
const revokeMutation = useMutation({
const revokeMutation = useMutationWithToast({
mutationFn: (inviteId: number) => guestsService.revokeInvite(eventId, inviteId),
onSuccess: () => {
toast.success(t('admin.guests.inviteRevoked', 'Invite revoked'));
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
},
onError: () => toast.error(t('admin.guests.inviteRevokeError', 'Failed to revoke invite')),
successMessage: t('admin.guests.inviteRevoked', 'Invite revoked'),
invalidateKeys: [['admin-guest-invites', eventId]],
errorMessage: () => t('admin.guests.inviteRevokeError', 'Failed to revoke invite'),
});
const copy = (invite: GuestInvite) => {
@@ -24,6 +24,7 @@ import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service';
import { businessProfileService } from '../../services/businessProfile.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
import { ProjectSelect } from './ProjectSelect';
export interface HoursSectionProps {
@@ -151,16 +152,11 @@ export const HoursSection: React.FC<HoursSectionProps> = ({
},
});
const deleteMutation = useMutation({
const deleteMutation = useMutationWithToast({
mutationFn: (entryId: number) => customerAdminService.deleteHourEntry(customerId, entryId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] });
toast.success(t('customers.hours.toast.deleted', 'Entry deleted'));
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || 'Failed to delete entry');
},
invalidateKeys: [['admin-customer-hour-entries', customerId], ['admin-customer', customerId]],
successMessage: t('customers.hours.toast.deleted', 'Entry deleted'),
errorMessage: 'Failed to delete entry',
});
const billMutation = useMutation({
@@ -14,6 +14,7 @@ import { toast } from 'react-toastify';
import { Save, Server, User, Lock, Eye, EyeOff, FolderSearch, PlugZap, Mailbox, RefreshCw } from 'lucide-react';
import { Button, Card, Input, Loading } from '../common';
import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service';
import { useMutationWithToast, useModal } from '../../hooks';
const labelCls = 'block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1';
const selectCls = 'w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-accent-dark';
@@ -23,14 +24,14 @@ export const IncomingMailConfigCard: React.FC = () => {
const qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['incoming-mail-config'], queryFn: () => emailService.getIncomingConfig() });
const [cfg, setCfg] = useState<IncomingMailConfig>({ imap_host: '', imap_port: 993, imap_secure: true, imap_user: '', imap_pass: '', imap_folder: 'INBOX' });
const [showPassword, setShowPassword] = useState(false);
const passwordVisibilityModal = useModal();
const [folders, setFolders] = useState<ImapFolder[] | null>(null);
useEffect(() => { if (data) setCfg(data); }, [data]);
const set = (k: keyof IncomingMailConfig, v: any) => setCfg((c) => ({ ...c, [k]: v }));
const save = useMutation({
const save = useMutationWithToast({
mutationFn: () => {
// Mirror the SMTP card's client-side required guard. Host + port +
// username are needed for the poller to authenticate (getImapConfig
@@ -40,20 +41,21 @@ export const IncomingMailConfigCard: React.FC = () => {
}
return emailService.updateIncomingConfig(cfg);
},
onSuccess: () => { toast.success(t('email.incoming.savedToast', 'Incoming mail settings saved.')); qc.invalidateQueries({ queryKey: ['incoming-mail-config'] }); },
onError: (e: any) => toast.error(e?.response?.data?.error || e?.response?.data?.errors?.[0]?.msg || e.message || 'Failed'),
successMessage: t('email.incoming.savedToast', 'Incoming mail settings saved.'),
invalidateKeys: [['incoming-mail-config']],
errorMessage: (e: any) => e?.response?.data?.error || e?.response?.data?.errors?.[0]?.msg || e.message || 'Failed',
});
const test = useMutation({
const test = useMutationWithToast({
mutationFn: () => emailService.testIncoming(cfg),
onSuccess: (r) => toast.success(t('email.incoming.testOk', 'Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.', { folder: r.folder, messages: r.messages, unseen: r.unseen })),
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.testFailed', 'Connection failed.')),
successMessage: (r) => t('email.incoming.testOk', 'Connected to {{folder}} — {{messages}} messages, {{unseen}} unread.', { folder: r.folder, messages: r.messages, unseen: r.unseen }),
errorMessage: (e: any) => e?.response?.data?.error || e.message || t('email.incoming.testFailed', 'Connection failed.'),
});
const roundTrip = useMutation({
const roundTrip = useMutationWithToast({
mutationFn: () => emailService.roundTripIncoming(),
onSuccess: (r) => toast.success(t('email.incoming.roundTripOk', 'Round-trip OK — delivered to {{recipient}} in {{seconds}}s.', { recipient: r.recipient, seconds: r.seconds })),
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || t('email.incoming.roundTripFailed', 'Round-trip test failed.')),
successMessage: (r) => t('email.incoming.roundTripOk', 'Round-trip OK — delivered to {{recipient}} in {{seconds}}s.', { recipient: r.recipient, seconds: r.seconds }),
errorMessage: (e: any) => e?.response?.data?.error || e.message || t('email.incoming.roundTripFailed', 'Round-trip test failed.'),
});
const poll = useMutation({
@@ -143,15 +145,15 @@ export const IncomingMailConfigCard: React.FC = () => {
<label className={labelCls}>{t('email.incoming.pass', 'Password')}</label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
type={passwordVisibilityModal.isOpen ? 'text' : 'password'}
value={cfg.imap_pass}
onChange={(e) => set('imap_pass', e.target.value)}
autoComplete="new-password"
placeholder={t('email.enterPassword', 'Enter password')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
/>
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600">
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
<button type="button" onClick={passwordVisibilityModal.toggle} className="absolute right-3 top-3 text-neutral-400 hover:text-neutral-600">
{passwordVisibilityModal.isOpen ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
@@ -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<PhotoExportMenuProps> = ({
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<PhotoExportMenuProps> = ({
})),
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<PhotoExportMenuProps> = ({
<div className="relative">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
onClick={menuModal.toggle}
disabled={isDisabled || isWorking}
className={`
inline-flex items-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm
@@ -167,15 +166,15 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
{selectedPhotoIds.length}
</span>
)}
<ChevronDown className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`} />
<ChevronDown className={`w-4 h-4 transition-transform ${menuModal.isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && !isDisabled && (
{menuModal.isOpen && !isDisabled && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)}
onClick={menuModal.close}
/>
{/* Dropdown Menu */}
@@ -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<string>(vat ? String(vat.rate) : '8.1');
const [direction, setDirection] = useState<VatDirection>(vat?.direction ?? 'input');
const [accountId, setAccountId] = useState<number | ''>(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 (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
@@ -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 } }));
@@ -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 = () => {
</ul>
<div className="mt-2">
<button
onClick={() => setOpen(true)}
onClick={detailsModal.open}
className="inline-flex items-center text-xs font-medium text-white bg-green-600 hover:bg-green-700 px-3 py-1.5 rounded-md transition-colors"
>
{t('admin.whatsnew.viewAll', "What's new")}
@@ -75,10 +76,10 @@ export const WhatsNewBanner: React.FC = () => {
</div>
</div>
{open && (
{detailsModal.isOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => setOpen(false)}
onClick={detailsModal.close}
>
<div
className="bg-white dark:bg-neutral-800 rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-auto p-6"
@@ -89,7 +90,7 @@ export const WhatsNewBanner: React.FC = () => {
<Sparkles className="w-5 h-5 text-green-600" />
{t('admin.whatsnew.modalTitle', "What's new")}
</h3>
<button onClick={() => setOpen(false)} className="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200">
<button onClick={detailsModal.close} className="p-1 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200">
<X className="w-5 h-5" />
</button>
</div>
@@ -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<WordFilter> }) =>
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 = () => {
+3 -1
View File
@@ -2,4 +2,6 @@ export * from './useSessionTimeout';
export * from './useOnClickOutside';
export * from './useLocalizedDate';
export * from './usePermission';
export * from './usePublicSettings';
export * from './usePublicSettings';
export * from './useMutationWithToast';
export * from './useModal';
+20
View File
@@ -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 };
}
@@ -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<TData, TError, TVariables, TContext>
extends UseMutationOptions<TData, TError, TVariables, TContext> {
/** 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<TData, TError, TVariables, TContext>
): UseMutationResult<TData, TError, TVariables, TContext> {
const queryClient = useQueryClient();
const { successMessage, errorMessage, invalidateKeys, onSuccess, onError, ...mutationOptions } =
options;
return useMutation<TData, TError, TVariables, TContext>({
...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);
},
});
}
+6 -9
View File
@@ -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
+10 -18
View File
@@ -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]) => {
+10 -21
View File
@@ -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<TabId>('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) {
+14 -23
View File
@@ -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
+5 -4
View File
@@ -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
+33 -39
View File
@@ -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<Partial<Pick<CustomerAccountDetail, EditableFields>>>({});
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={<SettingsIcon className="w-4 h-4" />}
onClick={() => setAssignedDialogOpen(true)}
onClick={() => assignedDialog.open()}
disabled={!customer.isActive}
>
{t('customers.detail.manageEvents', 'Manage galleries')}
@@ -495,13 +489,13 @@ export const CustomerDetailPage: React.FC = () => {
<AssignedEventsDialog
customerId={customer.id}
isOpen={assignedDialogOpen}
isOpen={assignedDialog.isOpen}
initial={customer.events.map((ev) => ({
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 = () => {
<Button
variant="outline"
leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => setConfirmDeactivate(true)}
onClick={() => deactivateModal.open()}
>
{t('customers.deactivate.button', 'Deactivate')}
</Button>
@@ -981,7 +975,7 @@ export const CustomerDetailPage: React.FC = () => {
<Button
variant="outline"
leftIcon={<Trash2 className="w-4 h-4 text-red-600" />}
onClick={() => setConfirmErase(true)}
onClick={() => eraseModal.open()}
>
<span className="text-red-600">
{t('customers.erase.button', 'Erase customer data')}
@@ -1000,7 +994,7 @@ export const CustomerDetailPage: React.FC = () => {
</Button>
</div>
{confirmDeactivate && (
{deactivateModal.isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div className="w-full max-w-md rounded-xl shadow-lg bg-white dark:bg-neutral-900">
<div className="p-6">
@@ -1017,13 +1011,13 @@ export const CustomerDetailPage: React.FC = () => {
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setConfirmDeactivate(false)}>
<Button variant="outline" onClick={() => deactivateModal.close()}>
{t('common.cancel', 'Cancel')}
</Button>
<Button
variant="primary"
isLoading={deactivateMutation.isPending}
onClick={() => { deactivateMutation.mutate(); setConfirmDeactivate(false); }}
onClick={() => { deactivateMutation.mutate(); deactivateModal.close(); }}
>
{t('common.confirm', 'Confirm')}
</Button>
@@ -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 && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
<div className="w-full max-w-md rounded-xl shadow-lg bg-white dark:bg-neutral-900">
<div className="p-6">
@@ -1054,14 +1048,14 @@ export const CustomerDetailPage: React.FC = () => {
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setConfirmErase(false)}>
<Button variant="outline" onClick={() => eraseModal.close()}>
{t('common.cancel', 'Cancel')}
</Button>
<button
type="button"
className="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-60 disabled:cursor-not-allowed"
disabled={eraseMutation.isPending}
onClick={() => { eraseMutation.mutate(); setConfirmErase(false); }}
onClick={() => { eraseMutation.mutate(); eraseModal.close(); }}
>
{eraseMutation.isPending
? t('customers.erase.confirmInFlight', 'Erasing…')
@@ -16,13 +16,13 @@
*/
import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import {
UserPlus, UserCog, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock,
} from 'lucide-react';
import { InlineCustomerCreate } from '../../components/admin/InlineCustomerCreate';
import { useMutationWithToast } from '../../hooks';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Card, Input, Loading } from '../../components/common';
@@ -96,22 +96,18 @@ export const CustomerManagementPage: React.FC = () => {
return list.filter((i) => i.email.toLowerCase().includes(term));
}, [invitations, debouncedTerm]);
const deactivateMutation = useMutation({
const deactivateMutation = useMutationWithToast({
mutationFn: (id: number) => customerAdminService.deactivate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
toast.success(t('customers.deactivate.success', 'Customer deactivated'));
},
onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')),
invalidateKeys: [['admin-customers']],
successMessage: t('customers.deactivate.success', 'Customer deactivated'),
errorMessage: () => t('customers.deactivate.error', 'Could not deactivate customer'),
});
const cancelInviteMutation = useMutation({
const cancelInviteMutation = useMutationWithToast({
mutationFn: (id: number) => customerAdminService.cancelInvitation(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-customer-invitations'] });
toast.success(t('customers.cancelInvitation.success', 'Invitation cancelled'));
},
onError: () => toast.error(t('customers.cancelInvitation.error', 'Could not cancel invitation')),
invalidateKeys: [['admin-customer-invitations']],
successMessage: t('customers.cancelInvitation.success', 'Invitation cancelled'),
errorMessage: () => t('customers.cancelInvitation.error', 'Could not cancel invitation'),
});
const renderCustomerName = (c: CustomerAccountSummary) => {
+21 -38
View File
@@ -22,7 +22,8 @@ import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel';
import { ReceivedEmailsPanel } from '../../components/admin/ReceivedEmailsPanel';
import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard';
import { Palette, RefreshCw, Info } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useModal, useMutationWithToast } from '../../hooks';
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
@@ -138,7 +139,7 @@ export const EmailConfigPage: React.FC = () => {
const [editingLang, setEditingLang] = useState<string>('en');
const [showPassword, setShowPassword] = useState(false);
const [testEmail, setTestEmail] = useState('');
const [showPreview, setShowPreview] = useState(false);
const previewModal = useModal();
const [previewData, setPreviewData] = useState<{ subject: string; htmlContent: string; textContent?: string }>({
subject: '',
htmlContent: '',
@@ -157,7 +158,6 @@ export const EmailConfigPage: React.FC = () => {
const [emailBodyTextColor, setEmailBodyTextColor] = useState('#333333');
const [emailMutedTextColor, setEmailMutedTextColor] = useState('#666666');
const [emailButtonTextColor, setEmailButtonTextColor] = useState('#ffffff');
const queryClient = useQueryClient();
const { flags: featureFlags } = useFeatureFlags();
// SMTP Configuration state
@@ -239,25 +239,17 @@ export const EmailConfigPage: React.FC = () => {
|| e?.message
|| fallback;
const saveConfigMutation = useMutation({
const saveConfigMutation = useMutationWithToast({
mutationFn: (config: EmailConfig) => emailService.updateConfig(config),
onSuccess: () => {
toast.success(t('toast.emailConfigSaved'));
queryClient.invalidateQueries({ queryKey: ['email-config'] });
},
onError: (e: any) => {
toast.error(errMsg(e, t('toast.saveError')));
}
successMessage: t('toast.emailConfigSaved'),
invalidateKeys: [['email-config']],
errorMessage: (e: any) => errMsg(e, t('toast.saveError')),
});
const testEmailMutation = useMutation({
const testEmailMutation = useMutationWithToast({
mutationFn: (email: string) => emailService.testEmail(email),
onSuccess: () => {
toast.success(t('email.testEmailSuccess'));
},
onError: (e: any) => {
toast.error(errMsg(e, t('toast.saveError')));
}
successMessage: t('email.testEmailSuccess'),
errorMessage: (e: any) => errMsg(e, t('toast.saveError')),
});
const flushQueueMutation = useMutation({
@@ -274,29 +266,20 @@ export const EmailConfigPage: React.FC = () => {
}
});
const saveTemplateMutation = useMutation({
const saveTemplateMutation = useMutationWithToast({
mutationFn: ({ key, translations }: { key: string; translations: Record<string, EmailTemplateTranslation> }) =>
emailService.updateTemplate(key, { translations }),
onSuccess: () => {
toast.success(t('toast.saveSuccess'));
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
queryClient.invalidateQueries({ queryKey: ['email-template', selectedTemplateKey] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
successMessage: t('toast.saveSuccess'),
invalidateKeys: [['email-templates'], ['email-template', selectedTemplateKey]],
errorMessage: () => t('toast.saveError'),
});
const saveEmailColorsMutation = useMutation({
const saveEmailColorsMutation = useMutationWithToast({
mutationFn: (colors: Record<string, string>) =>
settingsService.updateSettings(colors),
onSuccess: () => {
toast.success(t('toast.saveSuccess'));
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
successMessage: t('toast.saveSuccess'),
invalidateKeys: [['admin-settings']],
errorMessage: () => t('toast.saveError'),
});
const handleSaveEmailColors = () => {
@@ -431,7 +414,7 @@ export const EmailConfigPage: React.FC = () => {
htmlContent: preview.body_html,
textContent: preview.body_text
});
setShowPreview(true);
previewModal.open();
} catch (error) {
toast.error(t('toast.saveError'));
}
@@ -1057,8 +1040,8 @@ export const EmailConfigPage: React.FC = () => {
{/* Email Preview Modal */}
<EmailPreviewModal
isOpen={showPreview}
onClose={() => setShowPreview(false)}
isOpen={previewModal.isOpen}
onClose={previewModal.close}
subject={previewData.subject}
htmlContent={previewData.htmlContent}
textContent={previewData.textContent}
@@ -25,6 +25,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service';
import { feedbackService } from '../../services/feedback.service';
import type { PhotoFeedback, FeedbackAnalytics, FeedbackResponse } from '../../services/feedback.service';
import { useMutationWithToast } from '../../hooks';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export const EventFeedbackPage: React.FC = () => {
@@ -76,15 +77,11 @@ export const EventFeedbackPage: React.FC = () => {
});
// Update settings mutation
const updateSettingsMutation = useMutation({
const updateSettingsMutation = useMutationWithToast({
mutationFn: (newSettings: any) => feedbackService.updateEventFeedbackSettings(id!, newSettings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['feedback-settings', id] });
toast.success(t('feedback.settingsUpdated', 'Feedback settings updated'));
},
onError: () => {
toast.error(t('feedback.settingsUpdateError', 'Failed to update settings'));
}
invalidateKeys: [['feedback-settings', id]],
successMessage: t('feedback.settingsUpdated', 'Feedback settings updated'),
errorMessage: () => t('feedback.settingsUpdateError', 'Failed to update settings'),
});
// Moderate feedback mutation
+19 -26
View File
@@ -1,6 +1,5 @@
import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import {
Plus,
@@ -16,6 +15,7 @@ import {
} from 'lucide-react';
import { Button, Input, Card, Loading } from '../../components/common';
import { useModal, useMutationWithToast } from '../../hooks';
import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
@@ -27,12 +27,11 @@ const EMOJI_OPTIONS = [
export const EventTypesPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
// State
const [searchTerm, setSearchTerm] = useState('');
const [showInactive, setShowInactive] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const createModal = useModal();
const [editingType, setEditingType] = useState<EventType | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<EventType | null>(null);
@@ -43,40 +42,34 @@ export const EventTypesPage: React.FC = () => {
});
// Mutations
const createMutation = useMutation({
const createMutation = useMutationWithToast({
mutationFn: eventTypesService.createEventType,
invalidateKeys: [['admin-event-types']],
successMessage: t('eventTypes.created', 'Event type created successfully'),
errorMessage: t('eventTypes.createError', 'Failed to create event type'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
setShowCreateModal(false);
toast.success(t('eventTypes.created', 'Event type created successfully'));
},
onError: (error: any) => {
toast.error(error.response?.data?.error || t('eventTypes.createError', 'Failed to create event type'));
createModal.close();
}
});
const updateMutation = useMutation({
const updateMutation = useMutationWithToast({
mutationFn: ({ id, data }: { id: number; data: UpdateEventTypeData }) =>
eventTypesService.updateEventType(id, data),
invalidateKeys: [['admin-event-types']],
successMessage: t('eventTypes.updated', 'Event type updated successfully'),
errorMessage: t('eventTypes.updateError', 'Failed to update event type'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
setEditingType(null);
toast.success(t('eventTypes.updated', 'Event type updated successfully'));
},
onError: (error: any) => {
toast.error(error.response?.data?.error || t('eventTypes.updateError', 'Failed to update event type'));
}
});
const deleteMutation = useMutation({
const deleteMutation = useMutationWithToast({
mutationFn: eventTypesService.deleteEventType,
invalidateKeys: [['admin-event-types']],
successMessage: t('eventTypes.deleted', 'Event type deleted successfully'),
errorMessage: t('eventTypes.deleteError', 'Failed to delete event type'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
setDeleteConfirm(null);
toast.success(t('eventTypes.deleted', 'Event type deleted successfully'));
},
onError: (error: any) => {
toast.error(error.response?.data?.error || t('eventTypes.deleteError', 'Failed to delete event type'));
}
});
@@ -130,7 +123,7 @@ export const EventTypesPage: React.FC = () => {
<Button
variant="primary"
leftIcon={<Plus className="w-4 h-4" />}
onClick={() => setShowCreateModal(true)}
onClick={() => createModal.open()}
>
{t('eventTypes.createNew', 'New Event Type')}
</Button>
@@ -274,9 +267,9 @@ export const EventTypesPage: React.FC = () => {
</div>
{/* Create Modal */}
{showCreateModal && (
{createModal.isOpen && (
<EventTypeModal
onClose={() => setShowCreateModal(false)}
onClose={createModal.close}
onSubmit={(data) => createMutation.mutate(data as CreateEventTypeData)}
isLoading={createMutation.isPending}
/>
+20 -29
View File
@@ -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<number | null>(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<number | null>(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 = () => {
<Button
variant="outline"
size="sm"
onClick={() => setShowBulkArchiveModal(true)}
onClick={() => bulkArchiveModal.open()}
>
{t('events.archiveSelected')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowBulkDeleteModal(true)}
onClick={() => bulkDeleteModal.open()}
className="border-red-300 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30"
>
{t('events.deleteSelected', 'Delete Selected')}
@@ -758,8 +749,8 @@ export const EventsListPage: React.FC = () => {
{/* Bulk Archive Modal */}
<BulkArchiveModal
isOpen={showBulkArchiveModal}
onClose={() => setShowBulkArchiveModal(false)}
isOpen={bulkArchiveModal.isOpen}
onClose={bulkArchiveModal.close}
onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)}
selectedEvents={events.filter(e => selectedEvents.includes(e.id))}
isLoading={bulkArchiveMutation.isPending}
@@ -767,8 +758,8 @@ export const EventsListPage: React.FC = () => {
{/* Bulk Delete Modal */}
<BulkDeleteModal
isOpen={showBulkDeleteModal}
onClose={() => setShowBulkDeleteModal(false)}
isOpen={bulkDeleteModal.isOpen}
onClose={bulkDeleteModal.close}
onConfirm={async () => {
await bulkDeleteMutation.mutateAsync(selectedEvents);
}}
+10 -11
View File
@@ -5,34 +5,33 @@
*/
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query';
import { AlertCircle, RefreshCw, Trash2, CheckCircle } from 'lucide-react';
import { Button, Card, Loading } from '../../components/common';
import { useMutationWithToast } from '../../hooks';
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { systemHealthService } from '../../services/systemHealth.service';
export const SystemHealthPage: React.FC = () => {
const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate();
const qc = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['system-health-failures'],
queryFn: () => systemHealthService.getFailures(),
});
const invalidate = () => qc.invalidateQueries({ queryKey: ['system-health-failures'] });
const retryMutation = useMutation({
const retryMutation = useMutationWithToast({
mutationFn: (id: number) => systemHealthService.retryEmail(id),
onSuccess: () => { toast.success(t('systemHealth.retriedToast', 'Email re-queued.')); invalidate(); },
onError: () => toast.error(t('toast.saveError')),
invalidateKeys: [['system-health-failures']],
successMessage: t('systemHealth.retriedToast', 'Email re-queued.'),
errorMessage: () => t('toast.saveError'),
});
const dismissMutation = useMutation({
const dismissMutation = useMutationWithToast({
mutationFn: (id: number) => systemHealthService.dismissEmail(id),
onSuccess: () => { toast.success(t('systemHealth.dismissedToast', 'Dismissed.')); invalidate(); },
onError: () => toast.error(t('toast.saveError')),
invalidateKeys: [['system-health-failures']],
successMessage: t('systemHealth.dismissedToast', 'Dismissed.'),
errorMessage: () => t('toast.saveError'),
});
const stuckEmails = data?.stuckEmails ?? [];
+36 -50
View File
@@ -1,7 +1,6 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { useQuery } from '@tanstack/react-query';
import {
Users,
Mail,
@@ -23,7 +22,7 @@ import { parseISO, isPast } from 'date-fns';
import { Button, Input, Card, Loading } from '../../components/common';
import { userManagementService } from '../../services/userManagement.service';
import type { AdminUser, AdminRole, AdminInvitation } from '../../types';
import { useLocalizedDate } from "../../hooks";
import { useLocalizedDate, useModal, useMutationWithToast } from "../../hooks";
type TabType = 'users' | 'invitations';
@@ -369,14 +368,13 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
export const UserManagementPage: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const { formatDistanceToNow } = useLocalizedDate()
// State
const [activeTab, setActiveTab] = useState<TabType>('users');
const [searchTerm, setSearchTerm] = useState('');
const [showCreateInvitationModal, setShowCreateInvitationModal] = useState(false);
const [showEditUserModal, setShowEditUserModal] = useState(false);
const createInvitationModal = useModal();
const editUserModal = useModal();
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
@@ -413,80 +411,68 @@ export const UserManagementPage: React.FC = () => {
});
// Mutations
const createInvitationMutation = useMutation({
const createInvitationMutation = useMutationWithToast({
mutationFn: ({ email, roleId }: { email: string; roleId: number }) =>
userManagementService.createInvitation({ email, role_id: roleId }),
invalidateKeys: [['admin-invitations']],
successMessage: t('userManagement.invitationSent'),
errorMessage: (error: Error) => error.message || t('userManagement.invitationError'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-invitations'] });
setShowCreateInvitationModal(false);
toast.success(t('userManagement.invitationSent'));
},
onError: (error: Error) => {
toast.error(error.message || t('userManagement.invitationError'));
createInvitationModal.close();
},
});
const cancelInvitationMutation = useMutation({
const cancelInvitationMutation = useMutationWithToast({
mutationFn: userManagementService.cancelInvitation,
invalidateKeys: [['admin-invitations']],
successMessage: t('userManagement.invitationCancelled'),
errorMessage: () => t('userManagement.cancelInvitationError'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-invitations'] });
setConfirmDialog(null);
toast.success(t('userManagement.invitationCancelled'));
},
onError: () => {
toast.error(t('userManagement.cancelInvitationError'));
},
});
const updateUserMutation = useMutation({
const updateUserMutation = useMutationWithToast({
mutationFn: ({ id, roleId }: { id: number; roleId: number }) =>
userManagementService.updateUser(id, { roleId }),
invalidateKeys: [['admin-users']],
successMessage: t('userManagement.userUpdated'),
errorMessage: () => t('userManagement.updateUserError'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setShowEditUserModal(false);
editUserModal.close();
setSelectedUser(null);
toast.success(t('userManagement.userUpdated'));
},
onError: () => {
toast.error(t('userManagement.updateUserError'));
},
});
const deactivateUserMutation = useMutation({
const deactivateUserMutation = useMutationWithToast({
mutationFn: userManagementService.deactivateUser,
invalidateKeys: [['admin-users']],
successMessage: t('userManagement.userDeactivated'),
errorMessage: () => t('userManagement.deactivateUserError'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setConfirmDialog(null);
toast.success(t('userManagement.userDeactivated'));
},
onError: () => {
toast.error(t('userManagement.deactivateUserError'));
},
});
// #574 follow-up: reactivate + delete actions for the rows the
// deactivate button used to leave unmanageable.
const activateUserMutation = useMutation({
const activateUserMutation = useMutationWithToast({
mutationFn: userManagementService.activateUser,
invalidateKeys: [['admin-users']],
successMessage: t('userManagement.userActivated', 'User reactivated successfully'),
errorMessage: () => t('userManagement.activateUserError', 'Failed to reactivate user'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setConfirmDialog(null);
toast.success(t('userManagement.userActivated', 'User reactivated successfully'));
},
onError: () => {
toast.error(t('userManagement.activateUserError', 'Failed to reactivate user'));
},
});
const deleteUserMutation = useMutation({
const deleteUserMutation = useMutationWithToast({
mutationFn: userManagementService.deleteUser,
invalidateKeys: [['admin-users']],
successMessage: t('userManagement.userDeleted', 'User deleted successfully'),
errorMessage: () => t('userManagement.deleteUserError', 'Failed to delete user'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setConfirmDialog(null);
toast.success(t('userManagement.userDeleted', 'User deleted successfully'));
},
onError: () => {
toast.error(t('userManagement.deleteUserError', 'Failed to delete user'));
},
});
@@ -523,7 +509,7 @@ export const UserManagementPage: React.FC = () => {
const handleEditUser = (user: AdminUser) => {
setSelectedUser(user);
setShowEditUserModal(true);
editUserModal.open();
};
const handleUpdateUser = (userId: number, roleId: number) => {
@@ -641,7 +627,7 @@ export const UserManagementPage: React.FC = () => {
<Button
variant="primary"
leftIcon={<Plus className="w-5 h-5" />}
onClick={() => setShowCreateInvitationModal(true)}
onClick={createInvitationModal.open}
>
{t('userManagement.inviteUser')}
</Button>
@@ -983,8 +969,8 @@ export const UserManagementPage: React.FC = () => {
{/* Create Invitation Modal */}
<CreateInvitationModal
isOpen={showCreateInvitationModal}
onClose={() => 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 */}
<EditUserModal
isOpen={showEditUserModal}
isOpen={editUserModal.isOpen}
onClose={() => {
setShowEditUserModal(false);
editUserModal.close();
setSelectedUser(null);
}}
onSubmit={handleUpdateUser}
@@ -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<StatusFilter>('all');
const [openDeliveryId, setOpenDeliveryId] = useState<number | null>(null);
const [showTestDialog, setShowTestDialog] = useState(false);
const testDialog = useModal();
const [testEventType, setTestEventType] = useState<string>('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={<Send className="w-4 h-4" />}
onClick={() => setShowTestDialog(true)}
onClick={() => testDialog.open()}
>
Send test event
</Button>
@@ -338,8 +335,8 @@ export const WebhookDeliveriesPage: React.FC = () => {
)}
{/* Test event dialog */}
{showTestDialog && (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40" onClick={() => setShowTestDialog(false)}>
{testDialog.isOpen && (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40" onClick={() => testDialog.close()}>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl p-6 max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-3">Send test event</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">
@@ -354,7 +351,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
{WEBHOOK_EVENT_TYPES.map((e) => <option key={e} value={e}>{e}</option>)}
</select>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setShowTestDialog(false)}>Cancel</Button>
<Button variant="ghost" onClick={() => testDialog.close()}>Cancel</Button>
<Button variant="primary" isLoading={testMutation.isPending} onClick={() => testMutation.mutate()}>
Send
</Button>
@@ -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<PaymentMethod>('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 (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
@@ -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),
@@ -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<PaymentMethod>('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 (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
@@ -182,15 +185,16 @@ const InvoiceExpenseModal: React.FC<{ expense: Expense; onClose: () => void; onD
const [customer, setCustomer] = useState<SelectedCustomer[]>([]);
const [markupType, setMarkupType] = useState<MarkupType>('none');
const [markupValue, setMarkupValue] = useState<number>(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 (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
@@ -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<Expense | null>(null);
const [paidExpense, setPaidExpense] = useState<Expense | null>(null);
const [invoiceExpense, setInvoiceExpense] = useState<Expense | null>(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 = () => {
<option value="">{t('accounting.ledger.allKinds', 'All types')}</option>
{KINDS.map((k) => <option key={k} value={k}>{t(`accounting.expenseKind.${k}`, k)}</option>)}
</select>
<Button className="ml-auto" onClick={() => setShowAdd(true)}><Plus className="w-4 h-4 mr-1" /> {t('accounting.ledger.addExpense', 'Add expense')}</Button>
<Button className="ml-auto" onClick={() => addModal.open()}><Plus className="w-4 h-4 mr-1" /> {t('accounting.ledger.addExpense', 'Add expense')}</Button>
</div>
{isLoading ? <Loading /> : items.length === 0 ? (
@@ -318,7 +322,7 @@ export const ExpensesLedgerPage: React.FC = () => {
</div>
)}
{showAdd && <ExpenseFormModal categories={categories ?? []} onClose={() => setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
{addModal.isOpen && <ExpenseFormModal categories={categories ?? []} onClose={() => addModal.close()} onDone={() => { addModal.close(); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
{editExpense && <ExpenseFormModal categories={categories ?? []} expense={editExpense} onClose={() => setEditExpense(null)} onDone={() => { setEditExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
{paidExpense && <ExpensePaidModal expense={paidExpense} onClose={() => setPaidExpense(null)} onDone={() => { setPaidExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
{invoiceExpense && <InvoiceExpenseModal expense={invoiceExpense} onClose={() => setInvoiceExpense(null)} onDone={() => { setInvoiceExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
@@ -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
@@ -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 <Loading />;
@@ -1003,7 +990,7 @@ const RestampSignaturesCard: React.FC<RestampCardProps> = ({ 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<RestampCardProps> = ({ 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;
@@ -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
@@ -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<Record<string, any>>({});
useEffect(() => { if (data) setValues(data); }, [data]);
const saveAll = useMutation({
const saveAll = useMutationWithToast({
mutationFn: async () => {
const changed: Record<string, any> = {};
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 <Loading />;
@@ -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
@@ -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<BusinessProfile | null>(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 <Loading />;
@@ -614,24 +612,20 @@ const BankAccountsSection: React.FC<BankAccountsSectionProps> = ({ 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({
@@ -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.');
@@ -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 <div className="p-10"><Loading /></div>;
@@ -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;