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