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:
parent
f4ac033c70
commit
035a2e1dae
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
61
Surge365.MassEmailReact.Domain/Entities/MailingStatistic.cs
Normal file
61
Surge365.MassEmailReact.Domain/Entities/MailingStatistic.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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());
|
||||
});
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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 it’s above everything
|
||||
}}
|
||||
>
|
||||
<CircularProgress
|
||||
size={80} // Larger spinner
|
||||
thickness={4} // Slightly thicker for visibility
|
||||
sx={{ color: 'primary.main' }} // Use theme’s 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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}</>;
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{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
|
||||
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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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;
|
||||
@ -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={
|
||||
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
17
Surge365.MassEmailReact.Web/src/types/mailingStatistic.ts
Normal file
17
Surge365.MassEmailReact.Web/src/types/mailingStatistic.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user