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);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 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());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 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>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 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={
|
||||||
|
|||||||
@ -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 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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