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