Add mailing statistics endpoints and frontend components

Updated `MailingsController` to include endpoints for retrieving mailing statistics by status and ID. Modified `IMailingRepository` and `IMailingService` to support new methods for fetching statistics and canceling mailings. Introduced `MailingStatistic` class and corresponding Dapper mappings.

In the React frontend, added `ActiveMailings` and `CompletedMailings` components to display statistics, along with a `ConfirmationDialog` for canceling mailings. Updated authentication management in `AuthCheck` and `AuthContext`. Created `mailingStatistic.ts` for TypeScript interface definition.
This commit is contained in:
David Headrick 2025-03-25 14:02:02 -05:00
parent f4ac033c70
commit 035a2e1dae
19 changed files with 895 additions and 134 deletions

View File

@ -39,6 +39,13 @@ namespace Surge365.MassEmailReact.API.Controllers
return Ok(mailings);
}
[HttpGet("status/{statusCode}/stats")]
public async Task<IActionResult> GetStatisticsByStatus(string statusCode)
{
var mailings = await _mailingService.GetStatisticsByStatusAsync(statusCode);
return Ok(mailings);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetById(int id)
{
@ -46,6 +53,14 @@ namespace Surge365.MassEmailReact.API.Controllers
return mailing is not null ? Ok(mailing) : NotFound($"Mailing with id '{id}' not found.");
}
[HttpGet("{id}/stats")]
public async Task<IActionResult> GetStatisticById(int id)
{
var mailing = await _mailingService.GetStatisticByIdAsync(id);
return mailing is not null ? Ok(mailing) : NotFound($"Mailing statistics with id '{id}' not found.");
}
[HttpPost]
public async Task<IActionResult> CreateMailing([FromBody] MailingUpdateDto mailingUpdateDto)
{
@ -83,5 +98,16 @@ namespace Surge365.MassEmailReact.API.Controllers
var updatedMailing = await _mailingService.GetByIdAsync(id);
return Ok(updatedMailing);
}
[HttpPost("{id}/cancel")]
public async Task<IActionResult> CancelMailing(int id)
{
var success = await _mailingService.CancelMailingAsync(id);
if (!success)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to cancel mailing.");
var updatedMailing = await _mailingService.GetByIdAsync(id);
return Ok(updatedMailing);
}
}
}

View File

@ -9,9 +9,12 @@ namespace Surge365.MassEmailReact.Application.Interfaces
Task<Mailing?> GetByIdAsync(int id);
Task<List<Mailing>> GetAllAsync(bool activeOnly = true);
Task<List<Mailing>> GetByStatusAsync(string code);
Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code);
Task<MailingStatistic?> GetStatisticByIdAsync(int id);
Task<bool> NameIsAvailableAsync(int? id, string name);
Task<int?> CreateAsync(Mailing mailing);
Task<bool> UpdateAsync(Mailing mailing);
Task<bool> CancelMailingAsync(int id);
}
}

View File

@ -10,10 +10,12 @@ namespace Surge365.MassEmailReact.Application.Interfaces
Task<List<Mailing>> GetAllAsync(bool activeOnly = true);
Task<List<Mailing>> GetByStatusAsync(string code);
Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code);
Task<MailingStatistic?> GetStatisticByIdAsync(int id);
Task<bool> NameIsAvailableAsync(int? id, string name);
Task<int?> CreateAsync(MailingUpdateDto mailingDto);
Task<bool> UpdateAsync(MailingUpdateDto mailingDto);
Task<bool> CancelMailingAsync(int id);
}
}

View File

@ -0,0 +1,61 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class MailingStatistic
{
public int? MailingId { get; private set; }
public string MailingName { get; set; } = "";
public int SpamCount { get; set; }
public int UniqueClickCount { get; set; }
public int ClickCount { get; set; }
public int UniqueOpenCount { get; set; }
public int OpenCount { get; set; }
public int InvalidCount { get; set; }
public int BlockedCount { get; set; }
public int FailedCount { get; set; }
public int DeliveredCount { get; set; }
public int SendCount { get; set; }
public int EmailCount { get; set; }
public int BounceCount { get; set; }
public int UnsubscribeCount { get; set; }
public MailingStatistic() { }
private MailingStatistic(int? mailingId, string mailingName, int spamCount, int uniqueClickCount, int clickCount,
int uniqueOpenCount, int openCount, int invalidCount, int blockedCount,
int failedCount, int deliveredCount, int sendCount, int emailCount,
int bounceCount, int unsubscribeCount)
{
MailingId = mailingId;
MailingName = mailingName;
SpamCount = spamCount;
UniqueClickCount = uniqueClickCount;
ClickCount = clickCount;
UniqueOpenCount = uniqueOpenCount;
OpenCount = openCount;
InvalidCount = invalidCount;
BlockedCount = blockedCount;
FailedCount = failedCount;
DeliveredCount = deliveredCount;
SendCount = sendCount;
EmailCount = emailCount;
BounceCount = bounceCount;
UnsubscribeCount = unsubscribeCount;
}
public static MailingStatistic Create(int? mailingId, string mailingName, int spamCount, int uniqueClickCount,
int clickCount, int uniqueOpenCount, int openCount, int invalidCount,
int blockedCount, int failedCount, int deliveredCount, int sendCount,
int emailCount, int bounceCount, int unsubscribeCount)
{
return new MailingStatistic(mailingId, mailingName, spamCount, uniqueClickCount, clickCount, uniqueOpenCount,
openCount, invalidCount, blockedCount, failedCount, deliveredCount,
sendCount, emailCount, bounceCount, unsubscribeCount);
}
}
}

View File

@ -25,6 +25,7 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
config.AddMap(new TemplateMap());
config.AddMap(new EmailDomainMap());
config.AddMap(new MailingMap());
config.AddMap(new MailingStatisticMap());
config.AddMap(new UserMap());
});
}

View File

@ -0,0 +1,27 @@
using Dapper.FluentMap.Mapping;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{
public class MailingStatisticMap : EntityMap<MailingStatistic>
{
public MailingStatisticMap()
{
Map(m => m.MailingId).ToColumn("blast_key");
Map(m => m.MailingName).ToColumn("blast_name");
Map(m => m.SpamCount).ToColumn("spam_count");
Map(m => m.UniqueClickCount).ToColumn("unique_click_count");
Map(m => m.ClickCount).ToColumn("click_count");
Map(m => m.UniqueOpenCount).ToColumn("unique_open_count");
Map(m => m.OpenCount).ToColumn("open_count");
Map(m => m.InvalidCount).ToColumn("invalid_count");
Map(m => m.BlockedCount).ToColumn("blocked_count");
Map(m => m.FailedCount).ToColumn("failed_count");
Map(m => m.DeliveredCount).ToColumn("delivered_count");
Map(m => m.SendCount).ToColumn("send_count");
Map(m => m.EmailCount).ToColumn("email_count");
Map(m => m.BounceCount).ToColumn("bounce_count");
Map(m => m.UnsubscribeCount).ToColumn("unsubscribe_count");
}
}
}

View File

@ -44,7 +44,6 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<Mailing>("mem_get_blast_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList();
}
public async Task<List<Mailing>> GetByStatusAsync(string code)
{
ArgumentNullException.ThrowIfNull(ConnectionString);
@ -52,6 +51,20 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<Mailing>("mem_get_blast_by_status", new { blast_status_code = code }, commandType: CommandType.StoredProcedure)).ToList();
}
public async Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code)
{
ArgumentNullException.ThrowIfNull(ConnectionString);
using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<MailingStatistic>("mem_get_blast_statistic_by_status", new { blast_status_code = code }, commandType: CommandType.StoredProcedure)).ToList();
}
public async Task<MailingStatistic?> GetStatisticByIdAsync(int id)
{
ArgumentNullException.ThrowIfNull(ConnectionString);
using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<MailingStatistic>("mem_get_blast_statistic_by_blast", new { blast_key = id }, commandType: CommandType.StoredProcedure)).FirstOrDefault();
}
public async Task<bool> NameIsAvailableAsync(int? id, string name)
{
ArgumentNullException.ThrowIfNull(ConnectionString);
@ -121,6 +134,18 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
await conn.ExecuteAsync("mem_save_blast", parameters, commandType: CommandType.StoredProcedure);
return parameters.Get<bool>("@success");
}
public async Task<bool> CancelMailingAsync(int id)
{
using SqlConnection conn = new SqlConnection(ConnectionString);
var parameters = new DynamicParameters();
parameters.Add("@blast_key", id, DbType.Int32);
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_cancel_blast", parameters, commandType: CommandType.StoredProcedure);
return parameters.Get<bool>("@success");
}
}

View File

@ -31,6 +31,14 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
{
return await _mailingRepository.GetByStatusAsync(statusCode);
}
public async Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code)
{
return await _mailingRepository.GetStatisticsByStatusAsync(code);
}
public async Task<MailingStatistic?> GetStatisticByIdAsync(int id)
{
return await _mailingRepository.GetStatisticByIdAsync(id);
}
public async Task<bool> NameIsAvailableAsync(int? id, string name)
{
return await _mailingRepository.NameIsAvailableAsync(id, name);
@ -80,5 +88,9 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
return await _mailingRepository.UpdateAsync(mailing);
}
public async Task<bool> CancelMailingAsync(int id)
{
return await _mailingRepository.CancelMailingAsync(id);
}
}
}

View File

@ -1,30 +1,17 @@
// src/components/auth/AuthCheck.tsx
import React, { useEffect } from "react";
import React, { useEffect } from "react";
import { useNavigate, useLocation } from 'react-router-dom';
import utils from '@/ts/utils';
import { useAuth } from '@/components/auth/AuthContext'; // Import useAuth
import { useAuth } from '@/components/auth/AuthContext';
const AuthCheck: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
const { accessToken, setAuth } = useAuth(); // Use context
const { accessToken, setAuth, isLoading } = useAuth();
useEffect(() => {
const checkAuthStatus = async () => {
const currentPath = location.pathname;
if (currentPath.toLowerCase() === "/login")
return;
if (!accessToken) {
await tryRefreshToken();
} else {
if (utils.isTokenExpired(accessToken)) {
await tryRefreshToken();
} else {
// Do nothing, token is still valid
}
}
};
if (isLoading) return; // Wait for AuthProvider to finish
const currentPath = location.pathname;
if (currentPath.toLowerCase() === "/login") return;
const tryRefreshToken = async () => {
try {
@ -34,10 +21,9 @@ const AuthCheck: React.FC = () => {
});
if (response.ok) {
const data = await response.json();
setAuth(data.accessToken); // Update context instead of localStorage directly
// DO NOT NAVIGATE TO LOGIN PAGE
setAuth(data.accessToken);
} else {
setAuth(null); // Clear context on failure
setAuth(null);
navigate('/login');
}
} catch {
@ -46,8 +32,10 @@ const AuthCheck: React.FC = () => {
}
};
checkAuthStatus();
}, [navigate, location.pathname, accessToken, setAuth]); // Add accessToken and setAuth to deps
if (!accessToken || utils.isTokenExpired(accessToken)) {
tryRefreshToken();
}
}, [navigate, location.pathname, accessToken, setAuth, isLoading]);
return null;
};

View File

@ -1,22 +1,25 @@
// src/components/auth/AuthContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import utils from '@/ts/utils';
import { Box, CircularProgress, Typography } from '@mui/material';
interface AuthContextType {
accessToken: string | null;
userRoles: string[];
setAuth: (token: string | null) => void;
isLoading: boolean; // Add loading state
}
const AuthContext = createContext<AuthContextType>({
accessToken: null,
userRoles: [],
setAuth: () => { },
isLoading: true, // Default to loading
});
export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [accessToken, setAccessToken] = useState<string | null>(localStorage.getItem('accessToken'));
const [userRoles, setUserRoles] = useState<string[]>(accessToken ? utils.getUserRoles(accessToken) : []);
const [accessToken, setAccessToken] = useState<string | null>(null); // Start as null
const [userRoles, setUserRoles] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true); // Track loading
const setAuth = (token: string | null) => {
if (token) {
@ -30,9 +33,73 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
}
};
// Check auth on mount
useEffect(() => {
const initializeAuth = async () => {
const storedToken = localStorage.getItem('accessToken');
if (storedToken && !utils.isTokenExpired(storedToken)) {
setAccessToken(storedToken);
setUserRoles(utils.getUserRoles(storedToken));
setIsLoading(false);
} else {
try {
const response = await fetch('/api/authentication/refreshtoken', {
method: 'POST',
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
setAuth(data.accessToken);
} else {
setAuth(null);
}
} catch {
setAuth(null);
} finally {
setIsLoading(false); // Done loading regardless of outcome
}
}
};
initializeAuth();
}, []);
return (
<AuthContext.Provider value={{ accessToken, userRoles, setAuth }}>
{children}
<AuthContext.Provider value={{ accessToken, userRoles, setAuth, isLoading }}>
{isLoading ? (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.1)', // Optional: light overlay
zIndex: 9999, // Ensure its above everything
}}
>
<CircularProgress
size={80} // Larger spinner
thickness={4} // Slightly thicker for visibility
sx={{ color: 'primary.main' }} // Use themes primary color
/>
<Typography
variant="h6"
sx={{
mt: 2, // Margin-top for spacing
color: 'text.primary', // Theme-aware text color
}}
>
Loading...
</Typography>
</Box>
) : (
children
)}
</AuthContext.Provider>
);
};

View File

@ -1,14 +1,12 @@
// src/components/auth/ProtectedPageWrapper.tsx
import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useTitle } from "@/context/TitleContext";
import utils from '@/ts/utils';
import { useAuth } from '@/components/auth/AuthContext';
// Define role requirements for routes
export const routeRoleRequirements: Record<string, string[]> = {
'/home': [], // No role required
'/servers': ['ServerTab'], // Only Admins
'/targets': ['TargetTab'], // Users or Admins
'/home': [],
'/servers': ['ServerTab'],
'/targets': ['TargetTab'],
'/testEmailLists': ['TestListTab'],
'/blockedEmails': ['BlockedEmailTab'],
'/emailDomains': ['DomainTab'],
@ -23,48 +21,28 @@ export const routeRoleRequirements: Record<string, string[]> = {
const ProtectedPageWrapper: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => {
const navigate = useNavigate();
const { setTitle } = useTitle();
const accessToken = localStorage.getItem('accessToken');
const currentPath = window.location.pathname; // Or use useLocation().pathname
const { accessToken, userRoles, isLoading } = useAuth();
const currentPath = window.location.pathname;
useEffect(() => {
setTitle(title);
}, [title, setTitle]);
useEffect(() => {
const checkAuthAndRoles = async () => {
if (!accessToken || utils.isTokenExpired(accessToken)) {
try {
const response = await fetch('/api/authentication/refreshtoken', {
method: 'POST',
credentials: 'include',
});
if (response.ok) {
const data = await response.json();
localStorage.setItem('accessToken', data.accessToken);
} else {
navigate('/login');
}
} catch {
navigate('/login');
}
} else {
// Check roles
const userRoles = utils.getUserRoles(accessToken);
const requiredRoles = routeRoleRequirements[currentPath] || [];
const hasRequiredRole = requiredRoles.length === 0 || requiredRoles.some(role => userRoles.includes(role));
if (isLoading) return; // Wait for auth to resolve
if (!accessToken) {
navigate('/login');
return;
}
if (!hasRequiredRole) {
navigate('/home'); // Redirect to home if unauthorized
}
}
};
checkAuthAndRoles();
}, [navigate, accessToken, currentPath]);
if (!accessToken || utils.isTokenExpired(accessToken)) {
return null; // Or a loading spinner
}
const requiredRoles = routeRoleRequirements[currentPath] || [];
const hasRequiredRole = requiredRoles.length === 0 || requiredRoles.some(role => userRoles.includes(role));
if (!hasRequiredRole) {
navigate('/home');
}
}, [navigate, currentPath, accessToken, userRoles, isLoading]);
if (isLoading || !accessToken) return null; // Wait or redirect handled above
return <>{children}</>;
};

View File

@ -0,0 +1,37 @@
// components/ConfirmationDialog.tsx
import { Dialog, DialogTitle, DialogContent, DialogActions, Button, Typography } from '@mui/material';
interface ConfirmationDialogProps {
open: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmationDialog({
open,
title,
message,
onConfirm,
onCancel
}: ConfirmationDialogProps) {
return (
<Dialog open={open} onClose={onCancel} maxWidth="sm" fullWidth>
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Typography>{message}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} color="primary">
Cancel
</Button>
<Button onClick={onConfirm} color="primary" variant="contained">
OK
</Button>
</DialogActions>
</Dialog>
);
}
export default ConfirmationDialog;

View File

@ -15,6 +15,8 @@ import {
Select,
InputLabel
} from "@mui/material";
import VisibilityIcon from '@mui/icons-material/Visibility';
import Template from "@/types/template";
import Mailing from "@/types/mailing";
import Target from "@/types/target";
@ -304,37 +306,49 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
error={!!errors.description}
helperText={errors.description?.message}
/>
<Controller
name="templateId"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
options={setupData.templates}
getOptionLabel={(option) => option.name}
value={setupData.templates.find(t => t.id === field.value) || null}
onChange={(_, newValue) => {
field.onChange(newValue ? newValue.id : null);
trigger("templateId");
setCurrentTemplate(newValue);
}}
renderInput={(params) => (
<TextField
{...params}
label="Template"
fullWidth
margin="dense"
error={!!errors.templateId}
helperText={errors.templateId?.message}
/>
)}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Controller
name="templateId"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
options={setupData.templates}
getOptionLabel={(option) => option.name}
value={setupData.templates.find(t => t.id === field.value) || null}
onChange={(_, newValue) => {
field.onChange(newValue ? newValue.id : null);
trigger("templateId");
setCurrentTemplate(newValue);
}}
renderInput={(params) => (
<TextField
{...params}
label="Template"
fullWidth
margin="dense"
error={!!errors.templateId}
helperText={errors.templateId?.message}
/>
)}
sx={{ flexGrow: 1 }}
/>
)}
/>
{currentTemplate && (
<Button
onClick={handleTemplateViewerOpen}
variant="outlined"
startIcon={<VisibilityIcon />}
sx={{ height: '100%', alignSelf: 'center' }}
>
View
</Button>
)}
/>
{currentTemplate && (
<Button onClick={handleTemplateViewerOpen}>View Template</Button>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Controller
name="targetId"
control={control}
@ -359,12 +373,21 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
helperText={errors.targetId?.message}
/>
)}
sx={{ flexGrow: 1 }}
/>
)}
/>
{currentTarget && (
<Button onClick={handleTargetSampleViewerOpen}>View Target Sample</Button>
)}
/>
{currentTarget && (
<Button
onClick={handleTargetSampleViewerOpen}
variant="outlined"
startIcon={<VisibilityIcon />}
sx={{ height: '100%', alignSelf: 'center' }}
>
View
</Button>
)}
</Box>
<Autocomplete
options={setupData.testEmailLists}
getOptionLabel={(option) => option.name}

View File

@ -1,8 +1,11 @@
import { Dialog, DialogTitle, DialogContent, Typography, IconButton } from '@mui/material';
import { useState } from "react";
import { Dialog, DialogTitle, DialogContent, Typography, IconButton } from '@mui/material';
import { useSetupData } from '@/context/SetupDataContext';
import Mailing from '@/types/mailing';
import { useNavigate } from 'react-router-dom'; // Assuming you're using react-router for navigation
import VisibilityIcon from '@mui/icons-material/Visibility';
import CloseIcon from '@mui/icons-material/Close';
import TemplateViewer from "@/components/modals/TemplateViewer"
import TargetSampleViewer from "@/components/modals/TargetSampleViewer"
interface MailingViewProps {
open: boolean;
@ -12,7 +15,8 @@ interface MailingViewProps {
function MailingView({ open, mailing, onClose }: MailingViewProps) {
const setupData = useSetupData();
const navigate = useNavigate();
const [templateViewerOpen, setTemplateViewerOpen] = useState<boolean>(false);
const [targetSampleViewerOpen, setTargetSampleViewerOpen] = useState<boolean>(false);
if (!mailing) return null;
@ -32,11 +36,11 @@ function MailingView({ open, mailing, onClose }: MailingViewProps) {
const formatRecurringString = (typeCode: string, startDate: string): string => {
const date = new Date(startDate);
switch (typeCode.toUpperCase()) {
case 'DAILY':
case 'D':
return `Daily at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
case 'WEEKLY':
case 'W':
return `Weekly on ${date.toLocaleDateString('en-US', { weekday: 'long' })} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
case 'MONTHLY':
case 'M':
return `Monthly on day ${date.getDate()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
default:
return 'Custom recurring schedule';
@ -46,50 +50,77 @@ function MailingView({ open, mailing, onClose }: MailingViewProps) {
// Format recurring string
const recurringString = mailing.recurringTypeCode && mailing.recurringStartDate
? formatRecurringString(mailing.recurringTypeCode, mailing.recurringStartDate)
: 'One-time';
: 'No';
// Navigation handlers for viewing related entities
const handleViewTarget = () => {
if (target) {
navigate(`/targets/${target.id}`); // Adjust path based on your routing
setTargetSampleViewerOpen(!targetSampleViewerOpen);
}
};
const handleViewTemplate = () => {
if (template) {
navigate(`/templates/${template.id}`); // Adjust path based on your routing
setTemplateViewerOpen(!templateViewerOpen);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{mailing.name}</DialogTitle>
<DialogTitle>
{mailing.name}
<IconButton
aria-label="close"
onClick={onClose}
sx={{
position: 'absolute',
right: 8,
top: 8,
color: (theme) => theme.palette.grey[500],
}}
>
<CloseIcon />
</IconButton>
</DialogTitle>
<DialogContent>
<Typography sx={{ mb: 1 }}>Name: {mailing.name}</Typography>
<Typography sx={{ mb: 1 }}>
<Typography sx={{ mb: 1, height: "20px" }}>
Target Name: {target?.name || 'Unknown'}
{target && (
<IconButton size="small" onClick={handleViewTarget} sx={{ ml: 1 }}>
<IconButton onClick={handleViewTarget} sx={{ ml: 1, height: "100%" }}>
<VisibilityIcon />
</IconButton>
)}
</Typography>
<Typography sx={{ mb: 1 }}>
<Typography sx={{ mb: 1, height: "20px" }}>
Template Name: {template?.name || 'Unknown'}
{template && (
<IconButton size="small" onClick={handleViewTemplate} sx={{ ml: 1 }}>
<IconButton onClick={handleViewTemplate} sx={{ ml: 1, height: "100%" }}>
<VisibilityIcon />
</IconButton>
)}
</Typography>
<Typography sx={{ mb: 1 }}>From Name: {template?.fromName || 'N/A'}</Typography>
<Typography sx={{ mb: 1 }}>Domain: {domain?.name || 'N/A'}</Typography> {/* Assuming EmailDomain has a domainName field */}
<Typography sx={{ mb: 1 }}>Subject: {template?.subject || 'N/A'}</Typography>
<Typography sx={{ mb: 1 }}>Status: {statusString}</Typography>
<Typography sx={{ mb: 1 }}>Recurring: {recurringString}</Typography>
<Typography sx={{ mb: 1, height: "20px" }}>From Name: {template?.fromName || 'N/A'}</Typography>
<Typography sx={{ mb: 1, height: "20px" }}>Domain: {domain?.name || 'N/A'}</Typography> {/* Assuming EmailDomain has a domainName field */}
<Typography sx={{ mb: 1, height: "20px" }}>Subject: {template?.subject || 'N/A'}</Typography>
<Typography sx={{ mb: 1, height: "20px" }}>Status: {statusString}</Typography>
<Typography sx={{ mb: 1, height: "20px" }}>Recurring: {recurringString}</Typography>
{templateViewerOpen && (
<TemplateViewer
open={templateViewerOpen}
template={template!}
onClose={() => { setTemplateViewerOpen(false) }}
/>
)}
{targetSampleViewerOpen && (
<TargetSampleViewer
open={targetSampleViewerOpen}
target={target!}
onClose={() => { setTargetSampleViewerOpen(false) }}
/>
)}
</DialogContent>
</Dialog>
);

View File

@ -0,0 +1,172 @@
import { useState, useRef, useEffect } from 'react';
import RefreshIcon from '@mui/icons-material/Refresh';
import Switch from '@mui/material/Switch';
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, CircularProgress, IconButton, FormControlLabel } from '@mui/material';
import { DataGrid, GridColDef, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
import MailingStatistic from '@/types/mailingStatistic'; // Assuming you'll create this type based on the C# class
function ActiveMailings() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const [mailingsLoading, setMailingsLoading] = useState<boolean>(false);
const [mailingStats, setMailingStats] = useState<MailingStatistic[]>([]);
const [autoRefresh, setAutoRefresh] = useState<boolean>(true);
const isFetchingRef = useRef<boolean>(false);
const columns: GridColDef<MailingStatistic>[] = [
{ field: "mailingId", headerName: "Mailing ID", width: 100 },
{ field: "mailingName", headerName: "Name", flex: 1, minWidth: 160 },
{ field: "emailCount", headerName: "Emails", width: 100 },
{ field: "sendCount", headerName: "Active", width: 100 },
{ field: "deliveredCount", headerName: "Delivered", width: 100 },
{ field: "failedCount", headerName: "Failed", width: 100 },
{ field: "blockedCount", headerName: "Blocked", width: 100 },
{ field: "invalidCount", headerName: "Invalid", width: 100 },
{ field: "openCount", headerName: "Opens", width: 100 },
{ field: "uniqueOpenCount", headerName: "Unique Opens", width: 120 },
{ field: "clickCount", headerName: "Clicks", width: 100 },
{ field: "uniqueClickCount", headerName: "Unique Clicks", width: 120 },
{ field: "bounceCount", headerName: "Bounces", width: 100 },
{ field: "spamCount", headerName: "Spam", width: 100 },
{ field: "unsubscribeCount", headerName: "Unsubscribes", width: 120 },
];
const fetchMailingStats = async () => {
if (isFetchingRef.current) return; // Skip if a fetch is already in progress
isFetchingRef.current = true;
setMailingsLoading(true);
try {
const response = await fetch("/api/mailings/status/SD/stats");
const statsData = await response.json();
if (statsData) {
setMailingStats(statsData);
} else {
console.error("Failed to fetch mailing statistics");
}
} catch (error) {
console.error("Error fetching mailing stats:", error);
} finally {
setMailingsLoading(false);
isFetchingRef.current = false;
}
};
useEffect(() => {
fetchMailingStats(); // Initial fetch
let intervalId: NodeJS.Timeout | null = null;
if (autoRefresh) {
intervalId = setInterval(() => {
fetchMailingStats(); // Only triggers if no fetch is in progress
}, 5000);
}
return () => {
if (intervalId) clearInterval(intervalId); // Cleanup on unmount or autoRefresh change
};
}, [autoRefresh]);
const handleToggleAutoRefresh = () => {
setAutoRefresh((prev) => !prev);
};
const handleManualRefresh = () => {
if (!autoRefresh) {
fetchMailingStats();
}
};
return (
<Box ref={gridContainerRef} sx={{
position: 'relative', left: 0, right: 0, height: "calc(100vh - 124px)", overflow: "hidden",
transition: theme.transitions.create(['width', 'height'], {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.standard,
})
}}>
<Box sx={{ position: 'absolute', inset: 0 }}>
{isMobile ? (
<List>
{mailingStats.map((stat) => (
<Card key={stat.mailingId} sx={{ marginBottom: 2 }}>
<CardContent>
<Typography variant="h6">{stat.mailingName}</Typography>
<Typography variant="body2">ID: {stat.mailingId}</Typography>
<Typography variant="body2">Emails: {stat.emailCount}</Typography>
<Typography variant="body2">Active: {stat.sendCount}</Typography>
<Typography variant="body2">Delivered: {stat.deliveredCount}</Typography>
<Typography variant="body2">Failed: {stat.failedCount}</Typography>
<Typography variant="body2">Blocked: {stat.blockedCount}</Typography>
<Typography variant="body2">Invalid: {stat.invalidCount}</Typography>
<Typography variant="body2">Opens: {stat.openCount}</Typography>
<Typography variant="body2">Unique Opens: {stat.uniqueOpenCount}</Typography>
<Typography variant="body2">Clicks: {stat.clickCount}</Typography>
<Typography variant="body2">Unique Clicks: {stat.uniqueClickCount}</Typography>
<Typography variant="body2">Bounces: {stat.bounceCount}</Typography>
<Typography variant="body2">Spam: {stat.spamCount}</Typography>
<Typography variant="body2">Unsubscribes: {stat.unsubscribeCount}</Typography>
</CardContent>
</Card>
))}
</List>
) : (
<DataGrid
rows={mailingStats}
columns={columns}
getRowId={(row) => row.mailingId || Math.random()} // Use mailingId as row ID, fallback to random if null
autoPageSize
sx={{ minWidth: "600px" }}
slots={{
toolbar: () => (
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
<IconButton
size="small"
color="primary"
onClick={handleManualRefresh}
sx={{ marginLeft: 1 }}
disabled={autoRefresh || mailingsLoading}
>
{mailingsLoading ? <CircularProgress size={24} color="inherit" /> : <RefreshIcon />}
</IconButton>
<FormControlLabel
control={
<Switch
checked={autoRefresh}
onChange={handleToggleAutoRefresh}
color="primary"
/>
}
label="Auto Refresh"
sx={{ marginLeft: 1 }}
/>
<GridToolbarColumnsButton />
<GridToolbarDensitySelector />
<GridToolbarExport />
<GridToolbarQuickFilter sx={{ ml: "auto" }} />
</GridToolbarContainer>
),
}}
slotProps={{
toolbar: {
showQuickFilter: true,
},
}}
initialState={{
pagination: {
paginationModel: {
pageSize: 20,
},
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
</Box>
</Box>
);
}
export default ActiveMailings;

View File

@ -16,6 +16,8 @@ import Templates from '@/components/pages/Templates';
import EmailDomains from '@/components/pages/EmailDomains';
import NewMailings from '@/components/pages/NewMailings';
import ScheduledMailings from '@/components/pages/ScheduledMailings';
import ActiveMailings from '@/components/pages/ActiveMailings';
import CompletedMailings from '@/components/pages/CompletedMailings';
import AuthCheck from '@/components/auth/AuthCheck';
import { ColorModeContext } from '@/theme/theme';
@ -196,6 +198,26 @@ const App = () => {
</PageWrapper>
}
/>
<Route
path="/activeMailings"
element={
<PageWrapper title="Active Mailings">
<Layout>
<ActiveMailings />
</Layout>
</PageWrapper>
}
/>
<Route
path="/completedMailings"
element={
<PageWrapper title="Completed Mailings">
<Layout>
<CompletedMailings />
</Layout>
</PageWrapper>
}
/>
<Route
path="/login"
element={

View File

@ -0,0 +1,226 @@
import { useState, useRef, useEffect } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import RefreshIcon from '@mui/icons-material/Refresh';
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, CircularProgress, IconButton } from '@mui/material';
import { DataGrid, GridColDef, GridRenderCellParams, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
import MailingStatistic from '@/types/mailingStatistic';
import Mailing from '@/types/mailing';
import MailingView from "@/components/modals/MailingView";
import MailingEdit from "@/components/modals/MailingEdit";
function CompletedMailings() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const [mailingsLoading, setMailingsLoading] = useState<boolean>(false);
const [mailingStats, setMailingStats] = useState<MailingStatistic[]>([]);
const [selectedMailing, setSelectedMailing] = useState<Mailing | null>(null);
const [viewOpen, setViewOpen] = useState<boolean>(false);
const [editOpen, setEditOpen] = useState<boolean>(false);
const columns: GridColDef<MailingStatistic>[] = [
{
field: "actions",
headerName: "",
sortable: false,
width: 100,
renderCell: (params: GridRenderCellParams<MailingStatistic>) => (
<>
<IconButton color="primary" onClick={(e) => { e.stopPropagation(); handleView(params.row); }}>
<VisibilityIcon />
</IconButton>
<IconButton color="primary" onClick={(e) => { e.stopPropagation(); handleCopy(params.row); }}>
<ContentCopyIcon />
</IconButton>
</>
),
},
{ field: "mailingId", headerName: "Mailing ID", width: 100 },
{ field: "mailingName", headerName: "Name", flex: 1, minWidth: 160 },
{ field: "emailCount", headerName: "Emails", width: 100 },
{ field: "sendCount", headerName: "Active", width: 100 },
{ field: "deliveredCount", headerName: "Delivered", width: 100 },
{ field: "failedCount", headerName: "Failed", width: 100 },
{ field: "blockedCount", headerName: "Blocked", width: 100 },
{ field: "invalidCount", headerName: "Invalid", width: 100 },
{ field: "openCount", headerName: "Opens", width: 100 },
{ field: "uniqueOpenCount", headerName: "Unique Opens", width: 120 },
{ field: "clickCount", headerName: "Clicks", width: 100 },
{ field: "uniqueClickCount", headerName: "Unique Clicks", width: 120 },
{ field: "bounceCount", headerName: "Bounces", width: 100 },
{ field: "spamCount", headerName: "Spam", width: 100 },
{ field: "unsubscribeCount", headerName: "Unsubscribes", width: 120 },
];
const fetchMailingStats = async () => {
setMailingsLoading(true);
try {
const response = await fetch("/api/mailings/status/S/stats");
const statsData = await response.json();
if (statsData) {
setMailingStats(statsData);
} else {
console.error("Failed to fetch completed mailing statistics");
}
} catch (error) {
console.error("Error fetching mailing stats:", error);
} finally {
setMailingsLoading(false);
}
};
const fetchMailingDetails = async (mailingId: number) => {
try {
const response = await fetch(`/api/mailings/${mailingId}`);
const mailingData = await response.json();
if (mailingData) {
return mailingData;
} else {
console.error(`Failed to fetch mailing details for ID: ${mailingId}`);
return null;
}
} catch (error) {
console.error("Error fetching mailing details:", error);
return null;
}
};
const handleView = async (row: MailingStatistic) => {
if (row.mailingId) {
const mailing = await fetchMailingDetails(row.mailingId);
if (mailing) {
setSelectedMailing(mailing);
setViewOpen(true);
}
}
};
const handleCopy = async (row: MailingStatistic) => {
if (row.mailingId) {
const mailing = await fetchMailingDetails(row.mailingId);
if (mailing) {
const newMailing = { ...mailing, id: 0 }; // Copy all fields except ID
setSelectedMailing(newMailing);
setEditOpen(true);
}
}
};
const handleUpdateRow = () => {
// For copy action, we don't update the stats list, just close the edit modal
setEditOpen(false);
};
useEffect(() => {
fetchMailingStats(); // Initial fetch
}, []);
return (
<Box ref={gridContainerRef} sx={{
position: 'relative', left: 0, right: 0, height: "calc(100vh - 124px)", overflow: "hidden",
transition: theme.transitions.create(['width', 'height'], {
easing: theme.transitions.easing.easeInOut,
duration: theme.transitions.duration.standard,
})
}}>
<Box sx={{ position: 'absolute', inset: 0 }}>
{isMobile ? (
<List>
{mailingStats.map((stat) => (
<Card key={stat.mailingId} sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<CardContent>
<Typography variant="h6">{stat.mailingName}</Typography>
<Typography variant="body2">ID: {stat.mailingId}</Typography>
<Typography variant="body2">Emails: {stat.emailCount}</Typography>
<Typography variant="body2">Active: {stat.sendCount}</Typography>
<Typography variant="body2">Delivered: {stat.deliveredCount}</Typography>
<Typography variant="body2">Failed: {stat.failedCount}</Typography>
<Typography variant="body2">Blocked: {stat.blockedCount}</Typography>
<Typography variant="body2">Invalid: {stat.invalidCount}</Typography>
<Typography variant="body2">Opens: {stat.openCount}</Typography>
<Typography variant="body2">Unique Opens: {stat.uniqueOpenCount}</Typography>
<Typography variant="body2">Clicks: {stat.clickCount}</Typography>
<Typography variant="body2">Unique Clicks: {stat.uniqueClickCount}</Typography>
<Typography variant="body2">Bounces: {stat.bounceCount}</Typography>
<Typography variant="body2">Spam: {stat.spamCount}</Typography>
<Typography variant="body2">Unsubscribes: {stat.unsubscribeCount}</Typography>
</CardContent>
<Box>
<IconButton onClick={() => handleView(stat)}>
<VisibilityIcon />
</IconButton>
<IconButton onClick={() => handleCopy(stat)}>
<ContentCopyIcon />
</IconButton>
</Box>
</Box>
</Card>
))}
</List>
) : (
<DataGrid
rows={mailingStats}
columns={columns}
getRowId={(row) => row.mailingId || Math.random()}
autoPageSize
sx={{ minWidth: "600px" }}
slots={{
toolbar: () => (
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
<IconButton
size="small"
color="primary"
onClick={fetchMailingStats}
sx={{ marginLeft: 1 }}
disabled={mailingsLoading}
>
{mailingsLoading ? <CircularProgress size={24} color="inherit" /> : <RefreshIcon />}
</IconButton>
<GridToolbarColumnsButton />
<GridToolbarDensitySelector />
<GridToolbarExport />
<GridToolbarQuickFilter sx={{ ml: "auto" }} />
</GridToolbarContainer>
),
}}
slotProps={{
toolbar: {
showQuickFilter: true,
},
}}
initialState={{
pagination: {
paginationModel: {
pageSize: 20,
},
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
</Box>
{viewOpen && (
<MailingView
open={viewOpen}
mailing={selectedMailing}
onClose={() => setViewOpen(false)}
/>
)}
{editOpen && (
<MailingEdit
open={editOpen}
mailing={selectedMailing}
onClose={(reason) => { if (reason !== 'backdropClick') setEditOpen(false) }}
onSave={handleUpdateRow}
/>
)}
</Box>
);
}
export default CompletedMailings;

View File

@ -8,6 +8,7 @@ import { DataGrid, GridColDef, GridRenderCellParams, GridToolbarContainer, GridT
import Mailing from '@/types/mailing';
import MailingEdit from "@/components/modals/MailingEdit";
import MailingView from "@/components/modals/MailingView"; // Assume this is a new read-only view component
import ConfirmationDialog from "@/components/modals/ConfirmationDialog";
function ScheduleMailings() {
const theme = useTheme();
@ -18,6 +19,22 @@ function ScheduleMailings() {
const [selectedRow, setSelectedRow] = useState<Mailing | null>(null);
const [viewOpen, setViewOpen] = useState<boolean>(false);
const [editOpen, setEditOpen] = useState<boolean>(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState<boolean>(false);
const [mailingToCancel, setMailingToCancel] = useState<Mailing | null>(null);
const formatRecurringString = (typeCode: string, startDate: string): string => {
const date = new Date(startDate);
switch (typeCode.toUpperCase()) {
case 'D':
return `Daily at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
case 'W':
return `Weekly on ${date.toLocaleDateString('en-US', { weekday: 'long' })} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
case 'M':
return `Monthly on day ${date.getDate()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
default:
return 'Custom recurring schedule';
}
};
const columns: GridColDef<Mailing>[] = [
{
@ -33,7 +50,7 @@ function ScheduleMailings() {
<IconButton color="primary" onClick={(e) => { e.stopPropagation(); handleCopy(params.row); }}>
<ContentCopyIcon />
</IconButton>
<IconButton color="secondary" onClick={(e) => { e.stopPropagation(); handleCancel(params.row); }}>
<IconButton color="secondary" onClick={(e) => { e.stopPropagation(); handleCancelClick(params.row); }}>
<CancelIcon />
</IconButton>
</>
@ -49,10 +66,12 @@ function ScheduleMailings() {
},
{
field: "recurring",
headerName: "Recurring?",
headerName: "Recurring",
flex: 1,
minWidth: 200,
valueGetter: (_: any, row: Mailing) => row.recurringTypeCode !== "" || ""
valueGetter: (_: any, mailing: Mailing) => mailing.recurringTypeCode && mailing.recurringStartDate
? formatRecurringString(mailing.recurringTypeCode, mailing.recurringStartDate)
: ''
},
];
@ -80,21 +99,36 @@ function ScheduleMailings() {
setEditOpen(true);
};
const handleCancel = async (row: Mailing) => {
const handleCancelClick = (row: Mailing) => {
setMailingToCancel(row);
setConfirmDialogOpen(true);
};
const handleCancelConfirm = async () => {
if (!mailingToCancel) return;
try {
const response = await fetch(`/api/mailings/${row.id}/cancel`, { method: 'POST' });
const response = await fetch(`/api/mailings/${mailingToCancel.id}/cancel`, { method: 'POST' });
if (response.ok) {
setMailings((prev) => prev.filter(m => m.id !== row.id));
setMailings((prev) => prev.filter(m => m.id !== mailingToCancel.id));
} else {
console.error("Failed to cancel mailing");
}
} catch (error) {
console.error("Error cancelling mailing:", error);
} finally {
setConfirmDialogOpen(false);
setMailingToCancel(null);
}
};
const handleCancelDialogClose = () => {
setConfirmDialogOpen(false);
setMailingToCancel(null);
};
const handleUpdateRow = (updatedRow: Mailing) => {
setMailings((prev) => [...prev, updatedRow]); // Assuming new mailing from copy
setMailings((prev) => [...prev, updatedRow]);
};
useEffect(() => {
@ -160,6 +194,15 @@ function ScheduleMailings() {
onSave={handleUpdateRow}
/>
)}
{confirmDialogOpen && (
<ConfirmationDialog
open={confirmDialogOpen}
title="Cancel Mailing"
message={`Are you sure you want to cancel the mailing "${mailingToCancel?.name}"? This action cannot be undone.`}
onConfirm={handleCancelConfirm}
onCancel={handleCancelDialogClose}
/>
)}
</Box>
);
}

View File

@ -0,0 +1,17 @@
export default interface MailingStatistic {
mailingId: number;
mailingName: string;
spamCount: number;
uniqueClickCount: number;
clickCount: number;
uniqueOpenCount: number;
openCount: number;
invalidCount: number;
blockedCount: number;
failedCount: number;
deliveredCount: number;
sendCount: number;
emailCount: number;
bounceCount: number;
unsubscribeCount: number;
}