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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user