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:
parent
525a9b808c
commit
f4ac033c70
@ -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");
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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;"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;"
|
||||||
}
|
}
|
||||||
|
|||||||
30
Surge365.MassEmailReact.Application/DTOs/TargetSample.cs
Normal file
30
Surge365.MassEmailReact.Application/DTOs/TargetSample.cs
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>>();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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={
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
Loading…
x
Reference in New Issue
Block a user