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 = () => {