Refactor mailing and target sample management features

- Updated `UpdateMailing` to convert dates from UTC to local time.
- Renamed `GetTargetSample` to `TestTargetAsync` in `TargetsController`.
- Added `ConnectionStringTemplate` to `appsettings.Development.json`.
- Introduced `TestTargetSql` in `appsettings.json` for SQL queries.
- Created a new `TargetSample` class for target sample data structure.
- Updated `ITargetRepository` and `ITargetService` interfaces for new method signatures.
- Implemented `TestTargetAsync` in `TargetRepository` for sample data retrieval.
- Enhanced `MailingEdit.tsx` with new imports and validation for date fields.
- Added `MailingView` component for displaying mailing details.
- Introduced `ScheduleMailings` component for managing scheduled mailings.
- Updated `TargetSampleViewer` to accommodate new data structure.
- Modified routing in `App.tsx` to include scheduled mailings.
- Adjusted `NewMailings` component to manage mailing updates and removals.
This commit is contained in:
David Headrick 2025-03-23 19:15:17 -05:00
parent 525a9b808c
commit f4ac033c70
16 changed files with 679 additions and 186 deletions

View File

@ -66,6 +66,12 @@ namespace Surge365.MassEmailReact.API.Controllers
if (id != mailingUpdateDto.Id) if (id != mailingUpdateDto.Id)
return BadRequest("Id in URL does not match Id in request body"); return BadRequest("Id in URL does not match Id in request body");
if (mailingUpdateDto.ScheduleDate.HasValue && mailingUpdateDto.ScheduleDate.Value.Kind == DateTimeKind.Utc)
mailingUpdateDto.ScheduleDate = TimeZoneInfo.ConvertTimeFromUtc(mailingUpdateDto.ScheduleDate.Value, TimeZoneInfo.Local);
if (mailingUpdateDto.RecurringStartDate.HasValue && mailingUpdateDto.RecurringStartDate.Value.Kind == DateTimeKind.Utc)
mailingUpdateDto.RecurringStartDate = TimeZoneInfo.ConvertTimeFromUtc(mailingUpdateDto.RecurringStartDate.Value, TimeZoneInfo.Local);
var existingMailing = await _mailingService.GetByIdAsync(id); var existingMailing = await _mailingService.GetByIdAsync(id);
if (existingMailing == null) if (existingMailing == null)
return NotFound($"Mailing with Id {id} not found"); return NotFound($"Mailing with Id {id} not found");

View File

@ -69,7 +69,7 @@ namespace Surge365.MassEmailReact.API.Controllers
{ {
try try
{ {
var sample = await _targetService.GetSampleData(targetId); var sample = await _targetService.TestTargetAsync(targetId);
return Ok(sample); return Ok(sample);
} }
catch (Exception ex) catch (Exception ex)

View File

@ -4,5 +4,6 @@
"Default": "Information", "Default": "Information",
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
} },
"ConnectionStringTemplate": "data source=##server_name##,##port##;initial catalog=##database_name##;User ID=##username##;Password=##password##;persist security info=False;packet size=4096;TrustServerCertificate=True;"
} }

View File

@ -15,5 +15,7 @@
"ConnectionStrings": { "ConnectionStrings": {
"Marketing.ConnectionString": "data source=uat.surge365.com;initial catalog=Marketing;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;", //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT "Marketing.ConnectionString": "data source=uat.surge365.com;initial catalog=Marketing;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;", //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT
"MassEmail.ConnectionString": "data source=uat.surge365.com;initial catalog=MassEmail;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;" //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT "MassEmail.ConnectionString": "data source=uat.surge365.com;initial catalog=MassEmail;User ID=ytb;Password=YTB()nl!n3;Encrypt=yes;TrustServerCertificate=yes;Connection Timeout=3;Application Name=##application_name##;" //TODO: Move this to development.json, on server should go somewhere secure. GET IT OUT OF GIT
} },
"TestTargetSql": "CREATE TABLE #columns\r\n(\r\n primary_key INT NOT NULL IDENTITY(1,1) PRIMARY KEY,\r\n name VARCHAR(255),\r\n data_type CHAR(1)\r\n)\r\nSELECT TOP 10 *\r\nINTO #list\r\nFROM ##database_name##..##view_name##\r\n##filter##\r\n\r\nDECLARE @row_count INT\r\nSELECT @row_count = COUNT(*)\r\nFROM ##database_name##..##view_name##\r\n##filter##\r\n\r\nDECLARE c_curs CURSOR FOR \r\nSELECT c.name AS column_name, t.name AS data_type\r\nFROM tempdb.sys.columns c\r\nINNER JOIN tempdb.sys.types t ON c.user_type_id = t.user_type_id\r\n AND t.name NOT IN ('text','ntext','image','binary','varbinary','image','cursor','timestamp','hierarchyid','sql_variant','xml','table')\r\nWHERE object_id = object_id('tempdb..#list') \r\n AND ((t.name IN ('char','varchar') AND c.max_length <= 255)\r\n OR (t.name IN ('nchar','nvarchar') AND c.max_length <= 510)\r\n OR (t.name NOT IN ('char','varchar','nchar','nvarchar')))\r\n \r\nOPEN c_curs\r\nDECLARE @column_name VARCHAR(255), @column_type VARCHAR(255)\r\n\r\nFETCH NEXT FROM c_curs INTO @column_name, @column_type\r\nWHILE(@@FETCH_STATUS = 0)\r\nBEGIN \r\n DECLARE @data_type CHAR(1) = 'S'\r\n IF(@column_type IN ('date','datetime','datetime2','datetimeoffset','smalldatetime','time'))\r\n BEGIN\r\n SET @data_type = 'D'\r\n END\r\n ELSE IF(@column_type IN ('bit'))\r\n BEGIN\r\n SET @data_type = 'B'\r\n END\r\n ELSE IF(@column_type IN ('bigint','numeric','smallint','decimal','smallmoney','int','tinyint','money','float','real'))\r\n BEGIN\r\n SET @data_type = 'N'\r\n END\r\n INSERT INTO #columns(name, data_type) VALUES(@column_name, @data_type)\r\n FETCH NEXT FROM c_curs INTO @column_name, @column_type\r\nEND\r\nCLOSE c_curs\r\nDEALLOCATE c_curs\r\nSELECT * FROM #columns ORDER BY primary_key\r\nSELECT * FROM #list\r\nSELECT @row_count AS row_count\r\nDROP TABLE #columns\r\nDROP TABLE #list",
"ConnectionStringTemplate": "data source=##server_name##,##port##;initial catalog=##database_name##;User ID=##username##;Password=##password##;persist security info=False;packet size=4096;"
} }

View File

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.DTOs
{
public class TargetSample
{
public Dictionary<string, TargetSampleColumn> Columns { get; set; } = new Dictionary<string, TargetSampleColumn>();
public List<Dictionary<string, string>> Rows { get; set; } = new List<Dictionary<string, string>>();
}
public class TargetSampleColumn
{
public string Name { get; set; }
public string Type { get; set; }
}
public class TargetColumnType
{
public const string UniqueIdentifier = "I";
public const string BounceStatus = "B";
public const string UnsubscribeStatus = "U";
public const string StatusCode = "S";
public const string SoftBounceCount = "C";
public const string General = "G";
public const string EmailAddress = "E";
}
}

View File

@ -1,4 +1,5 @@
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Domain.Entities;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@ -13,6 +14,13 @@ namespace Surge365.MassEmailReact.Application.Interfaces
Task<List<Target>> GetAllAsync(bool activeOnly = true); Task<List<Target>> GetAllAsync(bool activeOnly = true);
Task<int?> CreateAsync(Target target); Task<int?> CreateAsync(Target target);
Task<bool> UpdateAsync(Target target); Task<bool> UpdateAsync(Target target);
Task<TargetSample> GetSampleData(int targetId); Task<TargetSample?> TestTargetAsync(
string serverName,
short port,
string username,
string password,
string databaseName,
string viewName,
string filter);
} }
} }

View File

@ -1,4 +1,5 @@
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Application.Interfaces namespace Surge365.MassEmailReact.Application.Interfaces
{ {
@ -8,6 +9,6 @@ namespace Surge365.MassEmailReact.Application.Interfaces
Task<List<Target>> GetAllAsync(bool activeOnly = true); Task<List<Target>> GetAllAsync(bool activeOnly = true);
Task<int?> CreateAsync(TargetUpdateDto targetDto); Task<int?> CreateAsync(TargetUpdateDto targetDto);
Task<bool> UpdateAsync(TargetUpdateDto targetDto); Task<bool> UpdateAsync(TargetUpdateDto targetDto);
Task<TargetSample> GetSampleData(int targetId); Task<TargetSample?> TestTargetAsync(int targetId);
} }
} }

View File

@ -1,15 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class TargetSample
{
public List<string> ColumnNames { get; set; } = new List<string>();
public List<Dictionary<string, string>> Rows { get; set; } = new List<Dictionary<string, string>>();
}
}

View File

@ -2,6 +2,7 @@
using Dapper.FluentMap; using Dapper.FluentMap;
using Microsoft.Data.SqlClient; using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Domain.Entities;
using Surge365.MassEmailReact.Domain.Enums; using Surge365.MassEmailReact.Domain.Enums;
@ -11,6 +12,7 @@ using System.Collections.Generic;
using System.Data; using System.Data;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Repositories namespace Surge365.MassEmailReact.Infrastructure.Repositories
@ -81,7 +83,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
// Retrieve the output parameter value // Retrieve the output parameter value
bool success = parameters.Get<bool>("@success"); bool success = parameters.Get<bool>("@success");
if(success) if (success)
return parameters.Get<int>("@target_key"); return parameters.Get<int>("@target_key");
return null; return null;
@ -112,48 +114,116 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
return success; return success;
} }
public async Task<TargetSample?> TestTargetAsync(
string serverName,
short port,
string username,
string password,
string databaseName,
string viewName,
string filter)
{
if (string.IsNullOrWhiteSpace(serverName)
|| port <= 0
|| string.IsNullOrWhiteSpace(username)
|| string.IsNullOrWhiteSpace(password)
|| string.IsNullOrWhiteSpace(databaseName)
|| string.IsNullOrWhiteSpace(viewName))
return null;
//public void Add(Target target) // Clean up filter
//{ if (!string.IsNullOrWhiteSpace(filter) && filter.Trim().ToUpper().StartsWith("WHERE "))
// Targets.Add(target); filter = filter.Substring(6).Trim();
//}
public async Task<TargetSample> GetSampleData(int targetId) // Clean up server name
serverName = serverName.Replace("110494-db", "www.surge365.com");
// Get configuration
string sql = _config["TestTargetSql"] ?? "";
string connectionStringTemplate = _config["ConnectionStringTemplate"] ?? "";
// Replace placeholders in SQL
sql = Regex.Replace(sql, "##database_name##", databaseName, RegexOptions.IgnoreCase);
sql = Regex.Replace(sql, "##view_name##", viewName, RegexOptions.IgnoreCase);
sql = Regex.Replace(sql, "##filter##",
string.IsNullOrWhiteSpace(filter) ? "" : $"WHERE {filter}",
RegexOptions.IgnoreCase);
// Build connection string
string connectionString = connectionStringTemplate;
connectionString = Regex.Replace(connectionString, "##database_name##", databaseName, RegexOptions.IgnoreCase);
connectionString = Regex.Replace(connectionString, "##server_name##", serverName, RegexOptions.IgnoreCase);
connectionString = Regex.Replace(connectionString, "##port##", port.ToString(), RegexOptions.IgnoreCase);
connectionString = Regex.Replace(connectionString, "##username##", username, RegexOptions.IgnoreCase);
connectionString = Regex.Replace(connectionString, "##password##", password, RegexOptions.IgnoreCase);
using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
// Assuming the SQL returns three result sets: columns, sample data, and row count
using var multi = await connection.QueryMultipleAsync(sql, commandTimeout: 300);
// Read column definitions
var columnEntities = await multi.ReadAsync<dynamic>();
if (!columnEntities.Any())
return null;
// Read sample data
var sampleRows = (await multi.ReadAsync<dynamic>()).ToList();
// Read row count
var rowCountResult = await multi.ReadSingleAsync<dynamic>();
int rowCount = Convert.ToInt32(rowCountResult.row_count);
// Build TargetSample
var targetSample = new TargetSample();
bool emailFound = false;
bool uniqueIDFound = false;
bool bounceFound = false;
bool unsubFound = false;
foreach (var col in columnEntities)
{ {
// Placeholder hardcoded sample data for testing string name = col.name;
var sample = new TargetSample string dataType = col.data_type;
string typeCode = TargetColumnType.General;
if (!emailFound && name.ToUpper().Contains("EMAIL"))
{ {
ColumnNames = new List<string> { "id", "name", "email", "age" }, typeCode = TargetColumnType.EmailAddress;
Rows = new List<Dictionary<string, string>> emailFound = true;
{
new Dictionary<string, string>
{
{ "id", "1" },
{ "name", "John Doe" },
{ "email", "john.doe@example.com" },
{ "age", "30" }
},
new Dictionary<string, string>
{
{ "id", "2" },
{ "name", "Jane Smith" },
{ "email", "jane.smith@example.com" },
{ "age", "25" }
},
new Dictionary<string, string>
{
{ "id", "3" },
{ "name", "Bob Johnson" },
{ "email", "bob.johnson@example.com" },
{ "age", "45" }
} }
else if (!uniqueIDFound && name.ToUpper().Contains("_KEY"))
{
typeCode = TargetColumnType.UniqueIdentifier;
uniqueIDFound = true;
} }
else if (!bounceFound && name.ToUpper().Contains("BOUNCE"))
{
typeCode = TargetColumnType.BounceStatus;
bounceFound = true;
}
else if (!unsubFound && name.ToUpper().Contains("UNSUB"))
{
typeCode = TargetColumnType.UnsubscribeStatus;
unsubFound = true;
}
targetSample.Columns[name] = new TargetSampleColumn
{
Name = name,
Type = typeCode
}; };
// Simulate async operation (e.g., DB call) for testing
await Task.Delay(100); // Optional: Mimics network latency
return sample;
} }
// Convert sample rows to dictionary format
foreach (var row in sampleRows)
{
var dict = ((IDictionary<string, object>)row)
.ToDictionary(k => k.Key, v => v.Value?.ToString() ?? "");
targetSample.Rows.Add(dict);
}
return targetSample;
}
} }
} }

View File

@ -1,4 +1,5 @@
using Surge365.MassEmailReact.Application.Interfaces; using Dapper;
using Surge365.MassEmailReact.Application.Interfaces;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@ -10,6 +11,9 @@ using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Domain.Entities;
using System.Security.Cryptography; using System.Security.Cryptography;
using Surge365.MassEmailReact.Application.DTOs;
using System.Text.RegularExpressions;
using Microsoft.Data.SqlClient;
namespace Surge365.MassEmailReact.Infrastructure.Services namespace Surge365.MassEmailReact.Infrastructure.Services
@ -17,11 +21,13 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
public class TargetService : ITargetService public class TargetService : ITargetService
{ {
private readonly ITargetRepository _targetRepository; private readonly ITargetRepository _targetRepository;
private readonly IServerRepository _serverRepository;
private readonly IConfiguration _config; private readonly IConfiguration _config;
public TargetService(ITargetRepository targetRepository, IConfiguration config) public TargetService(ITargetRepository targetRepository, IServerRepository serverRepository, IConfiguration config)
{ {
_targetRepository = targetRepository; _targetRepository = targetRepository;
_serverRepository = serverRepository;
_config = config; _config = config;
} }
@ -69,9 +75,15 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
return await _targetRepository.UpdateAsync(target); return await _targetRepository.UpdateAsync(target);
} }
public async Task<TargetSample> GetSampleData(int targetId) public async Task<TargetSample?> TestTargetAsync(int targetId)
{ {
return await _targetRepository.GetSampleData(targetId); Target? target = await _targetRepository.GetByIdAsync(targetId);
if (target == null) return null;
Server? server = await _serverRepository.GetByIdAsync(target.ServerId, true);
if(server == null) return null;
return await _targetRepository.TestTargetAsync(server.ServerName, server.Port, server.Username, server.Password, target.DatabaseName, target.ViewName, target.FilterQuery);
} }
} }
} }

View File

@ -8,8 +8,12 @@ import {
DialogActions, DialogActions,
Button, Button,
Checkbox, Checkbox,
FormControl,
FormControlLabel, FormControlLabel,
Box Box,
MenuItem,
Select,
InputLabel
} from "@mui/material"; } from "@mui/material";
import Template from "@/types/template"; import Template from "@/types/template";
import Mailing from "@/types/mailing"; import Mailing from "@/types/mailing";
@ -27,6 +31,9 @@ import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import dayjs, { Dayjs } from 'dayjs'; // Import Dayjs for date handling import dayjs, { Dayjs } from 'dayjs'; // Import Dayjs for date handling
import utc from 'dayjs/plugin/utc'; // Import the UTC plugin
dayjs.extend(utc);
type MailingEditProps = { type MailingEditProps = {
open: boolean; open: boolean;
@ -69,15 +76,24 @@ const schema = yup.object().shape({
return setupData.targets.some(t => t.id === value); return setupData.targets.some(t => t.id === value);
}), }),
statusCode: yup.string().default("ED"), statusCode: yup.string().default("ED"),
scheduleDate: yup.date() scheduleDate: yup.string()
.nullable() .nullable()
.when("$scheduleForLater", (scheduleForLater, schema) => { // Use context variable .when("$scheduleForLater", (scheduleForLater, schema) => {
return scheduleForLater const isScheduledForLater = scheduleForLater[0] ?? false;
return isScheduledForLater
? schema ? schema
.required("Schedule date is required when scheduled for later") .required("Schedule date is required when scheduled for later")
.min(new Date(), "Schedule date must be in the future") .matches(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/,
"Schedule date must be a valid UTC ISO string (e.g., 'YYYY-MM-DDTHH:mm:ssZ')"
)
.test("is-future", "Schedule date must be in the future", (value) => {
if (!value) return true; // Nullable when not required
return dayjs(value).isAfter(dayjs());
})
: schema.nullable(); : schema.nullable();
}), }),
//scheduleDate: yup.date().nullable(), //scheduleDate: yup.date().nullable(),
// .when("statusCode", { // .when("statusCode", {
@ -90,8 +106,30 @@ const schema = yup.object().shape({
recurringTypeCode: yup recurringTypeCode: yup
.string() .string()
.nullable() .nullable()
.oneOf(recurringTypeOptions.map((r) => r.code), "Invalid recurring type"), .when("$recurring", (recurring, schema) => { // Use context variable
recurringStartDate: yup.date().nullable() const isRecurring = recurring[0] ?? false;
return isRecurring
? schema.oneOf(recurringTypeOptions.map((r) => r.code), "Invalid recurring type")
: schema.nullable();
}),
recurringStartDate: yup.string()
.nullable()
.when("$recurring", (recurring, schema) => {
const isRecurring = recurring[0] ?? false;
return isRecurring
? schema
.required("Recurring start date is required when recurring")
.matches(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/,
"Recurring start date must be a valid UTC ISO string (e.g., 'YYYY-MM-DDTHH:mm:ssZ')"
)
.test("is-future", "Recurring start date must be in the future", (value) => {
if (!value) return true; // Nullable when not required
return dayjs(value).isAfter(dayjs());
})
: schema.nullable();
}),
//.when("recurringTypeCode", { //.when("recurringTypeCode", {
//is: (value: string) => value !== "" && value !== null, // String comparison for "None" //is: (value: string) => value !== "" && value !== null, // String comparison for "None"
@ -138,13 +176,21 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
...(mailing || defaultMailing), ...(mailing || defaultMailing),
}, },
resolver: yupResolver(schema) as Resolver<Mailing>, resolver: yupResolver(schema) as Resolver<Mailing>,
context: { setupData }, context: { setupData, scheduleForLater, recurring },
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
if (mailing) {
mailing.scheduleDate = null;
mailing.recurringTypeCode = null;
mailing.recurringStartDate = null;
}
setApproved(false);
setRecurring(false);
setScheduleForLater(false);
reset(mailing || defaultMailing, { keepDefaultValues: true }); reset(mailing || defaultMailing, { keepDefaultValues: true });
if (setupData.testEmailLists.length > 0) { if (setupData.testEmailLists.length > 0) {
setTestEmailListId(setupData.testEmailLists[0].id); setTestEmailListId(setupData.testEmailLists[0].id);
@ -190,10 +236,11 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
formData.recurringStartDate = null; formData.recurringStartDate = null;
} }
try { try {
const jsonPayload = JSON.stringify(formData);
const response = await fetch(apiUrl, { const response = await fetch(apiUrl, {
method: method, method: method,
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData), body: jsonPayload,
}); });
if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update"); if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update");
@ -341,7 +388,65 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
<Box sx={{ display: 'flex', flexDirection: 'column', ml: 3 }}> <Box sx={{ display: 'flex', flexDirection: 'column', ml: 3 }}>
<FormControlLabel control={<Checkbox checked={recurring} onChange={handleRecurringChange} />} label="Recurring Mailing" /> <FormControlLabel control={<Checkbox checked={recurring} onChange={handleRecurringChange} />} label="Recurring Mailing" />
{recurring && (<> {recurring && (<>
TODO: RECURRING OPTIONS HERE <FormControl fullWidth margin="dense">
<InputLabel id="recurring-type-select-label">Recurring Type</InputLabel>
<Controller
name="recurringTypeCode"
control={control}
render={({ field }) => (
<Select
{...field}
labelId="recurring-type-select-label"
id="recurring-type-select"
label="Recurring Type"
value={field.value || ''}
onChange={(e) => {
field.onChange(e.target.value);
trigger("recurringTypeCode");
}}
error={!!errors.recurringTypeCode}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{recurringTypeOptions.map((option) => (
<MenuItem key={option.code} value={option.code}>
{option.name}
</MenuItem>
))}
</Select>
)}
/>
{errors.recurringTypeCode && (
<span style={{ color: 'red', fontSize: '0.75rem' }}>
{errors.recurringTypeCode.message}
</span>
)}
</FormControl>
<Controller
name="recurringStartDate"
control={control}
render={({ field }) => (
<DateTimePicker
label="Recurring Start Date"
value={field.value ? dayjs(field.value) : null} // Convert to Dayjs
onChange={(newValue: Dayjs | null) => {
const utcString = newValue ? newValue.utc().format("YYYY-MM-DDTHH:mm:ss[Z]") : null;
field.onChange(utcString);
trigger("recurringStartDate");
}}
slotProps={{
textField: {
fullWidth: true,
margin: "dense",
error: !!errors.recurringStartDate,
helperText: errors.recurringStartDate?.message,
},
}}
minDateTime={dayjs()} // Enforce future date in UI
/>
)}
/>
</>)} </>)}
<FormControlLabel control={<Checkbox checked={scheduleForLater} onChange={handleScheduleForLaterChange} />} label="Schedule for Later" /> <FormControlLabel control={<Checkbox checked={scheduleForLater} onChange={handleScheduleForLaterChange} />} label="Schedule for Later" />
{scheduleForLater && ( {scheduleForLater && (
@ -353,8 +458,9 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
label="Schedule Date" label="Schedule Date"
value={field.value ? dayjs(field.value) : null} // Convert to Dayjs value={field.value ? dayjs(field.value) : null} // Convert to Dayjs
onChange={(newValue: Dayjs | null) => { onChange={(newValue: Dayjs | null) => {
field.onChange(newValue ? newValue.toDate() : null); // Convert back to Date const utcString = newValue ? newValue.utc().format("YYYY-MM-DDTHH:mm:ss[Z]") : null;
trigger("scheduleDate"); // Revalidate field.onChange(utcString);
trigger("scheduleDate");
}} }}
slotProps={{ slotProps={{
textField: { textField: {
@ -371,69 +477,6 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
)} )}
</Box> </Box>
</>)} </>)}
{/*<Controller
name="scheduleDate"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Schedule Date"
type="datetime-local"
fullWidth
margin="dense"
InputLabelProps={{ shrink: true }}
value={field.value ? new Date(field.value).toISOString().slice(0, 16) : ''}
onChange={(e) => field.onChange(e.target.value ? new Date(e.target.value) : null)}
error={!!errors.scheduleDate}
helperText={errors.scheduleDate?.message}
/>
)}
/>
<Controller
name="recurringTypeCode"
control={control}
render={({ field }) => (
<Autocomplete
{...field}
options={recurringTypeOptions}
getOptionLabel={(option) => option.name}
value={recurringTypeOptions.find(r => r.code === field.value) || null}
onChange={(_, newValue) => {
field.onChange(newValue ? newValue.code : null);
trigger("recurringTypeCode");
}}
renderInput={(params) => (
<TextField
{...params}
label="Recurring Type"
fullWidth
margin="dense"
error={!!errors.recurringTypeCode}
helperText={errors.recurringTypeCode?.message}
/>
)}
/>
)}
/>
<Controller
name="recurringStartDate"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Recurring Start Date"
type="datetime-local"
fullWidth
margin="dense"
InputLabelProps={{ shrink: true }}
value={field.value ? new Date(field.value).toISOString().slice(0, 16) : ''}
onChange={(e) => field.onChange(e.target.value ? new Date(e.target.value) : null)}
error={!!errors.recurringStartDate}
helperText={errors.recurringStartDate?.message}
/>
)}
/>
*/}
{templateViewerOpen && ( {templateViewerOpen && (
<TemplateViewer <TemplateViewer

View File

@ -0,0 +1,98 @@
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';
interface MailingViewProps {
open: boolean;
mailing: Mailing | null;
onClose: () => void;
}
function MailingView({ open, mailing, onClose }: MailingViewProps) {
const setupData = useSetupData();
const navigate = useNavigate();
if (!mailing) return null;
// Look up related data from setupData
const template = setupData.templates.find(t => t.id === mailing.templateId);
const target = setupData.targets.find(t => t.id === mailing.targetId);
const domain = template ? setupData.emailDomains.find(d => d.id === template.domainId) : null;
// Format status string
const statusString = mailing.scheduleDate
? `Scheduled for ${new Date(mailing.scheduleDate).toLocaleString()}`
: mailing.sentDate
? `Sent on ${new Date(mailing.sentDate).toLocaleString()}`
: 'N/A';
// Helper function to format recurring string (customize based on your recurringTypeCode logic)
const formatRecurringString = (typeCode: string, startDate: string): string => {
const date = new Date(startDate);
switch (typeCode.toUpperCase()) {
case 'DAILY':
return `Daily at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
case 'WEEKLY':
return `Weekly on ${date.toLocaleDateString('en-US', { weekday: 'long' })} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
case 'MONTHLY':
return `Monthly on day ${date.getDate()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
default:
return 'Custom recurring schedule';
}
};
// Format recurring string
const recurringString = mailing.recurringTypeCode && mailing.recurringStartDate
? formatRecurringString(mailing.recurringTypeCode, mailing.recurringStartDate)
: 'One-time';
// Navigation handlers for viewing related entities
const handleViewTarget = () => {
if (target) {
navigate(`/targets/${target.id}`); // Adjust path based on your routing
}
};
const handleViewTemplate = () => {
if (template) {
navigate(`/templates/${template.id}`); // Adjust path based on your routing
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{mailing.name}</DialogTitle>
<DialogContent>
<Typography sx={{ mb: 1 }}>Name: {mailing.name}</Typography>
<Typography sx={{ mb: 1 }}>
Target Name: {target?.name || 'Unknown'}
{target && (
<IconButton size="small" onClick={handleViewTarget} sx={{ ml: 1 }}>
<VisibilityIcon />
</IconButton>
)}
</Typography>
<Typography sx={{ mb: 1 }}>
Template Name: {template?.name || 'Unknown'}
{template && (
<IconButton size="small" onClick={handleViewTemplate} sx={{ ml: 1 }}>
<VisibilityIcon />
</IconButton>
)}
</Typography>
<Typography sx={{ mb: 1 }}>From Name: {template?.fromName || 'N/A'}</Typography>
<Typography sx={{ mb: 1 }}>Domain: {domain?.name || 'N/A'}</Typography> {/* Assuming EmailDomain has a domainName field */}
<Typography sx={{ mb: 1 }}>Subject: {template?.subject || 'N/A'}</Typography>
<Typography sx={{ mb: 1 }}>Status: {statusString}</Typography>
<Typography sx={{ mb: 1 }}>Recurring: {recurringString}</Typography>
</DialogContent>
</Dialog>
);
}
export default MailingView;

View File

@ -2,6 +2,7 @@
import { import {
IconButton, IconButton,
Box, Box,
CircularProgress,
Dialog, Dialog,
DialogTitle, DialogTitle,
DialogContent, DialogContent,
@ -16,20 +17,24 @@ type TargetSampleViewerProps = {
onClose: () => void; onClose: () => void;
}; };
type TargetSampleColumn = {
name: string;
type: string;
};
type TargetSample = { type TargetSample = {
columnNames: string[]; columns: { [key: string]: TargetSampleColumn }[];
rows: { [key: string]: string }[]; rows: { [key: string]: string }[];
}; };
const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps) => { const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps) => {
const [targetSample, setTargetSample] = useState<TargetSample | null>(null); // Store the full sample response const [targetSample, setTargetSample] = useState<TargetSample | null>(null);
const [loading, setLoading] = useState(false); // Optional: Add loading state const [loading, setLoading] = useState(false);
// Fetch sample data when dialog opens
useEffect(() => { useEffect(() => {
if (open) { if (open) {
const fetchSampleData = async () => { const fetchSampleData = async () => {
setLoading(true); setLoading(true);
//await new Promise(resolve => setTimeout(resolve, 3000)); // Simulate loading delay
try { try {
const response = await fetch(`/api/targets/${target.id}/sample`); const response = await fetch(`/api/targets/${target.id}/sample`);
if (!response.ok) throw new Error("Failed to fetch sample data"); if (!response.ok) throw new Error("Failed to fetch sample data");
@ -37,7 +42,7 @@ const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps)
setTargetSample(data); setTargetSample(data);
} catch (error) { } catch (error) {
console.error("Error fetching target sample:", error); console.error("Error fetching target sample:", error);
setTargetSample(null); // Reset on error setTargetSample(null);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -46,25 +51,56 @@ const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps)
} }
}, [open, target.id]); }, [open, target.id]);
// Transform columnNames into GridColDef array const columns: GridColDef[] = Object.keys(targetSample?.columns ?? {}).map((colName) => ({
const columns: GridColDef[] = targetSample?.columnNames?.map((colName) => ({
field: colName, field: colName,
headerName: colName.charAt(0).toUpperCase() + colName.slice(1), // Capitalize header headerName: colName,
flex: 1, minWidth: 150, // Minimum width to prevent excessive truncation
width: 200, // Default width
flex: 1, // Allow columns to grow/shrink proportionally
sortable: true, sortable: true,
})) || []; })) || [];
// Transform TargetRow into DataGrid-compatible rows
const rows = targetSample?.rows?.map((row, index) => { const rows = targetSample?.rows?.map((row, index) => {
const rowData: { [key: string]: string } = { id: index.toString() }; // Use index as unique id const rowData: { [key: string]: string } = { id: index.toString() };
targetSample.columnNames.forEach((key) => { Object.keys(targetSample.columns).forEach((key) => {
rowData[key] = row[key] ?? ""; // Map each key-value pair to the row object rowData[key] = row[key] ?? "";
}); });
return rowData; return rowData;
}) || []; }) || [];
// Calculate dialog width based on number of columns
const columnCount = columns.length;
const defaultColumnWidth = 200; // Match width from columns
const minDialogWidth = 300; // Minimum dialog width for very small datasets
const calculatedWidth = Math.max(
minDialogWidth,
Math.min(columnCount * defaultColumnWidth, 0.98 * window.innerWidth) // Cap at 98% of viewport
);
return ( return (
<Dialog open={open} onClose={onClose} maxWidth="lg" fullWidth> <Dialog
open={open}
onClose={onClose}
maxWidth={false}
sx={{
'& .MuiDialog-paper': {
width: `${calculatedWidth}px`, // Dynamic width
maxWidth: '98vw', // Upper limit
margin: 'auto',
height: loading ? '200px' : 'auto', // Explicitly set height for loading, reset to auto when done
maxHeight: '90vh', // Optional: Prevent dialog from growing too tall
}
}}
>
{loading && (
<DialogContent sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Box display="flex" alignItems="center" justifyContent="center" height="100%" width="100%">
<CircularProgress size={48} color="inherit" />
</Box>
</DialogContent>
)}
{!loading && (
<>
<DialogTitle> <DialogTitle>
<Box display="flex" alignItems="center" justifyContent="space-between"> <Box display="flex" alignItems="center" justifyContent="space-between">
<span>View Target Sample "{target.name}"</span> <span>View Target Sample "{target.name}"</span>
@ -79,21 +115,36 @@ const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps)
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<DataGrid <DataGrid
rows={rows} // Use transformed rows rows={rows}
columns={columns} // Use transformed columns columns={columns}
autoPageSize autoHeight
loading={loading} // Show loading state hideFooter
sx={{ width: "100%", height: "calc(90vh - 120px)" }} hideFooterPagination
sx={{
width: "100%",
maxHeight: "800px",
'& .MuiDataGrid-main': {
overflowX: 'auto',
},
'& .MuiDataGrid-columnHeaderTitle': {
overflow: 'visible',
whiteSpace: 'normal',
lineHeight: '1.2em',
textOverflow: 'ellipsis',
}
}}
initialState={{ initialState={{
pagination: { pagination: {
paginationModel: { paginationModel: {
pageSize: 20, pageSize: 10,
}, },
}, },
}} }}
pageSizeOptions={[10, 20, 50, 100]} pageSizeOptions={[]}
/> />
</DialogContent> </DialogContent>
</>
)}
</Dialog> </Dialog>
); );
}; };

View File

@ -15,6 +15,7 @@ import UnsubscribeUrls from '@/components/pages/UnsubscribeUrls';
import Templates from '@/components/pages/Templates'; 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 AuthCheck from '@/components/auth/AuthCheck'; import AuthCheck from '@/components/auth/AuthCheck';
import { ColorModeContext } from '@/theme/theme'; import { ColorModeContext } from '@/theme/theme';
@ -185,6 +186,16 @@ const App = () => {
</PageWrapper> </PageWrapper>
} }
/> />
<Route
path="/scheduledMailings"
element={
<PageWrapper title="Scheduled Mailings">
<Layout>
<ScheduledMailings />
</Layout>
</PageWrapper>
}
/>
<Route <Route
path="/login" path="/login"
element={ element={

View File

@ -70,6 +70,9 @@ function NewMailings() {
}; };
const handleUpdateRow = (updatedRow: Mailing) => { const handleUpdateRow = (updatedRow: Mailing) => {
if (updatedRow.statusCode.toUpperCase() !== "ED")
removeMailing(updatedRow);
else
updateMailings(updatedRow); updateMailings(updatedRow);
}; };
@ -77,7 +80,12 @@ function NewMailings() {
reloadMailings(); reloadMailings();
}, []); }, []);
const updateMailings = async (updatedMailing: Mailing) => { const removeMailing = (mailing: Mailing) => {
setMailings((prevMailings) => {
return prevMailings.filter(el => el.id !== mailing.id);
});
}
const updateMailings = (updatedMailing: Mailing) => {
setMailings((prev) => { setMailings((prev) => {
const exists = prev.some((e) => e.id === updatedMailing.id); const exists = prev.some((e) => e.id === updatedMailing.id);

View File

@ -0,0 +1,167 @@
import { useState, useRef, useEffect } from 'react';
import VisibilityIcon from '@mui/icons-material/Visibility';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import CancelIcon from '@mui/icons-material/Cancel';
import RefreshIcon from '@mui/icons-material/Refresh';
import { Box, useTheme, CircularProgress, IconButton } from '@mui/material';
import { DataGrid, GridColDef, GridRenderCellParams, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
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
function ScheduleMailings() {
const theme = useTheme();
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const [mailingsLoading, setMailingsLoading] = useState<boolean>(false);
const [mailings, setMailings] = useState<Mailing[]>([]);
const [selectedRow, setSelectedRow] = useState<Mailing | null>(null);
const [viewOpen, setViewOpen] = useState<boolean>(false);
const [editOpen, setEditOpen] = useState<boolean>(false);
const columns: GridColDef<Mailing>[] = [
{
field: "actions",
headerName: "",
sortable: false,
width: 140,
renderCell: (params: GridRenderCellParams<Mailing>) => (
<>
<IconButton color="primary" onClick={(e) => { e.stopPropagation(); handleView(params.row); }}>
<VisibilityIcon />
</IconButton>
<IconButton color="primary" onClick={(e) => { e.stopPropagation(); handleCopy(params.row); }}>
<ContentCopyIcon />
</IconButton>
<IconButton color="secondary" onClick={(e) => { e.stopPropagation(); handleCancel(params.row); }}>
<CancelIcon />
</IconButton>
</>
),
},
{ field: "name", headerName: "Name", flex: 1, minWidth: 160 },
{
field: "scheduleDate",
headerName: "Schedule Date",
flex: 1,
minWidth: 200,
valueGetter: (_: any, row: Mailing) => row.scheduleDate ? new Date(row.scheduleDate).toLocaleString() : 'N/A'
},
{
field: "recurring",
headerName: "Recurring?",
flex: 1,
minWidth: 200,
valueGetter: (_: any, row: Mailing) => row.recurringTypeCode !== "" || ""
},
];
const reloadMailings = async () => {
setMailingsLoading(true);
const mailingsResponse = await fetch("/api/mailings/status/sc"); // Adjust endpoint as needed
const mailingsData = await mailingsResponse.json();
if (mailingsData) {
setMailings(mailingsData);
setMailingsLoading(false);
} else {
console.error("Failed to fetch scheduled mailings");
setMailingsLoading(false);
}
};
const handleView = (row: Mailing) => {
setSelectedRow(row);
setViewOpen(true);
};
const handleCopy = (row: Mailing) => {
const newMailing = { ...row, id: 0 }; // Copy all fields except ID
setSelectedRow(newMailing);
setEditOpen(true);
};
const handleCancel = async (row: Mailing) => {
try {
const response = await fetch(`/api/mailings/${row.id}/cancel`, { method: 'POST' });
if (response.ok) {
setMailings((prev) => prev.filter(m => m.id !== row.id));
} else {
console.error("Failed to cancel mailing");
}
} catch (error) {
console.error("Error cancelling mailing:", error);
}
};
const handleUpdateRow = (updatedRow: Mailing) => {
setMailings((prev) => [...prev, updatedRow]); // Assuming new mailing from copy
};
useEffect(() => {
reloadMailings();
}, []);
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 }}>
<DataGrid
rows={mailings}
columns={columns}
autoPageSize
sx={{ minWidth: "600px" }}
slots={{
toolbar: () => (
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
<IconButton size="small" color="primary" onClick={() => reloadMailings()} sx={{ marginLeft: 1 }}>
{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={selectedRow}
onClose={() => setViewOpen(false)}
/>
)}
{editOpen && (
<MailingEdit
open={editOpen}
mailing={selectedRow}
onClose={(reason) => { if (reason !== 'backdropClick') setEditOpen(false) }}
onSave={handleUpdateRow}
/>
)}
</Box>
);
}
export default ScheduleMailings;