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); 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}")] [HttpGet("{id}")]
public async Task<IActionResult> GetById(int 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."); 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] [HttpPost]
public async Task<IActionResult> CreateMailing([FromBody] MailingUpdateDto mailingUpdateDto) public async Task<IActionResult> CreateMailing([FromBody] MailingUpdateDto mailingUpdateDto)
{ {
@ -83,5 +98,16 @@ namespace Surge365.MassEmailReact.API.Controllers
var updatedMailing = await _mailingService.GetByIdAsync(id); var updatedMailing = await _mailingService.GetByIdAsync(id);
return Ok(updatedMailing); 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<Mailing?> GetByIdAsync(int id);
Task<List<Mailing>> GetAllAsync(bool activeOnly = true); Task<List<Mailing>> GetAllAsync(bool activeOnly = true);
Task<List<Mailing>> GetByStatusAsync(string code); 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<bool> NameIsAvailableAsync(int? id, string name);
Task<int?> CreateAsync(Mailing mailing); Task<int?> CreateAsync(Mailing mailing);
Task<bool> UpdateAsync(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>> GetAllAsync(bool activeOnly = true);
Task<List<Mailing>> GetByStatusAsync(string code); 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<bool> NameIsAvailableAsync(int? id, string name);
Task<int?> CreateAsync(MailingUpdateDto mailingDto); Task<int?> CreateAsync(MailingUpdateDto mailingDto);
Task<bool> UpdateAsync(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 TemplateMap());
config.AddMap(new EmailDomainMap()); config.AddMap(new EmailDomainMap());
config.AddMap(new MailingMap()); config.AddMap(new MailingMap());
config.AddMap(new MailingStatisticMap());
config.AddMap(new UserMap()); 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); using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<Mailing>("mem_get_blast_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList(); 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) public async Task<List<Mailing>> GetByStatusAsync(string code)
{ {
ArgumentNullException.ThrowIfNull(ConnectionString); ArgumentNullException.ThrowIfNull(ConnectionString);
@ -52,6 +51,20 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
using SqlConnection conn = new SqlConnection(ConnectionString); 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(); 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) public async Task<bool> NameIsAvailableAsync(int? id, string name)
{ {
ArgumentNullException.ThrowIfNull(ConnectionString); ArgumentNullException.ThrowIfNull(ConnectionString);
@ -121,6 +134,18 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
await conn.ExecuteAsync("mem_save_blast", parameters, commandType: CommandType.StoredProcedure); 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"); return parameters.Get<bool>("@success");
} }
} }

View File

@ -31,6 +31,14 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
{ {
return await _mailingRepository.GetByStatusAsync(statusCode); 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) public async Task<bool> NameIsAvailableAsync(int? id, string name)
{ {
return await _mailingRepository.NameIsAvailableAsync(id, name); return await _mailingRepository.NameIsAvailableAsync(id, name);
@ -80,5 +88,9 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
return await _mailingRepository.UpdateAsync(mailing); 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 { useNavigate, useLocation } from 'react-router-dom';
import utils from '@/ts/utils'; import utils from '@/ts/utils';
import { useAuth } from '@/components/auth/AuthContext'; // Import useAuth import { useAuth } from '@/components/auth/AuthContext';
const AuthCheck: React.FC = () => { const AuthCheck: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { accessToken, setAuth } = useAuth(); // Use context const { accessToken, setAuth, isLoading } = useAuth();
useEffect(() => { useEffect(() => {
const checkAuthStatus = async () => { if (isLoading) return; // Wait for AuthProvider to finish
const currentPath = location.pathname; const currentPath = location.pathname;
if (currentPath.toLowerCase() === "/login") if (currentPath.toLowerCase() === "/login") return;
return;
if (!accessToken) {
await tryRefreshToken();
} else {
if (utils.isTokenExpired(accessToken)) {
await tryRefreshToken();
} else {
// Do nothing, token is still valid
}
}
};
const tryRefreshToken = async () => { const tryRefreshToken = async () => {
try { try {
@ -34,10 +21,9 @@ const AuthCheck: React.FC = () => {
}); });
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setAuth(data.accessToken); // Update context instead of localStorage directly setAuth(data.accessToken);
// DO NOT NAVIGATE TO LOGIN PAGE
} else { } else {
setAuth(null); // Clear context on failure setAuth(null);
navigate('/login'); navigate('/login');
} }
} catch { } catch {
@ -46,8 +32,10 @@ const AuthCheck: React.FC = () => {
} }
}; };
checkAuthStatus(); if (!accessToken || utils.isTokenExpired(accessToken)) {
}, [navigate, location.pathname, accessToken, setAuth]); // Add accessToken and setAuth to deps tryRefreshToken();
}
}, [navigate, location.pathname, accessToken, setAuth, isLoading]);
return null; return null;
}; };

View File

@ -1,22 +1,25 @@
// src/components/auth/AuthContext.tsx import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { createContext, useContext, useState, ReactNode } from 'react';
import utils from '@/ts/utils'; import utils from '@/ts/utils';
import { Box, CircularProgress, Typography } from '@mui/material';
interface AuthContextType { interface AuthContextType {
accessToken: string | null; accessToken: string | null;
userRoles: string[]; userRoles: string[];
setAuth: (token: string | null) => void; setAuth: (token: string | null) => void;
isLoading: boolean; // Add loading state
} }
const AuthContext = createContext<AuthContextType>({ const AuthContext = createContext<AuthContextType>({
accessToken: null, accessToken: null,
userRoles: [], userRoles: [],
setAuth: () => { }, setAuth: () => { },
isLoading: true, // Default to loading
}); });
export const AuthProvider = ({ children }: { children: ReactNode }) => { export const AuthProvider = ({ children }: { children: ReactNode }) => {
const [accessToken, setAccessToken] = useState<string | null>(localStorage.getItem('accessToken')); const [accessToken, setAccessToken] = useState<string | null>(null); // Start as null
const [userRoles, setUserRoles] = useState<string[]>(accessToken ? utils.getUserRoles(accessToken) : []); const [userRoles, setUserRoles] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true); // Track loading
const setAuth = (token: string | null) => { const setAuth = (token: string | null) => {
if (token) { 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 ( return (
<AuthContext.Provider value={{ accessToken, userRoles, setAuth }}> <AuthContext.Provider value={{ accessToken, userRoles, setAuth, isLoading }}>
{children} {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> </AuthContext.Provider>
); );
}; };

View File

@ -1,14 +1,12 @@
// src/components/auth/ProtectedPageWrapper.tsx
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTitle } from "@/context/TitleContext"; 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[]> = { export const routeRoleRequirements: Record<string, string[]> = {
'/home': [], // No role required '/home': [],
'/servers': ['ServerTab'], // Only Admins '/servers': ['ServerTab'],
'/targets': ['TargetTab'], // Users or Admins '/targets': ['TargetTab'],
'/testEmailLists': ['TestListTab'], '/testEmailLists': ['TestListTab'],
'/blockedEmails': ['BlockedEmailTab'], '/blockedEmails': ['BlockedEmailTab'],
'/emailDomains': ['DomainTab'], '/emailDomains': ['DomainTab'],
@ -23,48 +21,28 @@ export const routeRoleRequirements: Record<string, string[]> = {
const ProtectedPageWrapper: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => { const ProtectedPageWrapper: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { setTitle } = useTitle(); const { setTitle } = useTitle();
const accessToken = localStorage.getItem('accessToken'); const { accessToken, userRoles, isLoading } = useAuth();
const currentPath = window.location.pathname; // Or use useLocation().pathname const currentPath = window.location.pathname;
useEffect(() => { useEffect(() => {
setTitle(title); setTitle(title);
}, [title, setTitle]); }, [title, setTitle]);
useEffect(() => { useEffect(() => {
const checkAuthAndRoles = async () => { if (isLoading) return; // Wait for auth to resolve
if (!accessToken || utils.isTokenExpired(accessToken)) { if (!accessToken) {
try { navigate('/login');
const response = await fetch('/api/authentication/refreshtoken', { return;
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 (!hasRequiredRole) { const requiredRoles = routeRoleRequirements[currentPath] || [];
navigate('/home'); // Redirect to home if unauthorized const hasRequiredRole = requiredRoles.length === 0 || requiredRoles.some(role => userRoles.includes(role));
} if (!hasRequiredRole) {
} navigate('/home');
}; }
checkAuthAndRoles(); }, [navigate, currentPath, accessToken, userRoles, isLoading]);
}, [navigate, accessToken, currentPath]);
if (!accessToken || utils.isTokenExpired(accessToken)) {
return null; // Or a loading spinner
}
if (isLoading || !accessToken) return null; // Wait or redirect handled above
return <>{children}</>; 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, Select,
InputLabel InputLabel
} from "@mui/material"; } from "@mui/material";
import VisibilityIcon from '@mui/icons-material/Visibility';
import Template from "@/types/template"; import Template from "@/types/template";
import Mailing from "@/types/mailing"; import Mailing from "@/types/mailing";
import Target from "@/types/target"; import Target from "@/types/target";
@ -304,37 +306,49 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
error={!!errors.description} error={!!errors.description}
helperText={errors.description?.message} 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}
/>
)}
/>
)}
/>
{currentTemplate && (
<Button onClick={handleTemplateViewerOpen}>View Template</Button>
)}
<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>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Controller <Controller
name="targetId" name="targetId"
control={control} control={control}
@ -359,12 +373,21 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
helperText={errors.targetId?.message} helperText={errors.targetId?.message}
/> />
)} )}
sx={{ flexGrow: 1 }}
/> />
)} )}
/> />
{currentTarget && ( {currentTarget && (
<Button onClick={handleTargetSampleViewerOpen}>View Target Sample</Button> <Button
)} onClick={handleTargetSampleViewerOpen}
variant="outlined"
startIcon={<VisibilityIcon />}
sx={{ height: '100%', alignSelf: 'center' }}
>
View
</Button>
)}
</Box>
<Autocomplete <Autocomplete
options={setupData.testEmailLists} options={setupData.testEmailLists}
getOptionLabel={(option) => option.name} 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 { useSetupData } from '@/context/SetupDataContext';
import Mailing from '@/types/mailing'; 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 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 { interface MailingViewProps {
open: boolean; open: boolean;
@ -12,7 +15,8 @@ interface MailingViewProps {
function MailingView({ open, mailing, onClose }: MailingViewProps) { function MailingView({ open, mailing, onClose }: MailingViewProps) {
const setupData = useSetupData(); const setupData = useSetupData();
const navigate = useNavigate(); const [templateViewerOpen, setTemplateViewerOpen] = useState<boolean>(false);
const [targetSampleViewerOpen, setTargetSampleViewerOpen] = useState<boolean>(false);
if (!mailing) return null; if (!mailing) return null;
@ -32,11 +36,11 @@ function MailingView({ open, mailing, onClose }: MailingViewProps) {
const formatRecurringString = (typeCode: string, startDate: string): string => { const formatRecurringString = (typeCode: string, startDate: string): string => {
const date = new Date(startDate); const date = new Date(startDate);
switch (typeCode.toUpperCase()) { switch (typeCode.toUpperCase()) {
case 'DAILY': case 'D':
return `Daily at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; 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' })}`; 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' })}`; return `Monthly on day ${date.getDate()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
default: default:
return 'Custom recurring schedule'; return 'Custom recurring schedule';
@ -46,50 +50,77 @@ function MailingView({ open, mailing, onClose }: MailingViewProps) {
// Format recurring string // Format recurring string
const recurringString = mailing.recurringTypeCode && mailing.recurringStartDate const recurringString = mailing.recurringTypeCode && mailing.recurringStartDate
? formatRecurringString(mailing.recurringTypeCode, mailing.recurringStartDate) ? formatRecurringString(mailing.recurringTypeCode, mailing.recurringStartDate)
: 'One-time'; : 'No';
// Navigation handlers for viewing related entities // Navigation handlers for viewing related entities
const handleViewTarget = () => { const handleViewTarget = () => {
if (target) { if (target) {
navigate(`/targets/${target.id}`); // Adjust path based on your routing setTargetSampleViewerOpen(!targetSampleViewerOpen);
} }
}; };
const handleViewTemplate = () => { const handleViewTemplate = () => {
if (template) { if (template) {
navigate(`/templates/${template.id}`); // Adjust path based on your routing setTemplateViewerOpen(!templateViewerOpen);
} }
}; };
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth> <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> <DialogContent>
<Typography sx={{ mb: 1 }}>Name: {mailing.name}</Typography> <Typography sx={{ mb: 1, height: "20px" }}>
<Typography sx={{ mb: 1 }}>
Target Name: {target?.name || 'Unknown'} Target Name: {target?.name || 'Unknown'}
{target && ( {target && (
<IconButton size="small" onClick={handleViewTarget} sx={{ ml: 1 }}> <IconButton onClick={handleViewTarget} sx={{ ml: 1, height: "100%" }}>
<VisibilityIcon /> <VisibilityIcon />
</IconButton> </IconButton>
)} )}
</Typography> </Typography>
<Typography sx={{ mb: 1 }}> <Typography sx={{ mb: 1, height: "20px" }}>
Template Name: {template?.name || 'Unknown'} Template Name: {template?.name || 'Unknown'}
{template && ( {template && (
<IconButton size="small" onClick={handleViewTemplate} sx={{ ml: 1 }}> <IconButton onClick={handleViewTemplate} sx={{ ml: 1, height: "100%" }}>
<VisibilityIcon /> <VisibilityIcon />
</IconButton> </IconButton>
)} )}
</Typography> </Typography>
<Typography sx={{ mb: 1 }}>From Name: {template?.fromName || 'N/A'}</Typography> <Typography sx={{ mb: 1, height: "20px" }}>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, height: "20px" }}>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, height: "20px" }}>Subject: {template?.subject || 'N/A'}</Typography>
<Typography sx={{ mb: 1 }}>Status: {statusString}</Typography> <Typography sx={{ mb: 1, height: "20px" }}>Status: {statusString}</Typography>
<Typography sx={{ mb: 1 }}>Recurring: {recurringString}</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> </DialogContent>
</Dialog> </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 EmailDomains from '@/components/pages/EmailDomains';
import NewMailings from '@/components/pages/NewMailings'; import NewMailings from '@/components/pages/NewMailings';
import ScheduledMailings from '@/components/pages/ScheduledMailings'; 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 AuthCheck from '@/components/auth/AuthCheck';
import { ColorModeContext } from '@/theme/theme'; import { ColorModeContext } from '@/theme/theme';
@ -196,6 +198,26 @@ const App = () => {
</PageWrapper> </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 <Route
path="/login" path="/login"
element={ 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 Mailing from '@/types/mailing';
import MailingEdit from "@/components/modals/MailingEdit"; import MailingEdit from "@/components/modals/MailingEdit";
import MailingView from "@/components/modals/MailingView"; // Assume this is a new read-only view component import MailingView from "@/components/modals/MailingView"; // Assume this is a new read-only view component
import ConfirmationDialog from "@/components/modals/ConfirmationDialog";
function ScheduleMailings() { function ScheduleMailings() {
const theme = useTheme(); const theme = useTheme();
@ -18,6 +19,22 @@ function ScheduleMailings() {
const [selectedRow, setSelectedRow] = useState<Mailing | null>(null); const [selectedRow, setSelectedRow] = useState<Mailing | null>(null);
const [viewOpen, setViewOpen] = useState<boolean>(false); const [viewOpen, setViewOpen] = useState<boolean>(false);
const [editOpen, setEditOpen] = 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>[] = [ const columns: GridColDef<Mailing>[] = [
{ {
@ -33,7 +50,7 @@ function ScheduleMailings() {
<IconButton color="primary" onClick={(e) => { e.stopPropagation(); handleCopy(params.row); }}> <IconButton color="primary" onClick={(e) => { e.stopPropagation(); handleCopy(params.row); }}>
<ContentCopyIcon /> <ContentCopyIcon />
</IconButton> </IconButton>
<IconButton color="secondary" onClick={(e) => { e.stopPropagation(); handleCancel(params.row); }}> <IconButton color="secondary" onClick={(e) => { e.stopPropagation(); handleCancelClick(params.row); }}>
<CancelIcon /> <CancelIcon />
</IconButton> </IconButton>
</> </>
@ -49,10 +66,12 @@ function ScheduleMailings() {
}, },
{ {
field: "recurring", field: "recurring",
headerName: "Recurring?", headerName: "Recurring",
flex: 1, flex: 1,
minWidth: 200, 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); setEditOpen(true);
}; };
const handleCancel = async (row: Mailing) => { const handleCancelClick = (row: Mailing) => {
setMailingToCancel(row);
setConfirmDialogOpen(true);
};
const handleCancelConfirm = async () => {
if (!mailingToCancel) return;
try { 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) { if (response.ok) {
setMailings((prev) => prev.filter(m => m.id !== row.id)); setMailings((prev) => prev.filter(m => m.id !== mailingToCancel.id));
} else { } else {
console.error("Failed to cancel mailing"); console.error("Failed to cancel mailing");
} }
} catch (error) { } catch (error) {
console.error("Error cancelling mailing:", error); console.error("Error cancelling mailing:", error);
} finally {
setConfirmDialogOpen(false);
setMailingToCancel(null);
} }
}; };
const handleCancelDialogClose = () => {
setConfirmDialogOpen(false);
setMailingToCancel(null);
};
const handleUpdateRow = (updatedRow: Mailing) => { const handleUpdateRow = (updatedRow: Mailing) => {
setMailings((prev) => [...prev, updatedRow]); // Assuming new mailing from copy setMailings((prev) => [...prev, updatedRow]);
}; };
useEffect(() => { useEffect(() => {
@ -160,6 +194,15 @@ function ScheduleMailings() {
onSave={handleUpdateRow} 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> </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;
}