feat: implement failed emails management module with resend and deletion capabilities for superadmin and tenant dashboards
This commit is contained in:
parent
8c0c92865e
commit
edb631df36
@ -75,6 +75,7 @@ const superAdminSystemMenu: MenuItem[] = [
|
|||||||
isGroup: true,
|
isGroup: true,
|
||||||
children: [
|
children: [
|
||||||
{ label: "SMTP Config", path: "/settings/smtp" },
|
{ label: "SMTP Config", path: "/settings/smtp" },
|
||||||
|
{ label: "Failed Emails", path: "/settings/failed-emails" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -214,6 +215,10 @@ const tenantAdminSystemMenu: MenuItem[] = [
|
|||||||
{
|
{
|
||||||
label: "SMTP Settings",
|
label: "SMTP Settings",
|
||||||
path: "/tenant/settings/smtp",
|
path: "/tenant/settings/smtp",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Failed Emails",
|
||||||
|
path: "/tenant/settings/failed-emails",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
requiredPermission: { resource: "tenants" },
|
requiredPermission: { resource: "tenants" },
|
||||||
|
|||||||
226
src/components/shared/FailedEmailsTable.tsx
Normal file
226
src/components/shared/FailedEmailsTable.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { failedEmailsService, type FailedEmail } from '../../services/failed-emails-service';
|
||||||
|
import { DataTable } from './DataTable';
|
||||||
|
import { Modal } from './Modal';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { StatusBadge } from './StatusBadge';
|
||||||
|
import { Eye, RefreshCw, Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Pagination } from './Pagination';
|
||||||
|
|
||||||
|
export const FailedEmailsTable: React.FC = () => {
|
||||||
|
const [emails, setEmails] = useState<FailedEmail[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [limit, setLimit] = useState(50);
|
||||||
|
|
||||||
|
const [selectedEmail, setSelectedEmail] = useState<FailedEmail | null>(null);
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
const [isResendingAll, setIsResendingAll] = useState(false);
|
||||||
|
const [resendingId, setResendingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchEmails = async (page = 1, currentLimit = limit) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const offset = (page - 1) * currentLimit;
|
||||||
|
const res = await failedEmailsService.getFailedEmails(currentLimit, offset);
|
||||||
|
setEmails(res.data || []);
|
||||||
|
setTotal(res.total || 0);
|
||||||
|
setCurrentPage(page);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error('Error fetching failed emails', {
|
||||||
|
description: error.message
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEmails();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleResend = async (id: string) => {
|
||||||
|
setResendingId(id);
|
||||||
|
try {
|
||||||
|
await failedEmailsService.resendEmail(id);
|
||||||
|
toast.success('Email resent successfully.');
|
||||||
|
fetchEmails(currentPage);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error('Failed to resend email', { description: error.message });
|
||||||
|
} finally {
|
||||||
|
setResendingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResendAll = async () => {
|
||||||
|
setIsResendingAll(true);
|
||||||
|
try {
|
||||||
|
const res = await failedEmailsService.resendAll();
|
||||||
|
toast.success(res.message);
|
||||||
|
fetchEmails(currentPage);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error('Failed to resend all emails', { description: error.message });
|
||||||
|
} finally {
|
||||||
|
setIsResendingAll(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await failedEmailsService.deleteEmail(id);
|
||||||
|
toast.success('Email deleted successfully.');
|
||||||
|
fetchEmails(currentPage);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error('Failed to delete email', { description: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showEmailDetails = (email: FailedEmail) => {
|
||||||
|
setSelectedEmail(email);
|
||||||
|
setIsModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
label: 'Date',
|
||||||
|
key: 'created_at',
|
||||||
|
render: (record: FailedEmail) => format(new Date(record.created_at), 'yyyy-MM-dd HH:mm:ss')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'To',
|
||||||
|
key: 'to_email',
|
||||||
|
render: (record: FailedEmail) => record.to_email
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Subject',
|
||||||
|
key: 'subject',
|
||||||
|
render: (record: FailedEmail) => record.subject
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Status',
|
||||||
|
key: 'status',
|
||||||
|
render: (record: FailedEmail) => (
|
||||||
|
<StatusBadge variant={record.status === 'failed' ? 'failure' : 'success'}>
|
||||||
|
{record.status}
|
||||||
|
</StatusBadge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Actions',
|
||||||
|
key: 'actions',
|
||||||
|
render: (record: FailedEmail) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => showEmailDetails(record)}
|
||||||
|
title="View Details"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
{record.status === 'failed' && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleResend(record.id)}
|
||||||
|
title="Resend Email"
|
||||||
|
disabled={resendingId === record.id}
|
||||||
|
>
|
||||||
|
{resendingId === record.id ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(record.id)}
|
||||||
|
title="Delete Email"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6 lg:p-8 bg-white min-h-[calc(100vh-64px)] overflow-hidden">
|
||||||
|
<div className="mb-6 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||||
|
<h2 className="text-xl font-bold text-[#0f1724]">Failed Emails Log</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="outline" onClick={() => fetchEmails(currentPage)}>
|
||||||
|
<RefreshCw className="mr-2 w-4 h-4" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleResendAll}
|
||||||
|
disabled={isResendingAll || emails.filter(e => e.status === 'failed').length === 0}
|
||||||
|
>
|
||||||
|
{isResendingAll ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 w-4 h-4 animate-spin" />
|
||||||
|
Resending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Resend All Failed'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-[rgba(0,0,0,0.08)] bg-white rounded-lg shadow-sm">
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={emails}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
isLoading={loading}
|
||||||
|
emptyMessage="No failed emails found"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{total > limit && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={Math.ceil(total / limit)}
|
||||||
|
totalItems={total}
|
||||||
|
limit={limit}
|
||||||
|
onPageChange={(page) => fetchEmails(page)}
|
||||||
|
onLimitChange={(newLimit) => {
|
||||||
|
setLimit(newLimit);
|
||||||
|
fetchEmails(1, newLimit);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="Email Details"
|
||||||
|
isOpen={isModalVisible}
|
||||||
|
onClose={() => setIsModalVisible(false)}
|
||||||
|
maxWidth="xl"
|
||||||
|
>
|
||||||
|
{selectedEmail && (
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="grid grid-cols-1 gap-2 mb-4">
|
||||||
|
<p><strong className="text-[#0e1b2a]">To:</strong> <span className="text-[#6b7280]">{selectedEmail.to_email}</span></p>
|
||||||
|
<p><strong className="text-[#0e1b2a]">Subject:</strong> <span className="text-[#6b7280]">{selectedEmail.subject}</span></p>
|
||||||
|
<p><strong className="text-[#0e1b2a]">Error Message:</strong> <span className="text-[#ef4444]">{selectedEmail.error_message}</span></p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<strong className="text-[#0e1b2a] mb-2 block">Body:</strong>
|
||||||
|
<div
|
||||||
|
className="border border-[rgba(0,0,0,0.08)] rounded p-4 mt-2 max-h-[400px] overflow-auto bg-gray-50 text-sm"
|
||||||
|
dangerouslySetInnerHTML={{ __html: selectedEmail.body }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
12
src/pages/superadmin/FailedEmails.tsx
Normal file
12
src/pages/superadmin/FailedEmails.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { FailedEmailsTable } from "@/components/shared/FailedEmailsTable";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
|
||||||
|
export default function FailedEmails() {
|
||||||
|
return (
|
||||||
|
<Layout currentPage="Failed Emails">
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100">
|
||||||
|
<FailedEmailsTable />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/pages/tenant/FailedEmails.tsx
Normal file
12
src/pages/tenant/FailedEmails.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { FailedEmailsTable } from "@/components/shared/FailedEmailsTable";
|
||||||
|
import { Layout } from "@/components/layout/Layout";
|
||||||
|
|
||||||
|
export default function FailedEmails() {
|
||||||
|
return (
|
||||||
|
<Layout currentPage="Failed Emails">
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100">
|
||||||
|
<FailedEmailsTable />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ const AuditLogResourceTypes = lazy(() => import("@/pages/superadmin/AuditLogReso
|
|||||||
const NotificationMaster = lazy(() => import("@/pages/superadmin/NotificationMaster"));
|
const NotificationMaster = lazy(() => import("@/pages/superadmin/NotificationMaster"));
|
||||||
const NotificationTemplateMaster = lazy(() => import("@/pages/superadmin/NotificationTemplateMaster"));
|
const NotificationTemplateMaster = lazy(() => import("@/pages/superadmin/NotificationTemplateMaster"));
|
||||||
const SmtpConfig = lazy(() => import("@/pages/superadmin/SmtpConfig"));
|
const SmtpConfig = lazy(() => import("@/pages/superadmin/SmtpConfig"));
|
||||||
|
const FailedEmails = lazy(() => import("@/pages/superadmin/FailedEmails"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -100,4 +101,8 @@ export const superAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/settings/smtp",
|
path: "/settings/smtp",
|
||||||
element: <LazyRoute component={SmtpConfig} />,
|
element: <LazyRoute component={SmtpConfig} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/settings/failed-emails",
|
||||||
|
element: <LazyRoute component={FailedEmails} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@ -35,6 +35,7 @@ const FilesList = lazy(() => import("@/pages/tenant/FilesList"));
|
|||||||
const FileView = lazy(() => import("@/pages/tenant/FileView"));
|
const FileView = lazy(() => import("@/pages/tenant/FileView"));
|
||||||
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
|
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
|
||||||
const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings"));
|
const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings"));
|
||||||
|
const FailedEmails = lazy(() => import("@/pages/tenant/FailedEmails"));
|
||||||
|
|
||||||
// Loading fallback component
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -161,4 +162,8 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/settings/smtp",
|
path: "/tenant/settings/smtp",
|
||||||
element: <LazyRoute component={SmtpSettings} />,
|
element: <LazyRoute component={SmtpSettings} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/tenant/settings/failed-emails",
|
||||||
|
element: <LazyRoute component={FailedEmails} />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
47
src/services/failed-emails-service.ts
Normal file
47
src/services/failed-emails-service.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import api from './api-client';
|
||||||
|
|
||||||
|
export interface FailedEmail {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string | null;
|
||||||
|
user_id: string | null;
|
||||||
|
notification_id: string | null;
|
||||||
|
to_email: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
error_message: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
user_email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FailedEmailsResponse {
|
||||||
|
data: FailedEmail[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class FailedEmailsService {
|
||||||
|
async getFailedEmails(limit = 50, offset = 0): Promise<FailedEmailsResponse> {
|
||||||
|
const response = await api.get('/failed-emails', { params: { limit, offset } });
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resendEmail(id: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await api.post(`/failed-emails/${id}/resend`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async resendAll(): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await api.post('/failed-emails/resend-all');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEmail(id: string): Promise<{ success: boolean; message: string }> {
|
||||||
|
const response = await api.delete(`/failed-emails/${id}`);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const failedEmailsService = new FailedEmailsService();
|
||||||
Loading…
Reference in New Issue
Block a user