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:
@@ -17,14 +17,14 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { useMutationWithToast } from '../../hooks';
|
||||
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { UpdateNotification } from '../../components/admin/UpdateNotification';
|
||||
import { WhatsNewBanner } from '../../components/admin/WhatsNewBanner';
|
||||
import { CrmOverviewSection } from '../../components/admin/CrmOverviewSection';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { adminService, ActivityType } from '../../services/admin.service';
|
||||
import { workflowsService } from '../../services/workflows.service';
|
||||
@@ -82,19 +82,16 @@ export const AdminDashboard: React.FC = () => {
|
||||
// Pending workflow approvals — only when the workflow engine is live. These
|
||||
// are the human-in-the-loop gates (e.g. "review invoice before sending").
|
||||
const { flags } = useFeatureFlags();
|
||||
const qc = useQueryClient();
|
||||
const { data: pendingApprovals } = useQuery({
|
||||
queryKey: ['workflow-approvals'],
|
||||
queryFn: () => workflowsService.approvals(),
|
||||
enabled: !!flags.workflows,
|
||||
});
|
||||
const approvalMutation = useMutation({
|
||||
const approvalMutation = useMutationWithToast({
|
||||
mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workflow-approvals'] });
|
||||
toast.success(t('workflows.approvals.acted', 'Done') as string);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)),
|
||||
invalidateKeys: [['workflow-approvals']],
|
||||
successMessage: t('workflows.approvals.acted', 'Done') as string,
|
||||
errorMessage: t('common.error', 'Something went wrong') as string,
|
||||
});
|
||||
|
||||
// Admin detail route for an approval's run entity, so clicking opens the
|
||||
|
||||
@@ -16,10 +16,11 @@ import { format, parseISO, isValid } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { useMutationWithToast } from '../../hooks';
|
||||
// import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export const ArchivesPage: React.FC = () => {
|
||||
@@ -30,7 +31,6 @@ export const ArchivesPage: React.FC = () => {
|
||||
const [sortBy, setSortBy] = useState<'date' | 'size' | 'name'>('date');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
// const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Helper function to safely format dates
|
||||
const formatDate = (dateString: string | null | undefined, formatStr: string): string => {
|
||||
@@ -79,26 +79,18 @@ export const ArchivesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
// Mutations
|
||||
const restoreMutation = useMutation({
|
||||
const restoreMutation = useMutationWithToast({
|
||||
mutationFn: (id: number) => archiveService.restoreArchive(id),
|
||||
onSuccess: () => {
|
||||
toast.success(t('archives.restoreSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-archives'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('errors.somethingWentWrong'));
|
||||
}
|
||||
successMessage: t('archives.restoreSuccess'),
|
||||
errorMessage: () => t('errors.somethingWentWrong'),
|
||||
invalidateKeys: [['admin-archives']],
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
const deleteMutation = useMutationWithToast({
|
||||
mutationFn: (id: number) => archiveService.deleteArchive(id),
|
||||
onSuccess: () => {
|
||||
toast.success(t('archives.deleteSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-archives'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('errors.somethingWentWrong'));
|
||||
}
|
||||
successMessage: t('archives.deleteSuccess'),
|
||||
errorMessage: () => t('errors.somethingWentWrong'),
|
||||
invalidateKeys: [['admin-archives']],
|
||||
});
|
||||
|
||||
const handleDownload = async (archive: typeof archives[0]) => {
|
||||
|
||||
@@ -13,12 +13,12 @@ import {
|
||||
ShieldCheck,
|
||||
FolderTree,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { useMutationWithToast } from '../../hooks';
|
||||
import { BackupDashboard } from '../../components/admin/BackupDashboard';
|
||||
import { BackupConfiguration } from '../../components/admin/BackupConfiguration';
|
||||
import { BackupHistory } from '../../components/admin/BackupHistory';
|
||||
@@ -31,7 +31,6 @@ type TabId = 'dashboard' | 'configuration' | 'history' | 'restore' | 'integrity'
|
||||
|
||||
export const BackupManagement: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('dashboard');
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
|
||||
@@ -61,34 +60,24 @@ export const BackupManagement: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const manualBackupMutation = useMutation({
|
||||
const manualBackupMutation = useMutationWithToast({
|
||||
mutationFn: async () => {
|
||||
const response = await api.post('/admin/backup/run');
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('backup.messages.backupStarted'));
|
||||
queryClient.invalidateQueries({ queryKey: ['backup-status'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const message = error.response?.data?.error || t('backup.messages.backupFailed');
|
||||
toast.error(message);
|
||||
},
|
||||
successMessage: t('backup.messages.backupStarted'),
|
||||
errorMessage: t('backup.messages.backupFailed'),
|
||||
invalidateKeys: [['backup-status']],
|
||||
});
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
const updateConfigMutation = useMutationWithToast({
|
||||
mutationFn: async (config: unknown) => {
|
||||
const response = await api.put('/admin/backup/config', config);
|
||||
return response.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('backup.messages.configUpdated'));
|
||||
queryClient.invalidateQueries({ queryKey: ['backup-config'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
const message = error.response?.data?.error || t('backup.messages.configUpdateFailed');
|
||||
toast.error(message);
|
||||
},
|
||||
successMessage: t('backup.messages.configUpdated'),
|
||||
errorMessage: t('backup.messages.configUpdateFailed'),
|
||||
invalidateKeys: [['backup-config']],
|
||||
});
|
||||
|
||||
if (statusLoading || configLoading) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { toast } from 'react-toastify';
|
||||
import { Button, Card, Input, ErrorBoundary, Loading, MarkdownContent } from '../../components/common';
|
||||
import { ThemeCustomizerEnhanced, GalleryPreview } from '../../components/admin';
|
||||
import { useTheme, type ThemeConfig, GALLERY_THEME_PRESETS } from '../../contexts/ThemeContext';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { settingsService, type BrandingSettings } from '../../services/settings.service';
|
||||
import { businessProfileService } from '../../services/businessProfile.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -13,6 +13,7 @@ import { useFeatureEnabled, useFeatureFlags } from '../../contexts/FeatureFlagsC
|
||||
import { CustomerDashboardBrandingCard } from '../../components/admin/CustomerDashboardBrandingCard';
|
||||
import { PdfTypographyCard } from '../../components/admin/PdfTypographyCard';
|
||||
import { usePublicSettings } from '../../hooks/usePublicSettings';
|
||||
import { useMutationWithToast } from '../../hooks';
|
||||
|
||||
export const BrandingPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -88,33 +89,23 @@ export const BrandingPage: React.FC = () => {
|
||||
// Update branding mutation
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const brandingMutation = useMutation({
|
||||
const brandingMutation = useMutationWithToast({
|
||||
mutationFn: settingsService.updateBranding,
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.brandingUpdated'));
|
||||
// Invalidate all settings queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
},
|
||||
successMessage: t('toast.brandingUpdated'),
|
||||
errorMessage: () => t('toast.saveError'),
|
||||
// Invalidate all settings queries to refresh data
|
||||
invalidateKeys: [['admin-settings'], ['public-settings']],
|
||||
});
|
||||
|
||||
// Update theme mutation
|
||||
const themeMutation = useMutation({
|
||||
const themeMutation = useMutationWithToast({
|
||||
mutationFn: settingsService.updateTheme,
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.themeUpdated'));
|
||||
// Refresh both the admin settings cache (which the page reads from) and
|
||||
// the public-settings cache (which the gallery reads from) so the saved
|
||||
// theme is reflected without a manual reload (#317).
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
},
|
||||
successMessage: t('toast.themeUpdated'),
|
||||
errorMessage: () => t('toast.saveError'),
|
||||
// Refresh both the admin settings cache (which the page reads from) and
|
||||
// the public-settings cache (which the gallery reads from) so the saved
|
||||
// theme is reflected without a manual reload (#317).
|
||||
invalidateKeys: [['admin-settings'], ['public-settings']],
|
||||
});
|
||||
|
||||
// Initialize settings from database
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { CMSPage as CMSPageType } from '../../services/cms.service';
|
||||
import { settingsService, PublicSiteBranding } from '../../services/settings.service';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { useMutationWithToast } from '../../hooks';
|
||||
|
||||
export const CMSPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -221,14 +222,14 @@ export const CMSPage: React.FC = () => {
|
||||
},
|
||||
onError: () => toast.error(t('toast.uploadError')),
|
||||
});
|
||||
const clearLogoMutation = useMutation({
|
||||
const clearLogoMutation = useMutationWithToast({
|
||||
mutationFn: async () => cmsService.clearPageLogo(selectedPage),
|
||||
successMessage: t('cms.logoCleared', 'Logo cleared'),
|
||||
errorMessage: () => t('toast.saveError'),
|
||||
invalidateKeys: [['cms-pages']],
|
||||
onSuccess: () => {
|
||||
setEditForm(prev => ({ ...prev, logo_url: null }));
|
||||
queryClient.invalidateQueries({ queryKey: ['cms-pages'] });
|
||||
toast.success(t('cms.logoCleared', 'Logo cleared'));
|
||||
},
|
||||
onError: () => toast.error(t('toast.saveError')),
|
||||
});
|
||||
|
||||
// Warn before leaving with unsaved changes
|
||||
|
||||
@@ -32,6 +32,7 @@ import { CustomerCrmPanels } from '../../components/admin/CustomerCrmPanels';
|
||||
import { HoursSection } from '../../components/admin/HoursSection';
|
||||
import { formatMoney } from '../../components/admin/LineItemsTable';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { useMutationWithToast, useModal } from '../../hooks';
|
||||
import { useFeatureFlags } from '../../contexts/FeatureFlagsContext';
|
||||
|
||||
type EditableFields =
|
||||
@@ -101,13 +102,13 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const [form, setForm] = useState<Partial<Pick<CustomerAccountDetail, EditableFields>>>({});
|
||||
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
|
||||
const [confirmErase, setConfirmErase] = useState(false);
|
||||
const deactivateModal = useModal();
|
||||
const eraseModal = useModal();
|
||||
// Drives the "Manage galleries" modal launched from the Assigned
|
||||
// events card. We hold open-state here (rather than inside the
|
||||
// dialog) so the parent decides when to mount/unmount and the
|
||||
// dialog can hard-reset its internal state per open.
|
||||
const [assignedDialogOpen, setAssignedDialogOpen] = useState(false);
|
||||
const assignedDialog = useModal();
|
||||
|
||||
// Hydrate the form from the fetched record once. We deliberately do NOT
|
||||
// re-sync on every refetch so an admin's in-progress edits aren't blown
|
||||
@@ -187,10 +188,10 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
* Confirm dialog ahead of the click is surfaced via the same modal
|
||||
* pattern as deactivate.
|
||||
*/
|
||||
const passwordResetMutation = useMutation({
|
||||
const passwordResetMutation = useMutationWithToast({
|
||||
mutationFn: () => customerAdminService.sendPasswordReset(customerId),
|
||||
onSuccess: () => toast.success(t('customers.detail.passwordReset.success', 'Password reset email sent')),
|
||||
onError: () => toast.error(t('customers.detail.passwordReset.error', 'Could not send password reset')),
|
||||
successMessage: t('customers.detail.passwordReset.success', 'Password reset email sent'),
|
||||
errorMessage: () => t('customers.detail.passwordReset.error', 'Could not send password reset'),
|
||||
});
|
||||
|
||||
// Promote a passive customer to active by firing the standard
|
||||
@@ -219,25 +220,21 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
// before the configured day. Surfaces backend errors verbatim so
|
||||
// admin sees "No pending monthly bill" / "Draft is empty" when the
|
||||
// queue isn't ready.
|
||||
const triggerMonthlyBillMutation = useMutation({
|
||||
const triggerMonthlyBillMutation = useMutationWithToast({
|
||||
mutationFn: () => customerAdminService.triggerMonthlyBill(customerId),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customer-hour-entries', customerId] });
|
||||
invalidateKeys: [
|
||||
['admin-customer', customerId],
|
||||
['admin-customer-hour-entries', customerId],
|
||||
// Clear the draft preview so the list collapses to empty
|
||||
// immediately after the trigger ships — a new draft is minted
|
||||
// on the next createInvoice / hour-entry append.
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customer-monthly-draft', customerId] });
|
||||
toast.success(
|
||||
t('customers.billing.triggered',
|
||||
'Monthly bill issued: {{number}}',
|
||||
{ number: result.invoiceNumber }),
|
||||
);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.response?.data?.error
|
||||
|| t('customers.billing.triggerError', 'Could not trigger the monthly bill.'));
|
||||
},
|
||||
['admin-customer-monthly-draft', customerId],
|
||||
],
|
||||
successMessage: (result) =>
|
||||
t('customers.billing.triggered',
|
||||
'Monthly bill issued: {{number}}',
|
||||
{ number: result.invoiceNumber }),
|
||||
errorMessage: t('customers.billing.triggerError', 'Could not trigger the monthly bill.'),
|
||||
});
|
||||
|
||||
const deactivateMutation = useMutation({
|
||||
@@ -252,14 +249,11 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
});
|
||||
|
||||
/** Re-enable login for a deactivated customer. */
|
||||
const reactivateMutation = useMutation({
|
||||
const reactivateMutation = useMutationWithToast({
|
||||
mutationFn: () => customerAdminService.reactivate(customerId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customer', customerId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
||||
toast.success(t('customers.reactivate.success', 'Customer reactivated'));
|
||||
},
|
||||
onError: () => toast.error(t('customers.reactivate.error', 'Could not reactivate customer')),
|
||||
invalidateKeys: [['admin-customer', customerId], ['admin-customers']],
|
||||
successMessage: t('customers.reactivate.success', 'Customer reactivated'),
|
||||
errorMessage: () => t('customers.reactivate.error', 'Could not reactivate customer'),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -466,7 +460,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<SettingsIcon className="w-4 h-4" />}
|
||||
onClick={() => setAssignedDialogOpen(true)}
|
||||
onClick={() => assignedDialog.open()}
|
||||
disabled={!customer.isActive}
|
||||
>
|
||||
{t('customers.detail.manageEvents', 'Manage galleries')}
|
||||
@@ -495,13 +489,13 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
|
||||
<AssignedEventsDialog
|
||||
customerId={customer.id}
|
||||
isOpen={assignedDialogOpen}
|
||||
isOpen={assignedDialog.isOpen}
|
||||
initial={customer.events.map((ev) => ({
|
||||
id: ev.id,
|
||||
eventName: ev.eventName,
|
||||
eventDate: ev.eventDate || null,
|
||||
}))}
|
||||
onClose={() => setAssignedDialogOpen(false)}
|
||||
onClose={() => assignedDialog.close()}
|
||||
onSaved={() => {
|
||||
// Parent refetch is handled by the dialog's invalidateQueries.
|
||||
}}
|
||||
@@ -960,7 +954,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<Trash2 className="w-4 h-4" />}
|
||||
onClick={() => setConfirmDeactivate(true)}
|
||||
onClick={() => deactivateModal.open()}
|
||||
>
|
||||
{t('customers.deactivate.button', 'Deactivate')}
|
||||
</Button>
|
||||
@@ -981,7 +975,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
leftIcon={<Trash2 className="w-4 h-4 text-red-600" />}
|
||||
onClick={() => setConfirmErase(true)}
|
||||
onClick={() => eraseModal.open()}
|
||||
>
|
||||
<span className="text-red-600">
|
||||
{t('customers.erase.button', 'Erase customer data')}
|
||||
@@ -1000,7 +994,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{confirmDeactivate && (
|
||||
{deactivateModal.isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div className="w-full max-w-md rounded-xl shadow-lg bg-white dark:bg-neutral-900">
|
||||
<div className="p-6">
|
||||
@@ -1017,13 +1011,13 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setConfirmDeactivate(false)}>
|
||||
<Button variant="outline" onClick={() => deactivateModal.close()}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
isLoading={deactivateMutation.isPending}
|
||||
onClick={() => { deactivateMutation.mutate(); setConfirmDeactivate(false); }}
|
||||
onClick={() => { deactivateMutation.mutate(); deactivateModal.close(); }}
|
||||
>
|
||||
{t('common.confirm', 'Confirm')}
|
||||
</Button>
|
||||
@@ -1037,7 +1031,7 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
"irreversible" copy + red Confirm button so the click feels
|
||||
deliberate. The action anonymizes PII in place; assignments
|
||||
and audit-log references are preserved. */}
|
||||
{confirmErase && (
|
||||
{eraseModal.isOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div className="w-full max-w-md rounded-xl shadow-lg bg-white dark:bg-neutral-900">
|
||||
<div className="p-6">
|
||||
@@ -1054,14 +1048,14 @@ export const CustomerDetailPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setConfirmErase(false)}>
|
||||
<Button variant="outline" onClick={() => eraseModal.close()}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
disabled={eraseMutation.isPending}
|
||||
onClick={() => { eraseMutation.mutate(); setConfirmErase(false); }}
|
||||
onClick={() => { eraseMutation.mutate(); eraseModal.close(); }}
|
||||
>
|
||||
{eraseMutation.isPending
|
||||
? t('customers.erase.confirmInFlight', 'Erasing…')
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
*/
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
UserPlus, UserCog, Trash2, Search, X, AlertTriangle, CheckCircle2, Clock,
|
||||
} from 'lucide-react';
|
||||
import { InlineCustomerCreate } from '../../components/admin/InlineCustomerCreate';
|
||||
import { useMutationWithToast } from '../../hooks';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../components/common';
|
||||
@@ -96,22 +96,18 @@ export const CustomerManagementPage: React.FC = () => {
|
||||
return list.filter((i) => i.email.toLowerCase().includes(term));
|
||||
}, [invitations, debouncedTerm]);
|
||||
|
||||
const deactivateMutation = useMutation({
|
||||
const deactivateMutation = useMutationWithToast({
|
||||
mutationFn: (id: number) => customerAdminService.deactivate(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
|
||||
toast.success(t('customers.deactivate.success', 'Customer deactivated'));
|
||||
},
|
||||
onError: () => toast.error(t('customers.deactivate.error', 'Could not deactivate customer')),
|
||||
invalidateKeys: [['admin-customers']],
|
||||
successMessage: t('customers.deactivate.success', 'Customer deactivated'),
|
||||
errorMessage: () => t('customers.deactivate.error', 'Could not deactivate customer'),
|
||||
});
|
||||
|
||||
const cancelInviteMutation = useMutation({
|
||||
const cancelInviteMutation = useMutationWithToast({
|
||||
mutationFn: (id: number) => customerAdminService.cancelInvitation(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-customer-invitations'] });
|
||||
toast.success(t('customers.cancelInvitation.success', 'Invitation cancelled'));
|
||||
},
|
||||
onError: () => toast.error(t('customers.cancelInvitation.error', 'Could not cancel invitation')),
|
||||
invalidateKeys: [['admin-customer-invitations']],
|
||||
successMessage: t('customers.cancelInvitation.success', 'Invitation cancelled'),
|
||||
errorMessage: () => t('customers.cancelInvitation.error', 'Could not cancel invitation'),
|
||||
});
|
||||
|
||||
const renderCustomerName = (c: CustomerAccountSummary) => {
|
||||
|
||||
@@ -22,7 +22,8 @@ import { SentEmailsPanel } from '../../components/admin/SentEmailsPanel';
|
||||
import { ReceivedEmailsPanel } from '../../components/admin/ReceivedEmailsPanel';
|
||||
import { IncomingMailConfigCard } from '../../components/admin/IncomingMailConfigCard';
|
||||
import { Palette, RefreshCw, Info } from 'lucide-react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useModal, useMutationWithToast } from '../../hooks';
|
||||
import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -138,7 +139,7 @@ export const EmailConfigPage: React.FC = () => {
|
||||
const [editingLang, setEditingLang] = useState<string>('en');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [testEmail, setTestEmail] = useState('');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const previewModal = useModal();
|
||||
const [previewData, setPreviewData] = useState<{ subject: string; htmlContent: string; textContent?: string }>({
|
||||
subject: '',
|
||||
htmlContent: '',
|
||||
@@ -157,7 +158,6 @@ export const EmailConfigPage: React.FC = () => {
|
||||
const [emailBodyTextColor, setEmailBodyTextColor] = useState('#333333');
|
||||
const [emailMutedTextColor, setEmailMutedTextColor] = useState('#666666');
|
||||
const [emailButtonTextColor, setEmailButtonTextColor] = useState('#ffffff');
|
||||
const queryClient = useQueryClient();
|
||||
const { flags: featureFlags } = useFeatureFlags();
|
||||
|
||||
// SMTP Configuration state
|
||||
@@ -239,25 +239,17 @@ export const EmailConfigPage: React.FC = () => {
|
||||
|| e?.message
|
||||
|| fallback;
|
||||
|
||||
const saveConfigMutation = useMutation({
|
||||
const saveConfigMutation = useMutationWithToast({
|
||||
mutationFn: (config: EmailConfig) => emailService.updateConfig(config),
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.emailConfigSaved'));
|
||||
queryClient.invalidateQueries({ queryKey: ['email-config'] });
|
||||
},
|
||||
onError: (e: any) => {
|
||||
toast.error(errMsg(e, t('toast.saveError')));
|
||||
}
|
||||
successMessage: t('toast.emailConfigSaved'),
|
||||
invalidateKeys: [['email-config']],
|
||||
errorMessage: (e: any) => errMsg(e, t('toast.saveError')),
|
||||
});
|
||||
|
||||
const testEmailMutation = useMutation({
|
||||
const testEmailMutation = useMutationWithToast({
|
||||
mutationFn: (email: string) => emailService.testEmail(email),
|
||||
onSuccess: () => {
|
||||
toast.success(t('email.testEmailSuccess'));
|
||||
},
|
||||
onError: (e: any) => {
|
||||
toast.error(errMsg(e, t('toast.saveError')));
|
||||
}
|
||||
successMessage: t('email.testEmailSuccess'),
|
||||
errorMessage: (e: any) => errMsg(e, t('toast.saveError')),
|
||||
});
|
||||
|
||||
const flushQueueMutation = useMutation({
|
||||
@@ -274,29 +266,20 @@ export const EmailConfigPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const saveTemplateMutation = useMutation({
|
||||
const saveTemplateMutation = useMutationWithToast({
|
||||
mutationFn: ({ key, translations }: { key: string; translations: Record<string, EmailTemplateTranslation> }) =>
|
||||
emailService.updateTemplate(key, { translations }),
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.saveSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['email-template', selectedTemplateKey] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
successMessage: t('toast.saveSuccess'),
|
||||
invalidateKeys: [['email-templates'], ['email-template', selectedTemplateKey]],
|
||||
errorMessage: () => t('toast.saveError'),
|
||||
});
|
||||
|
||||
const saveEmailColorsMutation = useMutation({
|
||||
const saveEmailColorsMutation = useMutationWithToast({
|
||||
mutationFn: (colors: Record<string, string>) =>
|
||||
settingsService.updateSettings(colors),
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.saveSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
successMessage: t('toast.saveSuccess'),
|
||||
invalidateKeys: [['admin-settings']],
|
||||
errorMessage: () => t('toast.saveError'),
|
||||
});
|
||||
|
||||
const handleSaveEmailColors = () => {
|
||||
@@ -431,7 +414,7 @@ export const EmailConfigPage: React.FC = () => {
|
||||
htmlContent: preview.body_html,
|
||||
textContent: preview.body_text
|
||||
});
|
||||
setShowPreview(true);
|
||||
previewModal.open();
|
||||
} catch (error) {
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
@@ -1057,8 +1040,8 @@ export const EmailConfigPage: React.FC = () => {
|
||||
|
||||
{/* Email Preview Modal */}
|
||||
<EmailPreviewModal
|
||||
isOpen={showPreview}
|
||||
onClose={() => setShowPreview(false)}
|
||||
isOpen={previewModal.isOpen}
|
||||
onClose={previewModal.close}
|
||||
subject={previewData.subject}
|
||||
htmlContent={previewData.htmlContent}
|
||||
textContent={previewData.textContent}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { feedbackService } from '../../services/feedback.service';
|
||||
import type { PhotoFeedback, FeedbackAnalytics, FeedbackResponse } from '../../services/feedback.service';
|
||||
import { useMutationWithToast } from '../../hooks';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
export const EventFeedbackPage: React.FC = () => {
|
||||
@@ -76,15 +77,11 @@ export const EventFeedbackPage: React.FC = () => {
|
||||
});
|
||||
|
||||
// Update settings mutation
|
||||
const updateSettingsMutation = useMutation({
|
||||
const updateSettingsMutation = useMutationWithToast({
|
||||
mutationFn: (newSettings: any) => feedbackService.updateEventFeedbackSettings(id!, newSettings),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['feedback-settings', id] });
|
||||
toast.success(t('feedback.settingsUpdated', 'Feedback settings updated'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('feedback.settingsUpdateError', 'Failed to update settings'));
|
||||
}
|
||||
invalidateKeys: [['feedback-settings', id]],
|
||||
successMessage: t('feedback.settingsUpdated', 'Feedback settings updated'),
|
||||
errorMessage: () => t('feedback.settingsUpdateError', 'Failed to update settings'),
|
||||
});
|
||||
|
||||
// Moderate feedback mutation
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Plus,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { useModal, useMutationWithToast } from '../../hooks';
|
||||
import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service';
|
||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
|
||||
@@ -27,12 +27,11 @@ const EMOJI_OPTIONS = [
|
||||
|
||||
export const EventTypesPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const createModal = useModal();
|
||||
const [editingType, setEditingType] = useState<EventType | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<EventType | null>(null);
|
||||
|
||||
@@ -43,40 +42,34 @@ export const EventTypesPage: React.FC = () => {
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
const createMutation = useMutationWithToast({
|
||||
mutationFn: eventTypesService.createEventType,
|
||||
invalidateKeys: [['admin-event-types']],
|
||||
successMessage: t('eventTypes.created', 'Event type created successfully'),
|
||||
errorMessage: t('eventTypes.createError', 'Failed to create event type'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
|
||||
setShowCreateModal(false);
|
||||
toast.success(t('eventTypes.created', 'Event type created successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('eventTypes.createError', 'Failed to create event type'));
|
||||
createModal.close();
|
||||
}
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
const updateMutation = useMutationWithToast({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateEventTypeData }) =>
|
||||
eventTypesService.updateEventType(id, data),
|
||||
invalidateKeys: [['admin-event-types']],
|
||||
successMessage: t('eventTypes.updated', 'Event type updated successfully'),
|
||||
errorMessage: t('eventTypes.updateError', 'Failed to update event type'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
|
||||
setEditingType(null);
|
||||
toast.success(t('eventTypes.updated', 'Event type updated successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('eventTypes.updateError', 'Failed to update event type'));
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
const deleteMutation = useMutationWithToast({
|
||||
mutationFn: eventTypesService.deleteEventType,
|
||||
invalidateKeys: [['admin-event-types']],
|
||||
successMessage: t('eventTypes.deleted', 'Event type deleted successfully'),
|
||||
errorMessage: t('eventTypes.deleteError', 'Failed to delete event type'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
|
||||
setDeleteConfirm(null);
|
||||
toast.success(t('eventTypes.deleted', 'Event type deleted successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('eventTypes.deleteError', 'Failed to delete event type'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -130,7 +123,7 @@ export const EventTypesPage: React.FC = () => {
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
onClick={() => createModal.open()}
|
||||
>
|
||||
{t('eventTypes.createNew', 'New Event Type')}
|
||||
</Button>
|
||||
@@ -274,9 +267,9 @@ export const EventTypesPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
{createModal.isOpen && (
|
||||
<EventTypeModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onClose={createModal.close}
|
||||
onSubmit={(data) => createMutation.mutate(data as CreateEventTypeData)}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useModal, useMutationWithToast } from '../../hooks';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
|
||||
@@ -46,8 +47,8 @@ export const EventsListPage: React.FC = () => {
|
||||
// const [showFilters, setShowFilters] = useState(false);
|
||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
|
||||
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false);
|
||||
const bulkArchiveModal = useModal();
|
||||
const bulkDeleteModal = useModal();
|
||||
const [copiedEventId, setCopiedEventId] = useState<number | null>(null);
|
||||
|
||||
const copyShareLink = async (event: Event) => {
|
||||
@@ -169,29 +170,19 @@ export const EventsListPage: React.FC = () => {
|
||||
});
|
||||
|
||||
// Archive mutation
|
||||
const archiveMutation = useMutation({
|
||||
const archiveMutation = useMutationWithToast({
|
||||
mutationFn: eventsService.archiveEvent,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
|
||||
toast.success(t('toast.eventArchived'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.saveError'));
|
||||
},
|
||||
invalidateKeys: [['admin-events'], ['admin-dashboard-stats']],
|
||||
successMessage: t('toast.eventArchived'),
|
||||
errorMessage: () => t('toast.saveError'),
|
||||
});
|
||||
|
||||
// Delete mutation
|
||||
const deleteMutation = useMutation({
|
||||
const deleteMutation = useMutationWithToast({
|
||||
mutationFn: eventsService.deleteEvent,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
|
||||
toast.success(t('toast.deleteSuccess'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('toast.deleteError'));
|
||||
},
|
||||
invalidateKeys: [['admin-events'], ['admin-dashboard-stats']],
|
||||
successMessage: t('toast.deleteSuccess'),
|
||||
errorMessage: () => t('toast.deleteError'),
|
||||
});
|
||||
|
||||
// Bulk archive mutation
|
||||
@@ -201,7 +192,7 @@ export const EventsListPage: React.FC = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
|
||||
setSelectedEvents([]);
|
||||
setShowBulkArchiveModal(false);
|
||||
bulkArchiveModal.close();
|
||||
|
||||
if (data.results.failed.length === 0) {
|
||||
toast.success(t('events.bulkArchiveSuccess', { count: data.results.successful.length }));
|
||||
@@ -222,7 +213,7 @@ export const EventsListPage: React.FC = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
|
||||
setSelectedEvents([]);
|
||||
setShowBulkDeleteModal(false);
|
||||
bulkDeleteModal.close();
|
||||
|
||||
if (data.results.failed.length === 0) {
|
||||
toast.success(t('events.bulkDelete.successAll', { count: data.results.successful.length }));
|
||||
@@ -232,7 +223,7 @@ export const EventsListPage: React.FC = () => {
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('events.bulkDelete.errorGeneric'));
|
||||
setShowBulkDeleteModal(false);
|
||||
bulkDeleteModal.close();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -439,14 +430,14 @@ export const EventsListPage: React.FC = () => {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowBulkArchiveModal(true)}
|
||||
onClick={() => bulkArchiveModal.open()}
|
||||
>
|
||||
{t('events.archiveSelected')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowBulkDeleteModal(true)}
|
||||
onClick={() => bulkDeleteModal.open()}
|
||||
className="border-red-300 text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-400 dark:hover:bg-red-900/30"
|
||||
>
|
||||
{t('events.deleteSelected', 'Delete Selected')}
|
||||
@@ -758,8 +749,8 @@ export const EventsListPage: React.FC = () => {
|
||||
|
||||
{/* Bulk Archive Modal */}
|
||||
<BulkArchiveModal
|
||||
isOpen={showBulkArchiveModal}
|
||||
onClose={() => setShowBulkArchiveModal(false)}
|
||||
isOpen={bulkArchiveModal.isOpen}
|
||||
onClose={bulkArchiveModal.close}
|
||||
onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)}
|
||||
selectedEvents={events.filter(e => selectedEvents.includes(e.id))}
|
||||
isLoading={bulkArchiveMutation.isPending}
|
||||
@@ -767,8 +758,8 @@ export const EventsListPage: React.FC = () => {
|
||||
|
||||
{/* Bulk Delete Modal */}
|
||||
<BulkDeleteModal
|
||||
isOpen={showBulkDeleteModal}
|
||||
onClose={() => setShowBulkDeleteModal(false)}
|
||||
isOpen={bulkDeleteModal.isOpen}
|
||||
onClose={bulkDeleteModal.close}
|
||||
onConfirm={async () => {
|
||||
await bulkDeleteMutation.mutateAsync(selectedEvents);
|
||||
}}
|
||||
|
||||
@@ -5,34 +5,33 @@
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { AlertCircle, RefreshCw, Trash2, CheckCircle } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { useMutationWithToast } from '../../hooks';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { systemHealthService } from '../../services/systemHealth.service';
|
||||
|
||||
export const SystemHealthPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['system-health-failures'],
|
||||
queryFn: () => systemHealthService.getFailures(),
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['system-health-failures'] });
|
||||
|
||||
const retryMutation = useMutation({
|
||||
const retryMutation = useMutationWithToast({
|
||||
mutationFn: (id: number) => systemHealthService.retryEmail(id),
|
||||
onSuccess: () => { toast.success(t('systemHealth.retriedToast', 'Email re-queued.')); invalidate(); },
|
||||
onError: () => toast.error(t('toast.saveError')),
|
||||
invalidateKeys: [['system-health-failures']],
|
||||
successMessage: t('systemHealth.retriedToast', 'Email re-queued.'),
|
||||
errorMessage: () => t('toast.saveError'),
|
||||
});
|
||||
const dismissMutation = useMutation({
|
||||
const dismissMutation = useMutationWithToast({
|
||||
mutationFn: (id: number) => systemHealthService.dismissEmail(id),
|
||||
onSuccess: () => { toast.success(t('systemHealth.dismissedToast', 'Dismissed.')); invalidate(); },
|
||||
onError: () => toast.error(t('toast.saveError')),
|
||||
invalidateKeys: [['system-health-failures']],
|
||||
successMessage: t('systemHealth.dismissedToast', 'Dismissed.'),
|
||||
errorMessage: () => t('toast.saveError'),
|
||||
});
|
||||
|
||||
const stuckEmails = data?.stuckEmails ?? [];
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Users,
|
||||
Mail,
|
||||
@@ -23,7 +22,7 @@ import { parseISO, isPast } from 'date-fns';
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { userManagementService } from '../../services/userManagement.service';
|
||||
import type { AdminUser, AdminRole, AdminInvitation } from '../../types';
|
||||
import { useLocalizedDate } from "../../hooks";
|
||||
import { useLocalizedDate, useModal, useMutationWithToast } from "../../hooks";
|
||||
|
||||
type TabType = 'users' | 'invitations';
|
||||
|
||||
@@ -369,14 +368,13 @@ const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
|
||||
export const UserManagementPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { formatDistanceToNow } = useLocalizedDate()
|
||||
|
||||
// State
|
||||
const [activeTab, setActiveTab] = useState<TabType>('users');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showCreateInvitationModal, setShowCreateInvitationModal] = useState(false);
|
||||
const [showEditUserModal, setShowEditUserModal] = useState(false);
|
||||
const createInvitationModal = useModal();
|
||||
const editUserModal = useModal();
|
||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null);
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
@@ -413,80 +411,68 @@ export const UserManagementPage: React.FC = () => {
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createInvitationMutation = useMutation({
|
||||
const createInvitationMutation = useMutationWithToast({
|
||||
mutationFn: ({ email, roleId }: { email: string; roleId: number }) =>
|
||||
userManagementService.createInvitation({ email, role_id: roleId }),
|
||||
invalidateKeys: [['admin-invitations']],
|
||||
successMessage: t('userManagement.invitationSent'),
|
||||
errorMessage: (error: Error) => error.message || t('userManagement.invitationError'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-invitations'] });
|
||||
setShowCreateInvitationModal(false);
|
||||
toast.success(t('userManagement.invitationSent'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || t('userManagement.invitationError'));
|
||||
createInvitationModal.close();
|
||||
},
|
||||
});
|
||||
|
||||
const cancelInvitationMutation = useMutation({
|
||||
const cancelInvitationMutation = useMutationWithToast({
|
||||
mutationFn: userManagementService.cancelInvitation,
|
||||
invalidateKeys: [['admin-invitations']],
|
||||
successMessage: t('userManagement.invitationCancelled'),
|
||||
errorMessage: () => t('userManagement.cancelInvitationError'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-invitations'] });
|
||||
setConfirmDialog(null);
|
||||
toast.success(t('userManagement.invitationCancelled'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('userManagement.cancelInvitationError'));
|
||||
},
|
||||
});
|
||||
|
||||
const updateUserMutation = useMutation({
|
||||
const updateUserMutation = useMutationWithToast({
|
||||
mutationFn: ({ id, roleId }: { id: number; roleId: number }) =>
|
||||
userManagementService.updateUser(id, { roleId }),
|
||||
invalidateKeys: [['admin-users']],
|
||||
successMessage: t('userManagement.userUpdated'),
|
||||
errorMessage: () => t('userManagement.updateUserError'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
setShowEditUserModal(false);
|
||||
editUserModal.close();
|
||||
setSelectedUser(null);
|
||||
toast.success(t('userManagement.userUpdated'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('userManagement.updateUserError'));
|
||||
},
|
||||
});
|
||||
|
||||
const deactivateUserMutation = useMutation({
|
||||
const deactivateUserMutation = useMutationWithToast({
|
||||
mutationFn: userManagementService.deactivateUser,
|
||||
invalidateKeys: [['admin-users']],
|
||||
successMessage: t('userManagement.userDeactivated'),
|
||||
errorMessage: () => t('userManagement.deactivateUserError'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
setConfirmDialog(null);
|
||||
toast.success(t('userManagement.userDeactivated'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('userManagement.deactivateUserError'));
|
||||
},
|
||||
});
|
||||
|
||||
// #574 follow-up: reactivate + delete actions for the rows the
|
||||
// deactivate button used to leave unmanageable.
|
||||
const activateUserMutation = useMutation({
|
||||
const activateUserMutation = useMutationWithToast({
|
||||
mutationFn: userManagementService.activateUser,
|
||||
invalidateKeys: [['admin-users']],
|
||||
successMessage: t('userManagement.userActivated', 'User reactivated successfully'),
|
||||
errorMessage: () => t('userManagement.activateUserError', 'Failed to reactivate user'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
setConfirmDialog(null);
|
||||
toast.success(t('userManagement.userActivated', 'User reactivated successfully'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('userManagement.activateUserError', 'Failed to reactivate user'));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteUserMutation = useMutation({
|
||||
const deleteUserMutation = useMutationWithToast({
|
||||
mutationFn: userManagementService.deleteUser,
|
||||
invalidateKeys: [['admin-users']],
|
||||
successMessage: t('userManagement.userDeleted', 'User deleted successfully'),
|
||||
errorMessage: () => t('userManagement.deleteUserError', 'Failed to delete user'),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-users'] });
|
||||
setConfirmDialog(null);
|
||||
toast.success(t('userManagement.userDeleted', 'User deleted successfully'));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t('userManagement.deleteUserError', 'Failed to delete user'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -523,7 +509,7 @@ export const UserManagementPage: React.FC = () => {
|
||||
|
||||
const handleEditUser = (user: AdminUser) => {
|
||||
setSelectedUser(user);
|
||||
setShowEditUserModal(true);
|
||||
editUserModal.open();
|
||||
};
|
||||
|
||||
const handleUpdateUser = (userId: number, roleId: number) => {
|
||||
@@ -641,7 +627,7 @@ export const UserManagementPage: React.FC = () => {
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Plus className="w-5 h-5" />}
|
||||
onClick={() => setShowCreateInvitationModal(true)}
|
||||
onClick={createInvitationModal.open}
|
||||
>
|
||||
{t('userManagement.inviteUser')}
|
||||
</Button>
|
||||
@@ -983,8 +969,8 @@ export const UserManagementPage: React.FC = () => {
|
||||
|
||||
{/* Create Invitation Modal */}
|
||||
<CreateInvitationModal
|
||||
isOpen={showCreateInvitationModal}
|
||||
onClose={() => setShowCreateInvitationModal(false)}
|
||||
isOpen={createInvitationModal.isOpen}
|
||||
onClose={createInvitationModal.close}
|
||||
onSubmit={handleCreateInvitation}
|
||||
roles={roles || []}
|
||||
isLoading={createInvitationMutation.isPending}
|
||||
@@ -992,9 +978,9 @@ export const UserManagementPage: React.FC = () => {
|
||||
|
||||
{/* Edit User Modal */}
|
||||
<EditUserModal
|
||||
isOpen={showEditUserModal}
|
||||
isOpen={editUserModal.isOpen}
|
||||
onClose={() => {
|
||||
setShowEditUserModal(false);
|
||||
editUserModal.close();
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
onSubmit={handleUpdateUser}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeft, RefreshCw, RotateCw, Send, X, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { api } from '../../config/api';
|
||||
import { useModal, useMutationWithToast } from '../../hooks';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
const WEBHOOK_EVENT_TYPES = [
|
||||
@@ -64,12 +64,11 @@ function statusBadge(status: string) {
|
||||
export const WebhookDeliveriesPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const webhookId = parseInt(id || '', 10);
|
||||
const queryClient = useQueryClient();
|
||||
const { formatDateTime: fmtDateTime } = useLocalizedDate();
|
||||
|
||||
const [filter, setFilter] = useState<StatusFilter>('all');
|
||||
const [openDeliveryId, setOpenDeliveryId] = useState<number | null>(null);
|
||||
const [showTestDialog, setShowTestDialog] = useState(false);
|
||||
const testDialog = useModal();
|
||||
const [testEventType, setTestEventType] = useState<string>('event.published');
|
||||
|
||||
const { data: webhook, isLoading: loadingWebhook } = useQuery({
|
||||
@@ -108,24 +107,22 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
enabled: Number.isFinite(webhookId) && openDeliveryId !== null,
|
||||
});
|
||||
|
||||
const replayMutation = useMutation({
|
||||
const replayMutation = useMutationWithToast({
|
||||
mutationFn: async (deliveryId: number) =>
|
||||
api.post(`/admin/webhooks/${webhookId}/deliveries/${deliveryId}/replay`),
|
||||
onSuccess: () => {
|
||||
toast.success('Replay enqueued');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-webhook-deliveries', webhookId] });
|
||||
},
|
||||
onError: () => toast.error('Failed to replay'),
|
||||
invalidateKeys: [['admin-webhook-deliveries', webhookId]],
|
||||
successMessage: 'Replay enqueued',
|
||||
errorMessage: () => 'Failed to replay',
|
||||
});
|
||||
|
||||
const testMutation = useMutation({
|
||||
const testMutation = useMutationWithToast({
|
||||
mutationFn: async () => api.post(`/admin/webhooks/${webhookId}/test`, { event_type: testEventType }),
|
||||
invalidateKeys: [['admin-webhook-deliveries', webhookId]],
|
||||
successMessage: 'Test event enqueued',
|
||||
errorMessage: 'Failed to send test',
|
||||
onSuccess: () => {
|
||||
toast.success('Test event enqueued');
|
||||
setShowTestDialog(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-webhook-deliveries', webhookId] });
|
||||
testDialog.close();
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed to send test'),
|
||||
});
|
||||
|
||||
if (loadingWebhook) {
|
||||
@@ -174,7 +171,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Send className="w-4 h-4" />}
|
||||
onClick={() => setShowTestDialog(true)}
|
||||
onClick={() => testDialog.open()}
|
||||
>
|
||||
Send test event
|
||||
</Button>
|
||||
@@ -338,8 +335,8 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Test event dialog */}
|
||||
{showTestDialog && (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40" onClick={() => setShowTestDialog(false)}>
|
||||
{testDialog.isOpen && (
|
||||
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/40" onClick={() => testDialog.close()}>
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-xl p-6 max-w-md w-full mx-4" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-3">Send test event</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-3">
|
||||
@@ -354,7 +351,7 @@ export const WebhookDeliveriesPage: React.FC = () => {
|
||||
{WEBHOOK_EVENT_TYPES.map((e) => <option key={e} value={e}>{e}</option>)}
|
||||
</select>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setShowTestDialog(false)}>Cancel</Button>
|
||||
<Button variant="ghost" onClick={() => testDialog.close()}>Cancel</Button>
|
||||
<Button variant="primary" isLoading={testMutation.isPending} onClick={() => testMutation.mutate()}>
|
||||
Send
|
||||
</Button>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { EventBookingSelect } from '../../../components/admin/EventBookingSelect
|
||||
import { formatMoneyMinor } from '../../../utils/money';
|
||||
import { sortedCountryOptions } from '../../../constants/countries';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { useMutationWithToast } from '../../../hooks';
|
||||
import {
|
||||
accountingService, categoryLabel,
|
||||
type InboundDocument, type Disposition, type MarkupType, type PaymentMethod, type ExpenseCategory,
|
||||
@@ -89,10 +90,11 @@ const PayModal: React.FC<{ doc: InboundDocument; onClose: () => void; onDone: ()
|
||||
const [paidAt, setPaidAt] = useState('');
|
||||
const [method, setMethod] = useState<PaymentMethod>('bank_transfer');
|
||||
const [reference, setReference] = useState(doc.paymentReference || '');
|
||||
const save = useMutation({
|
||||
const save = useMutationWithToast({
|
||||
mutationFn: () => accountingService.markInboundPaid(doc.id, { paid: true, paidAt: paidAt || undefined, paymentMethod: method, paymentReference: reference || undefined }),
|
||||
onSuccess: () => { toast.success(t('accounting.incoming.paidToast', 'Marked as paid.')); onDone(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
successMessage: t('accounting.incoming.paidToast', 'Marked as paid.'),
|
||||
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
|
||||
onSuccess: () => onDone(),
|
||||
});
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
|
||||
@@ -205,7 +207,7 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
|
||||
// `pay` decides whether to also mark the supplier invoice paid in the same
|
||||
// step (#5/#1). When true we mark it paid directly (using the reference
|
||||
// entered) — no second dialog — so "Save & mark paid" actually pays.
|
||||
const save = useMutation({
|
||||
const save = useMutationWithToast({
|
||||
mutationFn: async (pay: boolean) => {
|
||||
await accountingService.updateInbound(doc.id, { supplierName: supplier || null, totalAmountMinor: totalMinor, currency: currency || null, invoiceDate: invoiceDate || null, paymentReference: reference || null, note: note || null, supplierCountry: supplierCountry || null });
|
||||
await accountingService.categorizeInbound(doc.id, {
|
||||
@@ -221,11 +223,9 @@ const TriageModal: React.FC<{ doc: InboundDocument; categories: ExpenseCategory[
|
||||
await accountingService.markInboundPaid(doc.id, { paid: true, paymentReference: reference || undefined });
|
||||
}
|
||||
},
|
||||
onSuccess: (_data, pay) => {
|
||||
toast.success(pay ? t('accounting.incoming.categorizedPaidToast', 'Categorized and marked paid.') : t('accounting.incoming.categorizedToast', 'Categorized.'));
|
||||
onDone();
|
||||
},
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
successMessage: (_data, pay) => pay ? t('accounting.incoming.categorizedPaidToast', 'Categorized and marked paid.') : t('accounting.incoming.categorizedToast', 'Categorized.'),
|
||||
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
|
||||
onSuccess: () => onDone(),
|
||||
});
|
||||
|
||||
const rebillNeedsCustomer = disposition === 'rebill' && !customer[0];
|
||||
@@ -342,10 +342,10 @@ export const AccountingInboxPage: React.FC = () => {
|
||||
onSuccess: (doc) => { toast.success(t('accounting.inbox.capturedToast', 'Document captured.')); qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); if (doc.status === 'unsorted') setTriageDoc(doc); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Upload failed'),
|
||||
});
|
||||
const unpay = useMutation({
|
||||
const unpay = useMutationWithToast({
|
||||
mutationFn: (id: number) => accountingService.markInboundPaid(id, { paid: false }),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['accounting-inbound'] }); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
invalidateKeys: [['accounting-inbound']],
|
||||
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
|
||||
});
|
||||
const billPending = useMutation({
|
||||
mutationFn: (customerAccountId: number) => accountingService.billPendingRebills(customerAccountId),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { X, Plus, Paperclip, Car, CalendarDays, Coins, Pencil, FileText, CheckCircle2, Circle, Lock } from 'lucide-react';
|
||||
@@ -18,6 +18,7 @@ import { EventBookingSelect } from '../../../components/admin/EventBookingSelect
|
||||
import { CustomerAccountPicker, type SelectedCustomer } from '../../../components/admin/CustomerAccountPicker';
|
||||
import { formatMoneyMinor } from '../../../utils/money';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { useMutationWithToast, useModal } from '../../../hooks';
|
||||
import {
|
||||
accountingService, categoryLabel,
|
||||
type Expense, type ExpenseKind, type ExpenseCategory, type MarkupType, type PaymentMethod,
|
||||
@@ -68,12 +69,13 @@ const ExpenseFormModal: React.FC<{ categories: ExpenseCategory[]; expense?: Expe
|
||||
description: description || null,
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
const save = useMutationWithToast({
|
||||
mutationFn: () => isEdit
|
||||
? accountingService.updateExpense(expense!.id, payload(), file)
|
||||
: accountingService.createExpense(payload(), file),
|
||||
onSuccess: () => { toast.success(isEdit ? t('accounting.ledger.updatedToast', 'Expense updated.') : t('accounting.ledger.createdToast', 'Expense added.')); onDone(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
successMessage: isEdit ? t('accounting.ledger.updatedToast', 'Expense updated.') : t('accounting.ledger.createdToast', 'Expense added.'),
|
||||
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
|
||||
onSuccess: () => onDone(),
|
||||
});
|
||||
|
||||
const qtyLabel = kind === 'mileage' ? t('accounting.expense.km', 'Kilometres') : t('accounting.expense.days', 'Days');
|
||||
@@ -143,10 +145,11 @@ const ExpensePaidModal: React.FC<{ expense: Expense; onClose: () => void; onDone
|
||||
const [paidAt, setPaidAt] = useState('');
|
||||
const [method, setMethod] = useState<PaymentMethod>('bank_transfer');
|
||||
const [reference, setReference] = useState('');
|
||||
const save = useMutation({
|
||||
const save = useMutationWithToast({
|
||||
mutationFn: () => accountingService.markExpensePaid(expense.id, { paid: true, paidAt: paidAt || undefined, paymentMethod: method, paymentReference: reference || undefined }),
|
||||
onSuccess: () => { toast.success(t('accounting.ledger.paidToast', 'Marked as paid.')); onDone(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
successMessage: t('accounting.ledger.paidToast', 'Marked as paid.'),
|
||||
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
|
||||
onSuccess: () => onDone(),
|
||||
});
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4">
|
||||
@@ -182,15 +185,16 @@ const InvoiceExpenseModal: React.FC<{ expense: Expense; onClose: () => void; onD
|
||||
const [customer, setCustomer] = useState<SelectedCustomer[]>([]);
|
||||
const [markupType, setMarkupType] = useState<MarkupType>('none');
|
||||
const [markupValue, setMarkupValue] = useState<number>(NaN);
|
||||
const save = useMutation({
|
||||
const save = useMutationWithToast({
|
||||
mutationFn: () => accountingService.invoiceExpense(expense.id, {
|
||||
customerAccountId: customer[0]!.id,
|
||||
markupType,
|
||||
markupPercent: markupType === 'percent' && Number.isFinite(markupValue) ? markupValue : null,
|
||||
markupFlatMinor: markupType === 'flat' && Number.isFinite(markupValue) ? Math.round(markupValue * 100) : null,
|
||||
}),
|
||||
onSuccess: () => { toast.success(t('accounting.ledger.invoicedToast', 'Added to a client invoice.')); onDone(); },
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
successMessage: t('accounting.ledger.invoicedToast', 'Added to a client invoice.'),
|
||||
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
|
||||
onSuccess: () => onDone(),
|
||||
});
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4">
|
||||
@@ -230,15 +234,15 @@ export const ExpensesLedgerPage: React.FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const { format } = useLocalizedDate();
|
||||
const [kind, setKind] = useState('');
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const addModal = useModal();
|
||||
const [editExpense, setEditExpense] = useState<Expense | null>(null);
|
||||
const [paidExpense, setPaidExpense] = useState<Expense | null>(null);
|
||||
const [invoiceExpense, setInvoiceExpense] = useState<Expense | null>(null);
|
||||
|
||||
const unpay = useMutation({
|
||||
const unpay = useMutationWithToast({
|
||||
mutationFn: (id: number) => accountingService.markExpensePaid(id, { paid: false }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['accounting-expenses'] }),
|
||||
onError: (e: any) => toast.error(e?.response?.data?.error || e.message || 'Failed'),
|
||||
invalidateKeys: [['accounting-expenses']],
|
||||
errorMessage: (e: any) => e?.response?.data?.error || e.message || 'Failed',
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -262,7 +266,7 @@ export const ExpensesLedgerPage: React.FC = () => {
|
||||
<option value="">{t('accounting.ledger.allKinds', 'All types')}</option>
|
||||
{KINDS.map((k) => <option key={k} value={k}>{t(`accounting.expenseKind.${k}`, k)}</option>)}
|
||||
</select>
|
||||
<Button className="ml-auto" onClick={() => setShowAdd(true)}><Plus className="w-4 h-4 mr-1" /> {t('accounting.ledger.addExpense', 'Add expense')}</Button>
|
||||
<Button className="ml-auto" onClick={() => addModal.open()}><Plus className="w-4 h-4 mr-1" /> {t('accounting.ledger.addExpense', 'Add expense')}</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? <Loading /> : items.length === 0 ? (
|
||||
@@ -318,7 +322,7 @@ export const ExpensesLedgerPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdd && <ExpenseFormModal categories={categories ?? []} onClose={() => setShowAdd(false)} onDone={() => { setShowAdd(false); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
|
||||
{addModal.isOpen && <ExpenseFormModal categories={categories ?? []} onClose={() => addModal.close()} onDone={() => { addModal.close(); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
|
||||
{editExpense && <ExpenseFormModal categories={categories ?? []} expense={editExpense} onClose={() => setEditExpense(null)} onDone={() => { setEditExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
|
||||
{paidExpense && <ExpensePaidModal expense={paidExpense} onClose={() => setPaidExpense(null)} onDone={() => { setPaidExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
|
||||
{invoiceExpense && <InvoiceExpenseModal expense={invoiceExpense} onClose={() => setInvoiceExpense(null)} onDone={() => { setInvoiceExpense(null); qc.invalidateQueries({ queryKey: ['accounting-expenses'] }); }} />}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { toast } from 'react-toastify';
|
||||
import { ArrowLeft, Plus, Trash2, Save } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../../../components/common';
|
||||
import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector';
|
||||
import { useMutationWithToast } from '../../../hooks';
|
||||
import {
|
||||
contractsService,
|
||||
type ContractBlock,
|
||||
@@ -147,14 +148,12 @@ export const BlockLibraryPage: React.FC = () => {
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.blocks.updateError', 'Update failed') as string),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
const deleteMutation = useMutationWithToast({
|
||||
mutationFn: (id: number) => contractsService.deleteBlock(id),
|
||||
onSuccess: () => {
|
||||
toast.success(t('contracts.blocks.deletedToast', 'Block deleted.') as string);
|
||||
queryClient.invalidateQueries({ queryKey: ['contracts', 'blocks'] });
|
||||
setSelection(null);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.blocks.deleteError', 'Delete failed') as string),
|
||||
successMessage: t('contracts.blocks.deletedToast', 'Block deleted.') as string,
|
||||
invalidateKeys: [['contracts', 'blocks']],
|
||||
errorMessage: t('contracts.blocks.deleteError', 'Delete failed') as string,
|
||||
onSuccess: () => setSelection(null),
|
||||
});
|
||||
|
||||
// Group blocks by section for sidebar rendering. Empty sections are
|
||||
|
||||
@@ -15,7 +15,7 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { billsService } from '../../../services/bills.service';
|
||||
import { quotesService } from '../../../services/quotes.service';
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
type ContractStatus,
|
||||
} from '../../../services/contracts.service';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { useMutationWithToast } from '../../../hooks';
|
||||
|
||||
function statusBadgeClass(status: ContractStatus): string {
|
||||
return status === 'fully_signed' ? 'bg-green-100 text-green-800'
|
||||
@@ -90,25 +91,21 @@ export const ContractDetailPage: React.FC = () => {
|
||||
select: (res) => res?.invoices?.filter((i) => i.sourceContractId === numericId) || [],
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
const sendMutation = useMutationWithToast({
|
||||
mutationFn: () => contractsService.send(numericId as number),
|
||||
onSuccess: () => {
|
||||
toast.success(t('contracts.detail.sentToast', 'Contract sent.') as string);
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', numericId] });
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.sendError', 'Send failed') as string),
|
||||
successMessage: t('contracts.detail.sentToast', 'Contract sent.') as string,
|
||||
invalidateKeys: [['contract', numericId]],
|
||||
errorMessage: t('contracts.detail.sendError', 'Send failed') as string,
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
const cancelMutation = useMutationWithToast({
|
||||
mutationFn: () => contractsService.cancel(numericId as number),
|
||||
onSuccess: () => {
|
||||
toast.success(t('contracts.detail.cancelledToast', 'Contract cancelled.') as string);
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', numericId] });
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.cancelError', 'Cancel failed') as string),
|
||||
successMessage: t('contracts.detail.cancelledToast', 'Contract cancelled.') as string,
|
||||
invalidateKeys: [['contract', numericId]],
|
||||
errorMessage: t('contracts.detail.cancelError', 'Cancel failed') as string,
|
||||
});
|
||||
|
||||
const countersignMutation = useMutation({
|
||||
const countersignMutation = useMutationWithToast({
|
||||
mutationFn: () => {
|
||||
// Capture the canvas signature (if drawn) at submit time so we
|
||||
// send a fresh data URL, not a stale one from an earlier mount.
|
||||
@@ -119,55 +116,45 @@ export const ContractDetailPage: React.FC = () => {
|
||||
signatureDataUrl,
|
||||
});
|
||||
},
|
||||
successMessage: t('contracts.detail.countersignedToast', 'Counter-signed.') as string,
|
||||
invalidateKeys: [['contract', numericId]],
|
||||
errorMessage: t('contracts.detail.countersignError', 'Counter-sign failed') as string,
|
||||
onSuccess: () => {
|
||||
toast.success(t('contracts.detail.countersignedToast', 'Counter-signed.') as string);
|
||||
setCountersignName('');
|
||||
countersignPadRef.current?.clear();
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', numericId] });
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.countersignError', 'Counter-sign failed') as string),
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
const uploadMutation = useMutationWithToast({
|
||||
mutationFn: (file: File) => contractsService.uploadSignedPdf(numericId as number, file),
|
||||
onSuccess: () => {
|
||||
toast.success(t('contracts.detail.uploadedToast', 'Signed PDF uploaded.') as string);
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', numericId] });
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.uploadError', 'Upload failed') as string),
|
||||
successMessage: t('contracts.detail.uploadedToast', 'Signed PDF uploaded.') as string,
|
||||
invalidateKeys: [['contract', numericId]],
|
||||
errorMessage: t('contracts.detail.uploadError', 'Upload failed') as string,
|
||||
});
|
||||
|
||||
const resendSignedMutation = useMutation({
|
||||
const resendSignedMutation = useMutationWithToast({
|
||||
mutationFn: () => contractsService.resendSigned(numericId as number),
|
||||
onSuccess: () => {
|
||||
toast.success(t('contracts.detail.resentSignedToast',
|
||||
'Signed contract re-sent to both parties.') as string);
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', numericId] });
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error
|
||||
|| t('contracts.detail.resendError', 'Resend failed') as string),
|
||||
successMessage: t('contracts.detail.resentSignedToast',
|
||||
'Signed contract re-sent to both parties.') as string,
|
||||
invalidateKeys: [['contract', numericId]],
|
||||
errorMessage: t('contracts.detail.resendError', 'Resend failed') as string,
|
||||
});
|
||||
|
||||
const convertToEventMutation = useMutation({
|
||||
const convertToEventMutation = useMutationWithToast({
|
||||
mutationFn: () => contractsService.convertToEvent(numericId as number),
|
||||
onSuccess: (result) => {
|
||||
toast.success(result.alreadyConverted
|
||||
? (t('contracts.detail.alreadyEventToast', 'Already linked to an event.') as string)
|
||||
: (t('contracts.detail.convertedToEventToast', 'Contract converted to event #{{id}}', { id: result.eventId }) as string));
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', numericId] });
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.convertError', 'Convert failed') as string),
|
||||
successMessage: (result) => result.alreadyConverted
|
||||
? (t('contracts.detail.alreadyEventToast', 'Already linked to an event.') as string)
|
||||
: (t('contracts.detail.convertedToEventToast', 'Contract converted to event #{{id}}', { id: result.eventId }) as string),
|
||||
invalidateKeys: [['contract', numericId]],
|
||||
errorMessage: t('contracts.detail.convertError', 'Convert failed') as string,
|
||||
});
|
||||
|
||||
const convertToInvoiceMutation = useMutation({
|
||||
const convertToInvoiceMutation = useMutationWithToast({
|
||||
mutationFn: () => contractsService.convertToInvoice(numericId as number),
|
||||
onSuccess: (result) => {
|
||||
toast.success(t('contracts.detail.convertedToInvoiceToast',
|
||||
'{{count}} invoice(s) created from this contract', { count: result.installmentsCreated }) as string);
|
||||
queryClient.invalidateQueries({ queryKey: ['contract', numericId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || t('contracts.detail.convertError', 'Convert failed') as string),
|
||||
successMessage: (result) => t('contracts.detail.convertedToInvoiceToast',
|
||||
'{{count}} invoice(s) created from this contract', { count: result.installmentsCreated }) as string,
|
||||
invalidateKeys: [['contract', numericId], ['invoices']],
|
||||
errorMessage: t('contracts.detail.convertError', 'Convert failed') as string,
|
||||
});
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
@@ -1003,7 +990,7 @@ const RestampSignaturesCard: React.FC<RestampCardProps> = ({ contract, onSuccess
|
||||
return () => { cleanupCustomer(); cleanupAdmin(); };
|
||||
}, []);
|
||||
|
||||
const mutation = useMutation({
|
||||
const mutation = useMutationWithToast({
|
||||
mutationFn: () => {
|
||||
const customerPad = customerPadRef.current;
|
||||
const adminPad = adminPadRef.current;
|
||||
@@ -1017,16 +1004,16 @@ const RestampSignaturesCard: React.FC<RestampCardProps> = ({ contract, onSuccess
|
||||
adminSignatureDataUrl,
|
||||
});
|
||||
},
|
||||
successMessage: t('contracts.detail.restampedToast',
|
||||
'Signatures re-stamped and PDF re-rendered.') as string,
|
||||
errorMessage: (err: any) => err?.response?.data?.error
|
||||
|| err?.message
|
||||
|| t('contracts.detail.restampError', 'Re-stamp failed') as string,
|
||||
onSuccess: () => {
|
||||
toast.success(t('contracts.detail.restampedToast',
|
||||
'Signatures re-stamped and PDF re-rendered.') as string);
|
||||
customerPadRef.current?.clear();
|
||||
adminPadRef.current?.clear();
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error
|
||||
|| err?.message
|
||||
|| t('contracts.detail.restampError', 'Re-stamp failed') as string),
|
||||
});
|
||||
|
||||
const missingCustomer = !contract.signedCustomerSignaturePath && contract.signedByCustomerAt;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../../../services/projects.service';
|
||||
import { eventsService } from '../../../services/events.service';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { useMutationWithToast } from '../../../hooks';
|
||||
import { formatMoneyMinor } from '../../../utils/money';
|
||||
import { useFeatureFlags, type FeatureKey } from '../../../contexts/FeatureFlagsContext';
|
||||
|
||||
@@ -121,29 +122,24 @@ export const ProjectCockpitPage: React.FC = () => {
|
||||
enabled: projectId !== null,
|
||||
});
|
||||
|
||||
const renameMutation = useMutation({
|
||||
const renameMutation = useMutationWithToast({
|
||||
mutationFn: (name: string) => projectsService.update(projectId as number, { name }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['project-overview', projectId] });
|
||||
qc.invalidateQueries({ queryKey: ['projects'] });
|
||||
setEditName(null);
|
||||
toast.success(t('projects.toast.saved', 'Project saved') as string);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.toast.saveFailed', 'Save failed') as string)),
|
||||
successMessage: t('projects.toast.saved', 'Project saved') as string,
|
||||
invalidateKeys: [['project-overview', projectId], ['projects']],
|
||||
errorMessage: t('projects.toast.saveFailed', 'Save failed') as string,
|
||||
onSuccess: () => setEditName(null),
|
||||
});
|
||||
|
||||
const emailActionMutation = useMutation({
|
||||
const emailActionMutation = useMutationWithToast({
|
||||
mutationFn: ({ action, emailId }: { action: 'resend' | 'cancel' | 'retry' | 'sendNow'; emailId: number }) => {
|
||||
if (action === 'resend') return projectsService.resendEmail(emailId);
|
||||
if (action === 'cancel') return projectsService.cancelEmail(emailId);
|
||||
if (action === 'retry') return projectsService.retryEmail(emailId);
|
||||
return projectsService.sendEmailNow(emailId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['project-overview', projectId] });
|
||||
toast.success(t('projects.toast.emailAction', 'Done') as string);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('projects.toast.emailActionFailed', 'Action failed') as string)),
|
||||
successMessage: t('projects.toast.emailAction', 'Done') as string,
|
||||
invalidateKeys: [['project-overview', projectId]],
|
||||
errorMessage: t('projects.toast.emailActionFailed', 'Action failed') as string,
|
||||
});
|
||||
|
||||
// Event search for the "attach event" control (results exclude events
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Save as SaveIcon, Workflow as WorkflowIcon } from 'lucide-react';
|
||||
import { Button, Card, Loading, Input } from '../../../components/common';
|
||||
import { settingsService } from '../../../services/settings.service';
|
||||
import { quotesService } from '../../../services/quotes.service';
|
||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useMutationWithToast } from '../../../hooks';
|
||||
|
||||
const SETTING_KEYS = [
|
||||
'crm_quotes_pdf_attachment_enabled',
|
||||
@@ -76,7 +76,6 @@ const SETTING_KEYS = [
|
||||
|
||||
export const CrmSettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { flags } = useFeatureFlags();
|
||||
// Show each section only when the corresponding master flag is on —
|
||||
// configuring Skonto on quotes is pointless when quotes itself is
|
||||
@@ -116,7 +115,7 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
const [values, setValues] = useState<Record<string, any>>({});
|
||||
useEffect(() => { if (data) setValues(data); }, [data]);
|
||||
|
||||
const saveAll = useMutation({
|
||||
const saveAll = useMutationWithToast({
|
||||
mutationFn: async () => {
|
||||
const changed: Record<string, any> = {};
|
||||
for (const key of SETTING_KEYS) {
|
||||
@@ -126,11 +125,9 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
await settingsService.updateSettings(changed);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('crmSettings.savedToast', 'CRM settings saved.'));
|
||||
qc.invalidateQueries({ queryKey: ['settings', 'crm'] });
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || 'Save failed'),
|
||||
successMessage: t('crmSettings.savedToast', 'CRM settings saved.'),
|
||||
invalidateKeys: [['settings', 'crm']],
|
||||
errorMessage: 'Save failed',
|
||||
});
|
||||
|
||||
if (isLoading) return <Loading />;
|
||||
|
||||
@@ -31,8 +31,7 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Save, AlertTriangle, Workflow as WorkflowIcon } from 'lucide-react';
|
||||
import { Button, Card, Loading, Input } from '../../../components/common';
|
||||
import { SUPPORTED_LANGUAGES } from '../../../components/common/LanguageSelector';
|
||||
@@ -41,6 +40,7 @@ import { eventTypesService } from '../../../services/eventTypes.service';
|
||||
import { emailService, type EmailTemplateTranslation } from '../../../services/email.service';
|
||||
import { settingsService } from '../../../services/settings.service';
|
||||
import { useFeatureFlags } from '../../../contexts/FeatureFlagsContext';
|
||||
import { useMutationWithToast } from '../../../hooks';
|
||||
|
||||
const TEMPLATE_KEY_DEFAULT = 'event_reminder_default';
|
||||
const TEMPLATE_KEY_PREFIX = 'event_reminder_';
|
||||
@@ -60,7 +60,6 @@ interface SidebarRow {
|
||||
|
||||
export const ReminderTemplatesPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Global on/off + lead time: owned by the "Pre-event reminder" workflow when
|
||||
// the engine is live; otherwise the legacy crm_event_reminders_* settings drive
|
||||
@@ -86,16 +85,14 @@ export const ReminderTemplatesPage: React.FC = () => {
|
||||
const d = Number(settings.crm_event_reminders_days_before);
|
||||
setDaysBefore(Number.isFinite(d) ? d : 2);
|
||||
}, [settings]);
|
||||
const saveSettingsMutation = useMutation({
|
||||
const saveSettingsMutation = useMutationWithToast({
|
||||
mutationFn: () => settingsService.updateSettings({
|
||||
crm_event_reminders_enabled: enabled,
|
||||
crm_event_reminders_days_before: daysBefore,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success(t('reminderTemplates.settingsSaved', 'Reminder settings saved.'));
|
||||
queryClient.invalidateQueries({ queryKey: ['reminder-settings'] });
|
||||
},
|
||||
onError: () => toast.error(t('reminderTemplates.settingsSaveError', 'Could not save reminder settings.')),
|
||||
successMessage: t('reminderTemplates.settingsSaved', 'Reminder settings saved.'),
|
||||
invalidateKeys: [['reminder-settings']],
|
||||
errorMessage: () => t('reminderTemplates.settingsSaveError', 'Could not save reminder settings.'),
|
||||
});
|
||||
|
||||
// ---- Event types catalog ---------------------------------------------
|
||||
@@ -195,7 +192,7 @@ export const ReminderTemplatesPage: React.FC = () => {
|
||||
}, [selectedKey, selectedTemplate, defaultTemplate]);
|
||||
|
||||
// ---- Save -------------------------------------------------------------
|
||||
const saveMutation = useMutation({
|
||||
const saveMutation = useMutationWithToast({
|
||||
mutationFn: async () => {
|
||||
// Only send non-empty translations so we don't clobber DB rows
|
||||
// for locales the admin hasn't touched.
|
||||
@@ -220,15 +217,9 @@ export const ReminderTemplatesPage: React.FC = () => {
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('reminderTemplates.saved', 'Template saved.'));
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['email-template', selectedKey] });
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const e = err as { response?: { data?: { error?: string } } };
|
||||
toast.error(e?.response?.data?.error || t('reminderTemplates.saveError', 'Could not save template.'));
|
||||
},
|
||||
successMessage: t('reminderTemplates.saved', 'Template saved.'),
|
||||
invalidateKeys: [['email-templates'], ['email-template', selectedKey]],
|
||||
errorMessage: t('reminderTemplates.saveError', 'Could not save template.'),
|
||||
});
|
||||
|
||||
// Translation completeness pill for the sidebar — matches the email
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { Button, Card, Loading, Input, CountrySelect, TimeField } from '../../../components/common';
|
||||
import { toast } from 'react-toastify';
|
||||
import { currencyOptions, normalizeCurrency } from '../../../constants/currencies';
|
||||
import { useMutationWithToast } from '../../../hooks';
|
||||
|
||||
// Full IANA timezone list for the picker. `Intl.supportedValuesOf` is ES2022
|
||||
// (all current browsers); fall back to a small CH/LI-relevant set on the rare
|
||||
@@ -35,7 +36,6 @@ const IANA_TIMEZONES: string[] = (() => {
|
||||
|
||||
export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['business-profile'],
|
||||
queryFn: () => businessProfileService.get(),
|
||||
@@ -44,7 +44,7 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
const [profile, setProfile] = useState<BusinessProfile | null>(null);
|
||||
useEffect(() => { if (data?.profile) setProfile(data.profile); }, [data]);
|
||||
|
||||
const saveProfile = useMutation({
|
||||
const saveProfile = useMutationWithToast({
|
||||
// vatLabel + defaultHourlyRateMinor now live on Settings → Accounting, and
|
||||
// vatRateDefault is retired (the rates are the Accounting VAT codes). Strip
|
||||
// them from this save so an open Business-profile page can't clobber an edit
|
||||
@@ -55,11 +55,9 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
void vatLabel; void defaultHourlyRateMinor; void vatRateDefault;
|
||||
return businessProfileService.update(rest);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('businessProfile.savedToast', 'Business profile saved.'));
|
||||
qc.invalidateQueries({ queryKey: ['business-profile'] });
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || 'Save failed'),
|
||||
successMessage: t('businessProfile.savedToast', 'Business profile saved.'),
|
||||
invalidateKeys: [['business-profile']],
|
||||
errorMessage: 'Save failed',
|
||||
});
|
||||
|
||||
if (isLoading || !profile) return <Loading />;
|
||||
@@ -614,24 +612,20 @@ const BankAccountsSection: React.FC<BankAccountsSectionProps> = ({ accounts }) =
|
||||
};
|
||||
const closeForm = () => { setOpenForm(null); setDraft(EMPTY_DRAFT); };
|
||||
|
||||
const create = useMutation({
|
||||
const create = useMutationWithToast({
|
||||
mutationFn: () => businessProfileService.createBankAccount(draft),
|
||||
onSuccess: () => {
|
||||
toast.success(t('businessProfile.bankCreatedToast', 'Bank account added.'));
|
||||
qc.invalidateQueries({ queryKey: ['business-profile'] });
|
||||
closeForm();
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed'),
|
||||
successMessage: t('businessProfile.bankCreatedToast', 'Bank account added.'),
|
||||
invalidateKeys: [['business-profile']],
|
||||
errorMessage: 'Failed',
|
||||
onSuccess: () => closeForm(),
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
const update = useMutationWithToast({
|
||||
mutationFn: (id: number) => businessProfileService.updateBankAccount(id, draft),
|
||||
onSuccess: () => {
|
||||
toast.success(t('businessProfile.bankUpdatedToast', 'Bank account updated.'));
|
||||
qc.invalidateQueries({ queryKey: ['business-profile'] });
|
||||
closeForm();
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || 'Failed'),
|
||||
successMessage: t('businessProfile.bankUpdatedToast', 'Bank account updated.'),
|
||||
invalidateKeys: [['business-profile']],
|
||||
errorMessage: 'Failed',
|
||||
onSuccess: () => closeForm(),
|
||||
});
|
||||
|
||||
const setDefault = useMutation({
|
||||
|
||||
@@ -7,17 +7,16 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Check, X } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../../../components/common';
|
||||
import { workflowsService, type WorkflowApproval } from '../../../services/workflows.service';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
import { useMutationWithToast } from '../../../hooks';
|
||||
|
||||
export const WorkflowApprovalsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { formatDateTime } = useLocalizedDate();
|
||||
|
||||
const { data: approvals, isLoading } = useQuery({
|
||||
@@ -25,13 +24,11 @@ export const WorkflowApprovalsPage: React.FC = () => {
|
||||
queryFn: () => workflowsService.approvals(),
|
||||
});
|
||||
|
||||
const actMutation = useMutation({
|
||||
const actMutation = useMutationWithToast({
|
||||
mutationFn: ({ id, action }: { id: number; action: 'confirm' | 'deny' }) => workflowsService.actApproval(id, action),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workflow-approvals'] });
|
||||
toast.success(t('workflows.approvals.recorded', 'Response recorded') as string);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)),
|
||||
successMessage: t('workflows.approvals.recorded', 'Response recorded') as string,
|
||||
invalidateKeys: [['workflow-approvals']],
|
||||
errorMessage: t('common.error', 'Something went wrong') as string,
|
||||
});
|
||||
|
||||
const promptOf = (a: WorkflowApproval) => (a.payload && (a.payload.prompt as string)) || t('workflows.approvals.defaultPrompt', 'A workflow needs your confirmation.');
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
ReactFlow, Background, Controls, MiniMap, addEdge, useNodesState, useEdgesState,
|
||||
@@ -22,6 +22,7 @@ import { ArrowLeft, Save, Trash2, Wand2, Code } from 'lucide-react';
|
||||
import { Button, Loading } from '../../../components/common';
|
||||
import { api } from '../../../config/api';
|
||||
import { useAdminDarkMode } from '../../../contexts/AdminDarkModeContext';
|
||||
import { useMutationWithToast } from '../../../hooks';
|
||||
import { workflowsService, type WorkflowNodeType } from '../../../services/workflows.service';
|
||||
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||
|
||||
@@ -117,7 +118,6 @@ function layoutGraph(nodes: Node[], edges: Edge[]): Node[] {
|
||||
export const WorkflowEditorPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { isDark } = useAdminDarkMode();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const workflowId = Number(id);
|
||||
@@ -245,7 +245,7 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const saveMutation = useMutation({
|
||||
const saveMutation = useMutationWithToast({
|
||||
mutationFn: () => workflowsService.update(workflowId, {
|
||||
name: name.trim() || 'Untitled',
|
||||
trigger_type: triggerType,
|
||||
@@ -257,12 +257,9 @@ export const WorkflowEditorPage: React.FC = () => {
|
||||
})),
|
||||
edges: edges.map((e) => ({ from_node: e.source, from_handle: e.sourceHandle || null, to_node: e.target })),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workflow', workflowId] });
|
||||
qc.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('workflows.editor.saved', 'Workflow saved') as string);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.editor.saveFailed', 'Could not save') as string)),
|
||||
successMessage: t('workflows.editor.saved', 'Workflow saved') as string,
|
||||
invalidateKeys: [['workflow', workflowId], ['workflows']],
|
||||
errorMessage: t('workflows.editor.saveFailed', 'Could not save') as string,
|
||||
});
|
||||
|
||||
if (isLoading) return <div className="p-10"><Loading /></div>;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Plus, Workflow as WorkflowIcon, Inbox, Trash2, Pencil, FlaskConical } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../../../components/common';
|
||||
import { useMutationWithToast } from '../../../hooks';
|
||||
import { workflowsService, type WorkflowSummary, type WorkflowSavePayload, type WorkflowTestResult } from '../../../services/workflows.service';
|
||||
|
||||
const NEW_WORKFLOW: WorkflowSavePayload = {
|
||||
@@ -55,19 +56,17 @@ export const WorkflowsListPage: React.FC = () => {
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.test.failed', 'Test run failed') as string)),
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
const toggleMutation = useMutationWithToast({
|
||||
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) => workflowsService.setEnabled(id, enabled),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['workflows'] }),
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('common.error', 'Something went wrong') as string)),
|
||||
invalidateKeys: [['workflows']],
|
||||
errorMessage: t('common.error', 'Something went wrong') as string,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
const deleteMutation = useMutationWithToast({
|
||||
mutationFn: (id: number) => workflowsService.remove(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['workflows'] });
|
||||
toast.success(t('workflows.toast.deleted', 'Workflow deleted') as string);
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error || (t('workflows.toast.deleteFailed', 'Could not delete workflow') as string)),
|
||||
successMessage: t('workflows.toast.deleted', 'Workflow deleted') as string,
|
||||
invalidateKeys: [['workflows']],
|
||||
errorMessage: t('workflows.toast.deleteFailed', 'Could not delete workflow') as string,
|
||||
});
|
||||
|
||||
const isEnabled = (w: WorkflowSummary) => w.enabled === true || w.enabled === 1;
|
||||
|
||||
Reference in New Issue
Block a user