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 React, { useState } from 'react';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react'; import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react';
import { Card, Button, Loading } from '../common'; import { Card, Button, Loading } from '../common';
@@ -9,6 +9,7 @@ import { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
import { GuestInviteDialog } from './GuestInviteDialog'; import { GuestInviteDialog } from './GuestInviteDialog';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast, useModal } from '../../hooks';
interface AdminGuestsListProps { interface AdminGuestsListProps {
eventId: number; eventId: number;
@@ -20,37 +21,34 @@ type View = 'list' | 'aggregate';
export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, eventName }) => { export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, eventName }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { format: fmtDate } = useLocalizedDate(); const { format: fmtDate } = useLocalizedDate();
const queryClient = useQueryClient();
const [view, setView] = useState<View>('list'); const [view, setView] = useState<View>('list');
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null); const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
const [mergeMode, setMergeMode] = useState(false); const [mergeMode, setMergeMode] = useState(false);
const [mergeSelection, setMergeSelection] = useState<number[]>([]); const [mergeSelection, setMergeSelection] = useState<number[]>([]);
const [inviteDialogOpen, setInviteDialogOpen] = useState(false); const inviteModal = useModal();
const { data, isLoading, refetch } = useQuery({ const { data, isLoading, refetch } = useQuery({
queryKey: ['admin-guests', eventId], queryKey: ['admin-guests', eventId],
queryFn: () => guestsService.getEventGuests(eventId), queryFn: () => guestsService.getEventGuests(eventId),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutationWithToast({
mutationFn: (guestId: number) => guestsService.deleteGuest(eventId, guestId), mutationFn: (guestId: number) => guestsService.deleteGuest(eventId, guestId),
onSuccess: () => { successMessage: t('admin.guests.deletedToast', 'Guest removed'),
toast.success(t('admin.guests.deletedToast', 'Guest removed')); invalidateKeys: [['admin-guests', eventId]],
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] }); errorMessage: () => t('admin.guests.deletedError', 'Failed to remove guest'),
},
onError: () => toast.error(t('admin.guests.deletedError', 'Failed to remove guest')),
}); });
const mergeMutation = useMutation({ const mergeMutation = useMutationWithToast({
mutationFn: ({ keepId, mergeIds }: { keepId: number; mergeIds: number[] }) => mutationFn: ({ keepId, mergeIds }: { keepId: number; mergeIds: number[] }) =>
guestsService.mergeGuests(eventId, keepId, mergeIds), guestsService.mergeGuests(eventId, keepId, mergeIds),
successMessage: t('admin.guests.mergedToast', 'Guests merged'),
invalidateKeys: [['admin-guests', eventId]],
onSuccess: () => { onSuccess: () => {
toast.success(t('admin.guests.mergedToast', 'Guests merged'));
setMergeMode(false); setMergeMode(false);
setMergeSelection([]); 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) => { const handleDelete = (guest: AdminGuest) => {
@@ -160,7 +158,7 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
variant="outline" variant="outline"
size="sm" size="sm"
leftIcon={<UserPlus className="w-4 h-4" />} leftIcon={<UserPlus className="w-4 h-4" />}
onClick={() => setInviteDialogOpen(true)} onClick={inviteModal.open}
> >
{t('admin.guests.createInvite', 'Create invite')} {t('admin.guests.createInvite', 'Create invite')}
</Button> </Button>
@@ -331,12 +329,12 @@ export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, event
/> />
)} )}
{inviteDialogOpen && ( {inviteModal.isOpen && (
<GuestInviteDialog <GuestInviteDialog
eventId={eventId} eventId={eventId}
eventName={eventName} eventName={eventName}
onClose={() => { onClose={() => {
setInviteDialogOpen(false); inviteModal.close();
refetch(); refetch();
}} }}
/> />
+22 -21
View File
@@ -9,6 +9,7 @@ import { useAdminAuth } from '../../contexts';
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext'; import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
import { useOnClickOutside } from '../../hooks/useOnClickOutside'; import { useOnClickOutside } from '../../hooks/useOnClickOutside';
import { usePublicSettings } from '../../hooks/usePublicSettings'; import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useModal } from '../../hooks';
import { PasswordChangeModal } from './PasswordChangeModal'; import { PasswordChangeModal } from './PasswordChangeModal';
import { LanguageSelector, SUPPORTED_LANGUAGES } from '../common'; import { LanguageSelector, SUPPORTED_LANGUAGES } from '../common';
import { notificationsService } from '../../services/notifications.service'; import { notificationsService } from '../../services/notifications.service';
@@ -25,10 +26,10 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode(); const { isDark, toggle: toggleDarkMode, forcedMode } = useAdminDarkMode();
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { format, formatDistanceToNow } = useLocalizedDate(); const { format, formatDistanceToNow } = useLocalizedDate();
const [showUserMenu, setShowUserMenu] = useState(false); const userMenuModal = useModal();
const [showUserMenuLangSection, setShowUserMenuLangSection] = useState(false); const userMenuLangSectionModal = useModal();
const [showNotifications, setShowNotifications] = useState(false); const notificationsModal = useModal();
const [showPasswordModal, setShowPasswordModal] = useState(false); const passwordModal = useModal();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: brandingSettings, isLoading: brandingLoading } = usePublicSettings(); 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 // outer dropdown closes, so re-opening it doesn't surprise the user
// with the language list already expanded from the previous session. // with the language list already expanded from the previous session.
const closeUserMenu = () => { const closeUserMenu = () => {
setShowUserMenu(false); userMenuModal.close();
setShowUserMenuLangSection(false); userMenuLangSectionModal.close();
}; };
const handleUserMenuLangSelect = (languageCode: string) => { const handleUserMenuLangSelect = (languageCode: string) => {
i18n.changeLanguage(languageCode); i18n.changeLanguage(languageCode);
@@ -150,7 +151,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const notificationRef = useRef<HTMLDivElement>(null); const notificationRef = useRef<HTMLDivElement>(null);
useOnClickOutside(userMenuRef, closeUserMenu); useOnClickOutside(userMenuRef, closeUserMenu);
useOnClickOutside(notificationRef, () => setShowNotifications(false)); useOnClickOutside(notificationRef, notificationsModal.close);
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
@@ -159,8 +160,8 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
// Fetch notifications // Fetch notifications
const { data: notificationsData } = useQuery({ const { data: notificationsData } = useQuery({
queryKey: ['notifications', showNotifications], queryKey: ['notifications', notificationsModal.isOpen],
queryFn: () => notificationsService.getNotifications(showNotifications, 20), queryFn: () => notificationsService.getNotifications(notificationsModal.isOpen, 20),
refetchInterval: 60000, // Refetch every minute refetchInterval: 60000, // Refetch every minute
}); });
@@ -297,7 +298,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{/* Notifications */} {/* Notifications */}
<div className="relative" ref={notificationRef}> <div className="relative" ref={notificationRef}>
<button <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" 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" /> <Bell className="w-5 h-5" />
@@ -307,7 +308,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
</button> </button>
{/* Notifications dropdown */} {/* 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="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"> <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> <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 && ( {notifications.length > 0 && (
<div className="px-4 py-2 border-t border-neutral-100 dark:border-neutral-700 text-center"> <div className="px-4 py-2 border-t border-neutral-100 dark:border-neutral-700 text-center">
<button <button
onClick={() => setShowNotifications(false)} onClick={notificationsModal.close}
className="text-sm text-accent hover:opacity-80" className="text-sm text-accent hover:opacity-80"
> >
{t('admin.close')} {t('admin.close')}
@@ -382,7 +383,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{/* User menu */} {/* User menu */}
<div className="relative" ref={userMenuRef}> <div className="relative" ref={userMenuRef}>
<button <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" 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"> <div className="text-right hidden sm:block">
@@ -395,7 +396,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
</button> </button>
{/* User dropdown */} {/* 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="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"> <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> <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. */} so the menu isn't 8 rows taller by default. */}
<div className="sm:hidden border-b border-neutral-100 dark:border-neutral-700"> <div className="sm:hidden border-b border-neutral-100 dark:border-neutral-700">
<button <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" 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" /> <Globe className="w-4 h-4" />
<span className="flex-1">{t('common.language', 'Language')}</span> <span className="flex-1">{t('common.language', 'Language')}</span>
<currentLanguage.Flag className="w-4 h-4" /> <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> </button>
{showUserMenuLangSection && ( {userMenuLangSectionModal.isOpen && (
<div className="bg-neutral-50 dark:bg-neutral-900 py-1"> <div className="bg-neutral-50 dark:bg-neutral-900 py-1">
{SUPPORTED_LANGUAGES.map((language) => ( {SUPPORTED_LANGUAGES.map((language) => (
<button <button
@@ -449,7 +450,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<button <button
onClick={() => { onClick={() => {
closeUserMenu(); 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" 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"
> >
@@ -472,8 +473,8 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
{/* Password Change Modal */} {/* Password Change Modal */}
<PasswordChangeModal <PasswordChangeModal
isOpen={showPasswordModal} isOpen={passwordModal.isOpen}
onClose={() => setShowPasswordModal(false)} onClose={passwordModal.close}
/> />
</header> </header>
); );
@@ -1,7 +1,7 @@
import React, { useState } from 'react'; 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 { 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 { 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 { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service'; import { photosService } from '../../services/photos.service';
@@ -10,6 +10,7 @@ import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo'; import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast, useModal } from '../../hooks';
type AdminFeedbackResponse = { type AdminFeedbackResponse = {
feedback: PhotoFeedback[]; feedback: PhotoFeedback[];
@@ -35,8 +36,8 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
}) => { }) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [showCategoryMenu, setShowCategoryMenu] = useState(false); const categoryMenuModal = useModal();
const [expandedComments, setExpandedComments] = useState(false); const commentsModal = useModal();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { formatDateTime: fmtDateTime } = useLocalizedDate(); const { formatDateTime: fmtDateTime } = useLocalizedDate();
@@ -115,7 +116,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
try { try {
await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId); await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId);
toast.success('Category updated'); toast.success('Category updated');
setShowCategoryMenu(false); categoryMenuModal.close();
// Invalidate photos query to refresh data // Invalidate photos query to refresh data
await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId.toString()] }); await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId.toString()] });
await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId] }); await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId] });
@@ -127,27 +128,19 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
}; };
// Mutations for feedback moderation // Mutations for feedback moderation
const moderateFeedbackMutation = useMutation({ const moderateFeedbackMutation = useMutationWithToast({
mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) => mutationFn: ({ feedbackId, action }: { feedbackId: string; action: 'approve' | 'hide' | 'reject' }) =>
feedbackService.moderateFeedback(feedbackId, action), feedbackService.moderateFeedback(feedbackId, action),
onSuccess: () => { invalidateKeys: [['admin-photo-feedback', eventId, currentPhoto?.id]],
queryClient.invalidateQueries({ queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id] }); successMessage: 'Feedback moderated successfully',
toast.success('Feedback moderated successfully'); errorMessage: () => 'Failed to moderate feedback'
},
onError: () => {
toast.error('Failed to moderate feedback');
}
}); });
const deleteFeedbackMutation = useMutation({ const deleteFeedbackMutation = useMutationWithToast({
mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId), mutationFn: (feedbackId: string) => feedbackService.deleteFeedback(feedbackId),
onSuccess: () => { invalidateKeys: [['admin-photo-feedback', eventId, currentPhoto?.id]],
queryClient.invalidateQueries({ queryKey: ['admin-photo-feedback', eventId, currentPhoto?.id] }); successMessage: 'Feedback deleted successfully',
toast.success('Feedback deleted successfully'); errorMessage: () => 'Failed to delete feedback'
},
onError: () => {
toast.error('Failed to delete feedback');
}
}); });
React.useEffect(() => { React.useEffect(() => {
@@ -266,7 +259,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
Category Category
</span> </span>
<button <button
onClick={() => setShowCategoryMenu(!showCategoryMenu)} onClick={categoryMenuModal.toggle}
className="text-xs text-accent hover:text-accent-dark" className="text-xs text-accent hover:text-accent-dark"
> >
Change Change
@@ -276,7 +269,7 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
{currentPhoto.category_name || 'Uncategorized'} {currentPhoto.category_name || 'Uncategorized'}
</p> </p>
{showCategoryMenu && ( {categoryMenuModal.isOpen && (
<div className="mt-2 bg-neutral-800 rounded-lg p-2"> <div className="mt-2 bg-neutral-800 rounded-lg p-2">
<button <button
onClick={() => handleCategoryChange(null)} onClick={() => handleCategoryChange(null)}
@@ -393,13 +386,13 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
{comments.length > 0 && ( {comments.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<button <button
onClick={() => setExpandedComments(!expandedComments)} onClick={commentsModal.toggle}
className="text-xs text-accent hover:text-accent-dark mb-2" 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> </button>
{expandedComments && ( {commentsModal.isOpen && (
<div className="space-y-3 max-h-64 overflow-y-auto"> <div className="space-y-3 max-h-64 overflow-y-auto">
{comments.map((comment) => ( {comments.map((comment) => (
<div key={comment.id} className="bg-neutral-800 rounded-lg p-3"> <div key={comment.id} className="bg-neutral-800 rounded-lg p-3">
@@ -20,10 +20,10 @@ import {
RefreshCw, RefreshCw,
Loader2 Loader2
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'react-toastify'; import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Input, Loading } from '../common'; import { Button, Card, Input, Loading } from '../common';
import { api } from '../../config/api'; import { api } from '../../config/api';
import { useMutationWithToast } from '../../hooks';
// Per [[feedback_respect_general_format_settings]]: route every displayed // Per [[feedback_respect_general_format_settings]]: route every displayed
// date/time through useLocalizedDate so the admin's general_date_format + // date/time through useLocalizedDate so the admin's general_date_format +
// general_time_format settings apply uniformly. Previously the backup // general_time_format settings apply uniformly. Previously the backup
@@ -53,7 +53,6 @@ export const BackupHistory = () => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all'); const [filterStatus, setFilterStatus] = useState('all');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const queryClient = useQueryClient();
// Locale-aware formatters that respect admin's general_date_format + // Locale-aware formatters that respect admin's general_date_format +
// general_time_format settings. See useLocalizedDate.ts for the full // general_time_format settings. See useLocalizedDate.ts for the full
// contract; formatTime gives "HH:mm" (24h) or "h:mm a" (12h) based on // contract; formatTime gives "HH:mm" (24h) or "h:mm a" (12h) based on
@@ -77,18 +76,14 @@ export const BackupHistory = () => {
}); });
// Delete backup mutation // Delete backup mutation
const deleteMutation = useMutation({ const deleteMutation = useMutationWithToast({
mutationFn: async (backupId) => { mutationFn: async (backupId) => {
const response = await api.delete(`/admin/backup/runs/${backupId}`); const response = await api.delete(`/admin/backup/runs/${backupId}`);
return response.data; return response.data;
}, },
onSuccess: () => { successMessage: 'Backup deleted successfully',
toast.success('Backup deleted successfully'); invalidateKeys: [['backup-history']],
queryClient.invalidateQueries({ queryKey: ['backup-history'] }); errorMessage: 'Failed to delete backup'
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to delete backup');
}
}); });
const toggleRowExpansion = (id) => { const toggleRowExpansion = (id) => {
@@ -1,15 +1,14 @@
import React, { useState } from 'react'; 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 { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { categoriesService, type PhotoCategory } from '../../services/categories.service'; import { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { Button } from '../common'; import { Button } from '../common';
import { useMutationWithToast, useModal } from '../../hooks';
export const CategoryManager: React.FC = () => { export const CategoryManager: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient(); const addingModal = useModal();
const [isAdding, setIsAdding] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingId, setEditingId] = useState<number | null>(null);
const [newCategoryName, setNewCategoryName] = useState(''); const [newCategoryName, setNewCategoryName] = useState('');
const [editingName, setEditingName] = useState(''); const [editingName, setEditingName] = useState('');
@@ -21,45 +20,37 @@ export const CategoryManager: React.FC = () => {
}); });
// Create category mutation // Create category mutation
const createMutation = useMutation({ const createMutation = useMutationWithToast({
mutationFn: (name: string) => mutationFn: (name: string) =>
categoriesService.createCategory({ name, is_global: true }), categoriesService.createCategory({ name, is_global: true }),
invalidateKeys: [['global-categories']],
successMessage: t('categories.categoryCreatedSuccess'),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success(t('categories.categoryCreatedSuccess'));
setNewCategoryName(''); setNewCategoryName('');
setIsAdding(false); addingModal.close();
},
onError: (error: any) => {
toast.error(error.response?.data?.error || t('categories.failedToCreateCategory'));
}, },
errorMessage: t('categories.failedToCreateCategory'),
}); });
// Update category mutation // Update category mutation
const updateMutation = useMutation({ const updateMutation = useMutationWithToast({
mutationFn: ({ id, name }: { id: number; name: string }) => mutationFn: ({ id, name }: { id: number; name: string }) =>
categoriesService.updateCategory(id, name), categoriesService.updateCategory(id, name),
invalidateKeys: [['global-categories']],
successMessage: t('toast.categoryUpdated'),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['global-categories'] });
toast.success(t('toast.categoryUpdated'));
setEditingId(null); setEditingId(null);
setEditingName(''); setEditingName('');
}, },
onError: (error: any) => { errorMessage: t('toast.saveError'),
toast.error(error.response?.data?.error || t('toast.saveError'));
},
}); });
// Delete category mutation // Delete category mutation
const deleteMutation = useMutation({ const deleteMutation = useMutationWithToast({
mutationFn: categoriesService.deleteCategory, mutationFn: categoriesService.deleteCategory,
onSuccess: () => { invalidateKeys: [['global-categories']],
queryClient.invalidateQueries({ queryKey: ['global-categories'] }); successMessage: t('categories.categoryDeletedSuccess'),
toast.success(t('categories.categoryDeletedSuccess')); errorMessage: t('categories.failedToDeleteCategory'),
},
onError: (error: any) => {
toast.error(error.response?.data?.error || t('categories.failedToDeleteCategory'));
},
}); });
const handleCreate = () => { const handleCreate = () => {
@@ -102,11 +93,11 @@ export const CategoryManager: React.FC = () => {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('categories.title')}</h3> <h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('categories.title')}</h3>
{!isAdding && ( {!addingModal.isOpen && (
<Button <Button
variant="primary" variant="primary"
size="sm" size="sm"
onClick={() => setIsAdding(true)} onClick={addingModal.open}
leftIcon={<Plus className="w-4 h-4" />} leftIcon={<Plus className="w-4 h-4" />}
> >
{t('categories.addCategory')} {t('categories.addCategory')}
@@ -115,7 +106,7 @@ export const CategoryManager: React.FC = () => {
</div> </div>
{/* Add new category form */} {/* Add new category form */}
{isAdding && ( {addingModal.isOpen && (
<div className="flex gap-2 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg"> <div className="flex gap-2 p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<input <input
type="text" type="text"
@@ -142,7 +133,7 @@ export const CategoryManager: React.FC = () => {
variant="secondary" variant="secondary"
size="sm" size="sm"
onClick={() => { onClick={() => {
setIsAdding(false); addingModal.close();
setNewCategoryName(''); setNewCategoryName('');
}} }}
> >
@@ -13,15 +13,15 @@
* VatCodesManager. Scoping each patch keeps the two from reverting each other. * VatCodesManager. Scoping each patch keeps the two from reverting each other.
*/ */
import React, { useEffect, useMemo, useState } from 'react'; 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 { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { X, Plus, Pencil, Trash2, AlertCircle } from 'lucide-react'; import { X, Plus, Pencil, Trash2, AlertCircle } from 'lucide-react';
import { Button, Card, CardContent, Input, Loading } from '../common'; import { Button, Card, CardContent, Input, Loading } from '../common';
import { import {
ledgerService, type LedgerAccount, type AccountType, type LedgerSettings, ledgerService, type LedgerAccount, type AccountType, type LedgerSettings,
} from '../../services/ledger.service'; } from '../../services/ledger.service';
import { categoryLabel } from '../../services/accounting.service'; import { categoryLabel } from '../../services/accounting.service';
import { useMutationWithToast } from '../../hooks';
const ACCOUNT_TYPES: AccountType[] = ['asset', 'liability', 'equity', 'revenue', 'expense']; 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'; 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 [number, setNumber] = useState(account?.number ?? '');
const [name, setName] = useState(account?.name ?? ''); const [name, setName] = useState(account?.name ?? '');
const [type, setType] = useState<AccountType>(account?.type ?? 'expense'); 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 }), mutationFn: () => isEdit ? ledgerService.updateAccount(account!.id, { number, name, type }) : ledgerService.createAccount({ number, name, type }),
onSuccess: () => { toast.success(t('common.saved', 'Saved.')); onDone(); }, successMessage: t('common.saved', 'Saved.'),
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), onSuccess: () => onDone(),
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
}); });
return ( return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4"> <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 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), mutationFn: (id: number) => ledgerService.deleteAccount(id),
onSuccess: () => { toast.success(t('common.deleted', 'Deleted.')); refetchAll(); }, successMessage: t('common.deleted', 'Deleted.'),
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), 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), mutationFn: ({ id, accId }: { id: number; accId: number | null }) => ledgerService.setCategoryAccount(id, accId),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); }, invalidateKeys: [['ledger-mappings']],
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
}); });
// Save ONLY the account keys — the VAT maps are owned by VatCodesManager and // 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 // updateSettings is a partial merge, so scoping the patch here prevents a
// stale full-settings save from reverting the maps. // stale full-settings save from reverting the maps.
const saveSettings = useMutation({ const saveSettings = useMutationWithToast({
mutationFn: () => { mutationFn: () => {
const patch: Partial<LedgerSettings> = {}; const patch: Partial<LedgerSettings> = {};
for (const k of SETTING_ACCOUNT_KEYS) patch[k] = settings[k]; for (const k of SETTING_ACCOUNT_KEYS) patch[k] = settings[k];
return ledgerService.updateSettings(patch); return ledgerService.updateSettings(patch);
}, },
onSuccess: () => { toast.success(t('ledger.settingsSaved', 'Mappings saved.')); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); }, successMessage: t('ledger.settingsSaved', 'Mappings saved.'),
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',
}); });
const setAcctSetting = (key: keyof LedgerSettings, value: string) => setSettings((s) => ({ ...s, [key]: value })); 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 { Button, Card, Loading } from '../common';
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service'; import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
export const CssTemplateEditor: React.FC = () => { export const CssTemplateEditor: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -57,15 +58,11 @@ export const CssTemplateEditor: React.FC = () => {
}); });
// Reset mutation // Reset mutation
const resetMutation = useMutation({ const resetMutation = useMutationWithToast({
mutationFn: () => cssTemplatesService.resetToDefault(), mutationFn: () => cssTemplatesService.resetToDefault(),
onSuccess: () => { invalidateKeys: [['css-templates']],
queryClient.invalidateQueries({ queryKey: ['css-templates'] }); successMessage: t('cssTemplates.reset', 'Template reset to default'),
toast.success(t('cssTemplates.reset', 'Template reset to default')); errorMessage: (error: Error) => error.message || t('cssTemplates.resetFailed', 'Failed to reset template')
},
onError: (error: Error) => {
toast.error(error.message || t('cssTemplates.resetFailed', 'Failed to reset template'));
}
}); });
const activeTemplate = localTemplates.find(t => t.slot_number === activeSlot); const activeTemplate = localTemplates.find(t => t.slot_number === activeSlot);
@@ -14,11 +14,11 @@
*/ */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { Save, Image as ImageIcon, Type, UserCog } from 'lucide-react'; import { Save, Image as ImageIcon, Type, UserCog } from 'lucide-react';
import { Button, Card, Loading } from '../common'; import { Button, Card, Loading } from '../common';
import { api } from '../../config/api'; import { api } from '../../config/api';
import { useMutationWithToast } from '../../hooks';
interface CustomerSurfaceSettings { interface CustomerSurfaceSettings {
customer_show_logo: boolean; customer_show_logo: boolean;
@@ -74,7 +74,6 @@ const Toggle: React.FC<ToggleProps> = ({ enabled, onChange, label, hint, icon: I
export const CustomerDashboardBrandingCard: React.FC = () => { export const CustomerDashboardBrandingCard: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['admin-settings-customer-surface'], queryKey: ['admin-settings-customer-surface'],
@@ -87,17 +86,14 @@ export const CustomerDashboardBrandingCard: React.FC = () => {
const [form, setForm] = useState<CustomerSurfaceSettings>(DEFAULTS); const [form, setForm] = useState<CustomerSurfaceSettings>(DEFAULTS);
useEffect(() => { if (data) setForm(data); }, [data]); useEffect(() => { if (data) setForm(data); }, [data]);
const saveMutation = useMutation({ const saveMutation = useMutationWithToast({
mutationFn: () => api.put('/admin/settings/customer-surface', form), 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) // The customer-side session response (/api/customer/auth/session)
// also bundles these as branding flags — invalidate so a customer // also bundles these as branding flags — invalidate public-settings so
// tab refresh picks up the new visibility on the next focus. // a customer tab refresh picks up the new visibility on the next focus.
qc.invalidateQueries({ queryKey: ['public-settings'] }); invalidateKeys: [['admin-settings-customer-surface'], ['public-settings']],
toast.success(t('settings.customerSurface.saved', 'Customer dashboard branding saved')); successMessage: t('settings.customerSurface.saved', 'Customer dashboard branding saved'),
}, errorMessage: () => t('settings.customerSurface.error', 'Could not save settings'),
onError: () => toast.error(t('settings.customerSurface.error', 'Could not save settings')),
}); });
const toggle = (key: keyof CustomerSurfaceSettings) => { const toggle = (key: keyof CustomerSurfaceSettings) => {
@@ -1,20 +1,19 @@
import React, { useState } from 'react'; 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 { 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 { categoriesService, type PhotoCategory } from '../../services/categories.service';
import { photosService } from '../../services/photos.service'; import { photosService } from '../../services/photos.service';
import { Button, Card, AuthenticatedImage } from '../common'; import { Button, Card, AuthenticatedImage } from '../common';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMutationWithToast, useModal } from '../../hooks';
interface EventCategoryManagerProps { interface EventCategoryManagerProps {
eventId: number; eventId: number;
} }
export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ eventId }) => { export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ eventId }) => {
const queryClient = useQueryClient();
const { t } = useTranslation(); const { t } = useTranslation();
const [isAdding, setIsAdding] = useState(false); const addingModal = useModal();
const [newCategoryName, setNewCategoryName] = useState(''); const [newCategoryName, setNewCategoryName] = useState('');
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null); 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); const eventCategories = categories.filter(cat => !cat.is_global);
// Create category mutation // Create category mutation
const createMutation = useMutation({ const createMutation = useMutationWithToast({
mutationFn: (name: string) => mutationFn: (name: string) =>
categoriesService.createCategory({ categoriesService.createCategory({
name, name,
is_global: false, is_global: false,
event_id: eventId event_id: eventId
}), }),
invalidateKeys: [['event-categories', eventId]],
successMessage: t('categories.categoryCreatedSuccess'),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] });
toast.success(t('categories.categoryCreatedSuccess'));
setNewCategoryName(''); setNewCategoryName('');
setIsAdding(false); addingModal.close();
},
onError: (error: any) => {
toast.error(error.response?.data?.error || t('categories.failedToCreateCategory'));
}, },
errorMessage: t('categories.failedToCreateCategory'),
}); });
// Delete category mutation // Delete category mutation
const deleteMutation = useMutation({ const deleteMutation = useMutationWithToast({
mutationFn: categoriesService.deleteCategory, mutationFn: categoriesService.deleteCategory,
onSuccess: () => { invalidateKeys: [['event-categories', eventId]],
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] }); successMessage: t('categories.categoryDeletedSuccess'),
toast.success(t('categories.categoryDeletedSuccess')); errorMessage: t('categories.failedToDeleteCategory'),
},
onError: (error: any) => {
toast.error(error.response?.data?.error || t('categories.failedToDeleteCategory'));
},
}); });
// Set hero photo mutation // Set hero photo mutation
const heroMutation = useMutation({ const heroMutation = useMutationWithToast({
mutationFn: ({ categoryId, photoId }: { categoryId: number; photoId: number | null }) => mutationFn: ({ categoryId, photoId }: { categoryId: number; photoId: number | null }) =>
categoriesService.setCategoryHeroPhoto(categoryId, photoId), categoriesService.setCategoryHeroPhoto(categoryId, photoId),
onSuccess: (_data, variables) => { invalidateKeys: [['event-categories', eventId]],
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] }); successMessage: (_data, variables) =>
variables.photoId ? t('categories.coverPhotoSet') : t('categories.coverPhotoRemoved'),
onSuccess: () => {
setHeroPickerCategoryId(null); 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 // Toggle per-category download permission (#640). The backend AND's this
// with the event-level `allow_downloads`, so disabling at either level // with the event-level `allow_downloads`, so disabling at either level
// blocks downloads for this category's photos. // blocks downloads for this category's photos.
const downloadToggleMutation = useMutation({ const downloadToggleMutation = useMutationWithToast({
mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) => mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) =>
categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }), categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }),
onSuccess: (_data, variables) => { invalidateKeys: [['event-categories', eventId]],
queryClient.invalidateQueries({ queryKey: ['event-categories', eventId] }); successMessage: (_data, variables) =>
toast.success(
variables.allow variables.allow
? t('categories.downloadsEnabled', 'Downloads enabled for this category') ? t('categories.downloadsEnabled', 'Downloads enabled for this category')
: t('categories.downloadsDisabled', 'Downloads disabled for this category') : t('categories.downloadsDisabled', 'Downloads disabled for this category'),
); errorMessage: t('categories.failedToToggleDownloads', 'Failed to update download permission'),
},
onError: (error: any) => {
toast.error(error.response?.data?.error || t('categories.failedToToggleDownloads', 'Failed to update download permission'));
},
}); });
const handleCreate = () => { const handleCreate = () => {
@@ -130,11 +117,11 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3> <h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3>
{!isAdding && ( {!addingModal.isOpen && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setIsAdding(true)} onClick={addingModal.open}
leftIcon={<Plus className="w-3 h-3" />} leftIcon={<Plus className="w-3 h-3" />}
> >
{t('common.add')} {t('common.add')}
@@ -148,7 +135,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
</p> </p>
{/* Add new category form */} {/* Add new category form */}
{isAdding && ( {addingModal.isOpen && (
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
@@ -175,7 +162,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
setIsAdding(false); addingModal.close();
setNewCategoryName(''); setNewCategoryName('');
}} }}
> >
@@ -25,11 +25,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { Bell, BellOff, Save } from 'lucide-react';
import { Button, Card, Input } from '../common'; import { Button, Card, Input } from '../common';
import { api } from '../../config/api'; import { api } from '../../config/api';
import { useMutationWithToast } from '../../hooks';
export interface EventReminderOverrideCardProps { export interface EventReminderOverrideCardProps {
eventId: number; eventId: number;
@@ -48,7 +47,6 @@ export const EventReminderOverrideCard: React.FC<EventReminderOverrideCardProps>
eventId, initial, onSaved, eventId, initial, onSaved,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const [disabled, setDisabled] = useState<boolean>(!!initial.event_reminder_disabled); const [disabled, setDisabled] = useState<boolean>(!!initial.event_reminder_disabled);
const [offsetDays, setOffsetDays] = useState<string>( const [offsetDays, setOffsetDays] = useState<string>(
@@ -64,7 +62,7 @@ export const EventReminderOverrideCard: React.FC<EventReminderOverrideCardProps>
setBodyOverride(initial.event_reminder_body_override || ''); setBodyOverride(initial.event_reminder_body_override || '');
}, [initial.event_reminder_disabled, initial.event_reminder_offset_days, 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 () => { mutationFn: async () => {
const payload: Record<string, unknown> = { const payload: Record<string, unknown> = {
event_reminder_disabled: disabled, event_reminder_disabled: disabled,
@@ -83,18 +81,17 @@ export const EventReminderOverrideCard: React.FC<EventReminderOverrideCardProps>
payload.event_reminder_body_override = bodyOverride.trim() === '' ? null : bodyOverride; payload.event_reminder_body_override = bodyOverride.trim() === '' ? null : bodyOverride;
await api.put(`/admin/events/${eventId}`, payload); await api.put(`/admin/events/${eventId}`, payload);
}, },
successMessage: t('eventReminderOverride.saved', 'Reminder override saved.'),
invalidateKeys: [['admin-event', eventId], ['adminEvent', eventId]],
onSuccess: () => { onSuccess: () => {
toast.success(t('eventReminderOverride.saved', 'Reminder override saved.'));
queryClient.invalidateQueries({ queryKey: ['admin-event', eventId] });
queryClient.invalidateQueries({ queryKey: ['adminEvent', eventId] });
onSaved?.(); onSaved?.();
}, },
onError: (err: unknown) => { errorMessage: (err: unknown) => {
const e = err as { message?: string; response?: { data?: { error?: string } } }; const e = err as { message?: string; response?: { data?: { error?: string } } };
toast.error( return (
e?.response?.data?.error e?.response?.data?.error
|| e?.message || 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
@@ -8,12 +8,12 @@ import {
CheckCircle, CheckCircle,
User User
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'react-toastify';
import { Card, Loading, Button } from '../common'; import { Card, Loading, Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage'; import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { feedbackService, type FeedbackResponse, type PhotoFeedback } from '../../services/feedback.service'; import { feedbackService, type FeedbackResponse, type PhotoFeedback } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useModal } from '../../hooks';
interface FeedbackModerationPanelProps { interface FeedbackModerationPanelProps {
eventId: number; eventId: number;
@@ -31,7 +31,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
const { t } = useTranslation(); const { t } = useTranslation();
const { formatDateTime } = useLocalizedDate(); const { formatDateTime } = useLocalizedDate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [showAll, setShowAll] = useState(false); const showAllModal = useModal();
// Fetch pending feedback // Fetch pending feedback
const { data: feedbackData, isLoading } = useQuery<FeedbackResponse>({ const { data: feedbackData, isLoading } = useQuery<FeedbackResponse>({
@@ -39,7 +39,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
queryFn: () => feedbackService.getEventFeedback(eventId.toString(), { queryFn: () => feedbackService.getEventFeedback(eventId.toString(), {
type: 'comment', type: 'comment',
status: 'pending', status: 'pending',
limit: showAll ? 100 : maxItems limit: showAllModal.isOpen ? 100 : maxItems
}), }),
refetchInterval: 30000 // Refresh every 30 seconds refetchInterval: 30000 // Refresh every 30 seconds
}); });
@@ -97,7 +97,7 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
</div> </div>
) : ( ) : (
<div className="space-y-3"> <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 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 items-start gap-3">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@@ -182,9 +182,9 @@ export const FeedbackModerationPanel: React.FC<FeedbackModerationPanelProps> = (
</div> </div>
))} ))}
{pendingComments.length > maxItems && !showAll && ( {pendingComments.length > maxItems && !showAllModal.isOpen && (
<button <button
onClick={() => setShowAll(true)} onClick={showAllModal.open}
className="w-full text-center py-2 text-sm text-accent hover:opacity-80 font-medium" 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 })} {t('feedback.showAll', 'Show all {{count}} pending comments', { count: pendingComments.length })}
@@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { X, Copy, Check, Trash2 } from 'lucide-react'; 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 { Button, Input, Loading } from '../common';
import { guestsService, GuestInvite } from '../../services/guests.service'; import { guestsService, GuestInvite } from '../../services/guests.service';
import { toast } from 'react-toastify'; import { useMutationWithToast } from '../../hooks';
interface GuestInviteDialogProps { interface GuestInviteDialogProps {
eventId: number; eventId: number;
@@ -19,7 +19,6 @@ interface GuestInviteDialogProps {
*/ */
export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, onClose }) => { export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, onClose }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState(''); const [name, setName] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [copiedId, setCopiedId] = useState<number | null>(null); const [copiedId, setCopiedId] = useState<number | null>(null);
@@ -29,25 +28,22 @@ export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, o
queryFn: () => guestsService.listInvites(eventId), queryFn: () => guestsService.listInvites(eventId),
}); });
const createMutation = useMutation({ const createMutation = useMutationWithToast({
mutationFn: () => guestsService.createInvite(eventId, { name, email: email || undefined }), mutationFn: () => guestsService.createInvite(eventId, { name, email: email || undefined }),
successMessage: t('admin.guests.inviteCreated', 'Invite created'),
invalidateKeys: [['admin-guest-invites', eventId], ['admin-guests', eventId]],
onSuccess: () => { onSuccess: () => {
setName(''); setName('');
setEmail(''); 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), mutationFn: (inviteId: number) => guestsService.revokeInvite(eventId, inviteId),
onSuccess: () => { successMessage: t('admin.guests.inviteRevoked', 'Invite revoked'),
toast.success(t('admin.guests.inviteRevoked', 'Invite revoked')); invalidateKeys: [['admin-guest-invites', eventId]],
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] }); errorMessage: () => t('admin.guests.inviteRevokeError', 'Failed to revoke invite'),
},
onError: () => toast.error(t('admin.guests.inviteRevokeError', 'Failed to revoke invite')),
}); });
const copy = (invite: GuestInvite) => { const copy = (invite: GuestInvite) => {
@@ -24,6 +24,7 @@ import { parseLocaleDecimal, parseDuration } from '../../utils/parsers';
import { customerAdminService } from '../../services/customerAdmin.service'; import { customerAdminService } from '../../services/customerAdmin.service';
import { businessProfileService } from '../../services/businessProfile.service'; import { businessProfileService } from '../../services/businessProfile.service';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
import { ProjectSelect } from './ProjectSelect'; import { ProjectSelect } from './ProjectSelect';
export interface HoursSectionProps { 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), mutationFn: (entryId: number) => customerAdminService.deleteHourEntry(customerId, entryId),
onSuccess: () => { invalidateKeys: [['admin-customer-hour-entries', customerId], ['admin-customer', customerId]],
qc.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] }); successMessage: t('customers.hours.toast.deleted', 'Entry deleted'),
qc.invalidateQueries({ queryKey: ['admin-customer', customerId] }); errorMessage: 'Failed to delete entry',
toast.success(t('customers.hours.toast.deleted', 'Entry deleted'));
},
onError: (err: any) => {
toast.error(err?.response?.data?.error || 'Failed to delete entry');
},
}); });
const billMutation = useMutation({ 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 { Save, Server, User, Lock, Eye, EyeOff, FolderSearch, PlugZap, Mailbox, RefreshCw } from 'lucide-react';
import { Button, Card, Input, Loading } from '../common'; import { Button, Card, Input, Loading } from '../common';
import { emailService, type IncomingMailConfig, type ImapFolder } from '../../services/email.service'; 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 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'; 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 qc = useQueryClient();
const { data, isLoading } = useQuery({ queryKey: ['incoming-mail-config'], queryFn: () => emailService.getIncomingConfig() }); 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 [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); const [folders, setFolders] = useState<ImapFolder[] | null>(null);
useEffect(() => { if (data) setCfg(data); }, [data]); useEffect(() => { if (data) setCfg(data); }, [data]);
const set = (k: keyof IncomingMailConfig, v: any) => setCfg((c) => ({ ...c, [k]: v })); const set = (k: keyof IncomingMailConfig, v: any) => setCfg((c) => ({ ...c, [k]: v }));
const save = useMutation({ const save = useMutationWithToast({
mutationFn: () => { mutationFn: () => {
// Mirror the SMTP card's client-side required guard. Host + port + // Mirror the SMTP card's client-side required guard. Host + port +
// username are needed for the poller to authenticate (getImapConfig // username are needed for the poller to authenticate (getImapConfig
@@ -40,20 +41,21 @@ export const IncomingMailConfigCard: React.FC = () => {
} }
return emailService.updateIncomingConfig(cfg); return emailService.updateIncomingConfig(cfg);
}, },
onSuccess: () => { toast.success(t('email.incoming.savedToast', 'Incoming mail settings saved.')); qc.invalidateQueries({ queryKey: ['incoming-mail-config'] }); }, successMessage: t('email.incoming.savedToast', 'Incoming mail settings saved.'),
onError: (e: any) => toast.error(e?.response?.data?.error || e?.response?.data?.errors?.[0]?.msg || e.message || 'Failed'), 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), 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 })), successMessage: (r) => 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.')), errorMessage: (e: any) => e?.response?.data?.error || e.message || t('email.incoming.testFailed', 'Connection failed.'),
}); });
const roundTrip = useMutation({ const roundTrip = useMutationWithToast({
mutationFn: () => emailService.roundTripIncoming(), 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 })), successMessage: (r) => 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.')), errorMessage: (e: any) => e?.response?.data?.error || e.message || t('email.incoming.roundTripFailed', 'Round-trip test failed.'),
}); });
const poll = useMutation({ const poll = useMutation({
@@ -143,15 +145,15 @@ export const IncomingMailConfigCard: React.FC = () => {
<label className={labelCls}>{t('email.incoming.pass', 'Password')}</label> <label className={labelCls}>{t('email.incoming.pass', 'Password')}</label>
<div className="relative"> <div className="relative">
<Input <Input
type={showPassword ? 'text' : 'password'} type={passwordVisibilityModal.isOpen ? 'text' : 'password'}
value={cfg.imap_pass} value={cfg.imap_pass}
onChange={(e) => set('imap_pass', e.target.value)} onChange={(e) => set('imap_pass', e.target.value)}
autoComplete="new-password" autoComplete="new-password"
placeholder={t('email.enterPassword', 'Enter password')} placeholder={t('email.enterPassword', 'Enter password')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />} 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"> <button type="button" onClick={passwordVisibilityModal.toggle} 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" />} {passwordVisibilityModal.isOpen ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button> </button>
</div> </div>
</div> </div>
@@ -5,6 +5,7 @@ import { useMutation } from '@tanstack/react-query';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { photosService, ExportOptions, FeedbackFilters } from '../../services/photos.service'; import { photosService, ExportOptions, FeedbackFilters } from '../../services/photos.service';
import { ExportPreviewModal } from './ExportPreviewModal'; import { ExportPreviewModal } from './ExportPreviewModal';
import { useMutationWithToast, useModal } from '../../hooks';
// TXT + CSV render through the preview modal (with copy-to-clipboard and a // 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 // fallback download button). XMP is a ZIP archive — no textarea preview makes
@@ -53,22 +54,20 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
disabled = false disabled = false
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false); const menuModal = useModal();
const [preview, setPreview] = useState<{ const [preview, setPreview] = useState<{
format: 'txt' | 'csv'; format: 'txt' | 'csv';
content: string; content: string;
filename: string; filename: string;
} | null>(null); } | null>(null);
const exportMutation = useMutation({ const exportMutation = useMutationWithToast({
mutationFn: (options: ExportOptions) => photosService.exportPhotos(eventId, options), mutationFn: (options: ExportOptions) => photosService.exportPhotos(eventId, options),
successMessage: t('export.success', 'Export downloaded successfully'),
onSuccess: () => { onSuccess: () => {
toast.success(t('export.success', 'Export downloaded successfully')); menuModal.close();
setIsOpen(false);
}, },
onError: (error: Error) => { errorMessage: (error: Error) => t('export.error', 'Export failed: ') + error.message
toast.error(t('export.error', 'Export failed: ') + error.message);
}
}); });
const previewMutation = useMutation({ const previewMutation = useMutation({
@@ -79,7 +78,7 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
})), })),
onSuccess: (result) => { onSuccess: (result) => {
setPreview(result); setPreview(result);
setIsOpen(false); menuModal.close();
}, },
onError: (error: Error) => { onError: (error: Error) => {
toast.error(t('export.error', 'Export failed: ') + error.message); toast.error(t('export.error', 'Export failed: ') + error.message);
@@ -145,7 +144,7 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
<div className="relative"> <div className="relative">
<button <button
type="button" type="button"
onClick={() => setIsOpen(!isOpen)} onClick={menuModal.toggle}
disabled={isDisabled || isWorking} disabled={isDisabled || isWorking}
className={` className={`
inline-flex items-center gap-2 px-4 py-2 rounded-lg border font-medium text-sm 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} {selectedPhotoIds.length}
</span> </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> </button>
{isOpen && !isDisabled && ( {menuModal.isOpen && !isDisabled && (
<> <>
{/* Backdrop */} {/* Backdrop */}
<div <div
className="fixed inset-0 z-10" className="fixed inset-0 z-10"
onClick={() => setIsOpen(false)} onClick={menuModal.close}
/> />
{/* Dropdown Menu */} {/* Dropdown Menu */}
@@ -8,14 +8,14 @@
* keys, and the two never overwrite each other. * keys, and the two never overwrite each other.
*/ */
import React, { useEffect, useMemo, useState } from 'react'; 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 { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { X, Plus, Pencil, Trash2 } from 'lucide-react'; import { X, Plus, Pencil, Trash2 } from 'lucide-react';
import { Button, Card, CardContent, Input, Loading } from '../common'; import { Button, Card, CardContent, Input, Loading } from '../common';
import { import {
ledgerService, type LedgerAccount, type VatCode, type VatDirection, type LedgerSettings, ledgerService, type LedgerAccount, type VatCode, type VatDirection, type LedgerSettings,
} from '../../services/ledger.service'; } 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 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'; 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 [rate, setRate] = useState<string>(vat ? String(vat.rate) : '8.1');
const [direction, setDirection] = useState<VatDirection>(vat?.direction ?? 'input'); const [direction, setDirection] = useState<VatDirection>(vat?.direction ?? 'input');
const [accountId, setAccountId] = useState<number | ''>(vat?.account_id ?? ''); const [accountId, setAccountId] = useState<number | ''>(vat?.account_id ?? '');
const save = useMutation({ const save = useMutationWithToast({
mutationFn: () => { mutationFn: () => {
const payload = { code, name, rate: Number(rate) || 0, direction, accountId: accountId === '' ? null : Number(accountId) }; const payload = { code, name, rate: Number(rate) || 0, direction, accountId: accountId === '' ? null : Number(accountId) };
return isEdit ? ledgerService.updateVatCode(vat!.id, payload) : ledgerService.createVatCode(payload); return isEdit ? ledgerService.updateVatCode(vat!.id, payload) : ledgerService.createVatCode(payload);
}, },
onSuccess: () => { toast.success(t('common.saved', 'Saved.')); onDone(); }, successMessage: t('common.saved', 'Saved.'),
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), onSuccess: () => onDone(),
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
}); });
return ( return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4"> <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 refetch = () => { qc.invalidateQueries({ queryKey: ['ledger-vat-codes'] }); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); };
const delVat = useMutation({ const delVat = useMutationWithToast({
mutationFn: (id: number) => ledgerService.deleteVatCode(id), mutationFn: (id: number) => ledgerService.deleteVatCode(id),
onSuccess: () => { toast.success(t('common.deleted', 'Deleted.')); refetch(); }, successMessage: t('common.deleted', 'Deleted.'),
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), onSuccess: () => refetch(),
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
}); });
// PARTIAL save — only the two map keys, never the account keys. // PARTIAL save — only the two map keys, never the account keys.
const saveMaps = useMutation({ const saveMaps = useMutationWithToast({
mutationFn: () => ledgerService.updateSettings({ mutationFn: () => ledgerService.updateSettings({
ledger_vat_map: maps.ledger_vat_map || {}, ledger_vat_map: maps.ledger_vat_map || {},
ledger_output_vat_map: maps.ledger_output_vat_map || {}, ledger_output_vat_map: maps.ledger_output_vat_map || {},
}), }),
onSuccess: () => { toast.success(t('ledger.settingsSaved', 'Mappings saved.')); qc.invalidateQueries({ queryKey: ['ledger-mappings'] }); }, successMessage: t('ledger.settingsSaved', 'Mappings saved.'),
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',
}); });
const setVatMap = (tt: string, code: string) => setMaps((s) => ({ ...s, ledger_vat_map: { ...(s.ledger_vat_map || {}), [tt]: code } })); 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 { useTranslation } from 'react-i18next';
import { Sparkles, X, ExternalLink, ChevronRight } from 'lucide-react'; import { Sparkles, X, ExternalLink, ChevronRight } from 'lucide-react';
import { adminService } from '../../services/admin.service'; import { adminService } from '../../services/admin.service';
import { useModal } from '../../hooks';
export const WhatsNewBanner: React.FC = () => { export const WhatsNewBanner: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [open, setOpen] = useState(false); const detailsModal = useModal();
const [hidden, setHidden] = useState(false); const [hidden, setHidden] = useState(false);
const { data } = useQuery({ const { data } = useQuery({
@@ -31,7 +32,7 @@ export const WhatsNewBanner: React.FC = () => {
mutationFn: () => adminService.markWhatsNewSeen(), mutationFn: () => adminService.markWhatsNewSeen(),
onSuccess: () => { onSuccess: () => {
setHidden(true); setHidden(true);
setOpen(false); detailsModal.close();
qc.invalidateQueries({ queryKey: ['whatsnew'] }); qc.invalidateQueries({ queryKey: ['whatsnew'] });
}, },
}); });
@@ -56,7 +57,7 @@ export const WhatsNewBanner: React.FC = () => {
</ul> </ul>
<div className="mt-2"> <div className="mt-2">
<button <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" 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")} {t('admin.whatsnew.viewAll', "What's new")}
@@ -75,10 +76,10 @@ export const WhatsNewBanner: React.FC = () => {
</div> </div>
</div> </div>
{open && ( {detailsModal.isOpen && (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={() => setOpen(false)} onClick={detailsModal.close}
> >
<div <div
className="bg-white dark:bg-neutral-800 rounded-lg shadow-xl max-w-lg w-full max-h-[80vh] overflow-auto p-6" 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" /> <Sparkles className="w-5 h-5 text-green-600" />
{t('admin.whatsnew.modalTitle', "What's new")} {t('admin.whatsnew.modalTitle', "What's new")}
</h3> </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" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
@@ -15,6 +15,7 @@ import {
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { Card, Button, Input, Loading } from '../common'; import { Card, Button, Input, Loading } from '../common';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService } from '../../services/feedback.service';
import { useMutationWithToast } from '../../hooks';
interface WordFilter { interface WordFilter {
id: number; id: number;
@@ -62,29 +63,23 @@ export const WordFilterManager: React.FC = () => {
}); });
// Update word filter mutation // Update word filter mutation
const updateMutation = useMutation({ const updateMutation = useMutationWithToast({
mutationFn: ({ id, updates }: { id: number; updates: Partial<WordFilter> }) => mutationFn: ({ id, updates }: { id: number; updates: Partial<WordFilter> }) =>
feedbackService.updateWordFilter(id, updates), feedbackService.updateWordFilter(id, updates),
invalidateKeys: [['word-filters']],
successMessage: t('settings.moderation.filterUpdated', 'Word filter updated successfully'),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['word-filters'] });
toast.success(t('settings.moderation.filterUpdated', 'Word filter updated successfully'));
setEditingId(null); setEditingId(null);
}, },
onError: () => { errorMessage: () => t('settings.moderation.updateError', 'Failed to update word filter')
toast.error(t('settings.moderation.updateError', 'Failed to update word filter'));
}
}); });
// Delete word filter mutation // Delete word filter mutation
const deleteMutation = useMutation({ const deleteMutation = useMutationWithToast({
mutationFn: (id: number) => feedbackService.deleteWordFilter(id), mutationFn: (id: number) => feedbackService.deleteWordFilter(id),
onSuccess: () => { invalidateKeys: [['word-filters']],
queryClient.invalidateQueries({ queryKey: ['word-filters'] }); successMessage: t('settings.moderation.filterDeleted', 'Word filter deleted successfully'),
toast.success(t('settings.moderation.filterDeleted', 'Word filter deleted successfully')); errorMessage: () => t('settings.moderation.deleteError', 'Failed to delete word filter')
},
onError: () => {
toast.error(t('settings.moderation.deleteError', 'Failed to delete word filter'));
}
}); });
const handleAdd = () => { const handleAdd = () => {
+2
View File
@@ -3,3 +3,5 @@ export * from './useOnClickOutside';
export * from './useLocalizedDate'; export * from './useLocalizedDate';
export * from './usePermission'; 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'; } from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns'; import { differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
import { Button, Card, Loading } from '../../components/common'; import { Button, Card, Loading } from '../../components/common';
import { UpdateNotification } from '../../components/admin/UpdateNotification'; import { UpdateNotification } from '../../components/admin/UpdateNotification';
import { WhatsNewBanner } from '../../components/admin/WhatsNewBanner'; import { WhatsNewBanner } from '../../components/admin/WhatsNewBanner';
import { CrmOverviewSection } from '../../components/admin/CrmOverviewSection'; 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 { eventsService } from '../../services/events.service';
import { adminService, ActivityType } from '../../services/admin.service'; import { adminService, ActivityType } from '../../services/admin.service';
import { workflowsService } from '../../services/workflows.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 // Pending workflow approvals — only when the workflow engine is live. These
// are the human-in-the-loop gates (e.g. "review invoice before sending"). // are the human-in-the-loop gates (e.g. "review invoice before sending").
const { flags } = useFeatureFlags(); const { flags } = useFeatureFlags();
const qc = useQueryClient();
const { data: pendingApprovals } = useQuery({ const { data: pendingApprovals } = useQuery({
queryKey: ['workflow-approvals'], queryKey: ['workflow-approvals'],
queryFn: () => workflowsService.approvals(), queryFn: () => workflowsService.approvals(),
enabled: !!flags.workflows, enabled: !!flags.workflows,
}); });
const approvalMutation = useMutation({ const approvalMutation = useMutationWithToast({
mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action), mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action),
onSuccess: () => { invalidateKeys: [['workflow-approvals']],
qc.invalidateQueries({ queryKey: ['workflow-approvals'] }); successMessage: t('workflows.approvals.acted', 'Done') as string,
toast.success(t('workflows.approvals.acted', 'Done') as string); errorMessage: t('common.error', 'Something went wrong') as string,
},
onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)),
}); });
// Admin detail route for an approval's run entity, so clicking opens the // 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 { toast } from 'react-toastify';
import { Button, Input, Card, Loading } from '../../components/common'; 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 { archiveService } from '../../services/archive.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
// import { useNavigate } from 'react-router-dom'; // import { useNavigate } from 'react-router-dom';
export const ArchivesPage: React.FC = () => { export const ArchivesPage: React.FC = () => {
@@ -30,7 +31,6 @@ export const ArchivesPage: React.FC = () => {
const [sortBy, setSortBy] = useState<'date' | 'size' | 'name'>('date'); const [sortBy, setSortBy] = useState<'date' | 'size' | 'name'>('date');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
// const navigate = useNavigate(); // const navigate = useNavigate();
const queryClient = useQueryClient();
// Helper function to safely format dates // Helper function to safely format dates
const formatDate = (dateString: string | null | undefined, formatStr: string): string => { const formatDate = (dateString: string | null | undefined, formatStr: string): string => {
@@ -79,26 +79,18 @@ export const ArchivesPage: React.FC = () => {
}; };
// Mutations // Mutations
const restoreMutation = useMutation({ const restoreMutation = useMutationWithToast({
mutationFn: (id: number) => archiveService.restoreArchive(id), mutationFn: (id: number) => archiveService.restoreArchive(id),
onSuccess: () => { successMessage: t('archives.restoreSuccess'),
toast.success(t('archives.restoreSuccess')); errorMessage: () => t('errors.somethingWentWrong'),
queryClient.invalidateQueries({ queryKey: ['admin-archives'] }); invalidateKeys: [['admin-archives']],
},
onError: () => {
toast.error(t('errors.somethingWentWrong'));
}
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutationWithToast({
mutationFn: (id: number) => archiveService.deleteArchive(id), mutationFn: (id: number) => archiveService.deleteArchive(id),
onSuccess: () => { successMessage: t('archives.deleteSuccess'),
toast.success(t('archives.deleteSuccess')); errorMessage: () => t('errors.somethingWentWrong'),
queryClient.invalidateQueries({ queryKey: ['admin-archives'] }); invalidateKeys: [['admin-archives']],
},
onError: () => {
toast.error(t('errors.somethingWentWrong'));
}
}); });
const handleDownload = async (archive: typeof archives[0]) => { const handleDownload = async (archive: typeof archives[0]) => {
+10 -21
View File
@@ -13,12 +13,12 @@ import {
ShieldCheck, ShieldCheck,
FolderTree, FolderTree,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'react-toastify'; import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Card, Loading } from '../../components/common'; import { Button, Card, Loading } from '../../components/common';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
import { BackupDashboard } from '../../components/admin/BackupDashboard'; import { BackupDashboard } from '../../components/admin/BackupDashboard';
import { BackupConfiguration } from '../../components/admin/BackupConfiguration'; import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
import { BackupHistory } from '../../components/admin/BackupHistory'; import { BackupHistory } from '../../components/admin/BackupHistory';
@@ -31,7 +31,6 @@ type TabId = 'dashboard' | 'configuration' | 'history' | 'restore' | 'integrity'
export const BackupManagement: React.FC = () => { export const BackupManagement: React.FC = () => {
const [activeTab, setActiveTab] = useState<TabId>('dashboard'); const [activeTab, setActiveTab] = useState<TabId>('dashboard');
const queryClient = useQueryClient();
const { t } = useTranslation(); const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate(); const { formatDateTime: fmtDateTime } = useLocalizedDate();
@@ -61,34 +60,24 @@ export const BackupManagement: React.FC = () => {
}, },
}); });
const manualBackupMutation = useMutation({ const manualBackupMutation = useMutationWithToast({
mutationFn: async () => { mutationFn: async () => {
const response = await api.post('/admin/backup/run'); const response = await api.post('/admin/backup/run');
return response.data; return response.data;
}, },
onSuccess: () => { successMessage: t('backup.messages.backupStarted'),
toast.success(t('backup.messages.backupStarted')); errorMessage: t('backup.messages.backupFailed'),
queryClient.invalidateQueries({ queryKey: ['backup-status'] }); invalidateKeys: [['backup-status']],
},
onError: (error: any) => {
const message = error.response?.data?.error || t('backup.messages.backupFailed');
toast.error(message);
},
}); });
const updateConfigMutation = useMutation({ const updateConfigMutation = useMutationWithToast({
mutationFn: async (config: unknown) => { mutationFn: async (config: unknown) => {
const response = await api.put('/admin/backup/config', config); const response = await api.put('/admin/backup/config', config);
return response.data; return response.data;
}, },
onSuccess: () => { successMessage: t('backup.messages.configUpdated'),
toast.success(t('backup.messages.configUpdated')); errorMessage: t('backup.messages.configUpdateFailed'),
queryClient.invalidateQueries({ queryKey: ['backup-config'] }); invalidateKeys: [['backup-config']],
},
onError: (error: any) => {
const message = error.response?.data?.error || t('backup.messages.configUpdateFailed');
toast.error(message);
},
}); });
if (statusLoading || configLoading) { if (statusLoading || configLoading) {
+10 -19
View File
@@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
import { Button, Card, Input, ErrorBoundary, Loading, MarkdownContent } from '../../components/common'; import { Button, Card, Input, ErrorBoundary, Loading, MarkdownContent } from '../../components/common';
import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin'; import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin';
import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext'; 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 { settingsService, type BrandingSettings } from '../../services/settings.service';
import { businessProfileService } from '../../services/businessProfile.service'; import { businessProfileService } from '../../services/businessProfile.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -13,6 +13,7 @@ import { useFeatureEnabled, useFeatureFlags } from '../../contexts/FeatureFlagsC
import { CustomerDashboardBrandingCard } from '../../components/admin/CustomerDashboardBrandingCard'; import { CustomerDashboardBrandingCard } from '../../components/admin/CustomerDashboardBrandingCard';
import { PdfTypographyCard } from '../../components/admin/PdfTypographyCard'; import { PdfTypographyCard } from '../../components/admin/PdfTypographyCard';
import { usePublicSettings } from '../../hooks/usePublicSettings'; import { usePublicSettings } from '../../hooks/usePublicSettings';
import { useMutationWithToast } from '../../hooks';
export const BrandingPage: React.FC = () => { export const BrandingPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -88,33 +89,23 @@ export const BrandingPage: React.FC = () => {
// Update branding mutation // Update branding mutation
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const brandingMutation = useMutation({ const brandingMutation = useMutationWithToast({
mutationFn: settingsService.updateBranding, mutationFn: settingsService.updateBranding,
onSuccess: () => { successMessage: t('toast.brandingUpdated'),
toast.success(t('toast.brandingUpdated')); errorMessage: () => t('toast.saveError'),
// Invalidate all settings queries to refresh data // Invalidate all settings queries to refresh data
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); invalidateKeys: [['admin-settings'], ['public-settings']],
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
},
}); });
// Update theme mutation // Update theme mutation
const themeMutation = useMutation({ const themeMutation = useMutationWithToast({
mutationFn: settingsService.updateTheme, mutationFn: settingsService.updateTheme,
onSuccess: () => { successMessage: t('toast.themeUpdated'),
toast.success(t('toast.themeUpdated')); errorMessage: () => t('toast.saveError'),
// Refresh both the admin settings cache (which the page reads from) and // Refresh both the admin settings cache (which the page reads from) and
// the public-settings cache (which the gallery reads from) so the saved // the public-settings cache (which the gallery reads from) so the saved
// theme is reflected without a manual reload (#317). // theme is reflected without a manual reload (#317).
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); invalidateKeys: [['admin-settings'], ['public-settings']],
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
},
onError: () => {
toast.error(t('toast.saveError'));
},
}); });
// Initialize settings from database // 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 { settingsService, PublicSiteBranding } from '../../services/settings.service';
import { buildResourceUrl } from '../../utils/url'; import { buildResourceUrl } from '../../utils/url';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../hooks';
export const CMSPage: React.FC = () => { export const CMSPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -221,14 +222,14 @@ export const CMSPage: React.FC = () => {
}, },
onError: () => toast.error(t('toast.uploadError')), onError: () => toast.error(t('toast.uploadError')),
}); });
const clearLogoMutation = useMutation({ const clearLogoMutation = useMutationWithToast({
mutationFn: async () => cmsService.clearPageLogo(selectedPage), mutationFn: async () => cmsService.clearPageLogo(selectedPage),
successMessage: t('cms.logoCleared', 'Logo cleared'),
errorMessage: () => t('toast.saveError'),
invalidateKeys: [['cms-pages']],
onSuccess: () => { onSuccess: () => {
setEditForm(prev => ({ ...prev, logo_url: null })); 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 // Warn before leaving with unsaved changes
+30 -36
View File
@@ -32,6 +32,7 @@ import { CustomerCrmPanels } from '../../components/admin/CustomerCrmPanels';
import { HoursSection } from '../../components/admin/HoursSection'; import { HoursSection } from '../../components/admin/HoursSection';
import { formatMoney } from '../../components/admin/LineItemsTable'; import { formatMoney } from '../../components/admin/LineItemsTable';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { useMutationWithToast, useModal } from '../../hooks';
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext'; import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
type EditableFields = type EditableFields =
@@ -101,13 +102,13 @@ export const CustomerDetailPage: React.FC = () => {
}; };
const [form, setForm] = useState<Partial<Pick<CustomerAccountDetail, EditableFields>>>({}); const [form, setForm] = useState<Partial<Pick<CustomerAccountDetail, EditableFields>>>({});
const [confirmDeactivate, setConfirmDeactivate] = useState(false); const deactivateModal = useModal();
const [confirmErase, setConfirmErase] = useState(false); const eraseModal = useModal();
// Drives the "Manage galleries" modal launched from the Assigned // Drives the "Manage galleries" modal launched from the Assigned
// events card. We hold open-state here (rather than inside the // events card. We hold open-state here (rather than inside the
// dialog) so the parent decides when to mount/unmount and the // dialog) so the parent decides when to mount/unmount and the
// dialog can hard-reset its internal state per open. // 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 // 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 // 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 * Confirm dialog ahead of the click is surfaced via the same modal
* pattern as deactivate. * pattern as deactivate.
*/ */
const passwordResetMutation = useMutation({ const passwordResetMutation = useMutationWithToast({
mutationFn: () => customerAdminService.sendPasswordReset(customerId), mutationFn: () => customerAdminService.sendPasswordReset(customerId),
onSuccess: () => toast.success(t('customers.detail.passwordReset.success', 'Password reset email sent')), successMessage: t('customers.detail.passwordReset.success', 'Password reset email sent'),
onError: () => toast.error(t('customers.detail.passwordReset.error', 'Could not send password reset')), errorMessage: () => t('customers.detail.passwordReset.error', 'Could not send password reset'),
}); });
// Promote a passive customer to active by firing the standard // 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 // before the configured day. Surfaces backend errors verbatim so
// admin sees "No pending monthly bill" / "Draft is empty" when the // admin sees "No pending monthly bill" / "Draft is empty" when the
// queue isn't ready. // queue isn't ready.
const triggerMonthlyBillMutation = useMutation({ const triggerMonthlyBillMutation = useMutationWithToast({
mutationFn: () => customerAdminService.triggerMonthlyBill(customerId), mutationFn: () => customerAdminService.triggerMonthlyBill(customerId),
onSuccess: (result) => { invalidateKeys: [
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] }); ['admin-customer', customerId],
queryClient.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] }); ['admin-customer-hour-entries', customerId],
// Clear the draft preview so the list collapses to empty // Clear the draft preview so the list collapses to empty
// immediately after the trigger ships — a new draft is minted // immediately after the trigger ships — a new draft is minted
// on the next createInvoice / hour-entry append. // on the next createInvoice / hour-entry append.
queryClient.invalidateQueries({ queryKey: ['admin-customer-monthly-draft', customerId] }); ['admin-customer-monthly-draft', customerId],
toast.success( ],
successMessage: (result) =>
t('customers.billing.triggered', t('customers.billing.triggered',
'Monthly bill issued: {{number}}', 'Monthly bill issued: {{number}}',
{ number: result.invoiceNumber }), { number: result.invoiceNumber }),
); errorMessage: t('customers.billing.triggerError', 'Could not trigger the monthly bill.'),
},
onError: (err: any) => {
toast.error(err?.response?.data?.error
|| t('customers.billing.triggerError', 'Could not trigger the monthly bill.'));
},
}); });
const deactivateMutation = useMutation({ const deactivateMutation = useMutation({
@@ -252,14 +249,11 @@ export const CustomerDetailPage: React.FC = () => {
}); });
/** Re-enable login for a deactivated customer. */ /** Re-enable login for a deactivated customer. */
const reactivateMutation = useMutation({ const reactivateMutation = useMutationWithToast({
mutationFn: () => customerAdminService.reactivate(customerId), mutationFn: () => customerAdminService.reactivate(customerId),
onSuccess: () => { invalidateKeys: [['admin-customer', customerId], ['admin-customers']],
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] }); successMessage: t('customers.reactivate.success', 'Customer reactivated'),
queryClient.invalidateQueries({ queryKey: ['admin-customers'] }); errorMessage: () => t('customers.reactivate.error', 'Could not reactivate customer'),
toast.success(t('customers.reactivate.success', 'Customer reactivated'));
},
onError: () => toast.error(t('customers.reactivate.error', 'Could not reactivate customer')),
}); });
/** /**
@@ -466,7 +460,7 @@ export const CustomerDetailPage: React.FC = () => {
variant="outline" variant="outline"
size="sm" size="sm"
leftIcon={<SettingsIcon className="w-4 h-4" />} leftIcon={<SettingsIcon className="w-4 h-4" />}
onClick={() => setAssignedDialogOpen(true)} onClick={() => assignedDialog.open()}
disabled={!customer.isActive} disabled={!customer.isActive}
> >
{t('customers.detail.manageEvents', 'Manage galleries')} {t('customers.detail.manageEvents', 'Manage galleries')}
@@ -495,13 +489,13 @@ export const CustomerDetailPage: React.FC = () => {
<AssignedEventsDialog <AssignedEventsDialog
customerId={customer.id} customerId={customer.id}
isOpen={assignedDialogOpen} isOpen={assignedDialog.isOpen}
initial={customer.events.map((ev) => ({ initial={customer.events.map((ev) => ({
id: ev.id, id: ev.id,
eventName: ev.eventName, eventName: ev.eventName,
eventDate: ev.eventDate || null, eventDate: ev.eventDate || null,
}))} }))}
onClose={() => setAssignedDialogOpen(false)} onClose={() => assignedDialog.close()}
onSaved={() => { onSaved={() => {
// Parent refetch is handled by the dialog's invalidateQueries. // Parent refetch is handled by the dialog's invalidateQueries.
}} }}
@@ -960,7 +954,7 @@ export const CustomerDetailPage: React.FC = () => {
<Button <Button
variant="outline" variant="outline"
leftIcon={<Trash2 className="w-4 h-4" />} leftIcon={<Trash2 className="w-4 h-4" />}
onClick={() => setConfirmDeactivate(true)} onClick={() => deactivateModal.open()}
> >
{t('customers.deactivate.button', 'Deactivate')} {t('customers.deactivate.button', 'Deactivate')}
</Button> </Button>
@@ -981,7 +975,7 @@ export const CustomerDetailPage: React.FC = () => {
<Button <Button
variant="outline" variant="outline"
leftIcon={<Trash2 className="w-4 h-4 text-red-600" />} leftIcon={<Trash2 className="w-4 h-4 text-red-600" />}
onClick={() => setConfirmErase(true)} onClick={() => eraseModal.open()}
> >
<span className="text-red-600"> <span className="text-red-600">
{t('customers.erase.button', 'Erase customer data')} {t('customers.erase.button', 'Erase customer data')}
@@ -1000,7 +994,7 @@ export const CustomerDetailPage: React.FC = () => {
</Button> </Button>
</div> </div>
{confirmDeactivate && ( {deactivateModal.isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"> <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="w-full max-w-md rounded-xl shadow-lg bg-white dark:bg-neutral-900">
<div className="p-6"> <div className="p-6">
@@ -1017,13 +1011,13 @@ export const CustomerDetailPage: React.FC = () => {
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <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')} {t('common.cancel', 'Cancel')}
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
isLoading={deactivateMutation.isPending} isLoading={deactivateMutation.isPending}
onClick={() => { deactivateMutation.mutate(); setConfirmDeactivate(false); }} onClick={() => { deactivateMutation.mutate(); deactivateModal.close(); }}
> >
{t('common.confirm', 'Confirm')} {t('common.confirm', 'Confirm')}
</Button> </Button>
@@ -1037,7 +1031,7 @@ export const CustomerDetailPage: React.FC = () => {
"irreversible" copy + red Confirm button so the click feels "irreversible" copy + red Confirm button so the click feels
deliberate. The action anonymizes PII in place; assignments deliberate. The action anonymizes PII in place; assignments
and audit-log references are preserved. */} 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="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="w-full max-w-md rounded-xl shadow-lg bg-white dark:bg-neutral-900">
<div className="p-6"> <div className="p-6">
@@ -1054,14 +1048,14 @@ export const CustomerDetailPage: React.FC = () => {
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <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')} {t('common.cancel', 'Cancel')}
</Button> </Button>
<button <button
type="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" 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} disabled={eraseMutation.isPending}
onClick={() => { eraseMutation.mutate(); setConfirmErase(false); }} onClick={() => { eraseMutation.mutate(); eraseModal.close(); }}
> >
{eraseMutation.isPending {eraseMutation.isPending
? t('customers.erase.confirmInFlight', 'Erasing…') ? t('customers.erase.confirmInFlight', 'Erasing…')
@@ -16,13 +16,13 @@
*/ */
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; 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 { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify';
import { import {
UserPlus, UserCog, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock, UserPlus, UserCog, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock,
} from 'lucide-react'; } from 'lucide-react';
import { InlineCustomerCreate } from '../../components/admin/InlineCustomerCreate'; import { InlineCustomerCreate } from '../../components/admin/InlineCustomerCreate';
import { useMutationWithToast } from '../../hooks';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Card, Input, Loading } from '../../components/common'; 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)); return list.filter((i) => i.email.toLowerCase().includes(term));
}, [invitations, debouncedTerm]); }, [invitations, debouncedTerm]);
const deactivateMutation = useMutation({ const deactivateMutation = useMutationWithToast({
mutationFn: (id: number) => customerAdminService.deactivate(id), mutationFn: (id: number) => customerAdminService.deactivate(id),
onSuccess: () => { invalidateKeys: [['admin-customers']],
queryClient.invalidateQueries({ queryKey: ['admin-customers'] }); successMessage: t('customers.deactivate.success', 'Customer deactivated'),
toast.success(t('customers.deactivate.success', 'Customer deactivated')); errorMessage: () => t('customers.deactivate.error', 'Could not deactivate customer'),
},
onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')),
}); });
const cancelInviteMutation = useMutation({ const cancelInviteMutation = useMutationWithToast({
mutationFn: (id: number) => customerAdminService.cancelInvitation(id), mutationFn: (id: number) => customerAdminService.cancelInvitation(id),
onSuccess: () => { invalidateKeys: [['admin-customer-invitations']],
queryClient.invalidateQueries({ queryKey: ['admin-customer-invitations'] }); successMessage: t('customers.cancelInvitation.success', 'Invitation cancelled'),
toast.success(t('customers.cancelInvitation.success', 'Invitation cancelled')); errorMessage: () => t('customers.cancelInvitation.error', 'Could not cancel invitation'),
},
onError: () => toast.error(t('customers.cancelInvitation.error', 'Could not cancel invitation')),
}); });
const renderCustomerName = (c: CustomerAccountSummary) => { 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 { ReceivedEmailsPanel } from '../../components/admin/ReceivedEmailsPanel';
import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard'; import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard';
import { Palette, RefreshCw, Info } from 'lucide-react'; 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 { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
import { settingsService } from '../../services/settings.service'; import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -138,7 +139,7 @@ export const EmailConfigPage: React.FC = () => {
const [editingLang, setEditingLang] = useState<string>('en'); const [editingLang, setEditingLang] = useState<string>('en');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [testEmail, setTestEmail] = useState(''); const [testEmail, setTestEmail] = useState('');
const [showPreview, setShowPreview] = useState(false); const previewModal = useModal();
const [previewData, setPreviewData] = useState<{ subject: string; htmlContent: string; textContent?: string }>({ const [previewData, setPreviewData] = useState<{ subject: string; htmlContent: string; textContent?: string }>({
subject: '', subject: '',
htmlContent: '', htmlContent: '',
@@ -157,7 +158,6 @@ export const EmailConfigPage: React.FC = () => {
const [emailBodyTextColor, setEmailBodyTextColor] = useState('#333333'); const [emailBodyTextColor, setEmailBodyTextColor] = useState('#333333');
const [emailMutedTextColor, setEmailMutedTextColor] = useState('#666666'); const [emailMutedTextColor, setEmailMutedTextColor] = useState('#666666');
const [emailButtonTextColor, setEmailButtonTextColor] = useState('#ffffff'); const [emailButtonTextColor, setEmailButtonTextColor] = useState('#ffffff');
const queryClient = useQueryClient();
const { flags: featureFlags } = useFeatureFlags(); const { flags: featureFlags } = useFeatureFlags();
// SMTP Configuration state // SMTP Configuration state
@@ -239,25 +239,17 @@ export const EmailConfigPage: React.FC = () => {
|| e?.message || e?.message
|| fallback; || fallback;
const saveConfigMutation = useMutation({ const saveConfigMutation = useMutationWithToast({
mutationFn: (config: EmailConfig) => emailService.updateConfig(config), mutationFn: (config: EmailConfig) => emailService.updateConfig(config),
onSuccess: () => { successMessage: t('toast.emailConfigSaved'),
toast.success(t('toast.emailConfigSaved')); invalidateKeys: [['email-config']],
queryClient.invalidateQueries({ queryKey: ['email-config'] }); errorMessage: (e: any) => errMsg(e, t('toast.saveError')),
},
onError: (e: any) => {
toast.error(errMsg(e, t('toast.saveError')));
}
}); });
const testEmailMutation = useMutation({ const testEmailMutation = useMutationWithToast({
mutationFn: (email: string) => emailService.testEmail(email), mutationFn: (email: string) => emailService.testEmail(email),
onSuccess: () => { successMessage: t('email.testEmailSuccess'),
toast.success(t('email.testEmailSuccess')); errorMessage: (e: any) => errMsg(e, t('toast.saveError')),
},
onError: (e: any) => {
toast.error(errMsg(e, t('toast.saveError')));
}
}); });
const flushQueueMutation = useMutation({ 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> }) => mutationFn: ({ key, translations }: { key: string; translations: Record<string, EmailTemplateTranslation> }) =>
emailService.updateTemplate(key, { translations }), emailService.updateTemplate(key, { translations }),
onSuccess: () => { successMessage: t('toast.saveSuccess'),
toast.success(t('toast.saveSuccess')); invalidateKeys: [['email-templates'], ['email-template', selectedTemplateKey]],
queryClient.invalidateQueries({ queryKey: ['email-templates'] }); errorMessage: () => t('toast.saveError'),
queryClient.invalidateQueries({ queryKey: ['email-template', selectedTemplateKey] });
},
onError: () => {
toast.error(t('toast.saveError'));
}
}); });
const saveEmailColorsMutation = useMutation({ const saveEmailColorsMutation = useMutationWithToast({
mutationFn: (colors: Record<string, string>) => mutationFn: (colors: Record<string, string>) =>
settingsService.updateSettings(colors), settingsService.updateSettings(colors),
onSuccess: () => { successMessage: t('toast.saveSuccess'),
toast.success(t('toast.saveSuccess')); invalidateKeys: [['admin-settings']],
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); errorMessage: () => t('toast.saveError'),
},
onError: () => {
toast.error(t('toast.saveError'));
}
}); });
const handleSaveEmailColors = () => { const handleSaveEmailColors = () => {
@@ -431,7 +414,7 @@ export const EmailConfigPage: React.FC = () => {
htmlContent: preview.body_html, htmlContent: preview.body_html,
textContent: preview.body_text textContent: preview.body_text
}); });
setShowPreview(true); previewModal.open();
} catch (error) { } catch (error) {
toast.error(t('toast.saveError')); toast.error(t('toast.saveError'));
} }
@@ -1057,8 +1040,8 @@ export const EmailConfigPage: React.FC = () => {
{/* Email Preview Modal */} {/* Email Preview Modal */}
<EmailPreviewModal <EmailPreviewModal
isOpen={showPreview} isOpen={previewModal.isOpen}
onClose={() => setShowPreview(false)} onClose={previewModal.close}
subject={previewData.subject} subject={previewData.subject}
htmlContent={previewData.htmlContent} htmlContent={previewData.htmlContent}
textContent={previewData.textContent} textContent={previewData.textContent}
@@ -25,6 +25,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service'; import { eventsService } from '../../services/events.service';
import { feedbackService } from '../../services/feedback.service'; import { feedbackService } from '../../services/feedback.service';
import type { PhotoFeedback, FeedbackAnalytics, FeedbackResponse } from '../../services/feedback.service'; import type { PhotoFeedback, FeedbackAnalytics, FeedbackResponse } from '../../services/feedback.service';
import { useMutationWithToast } from '../../hooks';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
export const EventFeedbackPage: React.FC = () => { export const EventFeedbackPage: React.FC = () => {
@@ -76,15 +77,11 @@ export const EventFeedbackPage: React.FC = () => {
}); });
// Update settings mutation // Update settings mutation
const updateSettingsMutation = useMutation({ const updateSettingsMutation = useMutationWithToast({
mutationFn: (newSettings: any) => feedbackService.updateEventFeedbackSettings(id!, newSettings), mutationFn: (newSettings: any) => feedbackService.updateEventFeedbackSettings(id!, newSettings),
onSuccess: () => { invalidateKeys: [['feedback-settings', id]],
queryClient.invalidateQueries({ queryKey: ['feedback-settings', id] }); successMessage: t('feedback.settingsUpdated', 'Feedback settings updated'),
toast.success(t('feedback.settingsUpdated', 'Feedback settings updated')); errorMessage: () => t('feedback.settingsUpdateError', 'Failed to update settings'),
},
onError: () => {
toast.error(t('feedback.settingsUpdateError', 'Failed to update settings'));
}
}); });
// Moderate feedback mutation // Moderate feedback mutation
+19 -26
View File
@@ -1,6 +1,5 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Plus, Plus,
@@ -16,6 +15,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { Button, Input, Card, Loading } from '../../components/common'; import { Button, Input, Card, Loading } from '../../components/common';
import { useModal, useMutationWithToast } from '../../hooks';
import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service'; import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types'; import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
@@ -27,12 +27,11 @@ const EMOJI_OPTIONS = [
export const EventTypesPage: React.FC = () => { export const EventTypesPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
// State // State
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showInactive, setShowInactive] = useState(false); const [showInactive, setShowInactive] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false); const createModal = useModal();
const [editingType, setEditingType] = useState<EventType | null>(null); const [editingType, setEditingType] = useState<EventType | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<EventType | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<EventType | null>(null);
@@ -43,40 +42,34 @@ export const EventTypesPage: React.FC = () => {
}); });
// Mutations // Mutations
const createMutation = useMutation({ const createMutation = useMutationWithToast({
mutationFn: eventTypesService.createEventType, 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] }); createModal.close();
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'));
} }
}); });
const updateMutation = useMutation({ const updateMutation = useMutationWithToast({
mutationFn: ({ id, data }: { id: number; data: UpdateEventTypeData }) => mutationFn: ({ id, data }: { id: number; data: UpdateEventTypeData }) =>
eventTypesService.updateEventType(id, data), 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
setEditingType(null); 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, 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
setDeleteConfirm(null); 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 <Button
variant="primary" variant="primary"
leftIcon={<Plus className="w-4 h-4" />} leftIcon={<Plus className="w-4 h-4" />}
onClick={() => setShowCreateModal(true)} onClick={() => createModal.open()}
> >
{t('eventTypes.createNew', 'New Event Type')} {t('eventTypes.createNew', 'New Event Type')}
</Button> </Button>
@@ -274,9 +267,9 @@ export const EventTypesPage: React.FC = () => {
</div> </div>
{/* Create Modal */} {/* Create Modal */}
{showCreateModal && ( {createModal.isOpen && (
<EventTypeModal <EventTypeModal
onClose={() => setShowCreateModal(false)} onClose={createModal.close}
onSubmit={(data) => createMutation.mutate(data as CreateEventTypeData)} onSubmit={(data) => createMutation.mutate(data as CreateEventTypeData)}
isLoading={createMutation.isPending} isLoading={createMutation.isPending}
/> />
+20 -29
View File
@@ -20,6 +20,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns'; import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useModal, useMutationWithToast } from '../../hooks';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common'; import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
@@ -46,8 +47,8 @@ export const EventsListPage: React.FC = () => {
// const [showFilters, setShowFilters] = useState(false); // const [showFilters, setShowFilters] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null); const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null); const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false); const bulkArchiveModal = useModal();
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false); const bulkDeleteModal = useModal();
const [copiedEventId, setCopiedEventId] = useState<number | null>(null); const [copiedEventId, setCopiedEventId] = useState<number | null>(null);
const copyShareLink = async (event: Event) => { const copyShareLink = async (event: Event) => {
@@ -169,29 +170,19 @@ export const EventsListPage: React.FC = () => {
}); });
// Archive mutation // Archive mutation
const archiveMutation = useMutation({ const archiveMutation = useMutationWithToast({
mutationFn: eventsService.archiveEvent, mutationFn: eventsService.archiveEvent,
onSuccess: () => { invalidateKeys: [['admin-events'], ['admin-dashboard-stats']],
queryClient.invalidateQueries({ queryKey: ['admin-events'] }); successMessage: t('toast.eventArchived'),
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] }); errorMessage: () => t('toast.saveError'),
toast.success(t('toast.eventArchived'));
},
onError: () => {
toast.error(t('toast.saveError'));
},
}); });
// Delete mutation // Delete mutation
const deleteMutation = useMutation({ const deleteMutation = useMutationWithToast({
mutationFn: eventsService.deleteEvent, mutationFn: eventsService.deleteEvent,
onSuccess: () => { invalidateKeys: [['admin-events'], ['admin-dashboard-stats']],
queryClient.invalidateQueries({ queryKey: ['admin-events'] }); successMessage: t('toast.deleteSuccess'),
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] }); errorMessage: () => t('toast.deleteError'),
toast.success(t('toast.deleteSuccess'));
},
onError: () => {
toast.error(t('toast.deleteError'));
},
}); });
// Bulk archive mutation // Bulk archive mutation
@@ -201,7 +192,7 @@ export const EventsListPage: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] }); queryClient.invalidateQueries({ queryKey: ['admin-events'] });
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] }); queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
setSelectedEvents([]); setSelectedEvents([]);
setShowBulkArchiveModal(false); bulkArchiveModal.close();
if (data.results.failed.length === 0) { if (data.results.failed.length === 0) {
toast.success(t('events.bulkArchiveSuccess', { count: data.results.successful.length })); 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-events'] });
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] }); queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
setSelectedEvents([]); setSelectedEvents([]);
setShowBulkDeleteModal(false); bulkDeleteModal.close();
if (data.results.failed.length === 0) { if (data.results.failed.length === 0) {
toast.success(t('events.bulkDelete.successAll', { count: data.results.successful.length })); toast.success(t('events.bulkDelete.successAll', { count: data.results.successful.length }));
@@ -232,7 +223,7 @@ export const EventsListPage: React.FC = () => {
}, },
onError: () => { onError: () => {
toast.error(t('events.bulkDelete.errorGeneric')); toast.error(t('events.bulkDelete.errorGeneric'));
setShowBulkDeleteModal(false); bulkDeleteModal.close();
}, },
}); });
@@ -439,14 +430,14 @@ export const EventsListPage: React.FC = () => {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setShowBulkArchiveModal(true)} onClick={() => bulkArchiveModal.open()}
> >
{t('events.archiveSelected')} {t('events.archiveSelected')}
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
size="sm" 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" 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')} {t('events.deleteSelected', 'Delete Selected')}
@@ -758,8 +749,8 @@ export const EventsListPage: React.FC = () => {
{/* Bulk Archive Modal */} {/* Bulk Archive Modal */}
<BulkArchiveModal <BulkArchiveModal
isOpen={showBulkArchiveModal} isOpen={bulkArchiveModal.isOpen}
onClose={() => setShowBulkArchiveModal(false)} onClose={bulkArchiveModal.close}
onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)} onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)}
selectedEvents={events.filter(e => selectedEvents.includes(e.id))} selectedEvents={events.filter(e => selectedEvents.includes(e.id))}
isLoading={bulkArchiveMutation.isPending} isLoading={bulkArchiveMutation.isPending}
@@ -767,8 +758,8 @@ export const EventsListPage: React.FC = () => {
{/* Bulk Delete Modal */} {/* Bulk Delete Modal */}
<BulkDeleteModal <BulkDeleteModal
isOpen={showBulkDeleteModal} isOpen={bulkDeleteModal.isOpen}
onClose={() => setShowBulkDeleteModal(false)} onClose={bulkDeleteModal.close}
onConfirm={async () => { onConfirm={async () => {
await bulkDeleteMutation.mutateAsync(selectedEvents); await bulkDeleteMutation.mutateAsync(selectedEvents);
}} }}
+10 -11
View File
@@ -5,34 +5,33 @@
*/ */
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { AlertCircle, RefreshCw, Trash2, CheckCircle } from 'lucide-react'; import { AlertCircle, RefreshCw, Trash2, CheckCircle } from 'lucide-react';
import { Button, Card, Loading } from '../../components/common'; import { Button, Card, Loading } from '../../components/common';
import { useMutationWithToast } from '../../hooks';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { systemHealthService } from '../../services/systemHealth.service'; import { systemHealthService } from '../../services/systemHealth.service';
export const SystemHealthPage: React.FC = () => { export const SystemHealthPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { formatDateTime: fmtDateTime } = useLocalizedDate(); const { formatDateTime: fmtDateTime } = useLocalizedDate();
const qc = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['system-health-failures'], queryKey: ['system-health-failures'],
queryFn: () => systemHealthService.getFailures(), queryFn: () => systemHealthService.getFailures(),
}); });
const invalidate = () => qc.invalidateQueries({ queryKey: ['system-health-failures'] }); const retryMutation = useMutationWithToast({
const retryMutation = useMutation({
mutationFn: (id: number) => systemHealthService.retryEmail(id), mutationFn: (id: number) => systemHealthService.retryEmail(id),
onSuccess: () => { toast.success(t('systemHealth.retriedToast', 'Email re-queued.')); invalidate(); }, invalidateKeys: [['system-health-failures']],
onError: () => toast.error(t('toast.saveError')), successMessage: t('systemHealth.retriedToast', 'Email re-queued.'),
errorMessage: () => t('toast.saveError'),
}); });
const dismissMutation = useMutation({ const dismissMutation = useMutationWithToast({
mutationFn: (id: number) => systemHealthService.dismissEmail(id), mutationFn: (id: number) => systemHealthService.dismissEmail(id),
onSuccess: () => { toast.success(t('systemHealth.dismissedToast', 'Dismissed.')); invalidate(); }, invalidateKeys: [['system-health-failures']],
onError: () => toast.error(t('toast.saveError')), successMessage: t('systemHealth.dismissedToast', 'Dismissed.'),
errorMessage: () => t('toast.saveError'),
}); });
const stuckEmails = data?.stuckEmails ?? []; const stuckEmails = data?.stuckEmails ?? [];
+36 -50
View File
@@ -1,7 +1,6 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { import {
Users, Users,
Mail, Mail,
@@ -23,7 +22,7 @@ import { parseISO, isPast } from 'date-fns';
import { Button, Input, Card, Loading } from '../../components/common'; import { Button, Input, Card, Loading } from '../../components/common';
import { userManagementService } from '../../services/userManagement.service'; import { userManagementService } from '../../services/userManagement.service';
import type { AdminUser, AdminRole, AdminInvitation } from '../../types'; import type { AdminUser, AdminRole, AdminInvitation } from '../../types';
import { useLocalizedDate } from "../../hooks"; import { useLocalizedDate, useModal, useMutationWithToast } from "../../hooks";
type TabType = 'users' | 'invitations'; type TabType = 'users' | 'invitations';
@@ -369,14 +368,13 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
export const UserManagementPage: React.FC = () => { export const UserManagementPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const { formatDistanceToNow } = useLocalizedDate() const { formatDistanceToNow } = useLocalizedDate()
// State // State
const [activeTab, setActiveTab] = useState<TabType>('users'); const [activeTab, setActiveTab] = useState<TabType>('users');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showCreateInvitationModal, setShowCreateInvitationModal] = useState(false); const createInvitationModal = useModal();
const [showEditUserModal, setShowEditUserModal] = useState(false); const editUserModal = useModal();
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null); const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{ const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean; isOpen: boolean;
@@ -413,80 +411,68 @@ export const UserManagementPage: React.FC = () => {
}); });
// Mutations // Mutations
const createInvitationMutation = useMutation({ const createInvitationMutation = useMutationWithToast({
mutationFn: ({ email, roleId }: { email: string; roleId: number }) => mutationFn: ({ email, roleId }: { email: string; roleId: number }) =>
userManagementService.createInvitation({ email, role_id: roleId }), userManagementService.createInvitation({ email, role_id: roleId }),
invalidateKeys: [['admin-invitations']],
successMessage: t('userManagement.invitationSent'),
errorMessage: (error: Error) => error.message || t('userManagement.invitationError'),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-invitations'] }); createInvitationModal.close();
setShowCreateInvitationModal(false);
toast.success(t('userManagement.invitationSent'));
},
onError: (error: Error) => {
toast.error(error.message || t('userManagement.invitationError'));
}, },
}); });
const cancelInvitationMutation = useMutation({ const cancelInvitationMutation = useMutationWithToast({
mutationFn: userManagementService.cancelInvitation, mutationFn: userManagementService.cancelInvitation,
invalidateKeys: [['admin-invitations']],
successMessage: t('userManagement.invitationCancelled'),
errorMessage: () => t('userManagement.cancelInvitationError'),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-invitations'] });
setConfirmDialog(null); 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 }) => mutationFn: ({ id, roleId }: { id: number; roleId: number }) =>
userManagementService.updateUser(id, { roleId }), userManagementService.updateUser(id, { roleId }),
invalidateKeys: [['admin-users']],
successMessage: t('userManagement.userUpdated'),
errorMessage: () => t('userManagement.updateUserError'),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] }); editUserModal.close();
setShowEditUserModal(false);
setSelectedUser(null); setSelectedUser(null);
toast.success(t('userManagement.userUpdated'));
},
onError: () => {
toast.error(t('userManagement.updateUserError'));
}, },
}); });
const deactivateUserMutation = useMutation({ const deactivateUserMutation = useMutationWithToast({
mutationFn: userManagementService.deactivateUser, mutationFn: userManagementService.deactivateUser,
invalidateKeys: [['admin-users']],
successMessage: t('userManagement.userDeactivated'),
errorMessage: () => t('userManagement.deactivateUserError'),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setConfirmDialog(null); setConfirmDialog(null);
toast.success(t('userManagement.userDeactivated'));
},
onError: () => {
toast.error(t('userManagement.deactivateUserError'));
}, },
}); });
// #574 follow-up: reactivate + delete actions for the rows the // #574 follow-up: reactivate + delete actions for the rows the
// deactivate button used to leave unmanageable. // deactivate button used to leave unmanageable.
const activateUserMutation = useMutation({ const activateUserMutation = useMutationWithToast({
mutationFn: userManagementService.activateUser, mutationFn: userManagementService.activateUser,
invalidateKeys: [['admin-users']],
successMessage: t('userManagement.userActivated', 'User reactivated successfully'),
errorMessage: () => t('userManagement.activateUserError', 'Failed to reactivate user'),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setConfirmDialog(null); 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, mutationFn: userManagementService.deleteUser,
invalidateKeys: [['admin-users']],
successMessage: t('userManagement.userDeleted', 'User deleted successfully'),
errorMessage: () => t('userManagement.deleteUserError', 'Failed to delete user'),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
setConfirmDialog(null); 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) => { const handleEditUser = (user: AdminUser) => {
setSelectedUser(user); setSelectedUser(user);
setShowEditUserModal(true); editUserModal.open();
}; };
const handleUpdateUser = (userId: number, roleId: number) => { const handleUpdateUser = (userId: number, roleId: number) => {
@@ -641,7 +627,7 @@ export const UserManagementPage: React.FC = () => {
<Button <Button
variant="primary" variant="primary"
leftIcon={<Plus className="w-5 h-5" />} leftIcon={<Plus className="w-5 h-5" />}
onClick={() => setShowCreateInvitationModal(true)} onClick={createInvitationModal.open}
> >
{t('userManagement.inviteUser')} {t('userManagement.inviteUser')}
</Button> </Button>
@@ -983,8 +969,8 @@ export const UserManagementPage: React.FC = () => {
{/* Create Invitation Modal */} {/* Create Invitation Modal */}
<CreateInvitationModal <CreateInvitationModal
isOpen={showCreateInvitationModal} isOpen={createInvitationModal.isOpen}
onClose={() => setShowCreateInvitationModal(false)} onClose={createInvitationModal.close}
onSubmit={handleCreateInvitation} onSubmit={handleCreateInvitation}
roles={roles || []} roles={roles || []}
isLoading={createInvitationMutation.isPending} isLoading={createInvitationMutation.isPending}
@@ -992,9 +978,9 @@ export const UserManagementPage: React.FC = () => {
{/* Edit User Modal */} {/* Edit User Modal */}
<EditUserModal <EditUserModal
isOpen={showEditUserModal} isOpen={editUserModal.isOpen}
onClose={() => { onClose={() => {
setShowEditUserModal(false); editUserModal.close();
setSelectedUser(null); setSelectedUser(null);
}} }}
onSubmit={handleUpdateUser} onSubmit={handleUpdateUser}
@@ -1,10 +1,10 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { ArrowLeft, RefreshCw, RotateCw, Send, X, AlertCircle, CheckCircle2, Clock } from 'lucide-react'; import { ArrowLeft, RefreshCw, RotateCw, Send, X, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
import { Button, Card, Loading } from '../../components/common'; import { Button, Card, Loading } from '../../components/common';
import { api } from '../../config/api'; import { api } from '../../config/api';
import { useModal, useMutationWithToast } from '../../hooks';
import { useLocalizedDate } from '../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../hooks/useLocalizedDate';
const WEBHOOK_EVENT_TYPES = [ const WEBHOOK_EVENT_TYPES = [
@@ -64,12 +64,11 @@ function statusBadge(status: string) {
export const WebhookDeliveriesPage: React.FC = () => { export const WebhookDeliveriesPage: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const webhookId = parseInt(id || '', 10); const webhookId = parseInt(id || '', 10);
const queryClient = useQueryClient();
const { formatDateTime: fmtDateTime } = useLocalizedDate(); const { formatDateTime: fmtDateTime } = useLocalizedDate();
const [filter, setFilter] = useState<StatusFilter>('all'); const [filter, setFilter] = useState<StatusFilter>('all');
const [openDeliveryId, setOpenDeliveryId] = useState<number | null>(null); const [openDeliveryId, setOpenDeliveryId] = useState<number | null>(null);
const [showTestDialog, setShowTestDialog] = useState(false); const testDialog = useModal();
const [testEventType, setTestEventType] = useState<string>('event.published'); const [testEventType, setTestEventType] = useState<string>('event.published');
const { data: webhook, isLoading: loadingWebhook } = useQuery({ const { data: webhook, isLoading: loadingWebhook } = useQuery({
@@ -108,24 +107,22 @@ export const WebhookDeliveriesPage: React.FC = () => {
enabled: Number.isFinite(webhookId) && openDeliveryId !== null, enabled: Number.isFinite(webhookId) && openDeliveryId !== null,
}); });
const replayMutation = useMutation({ const replayMutation = useMutationWithToast({
mutationFn: async (deliveryId: number) => mutationFn: async (deliveryId: number) =>
api.post(`/admin/webhooks/${webhookId}/deliveries/${deliveryId}/replay`), api.post(`/admin/webhooks/${webhookId}/deliveries/${deliveryId}/replay`),
onSuccess: () => { invalidateKeys: [['admin-webhook-deliveries', webhookId]],
toast.success('Replay enqueued'); successMessage: 'Replay enqueued',
queryClient.invalidateQueries({ queryKey: ['admin-webhook-deliveries', webhookId] }); errorMessage: () => 'Failed to replay',
},
onError: () => toast.error('Failed to replay'),
}); });
const testMutation = useMutation({ const testMutation = useMutationWithToast({
mutationFn: async () => api.post(`/admin/webhooks/${webhookId}/test`, { event_type: testEventType }), 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: () => { onSuccess: () => {
toast.success('Test event enqueued'); testDialog.close();
setShowTestDialog(false);
queryClient.invalidateQueries({ queryKey: ['admin-webhook-deliveries', webhookId] });
}, },
onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed to send test'),
}); });
if (loadingWebhook) { if (loadingWebhook) {
@@ -174,7 +171,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
variant="outline" variant="outline"
size="sm" size="sm"
leftIcon={<Send className="w-4 h-4" />} leftIcon={<Send className="w-4 h-4" />}
onClick={() => setShowTestDialog(true)} onClick={() => testDialog.open()}
> >
Send test event Send test event
</Button> </Button>
@@ -338,8 +335,8 @@ export const WebhookDeliveriesPage: React.FC = () => {
)} )}
{/* Test event dialog */} {/* Test event dialog */}
{showTestDialog && ( {testDialog.isOpen && (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40" onClick={() => setShowTestDialog(false)}> <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()}> <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> <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"> <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>)} {WEBHOOK_EVENT_TYPES.map((e) => <option key={e} value={e}>{e}</option>)}
</select> </select>
<div className="flex justify-end gap-2"> <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()}> <Button variant="primary" isLoading={testMutation.isPending} onClick={() => testMutation.mutate()}>
Send Send
</Button> </Button>
@@ -18,6 +18,7 @@ import { EventBookingSelect } from '../../../components/admin/EventBookingSelect
import { formatMoneyMinor } from '../../../utils/money'; import { formatMoneyMinor } from '../../../utils/money';
import { sortedCountryOptions } from '../../../constants/countries'; import { sortedCountryOptions } from '../../../constants/countries';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../../hooks';
import { import {
accountingService, categoryLabel, accountingService, categoryLabel,
type InboundDocument, type Disposition, type MarkupType, type PaymentMethod, type ExpenseCategory, 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 [paidAt, setPaidAt] = useState('');
const [method, setMethod] = useState<PaymentMethod>('bank_transfer'); const [method, setMethod] = useState<PaymentMethod>('bank_transfer');
const [reference, setReference] = useState(doc.paymentReference || ''); 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 }), 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(); }, successMessage: t('accounting.incoming.paidToast', 'Marked as paid.'),
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
onSuccess: () => onDone(),
}); });
return ( return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4"> <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 // `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 // step (#5/#1). When true we mark it paid directly (using the reference
// entered) — no second dialog — so "Save & mark paid" actually pays. // entered) — no second dialog — so "Save & mark paid" actually pays.
const save = useMutation({ const save = useMutationWithToast({
mutationFn: async (pay: boolean) => { 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.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, { 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 }); await accountingService.markInboundPaid(doc.id, { paid: true, paymentReference: reference || undefined });
} }
}, },
onSuccess: (_data, pay) => { successMessage: (_data, pay) => pay ? t('accounting.incoming.categorizedPaidToast', 'Categorized and marked paid.') : t('accounting.incoming.categorizedToast', 'Categorized.'),
toast.success(pay ? t('accounting.incoming.categorizedPaidToast', 'Categorized and marked paid.') : t('accounting.incoming.categorizedToast', 'Categorized.')); errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
onDone(); onSuccess: () => onDone(),
},
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
}); });
const rebillNeedsCustomer = disposition === 'rebill' && !customer[0]; 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); }, 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'), 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 }), mutationFn: (id: number) => accountingService.markInboundPaid(id, { paid: false }),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); }, invalidateKeys: [['accounting-inbound']],
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
}); });
const billPending = useMutation({ const billPending = useMutation({
mutationFn: (customerAccountId: number) => accountingService.billPendingRebills(customerAccountId), mutationFn: (customerAccountId: number) => accountingService.billPendingRebills(customerAccountId),
@@ -8,7 +8,7 @@
*/ */
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Link } from 'react-router-dom'; 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 { useTranslation } from 'react-i18next';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { X, Plus, Paperclip, Car, CalendarDays, Coins, Pencil, FileText, CheckCircle2, Circle, Lock } from 'lucide-react'; 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 { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker';
import { formatMoneyMinor } from '../../../utils/money'; import { formatMoneyMinor } from '../../../utils/money';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useMutationWithToast, useModal } from '../../../hooks';
import { import {
accountingService, categoryLabel, accountingService, categoryLabel,
type Expense, type ExpenseKind, type ExpenseCategory, type MarkupType, type PaymentMethod, 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, description: description || null,
}); });
const save = useMutation({ const save = useMutationWithToast({
mutationFn: () => isEdit mutationFn: () => isEdit
? accountingService.updateExpense(expense!.id, payload(), file) ? accountingService.updateExpense(expense!.id, payload(), file)
: accountingService.createExpense(payload(), file), : accountingService.createExpense(payload(), file),
onSuccess: () => { toast.success(isEdit ? t('accounting.ledger.updatedToast', 'Expense updated.') : t('accounting.ledger.createdToast', 'Expense added.')); onDone(); }, successMessage: isEdit ? t('accounting.ledger.updatedToast', 'Expense updated.') : t('accounting.ledger.createdToast', 'Expense added.'),
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), 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'); 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 [paidAt, setPaidAt] = useState('');
const [method, setMethod] = useState<PaymentMethod>('bank_transfer'); const [method, setMethod] = useState<PaymentMethod>('bank_transfer');
const [reference, setReference] = useState(''); 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 }), 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(); }, successMessage: t('accounting.ledger.paidToast', 'Marked as paid.'),
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
onSuccess: () => onDone(),
}); });
return ( return (
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4"> <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 [customer, setCustomer] = useState<SelectedCustomer[]>([]);
const [markupType, setMarkupType] = useState<MarkupType>('none'); const [markupType, setMarkupType] = useState<MarkupType>('none');
const [markupValue, setMarkupValue] = useState<number>(NaN); const [markupValue, setMarkupValue] = useState<number>(NaN);
const save = useMutation({ const save = useMutationWithToast({
mutationFn: () => accountingService.invoiceExpense(expense.id, { mutationFn: () => accountingService.invoiceExpense(expense.id, {
customerAccountId: customer[0]!.id, customerAccountId: customer[0]!.id,
markupType, markupType,
markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null, markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null,
markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : 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(); }, successMessage: t('accounting.ledger.invoicedToast', 'Added to a client invoice.'),
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
onSuccess: () => onDone(),
}); });
return ( return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4"> <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 qc = useQueryClient();
const { format } = useLocalizedDate(); const { format } = useLocalizedDate();
const [kind, setKind] = useState(''); const [kind, setKind] = useState('');
const [showAdd, setShowAdd] = useState(false); const addModal = useModal();
const [editExpense, setEditExpense] = useState<Expense | null>(null); const [editExpense, setEditExpense] = useState<Expense | null>(null);
const [paidExpense, setPaidExpense] = useState<Expense | null>(null); const [paidExpense, setPaidExpense] = useState<Expense | null>(null);
const [invoiceExpense, setInvoiceExpense] = 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 }), mutationFn: (id: number) => accountingService.markExpensePaid(id, { paid: false }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['accounting-expenses'] }), invalidateKeys: [['accounting-expenses']],
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'), errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
}); });
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
@@ -262,7 +266,7 @@ export const ExpensesLedgerPage: React.FC = () => {
<option value="">{t('accounting.ledger.allKinds', 'All types')}</option> <option value="">{t('accounting.ledger.allKinds', 'All types')}</option>
{KINDS.map((k) => <option key={k} value={k}>{t(`accounting.expenseKind.${k}`, k)}</option>)} {KINDS.map((k) => <option key={k} value={k}>{t(`accounting.expenseKind.${k}`, k)}</option>)}
</select> </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> </div>
{isLoading ? <Loading /> : items.length === 0 ? ( {isLoading ? <Loading /> : items.length === 0 ? (
@@ -318,7 +322,7 @@ export const ExpensesLedgerPage: React.FC = () => {
</div> </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'] }); }} />} {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'] }); }} />} {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'] }); }} />} {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 { ArrowLeft, Plus, Trash2, Save } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common'; import { Button, Card, Loading } from '../../../components/common';
import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector'; import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector';
import { useMutationWithToast } from '../../../hooks';
import { import {
contractsService, contractsService,
type ContractBlock, 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), 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), mutationFn: (id: number) => contractsService.deleteBlock(id),
onSuccess: () => { successMessage: t('contracts.blocks.deletedToast', 'Block deleted.') as string,
toast.success(t('contracts.blocks.deletedToast', 'Block deleted.') as string); invalidateKeys: [['contracts', 'blocks']],
queryClient.invalidateQueries({ queryKey: ['contracts', 'blocks'] }); errorMessage: t('contracts.blocks.deleteError', 'Delete failed') as string,
setSelection(null); onSuccess: () => setSelection(null),
},
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.blocks.deleteError', 'Delete failed') as string),
}); });
// Group blocks by section for sidebar rendering. Empty sections are // 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 { useTranslation } from 'react-i18next';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext'; import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { Link, useNavigate, useParams } from 'react-router-dom'; 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 { toast } from 'react-toastify';
import { billsService } from '../../../services/bills.service'; import { billsService } from '../../../services/bills.service';
import { quotesService } from '../../../services/quotes.service'; import { quotesService } from '../../../services/quotes.service';
@@ -32,6 +32,7 @@ import {
type ContractStatus, type ContractStatus,
} from '../../../services/contracts.service'; } from '../../../services/contracts.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../../hooks';
function statusBadgeClass(status: ContractStatus): string { function statusBadgeClass(status: ContractStatus): string {
return status === 'fully_signed' ? 'bg-green-100 text-green-800' 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) || [], select: (res) => res?.invoices?.filter((i) => i.sourceContractId === numericId) || [],
}); });
const sendMutation = useMutation({ const sendMutation = useMutationWithToast({
mutationFn: () => contractsService.send(numericId as number), mutationFn: () => contractsService.send(numericId as number),
onSuccess: () => { successMessage: t('contracts.detail.sentToast', 'Contract sent.') as string,
toast.success(t('contracts.detail.sentToast', 'Contract sent.') as string); invalidateKeys: [['contract', numericId]],
queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); errorMessage: t('contracts.detail.sendError', 'Send failed') as string,
},
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.sendError', 'Send failed') as string),
}); });
const cancelMutation = useMutation({ const cancelMutation = useMutationWithToast({
mutationFn: () => contractsService.cancel(numericId as number), mutationFn: () => contractsService.cancel(numericId as number),
onSuccess: () => { successMessage: t('contracts.detail.cancelledToast', 'Contract cancelled.') as string,
toast.success(t('contracts.detail.cancelledToast', 'Contract cancelled.') as string); invalidateKeys: [['contract', numericId]],
queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); errorMessage: t('contracts.detail.cancelError', 'Cancel failed') as string,
},
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.cancelError', 'Cancel failed') as string),
}); });
const countersignMutation = useMutation({ const countersignMutation = useMutationWithToast({
mutationFn: () => { mutationFn: () => {
// Capture the canvas signature (if drawn) at submit time so we // Capture the canvas signature (if drawn) at submit time so we
// send a fresh data URL, not a stale one from an earlier mount. // send a fresh data URL, not a stale one from an earlier mount.
@@ -119,55 +116,45 @@ export const ContractDetailPage: React.FC = () => {
signatureDataUrl, signatureDataUrl,
}); });
}, },
successMessage: t('contracts.detail.countersignedToast', 'Counter-signed.') as string,
invalidateKeys: [['contract', numericId]],
errorMessage: t('contracts.detail.countersignError', 'Counter-sign failed') as string,
onSuccess: () => { onSuccess: () => {
toast.success(t('contracts.detail.countersignedToast', 'Counter-signed.') as string);
setCountersignName(''); setCountersignName('');
countersignPadRef.current?.clear(); 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), mutationFn: (file: File) => contractsService.uploadSignedPdf(numericId as number, file),
onSuccess: () => { successMessage: t('contracts.detail.uploadedToast', 'Signed PDF uploaded.') as string,
toast.success(t('contracts.detail.uploadedToast', 'Signed PDF uploaded.') as string); invalidateKeys: [['contract', numericId]],
queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); errorMessage: t('contracts.detail.uploadError', 'Upload failed') as string,
},
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.uploadError', 'Upload failed') as string),
}); });
const resendSignedMutation = useMutation({ const resendSignedMutation = useMutationWithToast({
mutationFn: () => contractsService.resendSigned(numericId as number), mutationFn: () => contractsService.resendSigned(numericId as number),
onSuccess: () => { successMessage: t('contracts.detail.resentSignedToast',
toast.success(t('contracts.detail.resentSignedToast', 'Signed contract re-sent to both parties.') as string,
'Signed contract re-sent to both parties.') as string); invalidateKeys: [['contract', numericId]],
queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); errorMessage: t('contracts.detail.resendError', 'Resend failed') as string,
},
onError: (err: any) => toast.error(err?.response?.data?.error
|| t('contracts.detail.resendError', 'Resend failed') as string),
}); });
const convertToEventMutation = useMutation({ const convertToEventMutation = useMutationWithToast({
mutationFn: () => contractsService.convertToEvent(numericId as number), mutationFn: () => contractsService.convertToEvent(numericId as number),
onSuccess: (result) => { successMessage: (result) => result.alreadyConverted
toast.success(result.alreadyConverted
? (t('contracts.detail.alreadyEventToast', 'Already linked to an event.') as string) ? (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)); : (t('contracts.detail.convertedToEventToast', 'Contract converted to event #{{id}}', { id: result.eventId }) as string),
queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); invalidateKeys: [['contract', numericId]],
}, errorMessage: t('contracts.detail.convertError', 'Convert failed') as string,
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.convertError', 'Convert failed') as string),
}); });
const convertToInvoiceMutation = useMutation({ const convertToInvoiceMutation = useMutationWithToast({
mutationFn: () => contractsService.convertToInvoice(numericId as number), mutationFn: () => contractsService.convertToInvoice(numericId as number),
onSuccess: (result) => { successMessage: (result) => t('contracts.detail.convertedToInvoiceToast',
toast.success(t('contracts.detail.convertedToInvoiceToast', '{{count}} invoice(s) created from this contract', { count: result.installmentsCreated }) as string,
'{{count}} invoice(s) created from this contract', { count: result.installmentsCreated }) as string); invalidateKeys: [['contract', numericId], ['invoices']],
queryClient.invalidateQueries({ queryKey: ['contract', numericId] }); errorMessage: t('contracts.detail.convertError', 'Convert failed') as string,
queryClient.invalidateQueries({ queryKey: ['invoices'] });
},
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.convertError', 'Convert failed') as string),
}); });
if (isLoading) return <Loading />; if (isLoading) return <Loading />;
@@ -1003,7 +990,7 @@ const RestampSignaturesCard: React.FC<RestampCardProps> = ({ contract, onSuccess
return () => { cleanupCustomer(); cleanupAdmin(); }; return () => { cleanupCustomer(); cleanupAdmin(); };
}, []); }, []);
const mutation = useMutation({ const mutation = useMutationWithToast({
mutationFn: () => { mutationFn: () => {
const customerPad = customerPadRef.current; const customerPad = customerPadRef.current;
const adminPad = adminPadRef.current; const adminPad = adminPadRef.current;
@@ -1017,16 +1004,16 @@ const RestampSignaturesCard: React.FC<RestampCardProps> = ({ contract, onSuccess
adminSignatureDataUrl, 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: () => { onSuccess: () => {
toast.success(t('contracts.detail.restampedToast',
'Signatures re-stamped and PDF re-rendered.') as string);
customerPadRef.current?.clear(); customerPadRef.current?.clear();
adminPadRef.current?.clear(); adminPadRef.current?.clear();
onSuccess(); 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; const missingCustomer = !contract.signedCustomerSignaturePath && contract.signedByCustomerAt;
@@ -24,6 +24,7 @@ import {
} from '../../../services/projects.service'; } from '../../../services/projects.service';
import { eventsService } from '../../../services/events.service'; import { eventsService } from '../../../services/events.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../../hooks';
import { formatMoneyMinor } from '../../../utils/money'; import { formatMoneyMinor } from '../../../utils/money';
import { useFeatureFlags, type FeatureKey } from '../../../contexts/FeatureFlagsContext'; import { useFeatureFlags, type FeatureKey } from '../../../contexts/FeatureFlagsContext';
@@ -121,29 +122,24 @@ export const ProjectCockpitPage: React.FC = () => {
enabled: projectId !== null, enabled: projectId !== null,
}); });
const renameMutation = useMutation({ const renameMutation = useMutationWithToast({
mutationFn: (name: string) => projectsService.update(projectId as number, { name }), mutationFn: (name: string) => projectsService.update(projectId as number, { name }),
onSuccess: () => { successMessage: t('projects.toast.saved', 'Project saved') as string,
qc.invalidateQueries({ queryKey: ['project-overview', projectId] }); invalidateKeys: [['project-overview', projectId], ['projects']],
qc.invalidateQueries({ queryKey: ['projects'] }); errorMessage: t('projects.toast.saveFailed', 'Save failed') as string,
setEditName(null); onSuccess: () => 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)),
}); });
const emailActionMutation = useMutation({ const emailActionMutation = useMutationWithToast({
mutationFn: ({ action, emailId }: { action: 'resend' | 'cancel' | 'retry' | 'sendNow'; emailId: number }) => { mutationFn: ({ action, emailId }: { action: 'resend' | 'cancel' | 'retry' | 'sendNow'; emailId: number }) => {
if (action === 'resend') return projectsService.resendEmail(emailId); if (action === 'resend') return projectsService.resendEmail(emailId);
if (action === 'cancel') return projectsService.cancelEmail(emailId); if (action === 'cancel') return projectsService.cancelEmail(emailId);
if (action === 'retry') return projectsService.retryEmail(emailId); if (action === 'retry') return projectsService.retryEmail(emailId);
return projectsService.sendEmailNow(emailId); return projectsService.sendEmailNow(emailId);
}, },
onSuccess: () => { successMessage: t('projects.toast.emailAction', 'Done') as string,
qc.invalidateQueries({ queryKey: ['project-overview', projectId] }); invalidateKeys: [['project-overview', projectId]],
toast.success(t('projects.toast.emailAction', 'Done') as string); errorMessage: t('projects.toast.emailActionFailed', 'Action failed') as string,
},
onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.toast.emailActionFailed', 'Action failed') as string)),
}); });
// Event search for the "attach event" control (results exclude events // Event search for the "attach event" control (results exclude events
@@ -9,13 +9,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; 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 { Save as SaveIcon, Workflow as WorkflowIcon } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common'; import { Button, Card, Loading, Input } from '../../../components/common';
import { settingsService } from '../../../services/settings.service'; import { settingsService } from '../../../services/settings.service';
import { quotesService } from '../../../services/quotes.service'; import { quotesService } from '../../../services/quotes.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext'; import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { toast } from 'react-toastify'; import { useMutationWithToast } from '../../../hooks';
const SETTING_KEYS = [ const SETTING_KEYS = [
'crm_quotes_pdf_attachment_enabled', 'crm_quotes_pdf_attachment_enabled',
@@ -76,7 +76,6 @@ const SETTING_KEYS = [
export const CrmSettingsPage: React.FC = () => { export const CrmSettingsPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient();
const { flags } = useFeatureFlags(); const { flags } = useFeatureFlags();
// Show each section only when the corresponding master flag is on — // Show each section only when the corresponding master flag is on —
// configuring Skonto on quotes is pointless when quotes itself is // 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>>({}); const [values, setValues] = useState<Record<string, any>>({});
useEffect(() => { if (data) setValues(data); }, [data]); useEffect(() => { if (data) setValues(data); }, [data]);
const saveAll = useMutation({ const saveAll = useMutationWithToast({
mutationFn: async () => { mutationFn: async () => {
const changed: Record<string, any> = {}; const changed: Record<string, any> = {};
for (const key of SETTING_KEYS) { for (const key of SETTING_KEYS) {
@@ -126,11 +125,9 @@ export const CrmSettingsPage: React.FC = () => {
await settingsService.updateSettings(changed); await settingsService.updateSettings(changed);
} }
}, },
onSuccess: () => { successMessage: t('crmSettings.savedToast', 'CRM settings saved.'),
toast.success(t('crmSettings.savedToast', 'CRM settings saved.')); invalidateKeys: [['settings', 'crm']],
qc.invalidateQueries({ queryKey: ['settings', 'crm'] }); errorMessage: 'Save failed',
},
onError: (err: any) => toast.error(err?.response?.data?.error || 'Save failed'),
}); });
if (isLoading) return <Loading />; if (isLoading) return <Loading />;
@@ -31,8 +31,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { ArrowLeft, Save, AlertTriangle, Workflow as WorkflowIcon } from 'lucide-react'; import { ArrowLeft, Save, AlertTriangle, Workflow as WorkflowIcon } from 'lucide-react';
import { Button, Card, Loading, Input } from '../../../components/common'; import { Button, Card, Loading, Input } from '../../../components/common';
import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector'; 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 { emailService, type EmailTemplateTranslation } from '../../../services/email.service';
import { settingsService } from '../../../services/settings.service'; import { settingsService } from '../../../services/settings.service';
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext'; import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
import { useMutationWithToast } from '../../../hooks';
const TEMPLATE_KEY_DEFAULT = 'event_reminder_default'; const TEMPLATE_KEY_DEFAULT = 'event_reminder_default';
const TEMPLATE_KEY_PREFIX = 'event_reminder_'; const TEMPLATE_KEY_PREFIX = 'event_reminder_';
@@ -60,7 +60,6 @@ interface SidebarRow {
export const ReminderTemplatesPage: React.FC = () => { export const ReminderTemplatesPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
// Global on/off + lead time: owned by the "Pre-event reminder" workflow when // 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 // 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); const d = Number(settings.crm_event_reminders_days_before);
setDaysBefore(Number.isFinite(d) ? d : 2); setDaysBefore(Number.isFinite(d) ? d : 2);
}, [settings]); }, [settings]);
const saveSettingsMutation = useMutation({ const saveSettingsMutation = useMutationWithToast({
mutationFn: () => settingsService.updateSettings({ mutationFn: () => settingsService.updateSettings({
crm_event_reminders_enabled: enabled, crm_event_reminders_enabled: enabled,
crm_event_reminders_days_before: daysBefore, crm_event_reminders_days_before: daysBefore,
}), }),
onSuccess: () => { successMessage: t('reminderTemplates.settingsSaved', 'Reminder settings saved.'),
toast.success(t('reminderTemplates.settingsSaved', 'Reminder settings saved.')); invalidateKeys: [['reminder-settings']],
queryClient.invalidateQueries({ queryKey: ['reminder-settings'] }); errorMessage: () => t('reminderTemplates.settingsSaveError', 'Could not save reminder settings.'),
},
onError: () => toast.error(t('reminderTemplates.settingsSaveError', 'Could not save reminder settings.')),
}); });
// ---- Event types catalog --------------------------------------------- // ---- Event types catalog ---------------------------------------------
@@ -195,7 +192,7 @@ export const ReminderTemplatesPage: React.FC = () => {
}, [selectedKey, selectedTemplate, defaultTemplate]); }, [selectedKey, selectedTemplate, defaultTemplate]);
// ---- Save ------------------------------------------------------------- // ---- Save -------------------------------------------------------------
const saveMutation = useMutation({ const saveMutation = useMutationWithToast({
mutationFn: async () => { mutationFn: async () => {
// Only send non-empty translations so we don't clobber DB rows // Only send non-empty translations so we don't clobber DB rows
// for locales the admin hasn't touched. // for locales the admin hasn't touched.
@@ -220,15 +217,9 @@ export const ReminderTemplatesPage: React.FC = () => {
}); });
} }
}, },
onSuccess: () => { successMessage: t('reminderTemplates.saved', 'Template saved.'),
toast.success(t('reminderTemplates.saved', 'Template saved.')); invalidateKeys: [['email-templates'], ['email-template', selectedKey]],
queryClient.invalidateQueries({ queryKey: ['email-templates'] }); errorMessage: t('reminderTemplates.saveError', 'Could not save template.'),
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.'));
},
}); });
// Translation completeness pill for the sidebar — matches the email // 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 { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../../components/common';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { currencyOptions, normalizeCurrency } from '../../../constants/currencies'; import { currencyOptions, normalizeCurrency } from '../../../constants/currencies';
import { useMutationWithToast } from '../../../hooks';
// Full IANA timezone list for the picker. `Intl.supportedValuesOf` is ES2022 // 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 // (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 = () => { export const SettingsBusinessProfilePage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient();
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ['business-profile'], queryKey: ['business-profile'],
queryFn: () => businessProfileService.get(), queryFn: () => businessProfileService.get(),
@@ -44,7 +44,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
const [profile, setProfile] = useState<BusinessProfile | null>(null); const [profile, setProfile] = useState<BusinessProfile | null>(null);
useEffect(() => { if (data?.profile) setProfile(data.profile); }, [data]); useEffect(() => { if (data?.profile) setProfile(data.profile); }, [data]);
const saveProfile = useMutation({ const saveProfile = useMutationWithToast({
// vatLabel + defaultHourlyRateMinor now live on Settings → Accounting, and // vatLabel + defaultHourlyRateMinor now live on Settings → Accounting, and
// vatRateDefault is retired (the rates are the Accounting VAT codes). Strip // 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 // 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; void vatLabel; void defaultHourlyRateMinor; void vatRateDefault;
return businessProfileService.update(rest); return businessProfileService.update(rest);
}, },
onSuccess: () => { successMessage: t('businessProfile.savedToast', 'Business profile saved.'),
toast.success(t('businessProfile.savedToast', 'Business profile saved.')); invalidateKeys: [['business-profile']],
qc.invalidateQueries({ queryKey: ['business-profile'] }); errorMessage: 'Save failed',
},
onError: (err: any) => toast.error(err?.response?.data?.error || 'Save failed'),
}); });
if (isLoading || !profile) return <Loading />; if (isLoading || !profile) return <Loading />;
@@ -614,24 +612,20 @@ const BankAccountsSection: React.FC<BankAccountsSectionProps> = ({ accounts }) =
}; };
const closeForm = () => { setOpenForm(null); setDraft(EMPTY_DRAFT); }; const closeForm = () => { setOpenForm(null); setDraft(EMPTY_DRAFT); };
const create = useMutation({ const create = useMutationWithToast({
mutationFn: () => businessProfileService.createBankAccount(draft), mutationFn: () => businessProfileService.createBankAccount(draft),
onSuccess: () => { successMessage: t('businessProfile.bankCreatedToast', 'Bank account added.'),
toast.success(t('businessProfile.bankCreatedToast', 'Bank account added.')); invalidateKeys: [['business-profile']],
qc.invalidateQueries({ queryKey: ['business-profile'] }); errorMessage: 'Failed',
closeForm(); onSuccess: () => closeForm(),
},
onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed'),
}); });
const update = useMutation({ const update = useMutationWithToast({
mutationFn: (id: number) => businessProfileService.updateBankAccount(id, draft), mutationFn: (id: number) => businessProfileService.updateBankAccount(id, draft),
onSuccess: () => { successMessage: t('businessProfile.bankUpdatedToast', 'Bank account updated.'),
toast.success(t('businessProfile.bankUpdatedToast', 'Bank account updated.')); invalidateKeys: [['business-profile']],
qc.invalidateQueries({ queryKey: ['business-profile'] }); errorMessage: 'Failed',
closeForm(); onSuccess: () => closeForm(),
},
onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed'),
}); });
const setDefault = useMutation({ const setDefault = useMutation({
@@ -7,17 +7,16 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { toast } from 'react-toastify';
import { ArrowLeft, Check, X } from 'lucide-react'; import { ArrowLeft, Check, X } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common'; import { Button, Card, Loading } from '../../../components/common';
import { workflowsService, type WorkflowApproval } from '../../../services/workflows.service'; import { workflowsService, type WorkflowApproval } from '../../../services/workflows.service';
import { useLocalizedDate } from '../../../hooks/useLocalizedDate'; import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
import { useMutationWithToast } from '../../../hooks';
export const WorkflowApprovalsPage: React.FC = () => { export const WorkflowApprovalsPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient();
const { formatDateTime } = useLocalizedDate(); const { formatDateTime } = useLocalizedDate();
const { data: approvals, isLoading } = useQuery({ const { data: approvals, isLoading } = useQuery({
@@ -25,13 +24,11 @@ export const WorkflowApprovalsPage: React.FC = () => {
queryFn: () => workflowsService.approvals(), queryFn: () => workflowsService.approvals(),
}); });
const actMutation = useMutation({ const actMutation = useMutationWithToast({
mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action), mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action),
onSuccess: () => { successMessage: t('workflows.approvals.recorded', 'Response recorded') as string,
qc.invalidateQueries({ queryKey: ['workflow-approvals'] }); invalidateKeys: [['workflow-approvals']],
toast.success(t('workflows.approvals.recorded', 'Response recorded') as string); errorMessage: t('common.error', 'Something went wrong') as string,
},
onError: (err: any) => toast.error(err?.response?.data?.error || (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.'); 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 React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate, useParams } from 'react-router-dom'; 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 { toast } from 'react-toastify';
import { import {
ReactFlow, Background, Controls, MiniMap, addEdge, useNodesState, useEdgesState, 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 { Button, Loading } from '../../../components/common';
import { api } from '../../../config/api'; import { api } from '../../../config/api';
import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext'; import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext';
import { useMutationWithToast } from '../../../hooks';
import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service'; import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service';
import { NodeConfigPanel } from './NodeConfigPanel'; import { NodeConfigPanel } from './NodeConfigPanel';
@@ -117,7 +118,6 @@ function layoutGraph(nodes: Node[], edges: Edge[]): Node[] {
export const WorkflowEditorPage: React.FC = () => { export const WorkflowEditorPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient();
const { isDark } = useAdminDarkMode(); const { isDark } = useAdminDarkMode();
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const workflowId = Number(id); const workflowId = Number(id);
@@ -245,7 +245,7 @@ export const WorkflowEditorPage: React.FC = () => {
setSelectedId(null); setSelectedId(null);
}; };
const saveMutation = useMutation({ const saveMutation = useMutationWithToast({
mutationFn: () => workflowsService.update(workflowId, { mutationFn: () => workflowsService.update(workflowId, {
name: name.trim() || 'Untitled', name: name.trim() || 'Untitled',
trigger_type: triggerType, 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 })), edges: edges.map((e) => ({ from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target })),
}), }),
onSuccess: () => { successMessage: t('workflows.editor.saved', 'Workflow saved') as string,
qc.invalidateQueries({ queryKey: ['workflow', workflowId] }); invalidateKeys: [['workflow', workflowId], ['workflows']],
qc.invalidateQueries({ queryKey: ['workflows'] }); errorMessage: t('workflows.editor.saveFailed', 'Could not save') as string,
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)),
}); });
if (isLoading) return <div className="p-10"><Loading /></div>; 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 { toast } from 'react-toastify';
import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil, FlaskConical } from 'lucide-react'; import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil, FlaskConical } from 'lucide-react';
import { Button, Card, Loading } from '../../../components/common'; import { Button, Card, Loading } from '../../../components/common';
import { useMutationWithToast } from '../../../hooks';
import { workflowsService, type WorkflowSummary, type WorkflowSavePayload, type WorkflowTestResult } from '../../../services/workflows.service'; import { workflowsService, type WorkflowSummary, type WorkflowSavePayload, type WorkflowTestResult } from '../../../services/workflows.service';
const NEW_WORKFLOW: WorkflowSavePayload = { 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)), 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), mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => workflowsService.setEnabled(id, enabled),
onSuccess: () => qc.invalidateQueries({ queryKey: ['workflows'] }), invalidateKeys: [['workflows']],
onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)), errorMessage: t('common.error', 'Something went wrong') as string,
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutationWithToast({
mutationFn: (id: number) => workflowsService.remove(id), mutationFn: (id: number) => workflowsService.remove(id),
onSuccess: () => { successMessage: t('workflows.toast.deleted', 'Workflow deleted') as string,
qc.invalidateQueries({ queryKey: ['workflows'] }); invalidateKeys: [['workflows']],
toast.success(t('workflows.toast.deleted', 'Workflow deleted') as string); errorMessage: t('workflows.toast.deleteFailed', 'Could not delete workflow') as string,
},
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.toast.deleteFailed', 'Could not delete workflow') as string)),
}); });
const isEnabled = (w: WorkflowSummary) => w.enabled === true || w.enabled === 1; const isEnabled = (w: WorkflowSummary) => w.enabled === true || w.enabled === 1;