Update mailing and target management features

- Added new methods for creating mailings and testing targets.
- Updated configuration files for JWT settings and connection strings.
- Introduced new DTOs for target column updates and test targets.
- Enhanced MailingStatistic with a new SentDate property.
- Created new components for handling cancelled mailings and target samples.
- Refactored authentication in Login.tsx to use fetch API.
- Updated various services and repositories to support new functionalities.
This commit is contained in:
David Headrick 2025-04-07 12:13:44 -05:00
parent 26abe9e028
commit f5b1fe6397
48 changed files with 1624 additions and 1242 deletions

View File

@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "9.0.3",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}

View File

@ -67,7 +67,6 @@ namespace Surge365.MassEmailReact.API.Controllers
return mailing is not null ? Ok(mailing) : NotFound($"Mailing statistics with id '{id}' not found.");
}
[HttpPost]
public async Task<IActionResult> CreateMailing([FromBody] MailingUpdateDto mailingUpdateDto)
{

View File

@ -78,5 +78,19 @@ namespace Surge365.MassEmailReact.API.Controllers
return StatusCode(500, new { message = "Error fetching sample data", error = ex.Message });
}
}
[HttpPost("test")]
public async Task<IActionResult> TestTargeByID(TestTargetDto testTarget)
{
try
{
var sample = await _targetService.TestTargetAsync(testTarget);
return Ok(sample);
}
catch (Exception ex)
{
// Log the exception (e.g., using ILogger if injected)
return StatusCode(500, new { message = "Error fetching sample data", error = ex.Message });
}
}
}
}

View File

@ -5,5 +5,22 @@
"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;"
"AllowedHosts": "*",
"Jwt": {
"Secret": "Z9R5aFml+eRMeb7tyf8N9wCq3tZpS/EM6nGqOxlXPtOw4cJ3zS1AByczrIlD5F9d"
},
"AppCode": "MassEmailReactApi",
"AuthAppCode": "MassEmailWeb",
"EnvironmentCode": "UAT",
"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;TrustServerCertificate=True;",
"DefaultUnsubscribeUrl": "https://uat.emailopentracking.surge365.com/unsubscribe.htm",
"SendGrid_TestMode": false,
"RegularExpression_Email": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
"SendGrid_Url": "smtp.sendgrid.net",
"SendGrid_Port": "587"
}

View File

@ -0,0 +1,18 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Jwt": {
"Secret": "1bXgXk7v/W9XksGoNiqWvM7+9/BERZonShxqoCVvdi8Ew47M1VFzJGA9sPMgkmn/HRmuZ83iytNsHXI6GkAb8g=="
},
"EnvironmentCode": "UAT",
"DefaultUnsubscribeUrl": "https://uat.emailopentracking.surge365.com/unsubscribe.htm",
"SendGrid_TestMode": true,
"RegularExpression_Email": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
"SendGrid_Url": "smtp.sendgrid.net",
"SendGrid_Port": "587"
}

View File

@ -7,18 +7,18 @@
},
"AllowedHosts": "*",
"Jwt": {
"Secret": "Z9R5aFml+eRMeb7tyf8N9wCq3tZpS/EM6nGqOxlXPtOw4cJ3zS1AByczrIlD5F9d"
"Secret": "4r1AJ0riBpEhgaTxhTWMIPs5rv9AlVZjTqrGUoU3DUz4i/Dx9ZfGciIubNODQRO0z3qJZq6VqxGXdsFRJgSb6Q=="
},
"AppCode": "MassEmailReactApi",
"AuthAppCode": "MassEmailWeb",
"EnvironmentCode": "UAT",
"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
"Marketing.ConnectionString": "data source=localhost;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=localhost;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;",
"DefaultUnsubscribeUrl": "http://emailopentracking.surge365.com/unsubscribe.htm",
"ConnectionStringTemplate": "data source=##server_name##,##port##;initial catalog=##database_name##;User ID=##username##;Password=##password##;persist security info=False;packet size=4096;TrustServerCertificate=True;",
"DefaultUnsubscribeUrl": "https://emailopentracking.surge365.com/unsubscribe.htm",
"SendGrid_TestMode": false,
"RegularExpression_Email": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
"SendGrid_Url": "smtp.sendgrid.net",

View File

@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class TargetColumnUpdateDto
{
public int? Id { get; private set; }
public string TypeCode { get; set; } = "";
public string DataTypeCode { get; set; } = "";
public string Name { get; set; } = "";
public bool WriteBack { get; set; }
public bool IsEmailAddress { get; set; }
}
}

View File

@ -13,8 +13,9 @@ namespace Surge365.MassEmailReact.Application.DTOs
}
public class TargetSampleColumn
{
public string Name { get; set; }
public string Type { get; set; }
public string Name { get; set; } = "";
public string DataType { get; set; } = "";
public string Type { get; set; } = "";
}
public class TargetColumnType

View File

@ -16,5 +16,6 @@ namespace Surge365.MassEmailReact.Domain.Entities
public string FilterQuery { get; set; } = "";
public bool AllowWriteBack { get; set; } = false;
public bool IsActive { get; set; } = true;
public List<TargetColumnUpdateDto> Columns { get; set; } = new List<TargetColumnUpdateDto>();
}
}

View File

@ -0,0 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class TestTargetDto
{
public int? Id { get; set; }
public int ServerId { get; set; }
public string DatabaseName { get; set; } = "";
public string ViewName { get; set; } = "";
public string FilterQuery { get; set; } = "";
}
}

View File

@ -8,8 +8,8 @@ namespace Surge365.MassEmailReact.Application.Interfaces
{
Task<Mailing?> GetByIdAsync(int id);
Task<List<Mailing>> GetAllAsync(bool activeOnly = true);
Task<List<Mailing>> GetByStatusAsync(string code, string? startDate, string? endDate);
Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code, string? startDate, string? endDate);
Task<List<Mailing>> GetByStatusAsync(string codes, string? startDate, string? endDate);
Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string codes, string? startDate, string? endDate);
Task<MailingStatistic?> GetStatisticByIdAsync(int id);
Task<bool> NameIsAvailableAsync(int? id, string name);
Task<string> GetNextAvailableNameAsync(int? id, string name);

View File

@ -9,8 +9,8 @@ namespace Surge365.MassEmailReact.Application.Interfaces
Task<Mailing?> GetByIdAsync(int id);
Task<List<Mailing>> GetAllAsync(bool activeOnly = true);
Task<List<Mailing>> GetByStatusAsync(string code, string? startDate, string? endDate);
Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code, string? startDate, string? endDate);
Task<List<Mailing>> GetByStatusAsync(string codes, string? startDate, string? endDate);
Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string codes, string? startDate, string? endDate);
Task<MailingStatistic?> GetStatisticByIdAsync(int id);
Task<bool> NameIsAvailableAsync(int? id, string name);
Task<string> GetNextAvailableNameAsync(int? id, string name);

View File

@ -10,5 +10,6 @@ namespace Surge365.MassEmailReact.Application.Interfaces
Task<int?> CreateAsync(TargetUpdateDto targetDto);
Task<bool> UpdateAsync(TargetUpdateDto targetDto);
Task<TargetSample?> TestTargetAsync(int targetId);
Task<TargetSample?> TestTargetAsync(TestTargetDto testTarget);
}
}

View File

@ -10,6 +10,7 @@ namespace Surge365.MassEmailReact.Domain.Entities
{
public int? MailingId { get; private set; }
public string MailingName { get; set; } = "";
public string SentDate { get; set; } = "";
public int SpamCount { get; set; }
public int UniqueClickCount { get; set; }
public int ClickCount { get; set; }
@ -26,13 +27,14 @@ namespace Surge365.MassEmailReact.Domain.Entities
public MailingStatistic() { }
private MailingStatistic(int? mailingId, string mailingName, int spamCount, int uniqueClickCount, int clickCount,
private MailingStatistic(int? mailingId, string mailingName, string sentDate, int spamCount, int uniqueClickCount, int clickCount,
int uniqueOpenCount, int openCount, int invalidCount, int blockedCount,
int failedCount, int deliveredCount, int sendCount, int emailCount,
int bounceCount, int unsubscribeCount)
{
MailingId = mailingId;
MailingName = mailingName;
SentDate = sentDate;
SpamCount = spamCount;
UniqueClickCount = uniqueClickCount;
ClickCount = clickCount;
@ -48,12 +50,12 @@ namespace Surge365.MassEmailReact.Domain.Entities
UnsubscribeCount = unsubscribeCount;
}
public static MailingStatistic Create(int? mailingId, string mailingName, int spamCount, int uniqueClickCount,
public static MailingStatistic Create(int? mailingId, string mailingName, string sentDate, int spamCount, int uniqueClickCount,
int clickCount, int uniqueOpenCount, int openCount, int invalidCount,
int blockedCount, int failedCount, int deliveredCount, int sendCount,
int emailCount, int bounceCount, int unsubscribeCount)
{
return new MailingStatistic(mailingId, mailingName, spamCount, uniqueClickCount, clickCount, uniqueOpenCount,
return new MailingStatistic(mailingId, mailingName, sentDate, spamCount, uniqueClickCount, clickCount, uniqueOpenCount,
openCount, invalidCount, blockedCount, failedCount, deliveredCount,
sendCount, emailCount, bounceCount, unsubscribeCount);
}

View File

@ -16,6 +16,7 @@ namespace Surge365.MassEmailReact.Domain.Entities
public string FilterQuery { get; set; } = "";
public bool AllowWriteBack { get; set; }
public bool IsActive { get; set; }
public List<TargetColumn> Columns { get; set; } = new List<TargetColumn>();
public Target() { }
private Target(int id, int serverId, string name, string databaseName, string viewName, string filterQuery, bool allowWriteBack, bool isActive)

View File

@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class TargetColumn
{
public int Id { get; private set; }
public int TargetId { get; set; }
public string TypeCode { get; set; } = "";
public string DataTypeCode { get; set; } = "";
public string Name { get; set; } = "";
public bool WriteBack { get; set; }
public bool IsEmailAddress { get; set; }
public TargetColumn() { }
private TargetColumn(int id, int targetId, string typeCode, string dataTypeCode, string name,
bool writeBack, bool isEmailAddress)
{
Id = id;
TargetId = targetId;
TypeCode = typeCode;
DataTypeCode = dataTypeCode;
Name = name;
WriteBack = writeBack;
IsEmailAddress = isEmailAddress;
}
public static TargetColumn Create(int id, int targetId, string typeCode, string dataTypeCode,
string name, bool writeBack, bool isEmailAddress)
{
return new TargetColumn(id, targetId, typeCode, dataTypeCode, name, writeBack, isEmailAddress);
}
}
}

View File

@ -18,6 +18,7 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
FluentMapper.Initialize(config =>
{
config.AddMap(new TargetMap());
config.AddMap(new TargetColumnMap());
config.AddMap(new ServerMap());
config.AddMap(new TestEmailListMap());
config.AddMap(new BouncedEmailMap());

View File

@ -9,6 +9,7 @@ namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{
Map(m => m.MailingId).ToColumn("blast_key");
Map(m => m.MailingName).ToColumn("blast_name");
Map(m => m.SentDate).ToColumn("sent_date");
Map(m => m.SpamCount).ToColumn("spam_count");
Map(m => m.UniqueClickCount).ToColumn("unique_click_count");
Map(m => m.ClickCount).ToColumn("click_count");

View File

@ -0,0 +1,24 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Dapper.FluentMap.Mapping;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Infrastructure.DapperMaps
{
public class TargetColumnMap : EntityMap<TargetColumn>
{
public TargetColumnMap()
{
Map(tc => tc.Id).ToColumn("target_column_key");
Map(tc => tc.TargetId).ToColumn("target_key");
Map(tc => tc.TypeCode).ToColumn("target_column_type_code");
Map(tc => tc.DataTypeCode).ToColumn("data_type_code");
Map(tc => tc.Name).ToColumn("name");
Map(tc => tc.WriteBack).ToColumn("write_back");
Map(tc => tc.IsEmailAddress).ToColumn("is_email_address");
}
}
}

View File

@ -44,19 +44,19 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<Mailing>("mem_get_blast_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList();
}
public async Task<List<Mailing>> GetByStatusAsync(string code, string? startDate, string? endDate)
public async Task<List<Mailing>> GetByStatusAsync(string codes, string? startDate, string? endDate)
{
ArgumentNullException.ThrowIfNull(ConnectionString);
using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<Mailing>("mem_get_blast_by_status", new { blast_status_code = code, start_date = startDate, end_date = endDate }, commandType: CommandType.StoredProcedure)).ToList();
return (await conn.QueryAsync<Mailing>("mem_get_blast_by_status", new { blast_status_codes = codes, start_date = startDate, end_date = endDate }, commandType: CommandType.StoredProcedure)).ToList();
}
public async Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code, string? startDate, string? endDate)
public async Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string codes, string? startDate, string? endDate)
{
ArgumentNullException.ThrowIfNull(ConnectionString);
using SqlConnection conn = new SqlConnection(ConnectionString);
return (await conn.QueryAsync<MailingStatistic>("mem_get_blast_statistic_by_status", new { blast_status_code = code, start_date = startDate, end_date = endDate }, commandType: CommandType.StoredProcedure)).ToList();
return (await conn.QueryAsync<MailingStatistic>("mem_get_blast_statistic_by_status", new { blast_status_codes = codes, start_date = startDate, end_date = endDate }, commandType: CommandType.StoredProcedure)).ToList();
}
public async Task<MailingStatistic?> GetStatisticByIdAsync(int id)
{
@ -75,7 +75,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
parameters.Add("@blast_name", name, DbType.String);
parameters.Add("@available", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_is_blast_name_available2", parameters, commandType: CommandType.StoredProcedure);
await conn.ExecuteAsync("mem_is_blast_name_available", parameters, commandType: CommandType.StoredProcedure);
return parameters.Get<bool>("@available");
}

View File

@ -12,6 +12,7 @@ using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
@ -44,8 +45,22 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
await conn.OpenAsync();
return (await conn.QueryAsync<Target>("mem_get_target_by_id", new { target_key = targetKey }, commandType: CommandType.StoredProcedure)).FirstOrDefault();
using var multi = await conn.QueryMultipleAsync(
"mem_get_target_by_id",
new { target_key = targetKey },
commandType: CommandType.StoredProcedure);
// Read the first result set (Target)
var target = await multi.ReadSingleOrDefaultAsync<Target>();
if (target == null) return null;
// Read the second result set (TargetColumns)
var columns = await multi.ReadAsync<TargetColumn>();
target.Columns = columns.ToList();
return target;
}
public async Task<List<Target>> GetAllAsync(bool activeOnly = true)
{
@ -53,8 +68,31 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
await conn.OpenAsync();
return (await conn.QueryAsync<Target>("mem_get_target_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList();
using var multi = await conn.QueryMultipleAsync(
"mem_get_target_all",
new { active_only = activeOnly },
commandType: CommandType.StoredProcedure);
// Read the first result set (Targets)
var targets = (await multi.ReadAsync<Target>()).ToList();
if (!targets.Any()) return targets;
// Read the second result set (TargetColumns)
var columns = (await multi.ReadAsync<TargetColumn>()).ToList();
// Map columns to their respective targets
var targetDictionary = targets.ToDictionary(t => t.Id!.Value);
foreach (var column in columns)
{
if (targetDictionary.TryGetValue(column.TargetId, out var target))
{
target.Columns.Add(column);
}
}
return targets;
}
public async Task<int?> CreateAsync(Target target)
@ -74,6 +112,8 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
parameters.Add("@filter_query", target.FilterQuery, DbType.String);
parameters.Add("@allow_write_back", target.AllowWriteBack, DbType.Boolean);
parameters.Add("@is_active", target.IsActive, DbType.Boolean);
if(target.Columns != null)
parameters.Add("@column_json", JsonSerializer.Serialize(target.Columns), DbType.String);
// Output parameter
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
@ -103,6 +143,8 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
parameters.Add("@filter_query", target.FilterQuery, DbType.String);
parameters.Add("@allow_write_back", target.AllowWriteBack, DbType.Boolean);
parameters.Add("@is_active", target.IsActive, DbType.Boolean);
if (target.Columns != null)
parameters.Add("@column_json", JsonSerializer.Serialize(target.Columns), DbType.String);
// Output parameter
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
@ -211,6 +253,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
targetSample.Columns[name] = new TargetSampleColumn
{
Name = name,
DataType = dataType,
Type = typeCode
};
}

View File

@ -73,13 +73,13 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
{
return await _mailingRepository.GetAllAsync(activeOnly);
}
public async Task<List<Mailing>> GetByStatusAsync(string statusCode, string? startDate, string? endDate)
public async Task<List<Mailing>> GetByStatusAsync(string codes, string? startDate, string? endDate)
{
return await _mailingRepository.GetByStatusAsync(statusCode, startDate, endDate);
return await _mailingRepository.GetByStatusAsync(codes, startDate, endDate);
}
public async Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string code, string? startDate, string? endDate)
public async Task<List<MailingStatistic>> GetStatisticsByStatusAsync(string codes, string? startDate, string? endDate)
{
return await _mailingRepository.GetStatisticsByStatusAsync(code, startDate, endDate);
return await _mailingRepository.GetStatisticsByStatusAsync(codes, startDate, endDate);
}
public async Task<MailingStatistic?> GetStatisticByIdAsync(int id)
{

View File

@ -55,6 +55,12 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
target.AllowWriteBack = targetDto.AllowWriteBack;
target.IsActive = targetDto.IsActive;
target.Columns = new List<TargetColumn>();
foreach (var columnDto in targetDto.Columns)
{
target.Columns.Add(TargetColumn.Create(columnDto.Id ?? 0, target.Id ?? 0, columnDto.TypeCode, columnDto.DataTypeCode, columnDto.Name, columnDto.WriteBack, columnDto.IsEmailAddress));
}
return await _targetRepository.CreateAsync(target);
}
public async Task<bool> UpdateAsync(TargetUpdateDto targetDto)
@ -73,6 +79,11 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
target.AllowWriteBack = targetDto.AllowWriteBack;
target.IsActive = targetDto.IsActive;
target.Columns = new List<TargetColumn>();
foreach (var columnDto in targetDto.Columns)
{
target.Columns.Add(TargetColumn.Create(columnDto.Id ?? 0, target.Id ?? 0, columnDto.TypeCode, columnDto.DataTypeCode, columnDto.Name, columnDto.WriteBack, columnDto.IsEmailAddress));
}
return await _targetRepository.UpdateAsync(target);
}
public async Task<TargetSample?> TestTargetAsync(int targetId)
@ -85,5 +96,12 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
return await _targetRepository.TestTargetAsync(server.ServerName, server.Port, server.Username, server.Password, target.DatabaseName, target.ViewName, target.FilterQuery);
}
public async Task<TargetSample?> TestTargetAsync(TestTargetDto testTarget)
{
Server? server = await _serverRepository.GetByIdAsync(testTarget.ServerId, true);
if (server == null) return null;
return await _targetRepository.TestTargetAsync(server.ServerName, server.Port, server.Username, server.Password, testTarget.DatabaseName, testTarget.ViewName, testTarget.FilterQuery);
}
}
}

View File

@ -1,610 +0,0 @@

//global variables
var table;
var allUsers = [];
var user = null;
//login stuff
function logout() {
$.webMethod({
'methodPage': 'UserMethods',
'methodName': 'LogOut',
'parameters': {},
success: function (json) {
document.location.href = '/login.aspx';
}
});
}
function checkLoggedInStatus() {
$.webMethod({
'methodPage': 'UserMethods',
'methodName': 'CheckAuthToken',
'parameters': {},
success: function (json) {
if ($.getBoolean(json.success)) {
$.localStorage("session_currentUser", json.data);
//set user info
user = $.localStorage("session_currentUser");
//set franchise if it isn't set yet.
//franchiseDeferred = $.Deferred();
//call back to page that called this method.
// $.when(franchiseDeferred).done(function () {
/*
if ($.sessionStorage("Auth-Impersonate-Guid") != null) {
//session is being impersonated, override name and signout
$("#spanProfileName").html("Signed in as " + user.FirstName + ' ' + user.LastName);
$("#spanProfileNameTitle").html("Signed in as " + user.FirstName + ' ' + user.LastName);
}
else {
$("#spanProfileName").html(user.FirstName + ' ' + user.LastName);
$("#spanProfileNameTitle").html(user.FirstName + ' ' + user.LastName);
} */
// $("#spanMenuName").html(user.FirstName + ' ' + user.LastName);
// var userId = $.localStorage("session_currentUser").userId;
// var imgid = userId + "&t=" + new Date().getTime();
/*
if (user.HasProfileImage) {
$("#imgRightProfile").attr("src", "/webservices/getprofileimage.ashx?id=" + imgid);
$("#imgRightProfile").on('error', function () {
$("#imgRightProfile").attr("src", "/img/generic_avatar.jpg");
});
$("#imgLeftProfile").attr("src", "/webservices/getprofileimage.ashx?id=" + imgid);
$("#imgLeftProfile").on('error', function () {
$("#imgLeftProfile").attr("src", "/img/generic_avatar.jpg");
});
$("#imgMainProfile").attr("src", "/webservices/getprofileimage.ashx?id=" + imgid);
$("#imgMainProfile").on('error', function () {
$("#imgMainProfile").attr("src", "/img/generic_avatar.jpg");
});
}
else {
$("#imgRightProfile").attr("src", "/img/generic_avatar.jpg");
$("#imgLeftProfile").attr("src", "/img/generic_avatar.jpg");
$("#imgMainProfile").attr("src", "/img/generic_avatar.jpg");
} */
loadPage();
}
else {
$.sessionStorage("redirect_url", document.location.href);
document.location.href = '/login';
}
}
});
}
function setCurrentFranchise(d) {
$.usaHaulersDB.getFranchises().then((data) => {
$.sessionStorage("currentFranchise", data);
if (d != null) {
d.resolve();
}
});
/*
search = {};
search.FranchiseCode = $.sessionStorage("franchiseCode");
$.webMethod({
'methodPage': 'UserMethods/',
'methodName': 'GetReport_Franchise',
'parameters': { "search": search },
success: function (json) {
if ($.getBoolean(json.success)) {
$.sessionStorage("currentFranchise", json.data[0]);
if (d != null) {
d.resolve();
}
}
}
}); */
}
function createSetting(obj, settingTypeCode, value) {
//see if setting exists
var bFound = false;
for (var x = 0; x < obj.Settings.length; x++) {
if (obj.Settings[x].SettingTypeCode == settingTypeCode) {
bFound = true;
break;
}
}
if (bFound) {
//existing setting
obj.Settings[x].Value = value;
}
else {
setting = {}
setting.SettingTypeCode = settingTypeCode;
setting.Value = value;
if (obj.Settings == null) {
obj.Settings = [];
}
obj.Settings.push(setting);
}
}
function createCaseSetting(obj, settingTypeCode, value) {
//see if setting exists
var bFound = false;
for (var x = 0; x < obj.settings.length; x++) {
if (obj.settings[x].case_setting_type_code == settingTypeCode) {
bFound = true;
break;
}
}
if (bFound) {
//existing setting
obj.settings[x].value = value;
}
else {
setting = {}
setting.case_setting_type_code = settingTypeCode;
setting.value = value;
if (obj.settings == null) {
obj.settings = [];
}
obj.settings.push(setting);
}
}
function getReportSetting(settings, setting_field, setting_code) {
var value = "";
if (settings != null) {
for (var x = 0; x < settings.length; x++) {
setting = settings[x];
if (setting[setting_field] == setting_code) {
value = setting["value"];
break;
}
}
}
return value;
}
function getSetting(settings, settingTypeCode) {
var value = "";
for (var x = 0; x < settings.length; x++) {
setting = settings[x];
if (setting.SettingTypeCode == settingTypeCode) {
value = setting.Value;
break;
}
}
return value;
}
//web calls
function callMethod(methodname, callback, sessionName, deferred, search) {
var params = {};
if (search == null) {
search = {};
}
$.webMethod({
'methodPage': 'UserMethods/',
'methodName': methodname,
'parameters': { "search": search },
success: function (json) {
$.sessionStorage(sessionName, json.data);
if ($.getBoolean(json.success)) {
if (deferred != null) {
deferred.resolve(true);
}
if (callback != null) {
callback();
}
}
else {
notify("Error", "There was an error performing this action. Please try again.")
}
}
});
}
function callSearchTransactions(methodname, callback, sessionName, deferred, search) {
//REPEAT OF CALL METHOD,, JSON.DATA DOESN'T WORK HERE..
//TODO MAKE DYNAMIC LATER.
var params = {};
if (search == null) {
search = {};
}
$.webMethod({
'methodPage': 'UserMethods/',
'methodName': methodname,
'parameters': { "search": search },
success: function (json) {
$.sessionStorage(sessionName, json);
if ($.getBoolean(json.success)) {
if (deferred != null) {
deferred.resolve(true);
}
if (callback != null) {
callback();
}
}
else {
notify("Error", "There was an error performing this action. Please try again.")
}
}
});
}
//overlay
function showwait(item) {
$("#waitOverlay").show();
if (item) {
item.show();
}
else {
$("#loading").show();
}
}
function hidewait(item) {
$("#waitOverlay").hide();
if (item) {
item.hide();
}
else {
$("#loading").hide();
}
}
//helper function
function getParameterByName(name) {
name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
results = regex.exec(location.search);
return results == null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
}
function getCurrency(val) {
return '$' + parseFloat(val, 10).toFixed(2).replace(/(\d)(?=(\d{3})+\.)/g, "$1,").toString()
}
function getDate(val) {
var newDate = new Date(Date.parse(val)).toLocaleDateString()
return newDate;
}
function changeDateFormat(inputDate) { // expects Y-m-d
var splitDate = inputDate.split('-');
if (splitDate.count == 0) {
return null;
}
var year = splitDate[0];
var month = splitDate[1];
var day = splitDate[2];
return month + '/' + day + '/' + year;
}
function getTime(val) {
var newDate = new Date(Date.parse(val)).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return newDate;
}
function getDateAndTime(val) {
var newDate = new Date(Date.parse(val)).toLocaleDateString() + ' ' + new Date(Date.parse(val)).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
return newDate;
}
function getDateAndTimeAndSeconds(val) {
var newDate = new Date(Date.parse(val)).toLocaleDateString() + ' ' + new Date(Date.parse(val)).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true, second: '2-digit' })
return newDate;
}
function notify(title, message) {
$.gritter.add({
title: title,
text: message,
sticky: false,
time: '2000',
});
}
function getObjectByKey(key, value, objectArray) {
for (var x = 0; x < objectArray.length; x++) {
if (objectArray[x][key] == value) {
return objectArray[x];
break;
}
}
}
function formatSelect2Data(id, name, data) {
var newdata = [];
//var blank = { };
// blank.id = "";
//blank.text = "";
//data.push(blank);
for (var x = 0; x < data.length; x++) {
var obj = {};
obj.id = data[x][id];
obj.text = data[x][name]
newdata.push(obj);
}
return newdata;
}
function formatSelect2DataForReturnedBatched(id, name, data) {
var newdata = [];
//var blank = { };
// blank.id = "";
//blank.text = "";
//data.push(blank);
for (var x = 0; x < data.length; x++) {
if (data[x].Type != "ShiftCharges") {
var obj = {};
obj.id = data[x][id];
obj.text = data[x][name]
newdata.push(obj);
}
}
return newdata;
}
function formatSelect2DataForUser(id, data) {
var newdata = [];
//var blank = { };
// blank.id = "";
//blank.text = "";
//data.push(blank);
for (var x = 0; x < data.length; x++) {
var obj = {};
obj.id = data[x][id];
obj.text = data[x]["FirstName"] + ' ' + data[x]["LastName"] + ' - ' + data[x]["userId"];
newdata.push(obj);
}
return newdata;
}
//batches
function fillDropDown(dropdown, method, session, deferred, fieldname, fieldvalue, placeholder) {
if ($.sessionStorage(session) == null) {
callMethod(method, fillDropDown, session, deferred, null);
}
else {
var data = formatSelect2Data(fieldname, fieldvalue, $.sessionStorage(session));
$(control).select2({
placeholder: placeholder,
allowClear: true,
selectOnClose: false,
//closeOnSelect: false,
data: data,
width: "200px"
});
batchDeferred.resolve(true);
}
}
var currentDropdown;
//location
function fillLocationsDropDown(deferred) {
var search = {};
if (!$.userHasRole("Administrators")) {
search.userId = $.localStorage("session_currentUser").userId;
}
else {
search = null;
}
$.webMethod({
'methodPage': 'UserMethods/',
'methodName': 'SearchLocations',
'parameters': { "search": search },
success: function (json) {
$.sessionStorage("session_locations", json.data);
if ($.getBoolean(json.success)) {
var data = formatSelect2Data("ID", "Name", $.sessionStorage("session_locations"));
if ($.userHasRole("Administrators")) {
$("#selLocation").select2({
placeholder: "Select a location...",
allowClear: true,
selectOnClose: false,
//closeOnSelect: false,
data: data
});
$("#selLocation").enable(true);
}
else {
$("#selLocation").select2({
data: data
});
$("#selLocation").select2('val', data[0].id);
$("#selLocation").enable(false);
}
if (deferred != null) {
deferred.resolve(true);
}
}
else {
notify("Error", "There was an error performing this action. Please try again.")
}
}
});
}
var currentSpinner = null
function spin2(spin) {
//spinner = $("#spanSpinner");
currentSpinner.show();
if (spin) {
rotation = function () {
currentSpinner.rotate({
angle: 0,
animateTo: 360,
callback: rotation
});
}
rotation();
}
else {
currentSpinner.stopRotate();
currentSpinner.hide();
}
}
function spin(spin, spinner) {
//spinner = $("#spanSpinner");
spinner.show();
if (spin) {
rotation = function () {
spinner.rotate({
angle: 0,
animateTo: 360,
callback: rotation
});
}
rotation();
}
else {
spinner.stopRotate();
spinner.hide();
}
}
//employee
function fillEmployeesDropDown(deferred, dropdown) {
if (dropdown == null) {
dropdown = $("#selEmployee");
}
$.webMethod({
'methodPage': 'UserMethods/',
'methodName': 'GetUsers',
'parameters': { "activeOnly": false },
success: function (json) {
$.sessionStorage("session_users", json.data);
if ($.getBoolean(json.success)) {
var data = formatSelect2DataForUser("userId", $.sessionStorage("session_users"));
dropdown.select2({
placeholder: "Select an employee...",
allowClear: true,
selectOnClose: false,
//closeOnSelect: false,
data: data
});
if (deferred != null) {
deferred.resolve(true);
}
}
else {
notify("Error", "There was an error performing this action. Please try again.")
}
}
});
}
//new imove global functions
function stripCharacters(str) {
return str.replace(/[-' ]/g, '').toUpperCase();
}
function searchUsers(d) {
search = {};
$.webMethod({
'methodPage': 'UserMethods/',
'methodName': 'SearchUsers',
'parameters': { "search": search },
success: function (json) {
if ($.getBoolean(json.success)) {
allUsers = json.data;
d.resolve();
}
}
});
}
function growlWarn(msg) {
$.bootstrapGrowl("<strong>" + msg + "</strong>", {
type: 'warning',
align: 'center',
width: 'auto',
allow_dismiss: true,
offset: { from: 'top', amount: 60 }
});
}
function growlSuccess(msg) {
$.bootstrapGrowl("<strong>" + msg + "</strong>", {
type: 'success',
align: 'center',
width: 'auto',
allow_dismiss: true,
offset: { from: 'top', amount: 60 }
});
}
function getUser(users, id) {
for (var x = 0; x < users.length; x++) {
if (users[x].userId == id) {
return users[x];
break;
}
}
}

View File

@ -1,342 +1,342 @@
//iMove utility functions
////iMove utility functions
//ie is the version of IE running.
var ie = (function () {
////ie is the version of IE running.
//var ie = (function () {
var undef,
v = 3,
div = document.createElement('div'),
all = div.getElementsByTagName('i');
// var undef,
// v = 3,
// div = document.createElement('div'),
// all = div.getElementsByTagName('i');
while (
div.innerHTML = '<!--[if gt IE ' + (++v) + ']><i></i><![endif]-->',
all[0]
);
// while (
// div.innerHTML = '<!--[if gt IE ' + (++v) + ']><i></i><![endif]-->',
// all[0]
// );
return v > 4 ? v : undef;
// return v > 4 ? v : undef;
} ());
//} ());
$(document).ajaxSend(function (event, xhr, settings) {
var authToken = $.cookie('Auth-Token');
if (authToken != null && authToken != undefined && authToken.trim().length > 0) {
xhr.setRequestHeader('Auth-Token', authToken);
}
//$(document).ajaxSend(function (event, xhr, settings) {
// var authToken = $.cookie('Auth-Token');
// if (authToken != null && authToken != undefined && authToken.trim().length > 0) {
// xhr.setRequestHeader('Auth-Token', authToken);
// }
var impersonateGuid = $.getParameterByName("impersonateid");
if (impersonateGuid != null && impersonateGuid != undefined && impersonateGuid.trim().length > 0) {
$.sessionStorage('Auth-Impersonate-Guid', impersonateGuid);
}
// var impersonateGuid = $.getParameterByName("impersonateid");
// if (impersonateGuid != null && impersonateGuid != undefined && impersonateGuid.trim().length > 0) {
// $.sessionStorage('Auth-Impersonate-Guid', impersonateGuid);
// }
impersonateGuid = $.sessionStorage('Auth-Impersonate-Guid');
if (impersonateGuid != null && impersonateGuid != undefined && impersonateGuid.trim().length > 0) {
xhr.setRequestHeader('Auth-Impersonate-Guid', impersonateGuid);
}
// impersonateGuid = $.sessionStorage('Auth-Impersonate-Guid');
// if (impersonateGuid != null && impersonateGuid != undefined && impersonateGuid.trim().length > 0) {
// xhr.setRequestHeader('Auth-Impersonate-Guid', impersonateGuid);
// }
var franchiseCode = $.sessionStorage('franchiseCode');
if (franchiseCode != null && franchiseCode != undefined && franchiseCode.trim().length > 0) {
xhr.setRequestHeader('Auth-Current-Franchise', franchiseCode);
}
});
// var franchiseCode = $.sessionStorage('franchiseCode');
// if (franchiseCode != null && franchiseCode != undefined && franchiseCode.trim().length > 0) {
// xhr.setRequestHeader('Auth-Current-Franchise', franchiseCode);
// }
//});
$.webMethod = function (options) {
var settings = $.extend({
'protocol': location.protocol, //http or https
'methodPage': '', //This should be something like UserMethods
'methodName': '', //Something like Createuser
'contentType': 'application/json; charset=utf-8',
'dataType': 'json',
'async': true,
'cache': false,
timeout: 300000,
'parameters': {},
success: function (response) { },
error: function (xhr, error, error_thrown) { }
}, options);
//$.webMethod = function (options) {
// var settings = $.extend({
// 'protocol': location.protocol, //http or https
// 'methodPage': '', //This should be something like UserMethods
// 'methodName': '', //Something like Createuser
// 'contentType': 'application/json; charset=utf-8',
// 'dataType': 'json',
// 'async': true,
// 'cache': false,
// timeout: 300000,
// 'parameters': {},
// success: function (response) { },
// error: function (xhr, error, error_thrown) { }
// }, options);
if (settings.protocol.indexOf(':') < 0) {
settings.protocol = settings.protocol + ':';
}
// if (settings.protocol.indexOf(':') < 0) {
// settings.protocol = settings.protocol + ':';
// }
var result;
var baseUrl = window.API_BASE_URL;
//var baseUrl = $("base").attr("href");
if (baseUrl === undefined || baseUrl === null || baseUrl.length === 0)
baseUrl = "";
if (baseUrl.length > 0 && baseUrl[baseUrl.length - 1] !== "/")
baseUrl = baseUrl + "/";
var url = baseUrl + settings.methodPage + (settings.methodName === undefined || settings.methodName === null || settings.methodName.length === 0 ? "" : "/" + settings.methodName);
$.ajax({
type: "POST",
url: url,
data: JSON.stringify(settings.parameters),
contentType: settings.contentType,
dataType: settings.dataType,
async: settings.async,
cache: settings.cache,
timeout: settings.timeout,
beforeSend: null,
success: function (value, textStatus, request) {
if (value.hasOwnProperty("d"))
result = $.parseJSON(value.d);
else if (typeof value === 'object')
result = value;
else
result = $.parseJSON(value);
var authToken = request.getResponseHeader('Auth-Token');
var loggedIn = $.getBoolean(request.getResponseHeader('usahl_logged_in'));
$.cookie.raw = true;
// var result;
// var baseUrl = window.API_BASE_URL;
// //var baseUrl = $("base").attr("href");
// if (baseUrl === undefined || baseUrl === null || baseUrl.length === 0)
// baseUrl = "";
// if (baseUrl.length > 0 && baseUrl[baseUrl.length - 1] !== "/")
// baseUrl = baseUrl + "/";
// var url = baseUrl + settings.methodPage + (settings.methodName === undefined || settings.methodName === null || settings.methodName.length === 0 ? "" : "/" + settings.methodName);
// $.ajax({
// type: "POST",
// url: url,
// data: JSON.stringify(settings.parameters),
// contentType: settings.contentType,
// dataType: settings.dataType,
// async: settings.async,
// cache: settings.cache,
// timeout: settings.timeout,
// beforeSend: null,
// success: function (value, textStatus, request) {
// if (value.hasOwnProperty("d"))
// result = $.parseJSON(value.d);
// else if (typeof value === 'object')
// result = value;
// else
// result = $.parseJSON(value);
// var authToken = request.getResponseHeader('Auth-Token');
// var loggedIn = $.getBoolean(request.getResponseHeader('usahl_logged_in'));
// $.cookie.raw = true;
if ($.getRememberMe() === true) {
$.cookie('Auth-Token', authToken, { expires: 14, path: '/' });
$.cookie('usahl_logged_in', loggedIn, { expires: 14, path: '/' });
}
else {
$.cookie('Auth-Token', authToken, { expires: 365, path: '/' });
$.cookie('usahl_logged_in', loggedIn, { expires: 365, path: '/' });
}
if (settings.success !== undefined)
settings.success(result);
},
error: function (xhr, error, errorThrown) {
if (settings.error !== undefined)
settings.error(xhr, error, errorThrown);
}
});
}
// if ($.getRememberMe() === true) {
// $.cookie('Auth-Token', authToken, { expires: 14, path: '/' });
// $.cookie('usahl_logged_in', loggedIn, { expires: 14, path: '/' });
// }
// else {
// $.cookie('Auth-Token', authToken, { expires: 365, path: '/' });
// $.cookie('usahl_logged_in', loggedIn, { expires: 365, path: '/' });
// }
// if (settings.success !== undefined)
// settings.success(result);
// },
// error: function (xhr, error, errorThrown) {
// if (settings.error !== undefined)
// settings.error(xhr, error, errorThrown);
// }
// });
//}
$.webMethodAsync = async function (options) {
var settings = $.extend({
'protocol': location.protocol, //http or https
'methodPage': '', //This should be something like UserMethods.
'methodName': '', //Something like Createuser
'contentType': 'application/json; charset=utf-8',
'dataType': 'json',
'async': true,
'cache': false,
timeout: 300000,
'parameters': {},
success: function (response) { },
error: function (xhr, error, error_thrown) { }
}, options);
//$.webMethodAsync = async function (options) {
// var settings = $.extend({
// 'protocol': location.protocol, //http or https
// 'methodPage': '', //This should be something like UserMethods.
// 'methodName': '', //Something like Createuser
// 'contentType': 'application/json; charset=utf-8',
// 'dataType': 'json',
// 'async': true,
// 'cache': false,
// timeout: 300000,
// 'parameters': {},
// success: function (response) { },
// error: function (xhr, error, error_thrown) { }
// }, options);
if (settings.protocol.indexOf(':') < 0) {
settings.protocol = settings.protocol + ':';
}
// if (settings.protocol.indexOf(':') < 0) {
// settings.protocol = settings.protocol + ':';
// }
var result;
var baseUrl = window.API_BASE_URL;
//var baseUrl = $("base").attr("href");
if (baseUrl === undefined || baseUrl === null || baseUrl.length === 0)
baseUrl = "";
if (baseUrl.length > 0 && baseUrl[baseUrl.length - 1] !== "/")
baseUrl = baseUrl + "/";
var url = baseUrl + settings.methodPage + (settings.methodName === undefined || settings.methodName === null || settings.methodName.length === 0 ? "" : "/" + settings.methodName);
return $.ajax({
type: "POST",
url: url,
data: JSON.stringify(settings.parameters),
contentType: settings.contentType,
dataType: settings.dataType,
async: settings.async,
cache: settings.cache,
timeout: settings.timeout,
beforeSend: null,
success: function (value, textStatus, request) {
if (value.hasOwnProperty("d"))
result = $.parseJSON(value.d);
else if (typeof value === 'object')
result = value;
else
result = $.parseJSON(value);
var authToken = request.getResponseHeader('Auth-Token');
var loggedIn = $.getBoolean(request.getResponseHeader('usahl_logged_in'));
$.cookie.raw = true;
// var result;
// var baseUrl = window.API_BASE_URL;
// //var baseUrl = $("base").attr("href");
// if (baseUrl === undefined || baseUrl === null || baseUrl.length === 0)
// baseUrl = "";
// if (baseUrl.length > 0 && baseUrl[baseUrl.length - 1] !== "/")
// baseUrl = baseUrl + "/";
// var url = baseUrl + settings.methodPage + (settings.methodName === undefined || settings.methodName === null || settings.methodName.length === 0 ? "" : "/" + settings.methodName);
// return $.ajax({
// type: "POST",
// url: url,
// data: JSON.stringify(settings.parameters),
// contentType: settings.contentType,
// dataType: settings.dataType,
// async: settings.async,
// cache: settings.cache,
// timeout: settings.timeout,
// beforeSend: null,
// success: function (value, textStatus, request) {
// if (value.hasOwnProperty("d"))
// result = $.parseJSON(value.d);
// else if (typeof value === 'object')
// result = value;
// else
// result = $.parseJSON(value);
// var authToken = request.getResponseHeader('Auth-Token');
// var loggedIn = $.getBoolean(request.getResponseHeader('usahl_logged_in'));
// $.cookie.raw = true;
if ($.getRememberMe() === true) {
$.cookie('Auth-Token', authToken, { expires: 14, path: '/' });
$.cookie('usahl_logged_in', loggedIn, { expires: 14, path: '/' });
}
else {
$.cookie('Auth-Token', authToken, { expires: 365, path: '/' });
$.cookie('usahl_logged_in', loggedIn, { expires: 365, path: '/' });
}
if (settings.success !== undefined)
settings.success(result);
},
error: function (xhr, error, errorThrown) {
if (settings.error !== undefined)
settings.error(xhr, error, errorThrown);
}
});
}
// if ($.getRememberMe() === true) {
// $.cookie('Auth-Token', authToken, { expires: 14, path: '/' });
// $.cookie('usahl_logged_in', loggedIn, { expires: 14, path: '/' });
// }
// else {
// $.cookie('Auth-Token', authToken, { expires: 365, path: '/' });
// $.cookie('usahl_logged_in', loggedIn, { expires: 365, path: '/' });
// }
// if (settings.success !== undefined)
// settings.success(result);
// },
// error: function (xhr, error, errorThrown) {
// if (settings.error !== undefined)
// settings.error(xhr, error, errorThrown);
// }
// });
//}
$.getBoolean = function (variable) {
var vtype;
var toReturn;
//$.getBoolean = function (variable) {
// var vtype;
// var toReturn;
if (variable != null) {
switch (typeof (variable)) {
case 'boolean':
vtype = "boolean";
return variable;
break;
// if (variable != null) {
// switch (typeof (variable)) {
// case 'boolean':
// vtype = "boolean";
// return variable;
// break;
case 'number':
vtype = "number";
if (variable == 0)
toReturn = false;
else toReturn = true;
break;
// case 'number':
// vtype = "number";
// if (variable == 0)
// toReturn = false;
// else toReturn = true;
// break;
case 'string':
vtype = "string";
if (variable.toLowerCase() == "true" || variable.toLowerCase() == "yes")
toReturn = true;
else if (variable.toLowerCase() == "false" || variable.toLowerCase() == "no")
toReturn = false;
else if (variable.length > 0)
toReturn = true;
else if (variable.length == 0)
toReturn = false;
break;
// case 'string':
// vtype = "string";
// if (variable.toLowerCase() == "true" || variable.toLowerCase() == "yes")
// toReturn = true;
// else if (variable.toLowerCase() == "false" || variable.toLowerCase() == "no")
// toReturn = false;
// else if (variable.length > 0)
// toReturn = true;
// else if (variable.length == 0)
// toReturn = false;
// break;
}
// }
return toReturn;
}
};
// return toReturn;
// }
//};
$.isLoggedIn = function () {
return $.getBoolean($.cookie('usahl_logged_in'));
}
//$.isLoggedIn = function () {
// return $.getBoolean($.cookie('usahl_logged_in'));
//}
//Send the auth-token in all ajax requests
$.ajaxSetup({
beforeSend: function (xhr, settings) {
xhr.setRequestHeader('Auth-Token', $.cookie('Auth-Token'));
}
});
////Send the auth-token in all ajax requests
//$.ajaxSetup({
// beforeSend: function (xhr, settings) {
// xhr.setRequestHeader('Auth-Token', $.cookie('Auth-Token'));
// }
//});
$.sessionStorage = function (key, value) {
if (value === undefined) {
var val = window.sessionStorage.getItem(key);
if ((/^usahl_json/).test(val)) {
val = val.substring(11, val.length);
val = $.parseJSON(val);
}
return val;
}
else {
var val = value;
if (typeof value === 'object') {
val = "usahl_json:" + JSON.stringify(value);
}
//$.sessionStorage = function (key, value) {
// if (value === undefined) {
// var val = window.sessionStorage.getItem(key);
// if ((/^usahl_json/).test(val)) {
// val = val.substring(11, val.length);
// val = $.parseJSON(val);
// }
// return val;
// }
// else {
// var val = value;
// if (typeof value === 'object') {
// val = "usahl_json:" + JSON.stringify(value);
// }
window.sessionStorage.setItem(key, val);
}
};
// window.sessionStorage.setItem(key, val);
// }
//};
$.sessionStorageClear = function () {
window.sessionStorage.clear();
};
//$.sessionStorageClear = function () {
// window.sessionStorage.clear();
//};
$.sessionStorageRemove = function (key) {
window.sessionStorage.removeItem(key);
};
//$.sessionStorageRemove = function (key) {
// window.sessionStorage.removeItem(key);
//};
$.localStorage = function (key, value) {
if (value === undefined) {
var val = window.localStorage.getItem(key);
if ((/^usahl_json/).test(val)) {
val = val.substring(11, val.length);
val = $.parseJSON(val);
}
return val;
}
else {
var val = value;
if (typeof value === 'object') {
val = "usahl_json:" + JSON.stringify(value);
}
//$.localStorage = function (key, value) {
// if (value === undefined) {
// var val = window.localStorage.getItem(key);
// if ((/^usahl_json/).test(val)) {
// val = val.substring(11, val.length);
// val = $.parseJSON(val);
// }
// return val;
// }
// else {
// var val = value;
// if (typeof value === 'object') {
// val = "usahl_json:" + JSON.stringify(value);
// }
window.localStorage.setItem(key, val);
}
};
// window.localStorage.setItem(key, val);
// }
//};
$.localStorageClear = function () {
window.localStorage.clear();
};
//$.localStorageClear = function () {
// window.localStorage.clear();
//};
$.localStorageRemove = function (key) {
window.localStorage.removeItem(key);
};
//$.localStorageRemove = function (key) {
// window.localStorage.removeItem(key);
//};
$.setRememberMe = function (rememberMe) {
$.sessionStorage("usahl_remember_me", rememberMe);
}
$.getRememberMe = function () {
return true;
/*
var rememberMe = $.sessionStorage("usahl_remember_me");
if (rememberMe === undefined || rememberMe == null) {
return false;
}
//$.setRememberMe = function (rememberMe) {
// $.sessionStorage("usahl_remember_me", rememberMe);
//}
//$.getRememberMe = function () {
// return true;
// /*
// var rememberMe = $.sessionStorage("usahl_remember_me");
// if (rememberMe === undefined || rememberMe == null) {
// return false;
// }
return $.getBoolean(rememberMe);
*/
}
// return $.getBoolean(rememberMe);
// */
//}
$.userHasPermission = function (permissionCode) {
var allowed = false;
var user = $.localStorage("session_currentUser");
$.each(user.Permissions, function () {
if ($.compareStrings(this.Code, permissionCode)) {
allowed = true;
}
});
return allowed;
}
//$.userHasPermission = function (permissionCode) {
// var allowed = false;
// var user = $.localStorage("session_currentUser");
// $.each(user.Permissions, function () {
// if ($.compareStrings(this.Code, permissionCode)) {
// allowed = true;
// }
// });
// return allowed;
//}
$.userHasRole = function (role) {
var hasRole = false;
var user = $.localStorage("session_currentUser");
$.each(user.Roles, function () {
if ($.compareStrings(this.FriendlyName, role)) {
hasRole = true;
}
});
return hasRole;
}
//$.userHasRole = function (role) {
// var hasRole = false;
// var user = $.localStorage("session_currentUser");
// $.each(user.Roles, function () {
// if ($.compareStrings(this.FriendlyName, role)) {
// hasRole = true;
// }
// });
// return hasRole;
//}
$.checkForRole = function (user, role) {
var hasRole = false;
$.each(user.Roles, function () {
if ($.compareStrings(this.FriendlyName, role)) {
hasRole = true;
}
});
return hasRole;
}
//$.checkForRole = function (user, role) {
// var hasRole = false;
// $.each(user.Roles, function () {
// if ($.compareStrings(this.FriendlyName, role)) {
// hasRole = true;
// }
// });
// return hasRole;
//}
$.checkForRoleCode = function (user, role) {
var hasRole = false;
$.each(user.roles, function () {
if ($.compareStrings(this.role_code, role)) {
hasRole = true;
}
});
return hasRole;
}
//$.checkForRoleCode = function (user, role) {
// var hasRole = false;
// $.each(user.roles, function () {
// if ($.compareStrings(this.role_code, role)) {
// hasRole = true;
// }
// });
// return hasRole;
//}
$.compareStrings = function (string1, string2) {
return string1.toLowerCase() === string2.toLowerCase();
}
//$.compareStrings = function (string1, string2) {
// return string1.toLowerCase() === string2.toLowerCase();
//}
$.getParameterByName = function (name) {
name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
results = regex.exec(location.search);
return results == null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
}
//$.getParameterByName = function (name) {
// name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]");
// var regex = new RegExp("[\\?&]" + name + "=([^&#]*)"),
// results = regex.exec(location.search);
// return results == null ? "" : decodeURIComponent(results[1].replace(/\+/g, " "));
//}

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<system.webServer>
<rewrite>
<rules>
<rule name="SPA" stopProcessing="true">
<match url=".*" />
<conditions logicalGrouping="MatchAll">
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
<add input="{URL}" pattern="^/api(/.*|$)" negate="true" />
</conditions>
<action type="Rewrite" url="index.html" />
</rule>
</rules>
</rewrite>
</system.webServer>
</configuration>

View File

@ -16,6 +16,7 @@ export const routeRoleRequirements: Record<string, string[]> = {
'/scheduledMailings': ['ScheduledMailingTab'],
'/activeMailings': ['ActiveMailingTab'],
'/completedMailings': ['CompletedMailingTab'],
'/cancelledMailings': ['CompletedMailingTab'],
};
const ProtectedPageWrapper: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => {

View File

@ -26,6 +26,7 @@ import ListItemText from '@mui/material/ListItemText';
import DashboardIcon from '@mui/icons-material/Dashboard';
import HttpIcon from '@mui/icons-material/Http';
import AccountBoxIcon from '@mui/icons-material/AccountBox';
import CancelIcon from '@mui/icons-material/Cancel';
import DnsIcon from '@mui/icons-material/Dns';
import TargetIcon from '@mui/icons-material/TrackChanges';
@ -78,11 +79,11 @@ interface LayoutProps {
}
const Layout = ({ children }: LayoutProps) => {
const [open, setOpen] = React.useState(true);
const { mode, setMode } = useColorScheme(); // MUI v6 hook for theme switching
const iconButtonRef = React.useRef<HTMLButtonElement>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); //TODO: Move this to shared utils?
const [open, setOpen] = React.useState(!isMobile);
const { mode, setMode } = useColorScheme(); // MUI v6 hook for theme switching
const iconButtonRef = React.useRef<HTMLButtonElement>(null);
const { title } = useTitle();
const navigate = useNavigate();
@ -98,6 +99,7 @@ const Layout = ({ children }: LayoutProps) => {
{ text: 'Scheduled Mailings', icon: <ScheduleSendIcon />, path: '/scheduledMailings' },
{ text: 'Active Mailings', icon: <AutorenewIcon />, path: '/activeMailings' },
{ text: 'Completed Mailings', icon: <CheckCircleIcon />, path: '/completedMailings' },
{ text: 'Cancelled Mailings', icon: <CancelIcon />, path: '/cancelledMailings' },
];
const { userRoles, setAuth } = useAuth(); // Use context
const [profileMenuAnchorEl, setProfileMenuAnchorEl] = React.useState<null | HTMLElement>(null);
@ -281,6 +283,11 @@ const Layout = ({ children }: LayoutProps) => {
variant={isMobile ? "temporary" : "persistent"}
anchor="left"
open={open}
onClose={handleDrawerClose}
ModalProps={{
keepMounted: true, // Keep mounted to avoid re-render issues
hideBackdrop: isMobile && !open, // Explicitly hide backdrop when closed in mobile
}}
sx={{
width: isMobile ? "100%" : open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
flexShrink: 0,

View File

@ -35,18 +35,22 @@ const ForgotPasswordModal: React.FC<ForgotPasswordModalProps> = ({ show, onClose
if (validate()) {
console.log('Processing forgot password for', username);
await utils.webMethod({
methodPage: 'authenticate',
methodName: 'generatepasswordrecovery',
parameters: { username },
success: (json: any) => {
if (utils.getBoolean(json.success)) {
setRecoveryStarted(true);
} else {
setUsernameNotFound(true);
}
},
const apiUrl = "/api/authentication/generatepasswordrecovery";
const response = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username}),
});
if (response.ok) {
const data = await response.json();
if (data && utils.getBoolean(data.success)) {
setRecoveryStarted(true);
return;
}
}
setUsernameNotFound(true);
}
};

View File

@ -23,7 +23,7 @@ import Target from "@/types/target";
import EmailList from "@/components/forms/EmailList";
import TestEmailList from "@/types/testEmailList";
import TemplateViewer from "@/components/modals/TemplateViewer"
import TargetSampleViewer from "@/components/modals/TargetSampleViewer"
import TargetSampleModal from "@/components/modals/TargetSampleModal"
import { useSetupData, SetupData } from "@/context/SetupDataContext";
import { useForm, Controller, Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
@ -176,7 +176,7 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
const [emails, setEmails] = useState<string[]>([]); // State for email array
const [templateViewerOpen, setTemplateViewerOpen] = useState<boolean>(false);
const [currentTemplate, setCurrentTemplate] = useState<Template | null>(null);
const [targetSampleViewerOpen, setTargetSampleViewerOpen] = useState<boolean>(false);
const [TargetSampleModalOpen, setTargetSampleModalOpen] = useState<boolean>(false);
const [currentTarget, setCurrentTarget] = useState<Target | null>(null);
const { register, trigger, control, handleSubmit, reset, formState: { errors } } = useForm<Mailing>({
@ -336,8 +336,8 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
const handleTemplateViewerOpen = () => {
setTemplateViewerOpen(!templateViewerOpen);
};
const handleTargetSampleViewerOpen = () => {
setTargetSampleViewerOpen(!targetSampleViewerOpen);
const handleTargetSampleModalOpen = () => {
setTargetSampleModalOpen(!TargetSampleModalOpen);
};
return (
<LocalizationProvider dateAdapter={AdapterDayjs}> {/* Wrap with LocalizationProvider */}
@ -433,7 +433,7 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
/>
{currentTarget && (
<Button
onClick={handleTargetSampleViewerOpen}
onClick={handleTargetSampleModalOpen}
variant="outlined"
startIcon={<VisibilityIcon />}
sx={{ height: '100%', alignSelf: 'center' }}
@ -562,11 +562,11 @@ const MailingEdit = ({ open, mailing, onClose, onSave }: MailingEditProps) => {
onClose={() => { setTemplateViewerOpen(false) }}
/>
)}
{targetSampleViewerOpen && (
<TargetSampleViewer
open={targetSampleViewerOpen}
{TargetSampleModalOpen && (
<TargetSampleModal
open={TargetSampleModalOpen}
target={currentTarget!}
onClose={() => { setTargetSampleViewerOpen(false) }}
onClose={() => { setTargetSampleModalOpen(false) }}
/>
)}
</DialogContent>

View File

@ -5,7 +5,7 @@ import Mailing from '@/types/mailing';
import VisibilityIcon from '@mui/icons-material/Visibility';
import CloseIcon from '@mui/icons-material/Close';
import TemplateViewer from "@/components/modals/TemplateViewer"
import TargetSampleViewer from "@/components/modals/TargetSampleViewer"
import TargetSampleModal from "@/components/modals/TargetSampleModal"
interface MailingViewProps {
open: boolean;
@ -16,7 +16,7 @@ interface MailingViewProps {
function MailingView({ open, mailing, onClose }: MailingViewProps) {
const setupData = useSetupData();
const [templateViewerOpen, setTemplateViewerOpen] = useState<boolean>(false);
const [targetSampleViewerOpen, setTargetSampleViewerOpen] = useState<boolean>(false);
const [TargetSampleModalOpen, setTargetSampleModalOpen] = useState<boolean>(false);
if (!mailing) return null;
@ -55,7 +55,7 @@ function MailingView({ open, mailing, onClose }: MailingViewProps) {
// Navigation handlers for viewing related entities
const handleViewTarget = () => {
if (target) {
setTargetSampleViewerOpen(!targetSampleViewerOpen);
setTargetSampleModalOpen(!TargetSampleModalOpen);
}
};
@ -114,11 +114,11 @@ function MailingView({ open, mailing, onClose }: MailingViewProps) {
onClose={() => { setTemplateViewerOpen(false) }}
/>
)}
{targetSampleViewerOpen && (
<TargetSampleViewer
open={targetSampleViewerOpen}
{TargetSampleModalOpen && (
<TargetSampleModal
open={TargetSampleModalOpen}
target={target!}
onClose={() => { setTargetSampleViewerOpen(false) }}
onClose={() => { setTargetSampleModalOpen(false) }}
/>
)}
</DialogContent>

View File

@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import {
Box,
Dialog,
DialogTitle,
DialogContent,
@ -9,12 +10,20 @@ import {
Button,
Switch,
FormControlLabel,
Typography,
IconButton,
} from "@mui/material";
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import Target from "@/types/target";
import { useSetupData, SetupData } from "@/context/SetupDataContext";
import { useForm, Controller, Resolver } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import * as yup from "yup";
import TargetColumn from "@/types/targetColumn";
import TargetSampleColumn from "@/types/targetSampleColumn";
import TargetSampleDataGrid from "@/components/shared/TargetSampleDataGrid";
import { GridColDef } from '@mui/x-data-grid';
type TargetEditProps = {
open: boolean;
@ -32,7 +41,6 @@ const schema = yup.object().shape({
.test("unique-name", "Name must be unique", function (value) {
const setupData = this.options.context?.setupData as { targets: Target[] };
if (!setupData) return true;
return !setupData.targets.some(
(t) => t.name.toLowerCase() === value?.toLowerCase() && (t.id === 0 || t.id !== this.parent.id)
);
@ -42,11 +50,30 @@ const schema = yup.object().shape({
filterQuery: yup.string().nullable(),
allowWriteBack: yup.boolean().default(false),
isActive: yup.boolean().default(true),
columns: yup
.array().of(
yup.object().shape({
id: yup.number(),
targetId: yup.number(),
typeCode: yup.string(),
dataTypeCode: yup.string(),
name: yup.string(),
writeBack: yup.boolean(),
isEmailAddress: yup.boolean(),
})
)
.test(
"email-address-required",
"One column must be marked as the email address",
(columns) => columns && columns.filter((col) => col.isEmailAddress === true).length === 1
)
.test(
"unique-id-required",
"One column must be marked as the unique ID",
(columns) => columns && columns.filter((col) => col.typeCode === "I").length === 1
),
});
//TODO: Make DatabaseName a select using new array in setupData.servers.databases
//TODO: Maybe Make View a select using new array in setupData.servers.databases.views+procs. But would have to allow free form entry just in case/no validation on found in select
//TODO: Add verify/test button on form, checks that server, db, view exist, query works and returns data. Show sample data on screen.
const defaultTarget: Target = {
id: 0,
name: "",
@ -56,35 +83,54 @@ const defaultTarget: Target = {
filterQuery: "",
allowWriteBack: false,
isActive: true,
columns: [],
};
const useColumnErrors = (errors: any, columns: TargetColumn[]) => {
const hasEmail = columns.some((col) => col.isEmailAddress);
const hasUniqueId = columns.some((col) => col.typeCode === "I");
return {
emailError: errors.columns && !hasEmail ? "One column must be marked as the email address" : "",
uniqueIdError: errors.columns && !hasUniqueId ? "One column must be marked as the unique ID" : "",
};
};
const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
const isNew = !target || target.id === 0;
const setupData: SetupData = useSetupData();
const [targetTested, setTargetTested] = useState<boolean>(false);
const [targetTestError, setTargetTestError] = useState<boolean>(false);
const [targetTestErrorMessage, setTargetTestErrorMessage] = useState<string>('');
const [availableColumns, setAvailableColumns] = useState<TargetColumn[]>([]);
const [sampleColumns, setSampleColumns] = useState<GridColDef[]>([]);
const [sampleRows, setSampleRows] = useState<{ [key: string]: string }[]>([]);
const [showSampleGrid, setShowSampleGrid] = useState<boolean>(true);
const { register, trigger, control, handleSubmit, reset, formState: { errors } } = useForm<Target>({
mode: "onBlur",
defaultValues: target || defaultTarget,
resolver: yupResolver(schema) as Resolver<Target>,
context: { setupData }
,
context: { setupData },
});
//const [formData, setFormData] = useState<Target>(target ? { ...target } : { ...defaultTarget });
//const [serverError, setServerError] = useState(false); // Track validation
const [loading, setLoading] = useState(false);
useEffect(() => { //Reset form to unedited state on open or target change
const [loading, setLoading] = useState(false);
const { emailError, uniqueIdError } = useColumnErrors(errors, control._formValues.columns || []);
useEffect(() => {
if (open) {
if (target && target.id > 0) {
setTargetTested(true);
}
setAvailableColumns(target?.columns || []);
setTargetTestErrorMessage('');
setTargetTestError(false);
setSampleColumns([]);
setSampleRows([]);
setShowSampleGrid(false);
reset(target || defaultTarget, { keepDefaultValues: true });
}
}, [open, target, reset]);
//const handleChange = (field: string, value: any) => {
// setFormData((prev) => ({ ...prev, [field]: value?.id || "" }));
// if (field === "serverId" && value) setServerError(false);
//};
const handleSave = async (formData: Target) => {
const apiUrl = isNew ? "/api/targets" : `/api/targets/${formData.id}`;
const method = isNew ? "POST" : "PUT";
@ -95,9 +141,7 @@ const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update");
const updatedTarget = await response.json();
onSave(updatedTarget);
onClose();
@ -108,16 +152,113 @@ const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
}
};
const getTargetColumnsFromSample = (columns: TargetSampleColumn[]) => {
const targetColumns: TargetColumn[] = [];
for (const colName in columns) {
const col = columns[colName];
targetColumns.push({
name: col.name,
typeCode: col.type === "E" ? "G" : col.type,
dataTypeCode: col.dataType,
id: 0,
targetId: target?.id || 0,
writeBack: false,
isEmailAddress: col.type === "E",
});
}
return targetColumns;
};
const handleTestTarget = async () => {
setLoading(true);
try {
const response = await fetch(`/api/targets/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
serverId: control._formValues.serverId,
databaseName: control._formValues.databaseName,
viewName: control._formValues.viewName,
filterQuery: control._formValues.filterQuery,
}),
});
const data = await response.json();
if (response.ok) {
let columns: TargetColumn[] = [];
if (data?.columns) {
columns = getTargetColumnsFromSample(data.columns);
}
const gridColumns: GridColDef[] = Object.keys(columns ?? {}).map((colName) => ({
field: colName,
headerName: colName,
minWidth: 150,
width: 200,
flex: 1,
sortable: true,
}));
const gridRows = data?.rows?.map((row: { [key: string]: string }, index: number) => ({
id: index.toString(),
...row,
})) || [];
setAvailableColumns(columns);
setSampleColumns(gridColumns);
setSampleRows(gridRows);
setTargetTested(true);
setTargetTestError(false);
setTargetTestErrorMessage('');
reset({ ...control._formValues, columns });
} else {
setTargetTested(false);
setTargetTestError(true);
setTargetTestErrorMessage(data.error);
setSampleColumns([]);
setSampleRows([]);
}
} catch (error) {
setTargetTested(false);
setTargetTestError(true);
setTargetTestErrorMessage(error instanceof Error ? error.message : String(error));
setSampleColumns([]);
setSampleRows([]);
console.error("Test target error:", error);
} finally {
setLoading(false);
}
};
const getColumnOptions = (typeCode: string) => {
switch (typeCode) {
case "E": // Email
case "S": // Status
return availableColumns.filter(col => col.dataTypeCode.toLowerCase() === "s");
case "I": // Unique Identifier
return availableColumns.filter(col =>
col.dataTypeCode.toLowerCase() === "s" || col.dataTypeCode.toLowerCase() === "n");
case "B": // Bounce
case "U": // Unsubscribe
return availableColumns.filter(col => col.dataTypeCode.toLowerCase() === "b");
case "C": // Soft Bounce Count
return availableColumns.filter(col => col.dataTypeCode.toLowerCase() === "n");
default:
return availableColumns;
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>{isNew ? "Add Target" : "Edit Target id=" + target.id}</DialogTitle>
<DialogTitle>{isNew ? "Add Target" : "Edit Target id=" + target?.id}</DialogTitle>
<DialogContent>
<Controller
name="serverId"
control={control}
rules={{ required: "Server is required" }}
render={({ field }) => (
<Autocomplete {...field}
<Autocomplete
{...field}
options={setupData.servers}
getOptionLabel={(option) => option.name}
value={setupData.servers.find((s) => s.id === Number(field.value)) || null}
@ -125,7 +266,6 @@ const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
field.onChange(newValue ? newValue.id : null);
trigger("serverId");
}}
renderInput={(params) => (
<TextField
{...params}
@ -167,20 +307,16 @@ const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
{...register("filterQuery")}
label="Filter Query"
fullWidth
multiline
rows={4}
margin="dense"
error={!!errors.filterQuery}
helperText={errors.filterQuery?.message}
/>
<FormControlLabel
control={
<Switch
{...register("allowWriteBack")}
/>
}
control={<Switch {...register("allowWriteBack")} />}
label="Allow Write Back"
/>
<Controller
name="isActive"
control={control}
@ -197,7 +333,246 @@ const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
/>
)}
/>
<Box sx={{ marginTop: 1, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
onClick={handleTestTarget}
variant="outlined"
sx={{ height: '100%', alignSelf: 'center' }}
disabled={loading}
>
{loading ? "Testing..." : "Test Target"}
</Button>
{targetTested && sampleColumns.length > 0 && (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<Typography variant="subtitle1">Sample Data</Typography>
<IconButton
onClick={() => setShowSampleGrid(prev => !prev)}
size="small"
sx={{ ml: 1 }}
>
{showSampleGrid ? <VisibilityOffIcon /> : <VisibilityIcon />}
</IconButton>
</Box>
)}
</Box>
{targetTestError && (
<Typography color="error" variant="body2" sx={{ mt: 1 }}>
{targetTestErrorMessage}
</Typography>
)}
{targetTested && sampleColumns.length > 0 && showSampleGrid && (
<Box sx={{ mt: 1, mb:1 }}>
<TargetSampleDataGrid
columns={sampleColumns}
rows={sampleRows.slice(0, 4)} // Limit to first 4 rows
/>
</Box>
)}
{control._formValues != null && control._formValues.columns != null && targetTested && (
<>
{/* Email Address Column Autocomplete */}
<Controller
name="columns"
control={control}
render={({ field }) => {
return (
<Autocomplete
options={getColumnOptions("E")}
getOptionLabel={(option) => option.name}
value={field.value.find((col) => col.isEmailAddress) || null}
onChange={(_, newValue) => {
const updatedColumns = field.value.map((col) => ({
...col,
typeCode: newValue && col.name === newValue.name ? "G" : col.typeCode,
isEmailAddress: newValue && col.name === newValue.name,
}));
field.onChange(updatedColumns);
trigger("columns");
}}
renderInput={(params) => (
<TextField
{...params}
label="Email Address Column"
fullWidth
margin="dense"
error={!!emailError}
helperText={emailError}
/>
)}
/>
);
}}
/>
{/* Unique ID Column Autocomplete */}
<Controller
name="columns"
control={control}
render={({ field }) => {
return (
<Autocomplete
options={getColumnOptions("I")}
getOptionLabel={(option) => option.name}
value={field.value.find((col) => col.typeCode === "I") || null}
onChange={(_, newValue) => {
const updatedColumns = field.value.map((col) => ({
...col,
typeCode: newValue && col.name === newValue.name ? "I" : col.typeCode === "I" ? "G" : col.typeCode,
isEmailAddress: newValue && col.name === newValue.name ? false : col.isEmailAddress,
}));
field.onChange(updatedColumns);
trigger("columns");
}}
renderInput={(params) => (
<TextField
{...params}
label="Unique ID Column"
fullWidth
margin="dense"
error={!!uniqueIdError}
helperText={uniqueIdError}
/>
)}
/>
);
}}
/>
<Controller
name="columns"
control={control}
render={({ field }) => {
return (
<Autocomplete
options={getColumnOptions("S")}
getOptionLabel={(option) => option.name}
value={field.value.find((col) => col.typeCode === "S") || null}
onChange={(_, newValue) => {
const updatedColumns = field.value.map((col) => ({
...col,
typeCode: newValue && col.name === newValue.name ? "S" : col.typeCode === "S" ? "G" : col.typeCode,
isEmailAddress: newValue && col.name === newValue.name ? false : col.isEmailAddress,
}));
field.onChange(updatedColumns);
trigger("columns");
}}
renderInput={(params) => (
<TextField
{...params}
label="Status Code Column"
fullWidth
margin="dense"
/*error={!!uniqueIdError}
helperText={uniqueIdError}*/
/>
)}
/>
);
}}
/>
<Controller
name="columns"
control={control}
render={({ field }) => {
return (
<Autocomplete
options={getColumnOptions("C")}
getOptionLabel={(option) => option.name}
value={field.value.find((col) => col.typeCode === "C") || null}
onChange={(_, newValue) => {
const updatedColumns = field.value.map((col) => ({
...col,
typeCode: newValue && col.name === newValue.name ? "C" : col.typeCode === "C" ? "G" : col.typeCode,
isEmailAddress: newValue && col.name === newValue.name ? false : col.isEmailAddress,
}));
field.onChange(updatedColumns);
trigger("columns");
}}
renderInput={(params) => (
<TextField
{...params}
label="Soft Bounce Count Column"
fullWidth
margin="dense"
/*error={!!uniqueIdError}
helperText={uniqueIdError}*/
/>
)}
/>
);
}}
/>
<Controller
name="columns"
control={control}
render={({ field }) => {
return (
<Autocomplete
options={getColumnOptions("B")}
getOptionLabel={(option) => option.name}
value={field.value.find((col) => col.typeCode === "B") || null}
onChange={(_, newValue) => {
const updatedColumns = field.value.map((col) => ({
...col,
typeCode: newValue && col.name === newValue.name ? "B" : col.typeCode === "B" ? "G" : col.typeCode,
isEmailAddress: newValue && col.name === newValue.name ? false : col.isEmailAddress,
}));
field.onChange(updatedColumns);
trigger("columns");
}}
renderInput={(params) => (
<TextField
{...params}
label="Bounce Column"
fullWidth
margin="dense"
/*error={!!uniqueIdError}
helperText={uniqueIdError}*/
/>
)}
/>
);
}}
/>
<Controller
name="columns"
control={control}
render={({ field }) => {
return (
<Autocomplete
options={getColumnOptions("U")}
getOptionLabel={(option) => option.name}
value={field.value.find((col) => col.typeCode === "U") || null}
onChange={(_, newValue) => {
const updatedColumns = field.value.map((col) => ({
...col,
typeCode: newValue && col.name === newValue.name ? "U" : col.typeCode === "U" ? "G" : col.typeCode,
isEmailAddress: newValue && col.name === newValue.name ? false : col.isEmailAddress,
}));
field.onChange(updatedColumns);
trigger("columns");
}}
renderInput={(params) => (
<TextField
{...params}
label="Unsubscribe Column"
fullWidth
margin="dense"
/*error={!!uniqueIdError}
helperText={uniqueIdError}*/
/>
)}
/>
);
}}
/>
</>
/*
B Bounce status
C Soft bounce count
G General
I Unique identifier
S Status code
U Unsubscribe status*/
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>Cancel</Button>

View File

@ -9,9 +9,10 @@ import {
} from "@mui/material";
import Target from "@/types/target";
import CloseIcon from '@mui/icons-material/Close';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import TargetSampleDataGrid from "@/components/shared/TargetSampleDataGrid";
import { GridColDef } from '@mui/x-data-grid';
type TargetSampleViewerProps = {
type TargetSampleModalProps = {
open: boolean;
target: Target;
onClose: () => void;
@ -26,7 +27,7 @@ type TargetSample = {
rows: { [key: string]: string }[];
};
const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps) => {
const TargetSampleModal = ({ open, target, onClose }: TargetSampleModalProps) => {
const [targetSample, setTargetSample] = useState<TargetSample | null>(null);
const [loading, setLoading] = useState(false);
@ -34,7 +35,6 @@ const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps)
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");
@ -54,27 +54,26 @@ const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps)
const columns: GridColDef[] = Object.keys(targetSample?.columns ?? {}).map((colName) => ({
field: colName,
headerName: colName,
minWidth: 150, // Minimum width to prevent excessive truncation
width: 200, // Default width
flex: 1, // Allow columns to grow/shrink proportionally
minWidth: 150,
width: 200,
flex: 1,
sortable: true,
})) || [];
const rows = targetSample?.rows?.map((row, index) => {
const rowData: { [key: string]: string } = { id: index.toString() };
Object.keys(targetSample.columns).forEach((key) => {
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 defaultColumnWidth = 200;
const minDialogWidth = 300;
const calculatedWidth = Math.max(
minDialogWidth,
Math.min(columnCount * defaultColumnWidth, 0.98 * window.innerWidth) // Cap at 98% of viewport
Math.min(columnCount * defaultColumnWidth, 0.98 * window.innerWidth)
);
return (
@ -84,12 +83,12 @@ const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps)
maxWidth={false}
sx={{
'& .MuiDialog-paper': {
width: `${calculatedWidth}px`, // Dynamic width
maxWidth: '98vw', // Upper limit
width: `${calculatedWidth}px`,
maxWidth: '98vw',
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
}
height: loading ? '200px' : 'auto',
maxHeight: '90vh',
},
}}
>
{loading && (
@ -114,34 +113,7 @@ const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps)
</Box>
</DialogTitle>
<DialogContent>
<DataGrid
rows={rows}
columns={columns}
autoHeight
hideFooter
hideFooterPagination
sx={{
width: "100%",
maxHeight: "800px",
'& .MuiDataGrid-main': {
overflowX: 'auto',
},
'& .MuiDataGrid-columnHeaderTitle': {
overflow: 'visible',
whiteSpace: 'normal',
lineHeight: '1.2em',
textOverflow: 'ellipsis',
}
}}
initialState={{
pagination: {
paginationModel: {
pageSize: 10,
},
},
}}
pageSizeOptions={[]}
/>
<TargetSampleDataGrid columns={columns} rows={rows} />
</DialogContent>
</>
)}
@ -149,4 +121,4 @@ const TargetSampleViewer = ({ open, target, onClose }: TargetSampleViewerProps)
);
};
export default TargetSampleViewer;
export default TargetSampleModal;

View File

@ -18,6 +18,7 @@ import NewMailings from '@/components/pages/NewMailings';
import ScheduledMailings from '@/components/pages/ScheduledMailings';
import ActiveMailings from '@/components/pages/ActiveMailings';
import CompletedMailings from '@/components/pages/CompletedMailings';
import CancelledMailings from '@/components/pages/CancelledMailings';
import AuthCheck from '@/components/auth/AuthCheck';
import { ColorModeContext } from '@/theme/theme';
@ -211,6 +212,16 @@ const App = () => {
</PageWrapper>
}
/>
<Route
path="/cancelledMailings"
element={
<PageWrapper title="Cancelled Mailings">
<Layout>
<CancelledMailings />
</Layout>
</PageWrapper>
}
/>
<Route
path="/completedMailings"
element={

View File

@ -0,0 +1,150 @@
import { useState, useRef, useEffect } from 'react';
import { useSetupData, SetupData } from "@/context/SetupDataContext";
import VisibilityIcon from '@mui/icons-material/Visibility';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
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 CancelledMailings() {
const theme = useTheme();
const setupData: SetupData = useSetupData();
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>
</>
),
},
{ field: "id", headerName: "ID", width: 80 },
{ field: "name", headerName: "Name", flex: 1, minWidth: 160 },
{ field: "description", headerName: "Description", flex: 1, minWidth: 200 },
{
field: "templateId",
headerName: "Subject",
flex: 1,
minWidth: 160,
valueGetter: (_: number, row: Mailing) => setupData.templates.find(t => t.id === row.templateId)?.subject || 'Unknown',
},
];
const reloadMailings = async () => {
setMailingsLoading(true);
const mailingsResponse = await fetch("/api/mailings/status/c");
const mailingsData = await mailingsResponse.json();
if (mailingsData) {
setMailings(mailingsData);
setMailingsLoading(false);
} else {
console.error("Failed to fetch cancelled 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 handleUpdateRow = () => {
};
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,
},
},
sorting: {
sortModel: [{ field: 'id', sort: 'desc' }], // Default sort by scheduleDate, descending
},
}}
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 CancelledMailings;

View File

@ -45,20 +45,26 @@ function CompletedMailings() {
</>
),
},
{ field: "mailingId", headerName: "Mailing ID", width: 100 },
{ field: "mailingId", headerName: "Mailing ID", width: 90 },
{ field: "mailingName", headerName: "Name", flex: 1, minWidth: 160 },
{ field: "emailCount", headerName: "Emails", width: 100 },
{ field: "sendCount", headerName: "Active", width: 100 },
{ field: "deliveredCount", headerName: "Delivered", width: 100 },
{ field: "failedCount", headerName: "Failed", width: 100 },
{ field: "blockedCount", headerName: "Blocked", width: 100 },
{ field: "invalidCount", headerName: "Invalid", width: 100 },
{ field: "openCount", headerName: "Opens", width: 100 },
{ field: "sentDate", headerName: "Sent Date", width: 180,
renderCell: (params: GridRenderCellParams<MailingStatistic>) => (
<>
{params.value ? new Date(params.value).toLocaleString() : ''}
</>
)},
{ field: "emailCount", headerName: "Emails", width: 70 },
{ field: "sendCount", headerName: "Active", width: 70 },
{ field: "deliveredCount", headerName: "Delivered", width: 90 },
{ field: "failedCount", headerName: "Failed", width: 70 },
{ field: "blockedCount", headerName: "Blocked", width: 80 },
{ field: "invalidCount", headerName: "Invalid", width: 80 },
{ field: "openCount", headerName: "Opens", width: 70 },
{ field: "uniqueOpenCount", headerName: "Unique Opens", width: 120 },
{ field: "clickCount", headerName: "Clicks", width: 100 },
{ field: "clickCount", headerName: "Clicks", width: 70 },
{ field: "uniqueClickCount", headerName: "Unique Clicks", width: 120 },
{ field: "bounceCount", headerName: "Bounces", width: 100 },
{ field: "spamCount", headerName: "Spam", width: 100 },
{ field: "bounceCount", headerName: "Bounces", width: 80 },
{ field: "spamCount", headerName: "Spam", width: 60 },
{ field: "unsubscribeCount", headerName: "Unsubscribes", width: 120 },
];
@ -261,6 +267,9 @@ function CompletedMailings() {
pageSize: 20,
},
},
sorting: {
sortModel: [{ field: 'sentDate', sort: 'desc' }], // Default sort by scheduleDate, descending
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>

View File

@ -1,34 +1,19 @@
// src/components/pages/Home.tsx
//import React from 'react';
import Typography from '@mui/material/Typography';
import Box from '@mui/material/Box';
import Grid2 from '@mui/material/Grid2'; // v6 Grid2
//import Card from '@mui/material/Card';
//import CardContent from '@mui/material/CardContent';
//import { CardActionArea } from '@mui/material';
import { BarChart } from '@mui/x-charts/BarChart';
import LineChartSample from '@/components/widgets/LineChartSample';
import RecentMailingStatsChart from '@/components/widgets/RecentMailingStatsChart';
const Home = () => {
return (
<Box sx={{ flexGrow: 1 }}>
<Box sx={{
position: 'relative', left: 0, right: 0, height: "calc(100vh - 124px)", maxHeight: "700px", overflow: "hidden",
}}>
<Typography variant="h4" component="h1" gutterBottom>
Welcome to Surge365
Sent Mailing Statistics
</Typography>
<Grid2 container spacing={2}>
<Grid2 size={{ xs: 12, sm: 6, md: 6 }}>
<BarChart
xAxis={[{ scaleType: 'band', data: ['group A', 'group B', 'group C'] }]}
series={[{ data: [4, 3, 5] }, { data: [1, 6, 3] }, { data: [2, 5, 6] }]}
width={500}
height={300}
/>
</Grid2>
<Grid2 size={{ xs: 12, sm: 6, md: 4 }}>
<LineChartSample />
</Grid2>
</Grid2>
<RecentMailingStatsChart days={14} />
</Box>
);
};

View File

@ -8,8 +8,7 @@ import {
Box,
Alert,
} from '@mui/material';
import { AuthResponse, AuthErrorResponse, User, isAuthErrorResponse } from '@/types/auth';
import utils from '@/ts/utils';
import { AuthResponse, User } from '@/types/auth';
//import ForgotPasswordModal from '@/components/modals/ForgotPasswordModal';
type SpinnerState = Record<string, boolean>;
@ -72,35 +71,46 @@ function Login() {
let loggedInUser: User | null = null;
let hadLoginError: boolean = false;
let hadLoginErrorMessage: string = '';
await utils.webMethod<AuthResponse>({
methodPage: 'authentication',
methodName: 'authenticate',
parameters: { username, password },
success: (json: AuthResponse) => {
try {
localStorage.setItem('accessToken', json.accessToken);
loggedInUser = json.user;
} catch {
const errorMsg: string = 'Unexpected Error';
hadLoginError = true;
hadLoginErrorMessage = errorMsg;
}
},
error: (err: unknown) => {
let errorMsg: string = 'Unexpected Error';
if (isAuthErrorResponse(err)) {
const errorResponse = err as AuthErrorResponse;
if (errorResponse.data?.message) {
errorMsg = errorResponse.data.message;
}
console.error(errorMsg);
setLoginErrorMessage(errorMsg);
}
hadLoginError = true;
hadLoginErrorMessage = errorMsg;
},
const apiUrl = "/api/authentication/authenticate";
const response = await fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
//if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update");
if (response.ok) {
const json: AuthResponse = await response.json();
try {
localStorage.setItem('accessToken', json.accessToken);
loggedInUser = json.user;
if (loggedInUser == null) {
setLoginError(true);
setIsLoading(false);
setSpinners({ Login: false });
} else {
await finishUserLogin(loggedInUser);
}
} catch {
hadLoginError = true;
hadLoginErrorMessage = 'Unexpected Error';
}
}
else {
let data = null;
try {
data = await response.json();
} catch {
// Intentionally empty, if not json ignore
}
hadLoginError = true;
hadLoginErrorMessage = data?.message ?? 'Unexpected Error';
}
if (hadLoginError) {
setLoginErrorMessage(hadLoginErrorMessage);
setLoginError(true);
@ -131,19 +141,20 @@ function Login() {
resetAppSettings();
}, []);
const finishUserLogin = async (loggedInUser: User) => {
const finishUserLogin = async (_: User) => {
setIsLoading(false);
setSpinners({ Login: false, LoginWithPasskey: false });
utils.localStorage('session_currentUser', loggedInUser);
//utils.localStorage('session_currentUser', loggedInUser);
const redirectUrl = utils.sessionStorage('redirect_url');
if (redirectUrl) {
utils.sessionStorage('redirect_url', null);
document.location.href = redirectUrl;
} else {
document.location.href = '/home';
}
//const redirectUrl = utils.sessionStorage('redirect_url');
//if (redirectUrl) {
// utils.sessionStorage('redirect_url', null);
// document.location.href = redirectUrl;
//} else {
// document.location.href = '/home';
//}
document.location.href = '/home';
};
return (

View File

@ -154,6 +154,9 @@ function NewMailings() {
pageSize: 20,
},
},
sorting: {
sortModel: [{ field: 'id', sort: 'asc' }],
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>

View File

@ -3,7 +3,7 @@ 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 { Box, useTheme, useMediaQuery, CircularProgress, IconButton, List, Card, CardContent, Typography } 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";
@ -12,6 +12,7 @@ import ConfirmationDialog from "@/components/modals/ConfirmationDialog";
function ScheduleMailings() {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const [mailingsLoading, setMailingsLoading] = useState<boolean>(false);
@ -21,6 +22,7 @@ function ScheduleMailings() {
const [editOpen, setEditOpen] = useState<boolean>(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState<boolean>(false);
const [mailingToCancel, setMailingToCancel] = useState<Mailing | null>(null);
const [forceRender, setForceRender] = useState(false);
const formatRecurringString = (typeCode: string, startDate: string): string => {
const date = new Date(startDate);
@ -82,6 +84,7 @@ function ScheduleMailings() {
if (mailingsData) {
setMailings(mailingsData);
setMailingsLoading(false);
setForceRender(true);
} else {
console.error("Failed to fetch scheduled mailings");
setMailingsLoading(false);
@ -135,6 +138,12 @@ function ScheduleMailings() {
reloadMailings();
}, []);
useEffect(() => {
if (forceRender) {
setForceRender(false);
}
}, [forceRender]);
return (
<Box ref={gridContainerRef} sx={{
position: 'relative', left: 0, right: 0, height: "calc(100vh - 124px)", overflow: "hidden",
@ -143,7 +152,43 @@ function ScheduleMailings() {
duration: theme.transitions.duration.standard,
})
}}>
<Box sx={{ position: 'absolute', inset: 0 }}>
<Box sx={{ position: 'absolute', inset: 0 }}>{isMobile ? (
mailingsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<CircularProgress />
</Box>
) : (
<List>
{mailings.map((mailing) => (
<Card key={mailing.id} sx={{ marginBottom: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<CardContent>
<Typography variant="h6">{mailing.name}</Typography>
<Typography variant="body2">
Schedule Date: {mailing.scheduleDate ? new Date(mailing.scheduleDate).toLocaleString() : 'N/A'}
</Typography>
<Typography variant="body2">
Recurring: {mailing.recurringTypeCode && mailing.recurringStartDate
? formatRecurringString(mailing.recurringTypeCode, mailing.recurringStartDate)
: 'None'}
</Typography>
</CardContent>
<Box>
<IconButton onClick={() => handleView(mailing)}>
<VisibilityIcon />
</IconButton>
<IconButton onClick={() => handleCopy(mailing)}>
<ContentCopyIcon />
</IconButton>
<IconButton color="secondary" onClick={() => handleCancelClick(mailing)}>
<CancelIcon />
</IconButton>
</Box>
</Box>
</Card>
))}
</List>
)) : (
<DataGrid
rows={mailings}
columns={columns}
@ -173,11 +218,14 @@ function ScheduleMailings() {
pageSize: 20,
},
},
sorting: {
sortModel: [{ field: 'scheduleDate', sort: 'asc' }], // Default sort by scheduleDate, descending
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
</Box>
{viewOpen && (
<MailingView
open={viewOpen}

View File

@ -32,14 +32,14 @@ function Targets() {
</IconButton>
),
},
{ field: "id", headerName: "ID", width: 60 },
{ field: "id", headerName: "ID", width: 100 },
//{ field: "serverKey", headerName: "Server Key", flex: 1, minWidth: 140 },
{ field: "name", headerName: "Name", flex: 1, minWidth: 160 },
{ field: "databaseName", headerName: "Database", flex: 1, minWidth: 100 },
{ field: "viewName", headerName: "View", flex: 1, minWidth: 300 },
{ field: "filterQuery", headerName: "Filter Query", flex: 1, minWidth: 100 },
{ field: "allowWriteBack", headerName: "WriteBack?", width: 100 },
{ field: "isActive", headerName: "Active", width: 75 },
{ field: "allowWriteBack", headerName: "Write Back", width: 150 },
{ field: "isActive", headerName: "Active", width: 115 },
];

View File

@ -0,0 +1,41 @@
import { DataGrid, GridColDef } from '@mui/x-data-grid';
type TargetSampleDataGridProps = {
columns: GridColDef[];
rows: { [key: string]: string }[];
};
const TargetSampleDataGrid = ({ columns, rows }: TargetSampleDataGridProps) => {
return (
<DataGrid
rows={rows}
columns={columns}
autoHeight
hideFooter
hideFooterPagination
sx={{
width: "100%",
maxHeight: "800px",
'& .MuiDataGrid-main': {
overflowX: 'auto',
},
'& .MuiDataGrid-columnHeaderTitle': {
overflow: 'visible',
whiteSpace: 'normal',
lineHeight: '1.2em',
textOverflow: 'ellipsis',
},
}}
initialState={{
pagination: {
paginationModel: {
pageSize: 10,
},
},
}}
pageSizeOptions={[]}
/>
);
};
export default TargetSampleDataGrid;

View File

@ -0,0 +1,108 @@
import { useState, useEffect } from 'react';
import { Box } from '@mui/material';
import { BarChart } from '@mui/x-charts/BarChart';
import dayjs from 'dayjs';
import MailingStatistic from '@/types/mailingStatistic';
export default function RecentMailingStatsChart({ days = 7 }: { days?: number }) {
const [stats, setStats] = useState<MailingStatistic[]>([]);
const [loading, setLoading] = useState(true);
var startDate = dayjs().subtract(days, 'day').format('YYYY-MM-DD');
var endDate = dayjs().format('YYYY-MM-DD');
const fetchStats = async () => {
try {
const response = await fetch(`/api/mailings/status/s%2Csd/stats?startDate=${startDate}&endDate=${endDate}`);
const data: MailingStatistic[] = await response.json();
setStats(data);
} catch (error) {
console.error('Error fetching mailing stats:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStats();
const intervalId = setInterval(fetchStats, 5000);
return () => clearInterval(intervalId);
}, [days]);
if (loading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>Loading...</Box>;
}
if (!stats.length) {
return <Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>No data available</Box>;
}
// Aggregate stats by date
const aggregatedStats = stats.reduce((acc, stat) => {
const dateStr = stat.sentDate ? dayjs(stat.sentDate).format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD');
if (!acc[dateStr]) {
acc[dateStr] = {
sentCount: 0,
deliveredCount: 0,
failedCount: 0,
bounceCount: 0,
blockedCount: 0,
invalidCount: 0,
};
}
acc[dateStr].sentCount += stat.sendCount;
acc[dateStr].deliveredCount += stat.deliveredCount;
acc[dateStr].failedCount += stat.failedCount;
acc[dateStr].bounceCount += stat.bounceCount;
acc[dateStr].blockedCount += stat.blockedCount;
acc[dateStr].invalidCount += stat.invalidCount;
return acc;
}, {} as Record<string, { sentCount: number; deliveredCount: number; failedCount: number; bounceCount: number; blockedCount: number; invalidCount: number }>);
// Prepare chart data, sorted by date ascending
const sortedDates = Object.keys(aggregatedStats).sort((a, b) => dayjs(a).isBefore(dayjs(b)) ? -1 : 1);
const dates = sortedDates.map(date => new Date(date));
const sentData = sortedDates.map(date => aggregatedStats[date].sentCount);
const deliveredData = sortedDates.map(date => aggregatedStats[date].deliveredCount);
const errorData = sortedDates.map(date => (
aggregatedStats[date].failedCount +
aggregatedStats[date].bounceCount +
aggregatedStats[date].blockedCount +
aggregatedStats[date].invalidCount
));
return (
<BarChart
margin={{
left: 80,
right: 80,
top: 80,
bottom: 80,
}}
series={[
{
data: errorData,
label: 'Errors',
stack: 'total',
},
{
data: deliveredData,
label: 'Delivered',
stack: 'total',
},
{
data: sentData,
label: 'Sent',
stack: 'total',
},
]}
xAxis={[
{
scaleType: 'band',
data: dates,
valueFormatter: (value) => dayjs(value).format("MMM DD"),
},
]}
/>
);
}

View File

@ -46,91 +46,91 @@ const utils = {
const results = regex.exec(window.location.search);
return results ? decodeURIComponent(results[1].replace(/\+/g, ' ')) : null;
},
addAuthHeaders: (headers: Record<string, string> = {}): Record<string, string> => {
const authToken = utils.getCookie('Auth-Token');
if (authToken) {
headers['Auth-Token'] = authToken;
}
//addAuthHeaders: (headers: Record<string, string> = {}): Record<string, string> => {
// const authToken = utils.getCookie('Auth-Token');
// if (authToken) {
// headers['Auth-Token'] = authToken;
// }
const impersonateGuid = utils.getParameterByName("impersonateid") || sessionStorage.getItem('Auth-Impersonate-Guid');
if (impersonateGuid) {
sessionStorage.setItem('Auth-Impersonate-Guid', impersonateGuid);
headers['Auth-Impersonate-Guid'] = impersonateGuid;
}
// const impersonateGuid = utils.getParameterByName("impersonateid") || sessionStorage.getItem('Auth-Impersonate-Guid');
// if (impersonateGuid) {
// sessionStorage.setItem('Auth-Impersonate-Guid', impersonateGuid);
// headers['Auth-Impersonate-Guid'] = impersonateGuid;
// }
const franchiseCode = sessionStorage.getItem('franchiseCode');
if (franchiseCode) {
headers['Auth-Current-Franchise'] = franchiseCode;
}
// const franchiseCode = sessionStorage.getItem('franchiseCode');
// if (franchiseCode) {
// headers['Auth-Current-Franchise'] = franchiseCode;
// }
return headers;
},
webMethod: async <T = unknown>({
httpMethod = 'POST',
baseMethodPath = 'api/',
methodPage = '',
methodName = '',
parameters = {} as Record<string, unknown>,
contentType = 'application/json;',
timeout = 300000,
success = (_data: T) => { },
error = (_err: unknown) => { },
}: {
httpMethod?: string;
baseMethodPath?: string;
methodPage?: string;
methodName?: string;
contentType?: string;
parameters?: Record<string, unknown>;
timeout?: number;
success?: (_data: T) => void;
error?: (_err: unknown) => void;
}): Promise<void> => {
try {
const baseUrl = window.API_BASE_URL || '';
const url = `${baseUrl.replace(/\/$/, '')}/${baseMethodPath.replace(/\/$/, '')}/${methodPage}${methodName ? '/' + methodName : ''}`;
// return headers;
//},
//webMethod: async <T = unknown>({
// httpMethod = 'POST',
// baseMethodPath = 'api/',
// methodPage = '',
// methodName = '',
// parameters = {} as Record<string, unknown>,
// contentType = 'application/json;',
// timeout = 300000,
// success = (_data: T) => { },
// error = (_err: unknown) => { },
//}: {
// httpMethod?: string;
// baseMethodPath?: string;
// methodPage?: string;
// methodName?: string;
// contentType?: string;
// parameters?: Record<string, unknown>;
// timeout?: number;
// success?: (_data: T) => void;
// error?: (_err: unknown) => void;
//}): Promise<void> => {
// try {
// const baseUrl = window.API_BASE_URL || '';
// const url = `${baseUrl.replace(/\/$/, '')}/${baseMethodPath.replace(/\/$/, '')}/${methodPage}${methodName ? '/' + methodName : ''}`;
const headers = utils.addAuthHeaders({
'Content-Type': contentType,
});
// const headers = utils.addAuthHeaders({
// 'Content-Type': contentType,
// });
const controller = new AbortController();
//const timeoutId = setTimeout(() => controller.abort(), timeout);
setTimeout(() => controller.abort(), timeout);
// const controller = new AbortController();
// //const timeoutId = setTimeout(() => controller.abort(), timeout);
// setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {
method: httpMethod,
headers,
body: (httpMethod.toUpperCase() == "GET" ? null : JSON.stringify(parameters)),
signal: controller.signal,
});
// const response = await fetch(url, {
// method: httpMethod,
// headers,
// body: (httpMethod.toUpperCase() == "GET" ? null : JSON.stringify(parameters)),
// signal: controller.signal,
// });
if (!response.ok) {
let data = null;
try {
data = await response.json();
} catch {
// Intentionally empty, if not json ignore
}
throw new ApiError(response.status, data);
}
// if (!response.ok) {
// let data = null;
// try {
// data = await response.json();
// } catch {
// // Intentionally empty, if not json ignore
// }
// throw new ApiError(response.status, data);
// }
const authToken = response.headers.get('Auth-Token');
const loggedIn = response.headers.get('usahl_logged_in') === 'true';
// const authToken = response.headers.get('Auth-Token');
// const loggedIn = response.headers.get('usahl_logged_in') === 'true';
const expires = loggedIn ? 365 : 14;
document.cookie = `Auth-Token=${authToken};path=/;max-age=${expires * 24 * 60 * 60}`;
document.cookie = `usahl_logged_in=${loggedIn};path=/;max-age=${expires * 24 * 60 * 60}`;
// const expires = loggedIn ? 365 : 14;
// document.cookie = `Auth-Token=${authToken};path=/;max-age=${expires * 24 * 60 * 60}`;
// document.cookie = `usahl_logged_in=${loggedIn};path=/;max-age=${expires * 24 * 60 * 60}`;
const data = await response.json();
success(data);
} catch (err) {
if ((err as Error).name === 'AbortError') {
console.error('Request timed out');
}
error(err);
}
},
// const data = await response.json();
// success(data);
// } catch (err) {
// if ((err as Error).name === 'AbortError') {
// console.error('Request timed out');
// }
// error(err);
// }
//},
getBoolean: (variable: any): boolean => {
if (variable != null) {
switch (typeof variable) {
@ -144,47 +144,47 @@ const utils = {
}
return false;
},
isLoggedIn: (): boolean => {
return utils.getBoolean(utils.getCookie('usahl_logged_in'));
},
sessionStorage: (key: string, value?: any): any => {
if (value === undefined) {
let val = window.sessionStorage.getItem(key);
if (val && val.startsWith('usahl_json:')) {
val = val.substring(11);
return JSON.parse(val);
}
return val;
} else {
const val = typeof value === 'object' ? `usahl_json:${JSON.stringify(value)}` : value;
window.sessionStorage.setItem(key, val);
}
},
sessionStorageClear: (): void => {
window.sessionStorage.clear();
},
sessionStorageRemove: (key: string): void => {
window.sessionStorage.removeItem(key);
},
localStorage: (key: string, value?: any): any => {
if (value === undefined) {
let val = window.localStorage.getItem(key);
if (val && val.startsWith('usahl_json:')) {
val = val.substring(11);
return JSON.parse(val);
}
return val;
} else {
const val = typeof value === 'object' ? `usahl_json:${JSON.stringify(value)}` : value;
window.localStorage.setItem(key, val);
}
},
localStorageClear: (): void => {
window.localStorage.clear();
},
localStorageRemove: (key: string): void => {
window.localStorage.removeItem(key);
},
//isLoggedIn: (): boolean => {
// return utils.getBoolean(utils.getCookie('usahl_logged_in'));
//},
//sessionStorage: (key: string, value?: any): any => {
// if (value === undefined) {
// let val = window.sessionStorage.getItem(key);
// if (val && val.startsWith('usahl_json:')) {
// val = val.substring(11);
// return JSON.parse(val);
// }
// return val;
// } else {
// const val = typeof value === 'object' ? `usahl_json:${JSON.stringify(value)}` : value;
// window.sessionStorage.setItem(key, val);
// }
//},
//sessionStorageClear: (): void => {
// window.sessionStorage.clear();
//},
//sessionStorageRemove: (key: string): void => {
// window.sessionStorage.removeItem(key);
//},
//localStorage: (key: string, value?: any): any => {
// if (value === undefined) {
// let val = window.localStorage.getItem(key);
// if (val && val.startsWith('usahl_json:')) {
// val = val.substring(11);
// return JSON.parse(val);
// }
// return val;
// } else {
// const val = typeof value === 'object' ? `usahl_json:${JSON.stringify(value)}` : value;
// window.localStorage.setItem(key, val);
// }
//},
//localStorageClear: (): void => {
// window.localStorage.clear();
//},
//localStorageRemove: (key: string): void => {
// window.localStorage.removeItem(key);
//},
};
declare global {

View File

@ -1,6 +1,7 @@
export default interface MailingStatistic {
mailingId: number;
mailingName: string;
sentDate: string;
spamCount: number;
uniqueClickCount: number;
clickCount: number;

View File

@ -1,3 +1,4 @@
import TargetColumn from './targetColumn';
export interface Target {
id: number;
serverId: number;
@ -7,6 +8,7 @@ export interface Target {
filterQuery: string;
allowWriteBack: boolean;
isActive: boolean;
columns: TargetColumn[];
}
export default Target;

View File

@ -0,0 +1,11 @@
export interface TargetColumn {
id: number;
targetId: number;
typeCode: string;
dataTypeCode: string;
name: string;
writeBack: boolean;
isEmailAddress: boolean;
}
export default TargetColumn;

View File

@ -0,0 +1,7 @@
export interface TargetSampleColumn {
name: string;
type: string;
dataType: string;
}
export default TargetSampleColumn;