diff --git a/Surge365.MassEmailReact.API/Controllers/MailingsController.cs b/Surge365.MassEmailReact.API/Controllers/MailingsController.cs index fefc75f..5c85076 100644 --- a/Surge365.MassEmailReact.API/Controllers/MailingsController.cs +++ b/Surge365.MassEmailReact.API/Controllers/MailingsController.cs @@ -66,11 +66,17 @@ namespace Surge365.MassEmailReact.API.Controllers if (id != mailingUpdateDto.Id) 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); if (existingMailing == null) return NotFound($"Mailing with Id {id} not found"); - var success = await _mailingService.UpdateAsync(mailingUpdateDto); + var success = await _mailingService.UpdateAsync(mailingUpdateDto); if (!success) return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update mailing."); diff --git a/Surge365.MassEmailReact.API/Controllers/TargetsController.cs b/Surge365.MassEmailReact.API/Controllers/TargetsController.cs index 0d2e5fd..961000d 100644 --- a/Surge365.MassEmailReact.API/Controllers/TargetsController.cs +++ b/Surge365.MassEmailReact.API/Controllers/TargetsController.cs @@ -69,7 +69,7 @@ namespace Surge365.MassEmailReact.API.Controllers { try { - var sample = await _targetService.GetSampleData(targetId); + var sample = await _targetService.TestTargetAsync(targetId); return Ok(sample); } catch (Exception ex) diff --git a/Surge365.MassEmailReact.API/appsettings.Development.json b/Surge365.MassEmailReact.API/appsettings.Development.json index 0c208ae..9a63e2f 100644 --- a/Surge365.MassEmailReact.API/appsettings.Development.json +++ b/Surge365.MassEmailReact.API/appsettings.Development.json @@ -4,5 +4,6 @@ "Default": "Information", "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;" } diff --git a/Surge365.MassEmailReact.API/appsettings.json b/Surge365.MassEmailReact.API/appsettings.json index 2936fe7..ba5de67 100644 --- a/Surge365.MassEmailReact.API/appsettings.json +++ b/Surge365.MassEmailReact.API/appsettings.json @@ -15,5 +15,7 @@ "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 "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;" } diff --git a/Surge365.MassEmailReact.Application/DTOs/TargetSample.cs b/Surge365.MassEmailReact.Application/DTOs/TargetSample.cs new file mode 100644 index 0000000..78a2284 --- /dev/null +++ b/Surge365.MassEmailReact.Application/DTOs/TargetSample.cs @@ -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 Columns { get; set; } = new Dictionary(); + public List> Rows { get; set; } = new List>(); + } + 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"; + } +} diff --git a/Surge365.MassEmailReact.Application/Interfaces/ITargetRepository.cs b/Surge365.MassEmailReact.Application/Interfaces/ITargetRepository.cs index df6a885..8a10d37 100644 --- a/Surge365.MassEmailReact.Application/Interfaces/ITargetRepository.cs +++ b/Surge365.MassEmailReact.Application/Interfaces/ITargetRepository.cs @@ -1,4 +1,5 @@ -using Surge365.MassEmailReact.Domain.Entities; +using Surge365.MassEmailReact.Application.DTOs; +using Surge365.MassEmailReact.Domain.Entities; using System; using System.Collections.Generic; using System.Linq; @@ -13,6 +14,13 @@ namespace Surge365.MassEmailReact.Application.Interfaces Task> GetAllAsync(bool activeOnly = true); Task CreateAsync(Target target); Task UpdateAsync(Target target); - Task GetSampleData(int targetId); + Task TestTargetAsync( + string serverName, + short port, + string username, + string password, + string databaseName, + string viewName, + string filter); } } diff --git a/Surge365.MassEmailReact.Application/Interfaces/ITargetService.cs b/Surge365.MassEmailReact.Application/Interfaces/ITargetService.cs index dd79023..8783524 100644 --- a/Surge365.MassEmailReact.Application/Interfaces/ITargetService.cs +++ b/Surge365.MassEmailReact.Application/Interfaces/ITargetService.cs @@ -1,4 +1,5 @@ -using Surge365.MassEmailReact.Domain.Entities; +using Surge365.MassEmailReact.Application.DTOs; +using Surge365.MassEmailReact.Domain.Entities; namespace Surge365.MassEmailReact.Application.Interfaces { @@ -8,6 +9,6 @@ namespace Surge365.MassEmailReact.Application.Interfaces Task> GetAllAsync(bool activeOnly = true); Task CreateAsync(TargetUpdateDto targetDto); Task UpdateAsync(TargetUpdateDto targetDto); - Task GetSampleData(int targetId); + Task TestTargetAsync(int targetId); } } diff --git a/Surge365.MassEmailReact.Domain/Entities/TargetSample.cs b/Surge365.MassEmailReact.Domain/Entities/TargetSample.cs deleted file mode 100644 index 6ab3a8d..0000000 --- a/Surge365.MassEmailReact.Domain/Entities/TargetSample.cs +++ /dev/null @@ -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 ColumnNames { get; set; } = new List(); - public List> Rows { get; set; } = new List>(); - } - -} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Infrastructure/Repositories/TargetRepository.cs b/Surge365.MassEmailReact.Infrastructure/Repositories/TargetRepository.cs index 9345d0b..406bf60 100644 --- a/Surge365.MassEmailReact.Infrastructure/Repositories/TargetRepository.cs +++ b/Surge365.MassEmailReact.Infrastructure/Repositories/TargetRepository.cs @@ -2,6 +2,7 @@ using Dapper.FluentMap; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Configuration; +using Surge365.MassEmailReact.Application.DTOs; using Surge365.MassEmailReact.Application.Interfaces; using Surge365.MassEmailReact.Domain.Entities; using Surge365.MassEmailReact.Domain.Enums; @@ -11,6 +12,7 @@ using System.Collections.Generic; using System.Data; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; namespace Surge365.MassEmailReact.Infrastructure.Repositories @@ -81,7 +83,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories // Retrieve the output parameter value bool success = parameters.Get("@success"); - if(success) + if (success) return parameters.Get("@target_key"); return null; @@ -112,48 +114,116 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories return success; } - - //public void Add(Target target) - //{ - // Targets.Add(target); - //} - - public async Task GetSampleData(int targetId) + public async Task TestTargetAsync( + string serverName, + short port, + string username, + string password, + string databaseName, + string viewName, + string filter) { - // Placeholder hardcoded sample data for testing - var sample = new TargetSample + if (string.IsNullOrWhiteSpace(serverName) + || port <= 0 + || string.IsNullOrWhiteSpace(username) + || string.IsNullOrWhiteSpace(password) + || string.IsNullOrWhiteSpace(databaseName) + || string.IsNullOrWhiteSpace(viewName)) + return null; + + // Clean up filter + if (!string.IsNullOrWhiteSpace(filter) && filter.Trim().ToUpper().StartsWith("WHERE ")) + filter = filter.Substring(6).Trim(); + + // 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(); + if (!columnEntities.Any()) + return null; + + // Read sample data + var sampleRows = (await multi.ReadAsync()).ToList(); + + // Read row count + var rowCountResult = await multi.ReadSingleAsync(); + 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) { - ColumnNames = new List { "id", "name", "email", "age" }, - Rows = new List> + string name = col.name; + string dataType = col.data_type; + + string typeCode = TargetColumnType.General; + if (!emailFound && name.ToUpper().Contains("EMAIL")) { - new Dictionary - { - { "id", "1" }, - { "name", "John Doe" }, - { "email", "john.doe@example.com" }, - { "age", "30" } - }, - new Dictionary - { - { "id", "2" }, - { "name", "Jane Smith" }, - { "email", "jane.smith@example.com" }, - { "age", "25" } - }, - new Dictionary - { - { "id", "3" }, - { "name", "Bob Johnson" }, - { "email", "bob.johnson@example.com" }, - { "age", "45" } - } + typeCode = TargetColumnType.EmailAddress; + emailFound = true; + } + 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; } - }; - // Simulate async operation (e.g., DB call) for testing - await Task.Delay(100); // Optional: Mimics network latency - return sample; + targetSample.Columns[name] = new TargetSampleColumn + { + Name = name, + Type = typeCode + }; + } + + // Convert sample rows to dictionary format + foreach (var row in sampleRows) + { + var dict = ((IDictionary)row) + .ToDictionary(k => k.Key, v => v.Value?.ToString() ?? ""); + targetSample.Rows.Add(dict); + } + + return targetSample; } - } -} +} \ No newline at end of file diff --git a/Surge365.MassEmailReact.Infrastructure/Services/TargetService.cs b/Surge365.MassEmailReact.Infrastructure/Services/TargetService.cs index 7031f98..9ea9e22 100644 --- a/Surge365.MassEmailReact.Infrastructure/Services/TargetService.cs +++ b/Surge365.MassEmailReact.Infrastructure/Services/TargetService.cs @@ -1,4 +1,5 @@ -using Surge365.MassEmailReact.Application.Interfaces; +using Dapper; +using Surge365.MassEmailReact.Application.Interfaces; using System; using System.Collections.Generic; using System.Linq; @@ -10,6 +11,9 @@ using Microsoft.IdentityModel.Tokens; using Microsoft.Extensions.Configuration; using Surge365.MassEmailReact.Domain.Entities; using System.Security.Cryptography; +using Surge365.MassEmailReact.Application.DTOs; +using System.Text.RegularExpressions; +using Microsoft.Data.SqlClient; namespace Surge365.MassEmailReact.Infrastructure.Services @@ -17,11 +21,13 @@ namespace Surge365.MassEmailReact.Infrastructure.Services public class TargetService : ITargetService { private readonly ITargetRepository _targetRepository; + private readonly IServerRepository _serverRepository; private readonly IConfiguration _config; - public TargetService(ITargetRepository targetRepository, IConfiguration config) + public TargetService(ITargetRepository targetRepository, IServerRepository serverRepository, IConfiguration config) { _targetRepository = targetRepository; + _serverRepository = serverRepository; _config = config; } @@ -69,9 +75,15 @@ namespace Surge365.MassEmailReact.Infrastructure.Services return await _targetRepository.UpdateAsync(target); } - public async Task GetSampleData(int targetId) + public async Task 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); } } } diff --git a/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx b/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx index 1f28de5..c8ce5b6 100644 --- a/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx +++ b/Surge365.MassEmailReact.Web/src/components/modals/MailingEdit.tsx @@ -8,8 +8,12 @@ import { DialogActions, Button, Checkbox, + FormControl, FormControlLabel, - Box + Box, + MenuItem, + Select, + InputLabel } from "@mui/material"; import Template from "@/types/template"; 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 { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; 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 = { open: boolean; @@ -69,15 +76,24 @@ const schema = yup.object().shape({ return setupData.targets.some(t => t.id === value); }), statusCode: yup.string().default("ED"), - scheduleDate: yup.date() + scheduleDate: yup.string() .nullable() - .when("$scheduleForLater", (scheduleForLater, schema) => { // Use context variable - return scheduleForLater + .when("$scheduleForLater", (scheduleForLater, schema) => { + const isScheduledForLater = scheduleForLater[0] ?? false; + return isScheduledForLater ? schema .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(); }), + //scheduleDate: yup.date().nullable(), // .when("statusCode", { @@ -90,8 +106,30 @@ const schema = yup.object().shape({ recurringTypeCode: yup .string() .nullable() - .oneOf(recurringTypeOptions.map((r) => r.code), "Invalid recurring type"), - recurringStartDate: yup.date().nullable() + .when("$recurring", (recurring, schema) => { // Use context variable + 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", { //is: (value: string) => value !== "" && value !== null, // String comparison for "None" @@ -138,13 +176,21 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => { ...(mailing || defaultMailing), }, resolver: yupResolver(schema) as Resolver, - context: { setupData }, + context: { setupData, scheduleForLater, recurring }, }); const [loading, setLoading] = useState(false); useEffect(() => { if (open) { + if (mailing) { + mailing.scheduleDate = null; + mailing.recurringTypeCode = null; + mailing.recurringStartDate = null; + } + setApproved(false); + setRecurring(false); + setScheduleForLater(false); reset(mailing || defaultMailing, { keepDefaultValues: true }); if (setupData.testEmailLists.length > 0) { setTestEmailListId(setupData.testEmailLists[0].id); @@ -190,10 +236,11 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => { formData.recurringStartDate = null; } try { + const jsonPayload = JSON.stringify(formData); const response = await fetch(apiUrl, { method: method, headers: { "Content-Type": "application/json" }, - body: JSON.stringify(formData), + body: jsonPayload, }); if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update"); @@ -341,7 +388,65 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => { } label="Recurring Mailing" /> {recurring && (<> - TODO: RECURRING OPTIONS HERE + + Recurring Type + ( + + )} + /> + {errors.recurringTypeCode && ( + + {errors.recurringTypeCode.message} + + )} + + ( + { + 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 + /> + )} + /> )} } label="Schedule for Later" /> {scheduleForLater && ( @@ -353,8 +458,9 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => { label="Schedule Date" value={field.value ? dayjs(field.value) : null} // Convert to Dayjs onChange={(newValue: Dayjs | null) => { - field.onChange(newValue ? newValue.toDate() : null); // Convert back to Date - trigger("scheduleDate"); // Revalidate + const utcString = newValue ? newValue.utc().format("YYYY-MM-DDTHH:mm:ss[Z]") : null; + field.onChange(utcString); + trigger("scheduleDate"); }} slotProps={{ textField: { @@ -371,69 +477,6 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => { )} )} - {/* ( - field.onChange(e.target.value ? new Date(e.target.value) : null)} - error={!!errors.scheduleDate} - helperText={errors.scheduleDate?.message} - /> - )} - /> - ( - option.name} - value={recurringTypeOptions.find(r => r.code === field.value) || null} - onChange={(_, newValue) => { - field.onChange(newValue ? newValue.code : null); - trigger("recurringTypeCode"); - }} - renderInput={(params) => ( - - )} - /> - )} - /> - ( - field.onChange(e.target.value ? new Date(e.target.value) : null)} - error={!!errors.recurringStartDate} - helperText={errors.recurringStartDate?.message} - /> - )} - /> - */} {templateViewerOpen && ( 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 ( + + {mailing.name} + + 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} + + + ); +} + +export default MailingView; \ No newline at end of file diff --git a/Surge365.MassEmailReact.Web/src/components/modals/TargetSampleViewer.tsx b/Surge365.MassEmailReact.Web/src/components/modals/TargetSampleViewer.tsx index d179a5c..d7b2dff 100644 --- a/Surge365.MassEmailReact.Web/src/components/modals/TargetSampleViewer.tsx +++ b/Surge365.MassEmailReact.Web/src/components/modals/TargetSampleViewer.tsx @@ -2,6 +2,7 @@ import { IconButton, Box, + CircularProgress, Dialog, DialogTitle, DialogContent, @@ -16,20 +17,24 @@ type TargetSampleViewerProps = { onClose: () => void; }; +type TargetSampleColumn = { + name: string; + type: string; +}; type TargetSample = { - columnNames: string[]; + columns: { [key: string]: TargetSampleColumn }[]; rows: { [key: string]: string }[]; }; const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps) => { - const [targetSample, setTargetSample] = useState(null); // Store the full sample response - const [loading, setLoading] = useState(false); // Optional: Add loading state + const [targetSample, setTargetSample] = useState(null); + const [loading, setLoading] = useState(false); - // Fetch sample data when dialog opens useEffect(() => { if (open) { const fetchSampleData = async () => { setLoading(true); + //await new Promise(resolve => setTimeout(resolve, 3000)); // Simulate loading delay try { const response = await fetch(`/api/targets/${target.id}/sample`); if (!response.ok) throw new Error("Failed to fetch sample data"); @@ -37,7 +42,7 @@ const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps) setTargetSample(data); } catch (error) { console.error("Error fetching target sample:", error); - setTargetSample(null); // Reset on error + setTargetSample(null); } finally { setLoading(false); } @@ -46,54 +51,100 @@ const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps) } }, [open, target.id]); - // Transform columnNames into GridColDef array - const columns: GridColDef[] = targetSample?.columnNames?.map((colName) => ({ + const columns: GridColDef[] = Object.keys(targetSample?.columns ?? {}).map((colName) => ({ field: colName, - headerName: colName.charAt(0).toUpperCase() + colName.slice(1), // Capitalize header - flex: 1, + headerName: colName, + minWidth: 150, // Minimum width to prevent excessive truncation + width: 200, // Default width + flex: 1, // Allow columns to grow/shrink proportionally sortable: true, })) || []; - // Transform TargetRow into DataGrid-compatible rows const rows = targetSample?.rows?.map((row, index) => { - const rowData: { [key: string]: string } = { id: index.toString() }; // Use index as unique id - targetSample.columnNames.forEach((key) => { - rowData[key] = row[key] ?? ""; // Map each key-value pair to the row object + const rowData: { [key: string]: string } = { id: index.toString() }; + Object.keys(targetSample.columns).forEach((key) => { + rowData[key] = row[key] ?? ""; }); 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 ( - - - - View Target Sample "{target.name}" - - - - - - - - + + {loading && ( + + + + + + )} + {!loading && ( + <> + + + View Target Sample "{target.name}" + + + + + + + + + + )} ); }; diff --git a/Surge365.MassEmailReact.Web/src/components/pages/App.tsx b/Surge365.MassEmailReact.Web/src/components/pages/App.tsx index 83392bb..44d51f2 100644 --- a/Surge365.MassEmailReact.Web/src/components/pages/App.tsx +++ b/Surge365.MassEmailReact.Web/src/components/pages/App.tsx @@ -15,6 +15,7 @@ import UnsubscribeUrls from '@/components/pages/UnsubscribeUrls'; 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 AuthCheck from '@/components/auth/AuthCheck'; import { ColorModeContext } from '@/theme/theme'; @@ -185,6 +186,16 @@ const App = () => { } /> + + + + + + } + /> { - updateMailings(updatedRow); + if (updatedRow.statusCode.toUpperCase() !== "ED") + removeMailing(updatedRow); + else + updateMailings(updatedRow); }; useEffect(() => { 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) => { const exists = prev.some((e) => e.id === updatedMailing.id); diff --git a/Surge365.MassEmailReact.Web/src/components/pages/ScheduledMailings.tsx b/Surge365.MassEmailReact.Web/src/components/pages/ScheduledMailings.tsx new file mode 100644 index 0000000..45e7355 --- /dev/null +++ b/Surge365.MassEmailReact.Web/src/components/pages/ScheduledMailings.tsx @@ -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(null); + const [mailingsLoading, setMailingsLoading] = useState(false); + const [mailings, setMailings] = useState([]); + const [selectedRow, setSelectedRow] = useState(null); + const [viewOpen, setViewOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + + const columns: GridColDef[] = [ + { + field: "actions", + headerName: "", + sortable: false, + width: 140, + renderCell: (params: GridRenderCellParams) => ( + <> + { e.stopPropagation(); handleView(params.row); }}> + + + { e.stopPropagation(); handleCopy(params.row); }}> + + + { e.stopPropagation(); handleCancel(params.row); }}> + + + + ), + }, + { 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 ( + + + ( + + reloadMailings()} sx={{ marginLeft: 1 }}> + {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 ScheduleMailings; \ No newline at end of file