diff --git a/Surge365.MassEmailReact.API/Controllers/MailingsController.cs b/Surge365.MassEmailReact.API/Controllers/MailingsController.cs index 5c85076..3e865b0 100644 --- a/Surge365.MassEmailReact.API/Controllers/MailingsController.cs +++ b/Surge365.MassEmailReact.API/Controllers/MailingsController.cs @@ -39,6 +39,13 @@ namespace Surge365.MassEmailReact.API.Controllers return Ok(mailings); } + [HttpGet("status/{statusCode}/stats")] + public async Task GetStatisticsByStatus(string statusCode) + { + var mailings = await _mailingService.GetStatisticsByStatusAsync(statusCode); + return Ok(mailings); + } + [HttpGet("{id}")] public async Task 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 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 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 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); + } } } \ No newline at end of file diff --git a/Surge365.MassEmailReact.Application/Interfaces/IMailingRepository.cs b/Surge365.MassEmailReact.Application/Interfaces/IMailingRepository.cs index b81a833..a237fd4 100644 --- a/Surge365.MassEmailReact.Application/Interfaces/IMailingRepository.cs +++ b/Surge365.MassEmailReact.Application/Interfaces/IMailingRepository.cs @@ -9,9 +9,12 @@ namespace Surge365.MassEmailReact.Application.Interfaces Task GetByIdAsync(int id); Task> GetAllAsync(bool activeOnly = true); Task> GetByStatusAsync(string code); + Task> GetStatisticsByStatusAsync(string code); + Task GetStatisticByIdAsync(int id); Task NameIsAvailableAsync(int? id, string name); Task CreateAsync(Mailing mailing); Task UpdateAsync(Mailing mailing); + Task CancelMailingAsync(int id); } } \ No newline at end of file diff --git a/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs b/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs index 0262864..592e01c 100644 --- a/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs +++ b/Surge365.MassEmailReact.Application/Interfaces/IMailingService.cs @@ -10,10 +10,12 @@ namespace Surge365.MassEmailReact.Application.Interfaces Task> GetAllAsync(bool activeOnly = true); Task> GetByStatusAsync(string code); + Task> GetStatisticsByStatusAsync(string code); + Task GetStatisticByIdAsync(int id); Task NameIsAvailableAsync(int? id, string name); Task CreateAsync(MailingUpdateDto mailingDto); Task UpdateAsync(MailingUpdateDto mailingDto); - + Task CancelMailingAsync(int id); } } \ No newline at end of file diff --git a/Surge365.MassEmailReact.Domain/Entities/MailingStatistic.cs b/Surge365.MassEmailReact.Domain/Entities/MailingStatistic.cs new file mode 100644 index 0000000..ca9a250 --- /dev/null +++ b/Surge365.MassEmailReact.Domain/Entities/MailingStatistic.cs @@ -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); + } + } +} diff --git a/Surge365.MassEmailReact.Infrastructure/DapperMaps/DapperConfiguration.cs b/Surge365.MassEmailReact.Infrastructure/DapperMaps/DapperConfiguration.cs index c07fe62..5579772 100644 --- a/Surge365.MassEmailReact.Infrastructure/DapperMaps/DapperConfiguration.cs +++ b/Surge365.MassEmailReact.Infrastructure/DapperMaps/DapperConfiguration.cs @@ -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()); }); } diff --git a/Surge365.MassEmailReact.Infrastructure/DapperMaps/MailingStatisticMap.cs b/Surge365.MassEmailReact.Infrastructure/DapperMaps/MailingStatisticMap.cs new file mode 100644 index 0000000..b246762 --- /dev/null +++ b/Surge365.MassEmailReact.Infrastructure/DapperMaps/MailingStatisticMap.cs @@ -0,0 +1,27 @@ +using Dapper.FluentMap.Mapping; +using Surge365.MassEmailReact.Domain.Entities; + +namespace Surge365.MassEmailReact.Infrastructure.DapperMaps +{ + public class MailingStatisticMap : EntityMap + { + 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"); + } + } +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Infrastructure/Repositories/MailingRepository.cs b/Surge365.MassEmailReact.Infrastructure/Repositories/MailingRepository.cs index df0444a..8ba4a9a 100644 --- a/Surge365.MassEmailReact.Infrastructure/Repositories/MailingRepository.cs +++ b/Surge365.MassEmailReact.Infrastructure/Repositories/MailingRepository.cs @@ -44,7 +44,6 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories using SqlConnection conn = new SqlConnection(ConnectionString); return (await conn.QueryAsync("mem_get_blast_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList(); } - public async Task> 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("mem_get_blast_by_status", new { blast_status_code = code }, commandType: CommandType.StoredProcedure)).ToList(); } + public async Task> GetStatisticsByStatusAsync(string code) + { + ArgumentNullException.ThrowIfNull(ConnectionString); + + using SqlConnection conn = new SqlConnection(ConnectionString); + return (await conn.QueryAsync("mem_get_blast_statistic_by_status", new { blast_status_code = code }, commandType: CommandType.StoredProcedure)).ToList(); + } + public async Task GetStatisticByIdAsync(int id) + { + ArgumentNullException.ThrowIfNull(ConnectionString); + + using SqlConnection conn = new SqlConnection(ConnectionString); + return (await conn.QueryAsync("mem_get_blast_statistic_by_blast", new { blast_key = id }, commandType: CommandType.StoredProcedure)).FirstOrDefault(); + } public async Task 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("@success"); + } + public async Task 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("@success"); } } diff --git a/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs b/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs index 137e7c3..199ec8d 100644 --- a/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs +++ b/Surge365.MassEmailReact.Infrastructure/Services/MailingService.cs @@ -31,6 +31,14 @@ namespace Surge365.MassEmailReact.Infrastructure.Services { return await _mailingRepository.GetByStatusAsync(statusCode); } + public async Task> GetStatisticsByStatusAsync(string code) + { + return await _mailingRepository.GetStatisticsByStatusAsync(code); + } + public async Task GetStatisticByIdAsync(int id) + { + return await _mailingRepository.GetStatisticByIdAsync(id); + } public async Task 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 CancelMailingAsync(int id) + { + return await _mailingRepository.CancelMailingAsync(id); + } } } \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/src/components/auth/AuthCheck.tsx b/Surge365.MassEmailReact.Web/src/components/auth/AuthCheck.tsx index 6d36a87..cf5113a 100644 --- a/Surge365.MassEmailReact.Web/src/components/auth/AuthCheck.tsx +++ b/Surge365.MassEmailReact.Web/src/components/auth/AuthCheck.tsx @@ -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; }; diff --git a/Surge365.MassEmailReact.Web/src/components/auth/AuthContext.tsx b/Surge365.MassEmailReact.Web/src/components/auth/AuthContext.tsx index 702c1f6..716b1df 100644 --- a/Surge365.MassEmailReact.Web/src/components/auth/AuthContext.tsx +++ b/Surge365.MassEmailReact.Web/src/components/auth/AuthContext.tsx @@ -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({ accessToken: null, userRoles: [], setAuth: () => { }, + isLoading: true, // Default to loading }); export const AuthProvider = ({ children }: { children: ReactNode }) => { - const [accessToken, setAccessToken] = useState(localStorage.getItem('accessToken')); - const [userRoles, setUserRoles] = useState(accessToken ? utils.getUserRoles(accessToken) : []); + const [accessToken, setAccessToken] = useState(null); // Start as null + const [userRoles, setUserRoles] = useState([]); + 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 ( - - {children} + + {isLoading ? ( + + + + Loading... + + + ) : ( + children + )} ); }; diff --git a/Surge365.MassEmailReact.Web/src/components/auth/ProtectedPageWrapper.tsx b/Surge365.MassEmailReact.Web/src/components/auth/ProtectedPageWrapper.tsx index 307c3ec..9f1f5e7 100644 --- a/Surge365.MassEmailReact.Web/src/components/auth/ProtectedPageWrapper.tsx +++ b/Surge365.MassEmailReact.Web/src/components/auth/ProtectedPageWrapper.tsx @@ -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 = { - '/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 = { 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}; }; diff --git a/Surge365.MassEmailReact.Web/src/components/modals/ConfirmationDialog.tsx b/Surge365.MassEmailReact.Web/src/components/modals/ConfirmationDialog.tsx new file mode 100644 index 0000000..54d97f9 --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/components/modals/ConfirmationDialog.tsx @@ -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 ( + + {title} + + {message} + + + + + + + ); +} + +export default ConfirmationDialog; \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx b/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx index c8ce5b6..5774bc4 100644 --- a/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx +++ b/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx @@ -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} /> - ( - 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) => ( - - )} - /> + + + ( + 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) => ( + + )} + sx={{ flexGrow: 1 }} + /> + )} + /> + {currentTemplate && ( + )} - /> - {currentTemplate && ( - - )} - + + + { helperText={errors.targetId?.message} /> )} + sx={{ flexGrow: 1 }} /> )} - /> - {currentTarget && ( - - )} + /> + {currentTarget && ( + + )} + option.name} diff --git a/Surge365.MassEmailReact.Web/src/components/modals/MailingView.tsx b/Surge365.MassEmailReact.Web/src/components/modals/MailingView.tsx index f363444..de487cb 100644 --- a/Surge365.MassEmailReact.Web/src/components/modals/MailingView.tsx +++ b/Surge365.MassEmailReact.Web/src/components/modals/MailingView.tsx @@ -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(false); + const [targetSampleViewerOpen, setTargetSampleViewerOpen] = useState(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 ( - {mailing.name} + + {mailing.name} + theme.palette.grey[500], + }} + > + + + - Name: {mailing.name} - - + Target Name: {target?.name || 'Unknown'} {target && ( - + )} - + Template Name: {template?.name || 'Unknown'} {template && ( - + )} - From Name: {template?.fromName || 'N/A'} - Domain: {domain?.name || 'N/A'} {/* Assuming EmailDomain has a domainName field */} - Subject: {template?.subject || 'N/A'} - Status: {statusString} - Recurring: {recurringString} + From Name: {template?.fromName || 'N/A'} + Domain: {domain?.name || 'N/A'} {/* Assuming EmailDomain has a domainName field */} + Subject: {template?.subject || 'N/A'} + Status: {statusString} + Recurring: {recurringString} + + {templateViewerOpen && ( + { setTemplateViewerOpen(false) }} + /> + )} + {targetSampleViewerOpen && ( + { setTargetSampleViewerOpen(false) }} + /> + )} ); diff --git a/Surge365.MassEmailReact.Web/src/components/pages/ActiveMailings.tsx b/Surge365.MassEmailReact.Web/src/components/pages/ActiveMailings.tsx new file mode 100644 index 0000000..114d60a --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/components/pages/ActiveMailings.tsx @@ -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(null); + const [mailingsLoading, setMailingsLoading] = useState(false); + const [mailingStats, setMailingStats] = useState([]); + const [autoRefresh, setAutoRefresh] = useState(true); + const isFetchingRef = useRef(false); + + const columns: GridColDef[] = [ + { 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 ( + + + {isMobile ? ( + + {mailingStats.map((stat) => ( + + + {stat.mailingName} + ID: {stat.mailingId} + Emails: {stat.emailCount} + Active: {stat.sendCount} + Delivered: {stat.deliveredCount} + Failed: {stat.failedCount} + Blocked: {stat.blockedCount} + Invalid: {stat.invalidCount} + Opens: {stat.openCount} + Unique Opens: {stat.uniqueOpenCount} + Clicks: {stat.clickCount} + Unique Clicks: {stat.uniqueClickCount} + Bounces: {stat.bounceCount} + Spam: {stat.spamCount} + Unsubscribes: {stat.unsubscribeCount} + + + ))} + + ) : ( + row.mailingId || Math.random()} // Use mailingId as row ID, fallback to random if null + autoPageSize + sx={{ minWidth: "600px" }} + slots={{ + toolbar: () => ( + + + {mailingsLoading ? : } + + + } + label="Auto Refresh" + sx={{ marginLeft: 1 }} + /> + + + + + + ), + }} + slotProps={{ + toolbar: { + showQuickFilter: true, + }, + }} + initialState={{ + pagination: { + paginationModel: { + pageSize: 20, + }, + }, + }} + pageSizeOptions={[10, 20, 50, 100]} + /> + )} + + + ); +} + +export default ActiveMailings; \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/src/components/pages/App.tsx b/Surge365.MassEmailReact.Web/src/components/pages/App.tsx index 44d51f2..7eace0a 100644 --- a/Surge365.MassEmailReact.Web/src/components/pages/App.tsx +++ b/Surge365.MassEmailReact.Web/src/components/pages/App.tsx @@ -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 = () => { } /> + + + + + + } + /> + + + + + + } + /> (null); + const [mailingsLoading, setMailingsLoading] = useState(false); + const [mailingStats, setMailingStats] = useState([]); + const [selectedMailing, setSelectedMailing] = useState(null); + const [viewOpen, setViewOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + + const columns: GridColDef[] = [ + { + field: "actions", + headerName: "", + sortable: false, + width: 100, + renderCell: (params: GridRenderCellParams) => ( + <> + { e.stopPropagation(); handleView(params.row); }}> + + + { e.stopPropagation(); handleCopy(params.row); }}> + + + + ), + }, + { 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 ( + + + {isMobile ? ( + + {mailingStats.map((stat) => ( + + + + {stat.mailingName} + ID: {stat.mailingId} + Emails: {stat.emailCount} + Active: {stat.sendCount} + Delivered: {stat.deliveredCount} + Failed: {stat.failedCount} + Blocked: {stat.blockedCount} + Invalid: {stat.invalidCount} + Opens: {stat.openCount} + Unique Opens: {stat.uniqueOpenCount} + Clicks: {stat.clickCount} + Unique Clicks: {stat.uniqueClickCount} + Bounces: {stat.bounceCount} + Spam: {stat.spamCount} + Unsubscribes: {stat.unsubscribeCount} + + + handleView(stat)}> + + + handleCopy(stat)}> + + + + + + ))} + + ) : ( + row.mailingId || Math.random()} + autoPageSize + sx={{ minWidth: "600px" }} + slots={{ + toolbar: () => ( + + + {mailingsLoading ? : } + + + + + + + ), + }} + slotProps={{ + toolbar: { + showQuickFilter: true, + }, + }} + initialState={{ + pagination: { + paginationModel: { + pageSize: 20, + }, + }, + }} + pageSizeOptions={[10, 20, 50, 100]} + /> + )} + + + {viewOpen && ( + setViewOpen(false)} + /> + )} + + {editOpen && ( + { if (reason !== 'backdropClick') setEditOpen(false) }} + onSave={handleUpdateRow} + /> + )} + + ); +} + +export default CompletedMailings; \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/src/components/pages/ScheduledMailings.tsx b/Surge365.MassEmailReact.Web/src/components/pages/ScheduledMailings.tsx index 45e7355..128fb28 100644 --- a/Surge365.MassEmailReact.Web/src/components/pages/ScheduledMailings.tsx +++ b/Surge365.MassEmailReact.Web/src/components/pages/ScheduledMailings.tsx @@ -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(null); const [viewOpen, setViewOpen] = useState(false); const [editOpen, setEditOpen] = useState(false); + const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); + const [mailingToCancel, setMailingToCancel] = useState(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[] = [ { @@ -33,7 +50,7 @@ function ScheduleMailings() { { e.stopPropagation(); handleCopy(params.row); }}> - { e.stopPropagation(); handleCancel(params.row); }}> + { e.stopPropagation(); handleCancelClick(params.row); }}> @@ -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 && ( + + )} ); } diff --git a/Surge365.MassEmailReact.Web/src/types/mailingStatistic.ts b/Surge365.MassEmailReact.Web/src/types/mailingStatistic.ts new file mode 100644 index 0000000..97ce4c9 --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/types/mailingStatistic.ts @@ -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; +} \ No newline at end of file