Add target and server management features

- Implemented CreateTarget and updated UpdateTarget methods in TargetsController.
- Introduced IServerService and IServerRepository for server operations.
- Created ServersController for handling server-related API requests.
- Added ServerUpdateDto for server updates and enhanced Target/Server classes with default values.
- Updated TargetRepository and TargetService for target creation and updates.
- Modified DapperConfiguration for server mappings.
- Updated package dependencies for form validation and state management.
- Enhanced Layout.tsx for improved responsiveness and user experience.
- Added TitleContext for managing page titles.
- Updated Servers.tsx and ServerEdit.tsx for server data handling.
- Improved error handling and loading states across components.
This commit is contained in:
David Headrick 2025-02-28 09:33:38 -06:00
parent 4180e50c9c
commit 6f00235702
30 changed files with 1043 additions and 107 deletions

View File

@ -0,0 +1,57 @@
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Surge365.MassEmailReact.Application.DTOs;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Server.Controllers
{
[Route("api/[controller]")]
[ApiController]
public class ServersController : ControllerBase
{
private readonly IServerService _serverService;
public ServersController(IServerService serverService)
{
_serverService = serverService;
}
[HttpGet("GetAll")]
public async Task<IActionResult> GetAll([FromQuery] bool? activeOnly, bool? returnPassword = null)
{
bool activeOnlyValue = activeOnly == null || activeOnly.Value ? true : false;
bool returnPasswordValue = returnPassword != null && returnPassword.Value ? true : false;
var servers = await _serverService.GetAllAsync(activeOnlyValue, returnPasswordValue);
return Ok(servers);
}
[HttpGet("{key}")]
public async Task<IActionResult> GetByKey(int id, bool? returnPassword = null)
{
bool returnPasswordValue = returnPassword == null || returnPassword.Value ? true : false;
var server = await _serverService.GetByIdAsync(id, returnPasswordValue);
return server is not null ? Ok(server) : NotFound($"Server with key '{id}' not found.");
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateServer(int id, [FromBody] ServerUpdateDto serverUpdateDto)
{
if (id != serverUpdateDto.Id)
return BadRequest("ID in URL does not match ID in request body");
var existingServer = await _serverService.GetByIdAsync(id);
if (existingServer == null)
return NotFound($"Server with ID {id} not found");
var success = await _serverService.UpdateAsync(serverUpdateDto);
if (!success)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to update server.");
var updatedServer = await _serverService.GetByIdAsync(id);
return Ok(updatedServer);
}
}
}

View File

@ -32,15 +32,29 @@ namespace Surge365.MassEmailReact.Server.Controllers
var target = await _targetService.GetByIdAsync(id);
return target is not null ? Ok(target) : NotFound($"Target with key '{id}' not found.");
}
[HttpPost()]
public async Task<IActionResult> CreateTarget(int id, [FromBody] TargetUpdateDto targetUpdateDto)
{
if (targetUpdateDto.Id != null && targetUpdateDto.Id > 0)
return BadRequest("Id must be null or 0");
var targetId = await _targetService.CreateAsync(targetUpdateDto);
if (targetId == null)
return StatusCode(StatusCodes.Status500InternalServerError, "Failed to craete target.");
var createdTarget = await _targetService.GetByIdAsync(targetId.Value);
return Ok(createdTarget);
}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateTarget(int id, [FromBody] TargetUpdateDto targetUpdateDto)
{
if (id != targetUpdateDto.Id)
return BadRequest("ID in URL does not match ID in request body");
return BadRequest("Id in URL does not match Id in request body");
var existingTarget = await _targetService.GetByIdAsync(id);
if (existingTarget == null)
return NotFound($"Target with ID {id} not found");
return NotFound($"Target with Id {id} not found");
var success = await _targetService.UpdateAsync(targetUpdateDto);
if (!success)

View File

@ -16,6 +16,8 @@ builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IAuthService, AuthService>();
builder.Services.AddScoped<ITargetService, TargetService>();
builder.Services.AddScoped<ITargetRepository, TargetRepository>();
builder.Services.AddScoped<IServerService, ServerService>();
builder.Services.AddScoped<IServerRepository, ServerRepository>();
var app = builder.Build();
app.UseDefaultFiles();

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 ServerUpdateDto
{
public int? Id { get; set; }
public string Name { get; set; } = "";
public string ServerName { get; set; } = "";
public short Port { get; set; }
public string Username { get; set; } = "";
public string Password { get; set; } = "";
}
}

View File

@ -0,0 +1,16 @@
using Surge365.MassEmailReact.Domain.Entities;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface IServerRepository
{
Task<Server?> GetByIdAsync(int id, bool returnPassword = false);
Task<List<Server>> GetAllAsync(bool activeOnly = true, bool returnPassword = false);
Task<bool> UpdateAsync(Server server);
}
}

View File

@ -0,0 +1,11 @@
using Surge365.MassEmailReact.Domain.Entities;
namespace Surge365.MassEmailReact.Application.Interfaces
{
public interface IServerService
{
Task<Server?> GetByIdAsync(int id, bool returnPassword = false);
Task<List<Server>> GetAllAsync(bool activeOnly = true, bool returnPassword = false);
Task<bool> UpdateAsync(ServerUpdateDto serverDto);
}
}

View File

@ -11,6 +11,7 @@ namespace Surge365.MassEmailReact.Application.Interfaces
{
Task<Target?> GetByIdAsync(int id);
Task<List<Target>> GetAllAsync(bool activeOnly = true);
Task<int?> CreateAsync(Target target);
Task<bool> UpdateAsync(Target target);
}
}

View File

@ -6,6 +6,7 @@ namespace Surge365.MassEmailReact.Application.Interfaces
{
Task<Target?> GetByIdAsync(int id);
Task<List<Target>> GetAllAsync(bool activeOnly = true);
Task<int?> CreateAsync(TargetUpdateDto targetDto);
Task<bool> UpdateAsync(TargetUpdateDto targetDto);
}
}

View File

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Domain.Entities
{
public class Server
{
public int? Id { get; private set; }
public string Name { get; set; } = "";
public string ServerName { get; set; } = "";
public short Port { get; set; }
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public Server() { }
}
}

View File

@ -10,10 +10,10 @@ namespace Surge365.MassEmailReact.Domain.Entities
{
public int? Id { get; private set; }
public int ServerId { get; set; }
public string Name { get; set; }
public string DatabaseName { get; set; }
public string ViewName { get; set; }
public string FilterQuery { get; set; }
public string Name { get; set; } = "";
public string DatabaseName { get; set; } = "";
public string ViewName { get; set; } = "";
public string FilterQuery { get; set; } = "";
public bool AllowWriteBack { get; set; }
public bool IsActive { get; set; }

View File

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

View File

@ -0,0 +1,22 @@
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 ServerMap : EntityMap<Server>
{
public ServerMap()
{
Map(t => t.Id).ToColumn("server_key");
Map(t => t.Name).ToColumn("name");
Map(t => t.ServerName).ToColumn("server_name");
Map(t => t.Port).ToColumn("port");
Map(t => t.Username).ToColumn("username");
}
}
}

View File

@ -0,0 +1,100 @@
using Dapper;
using Dapper.FluentMap;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Application.Interfaces;
using Surge365.MassEmailReact.Domain.Entities;
using Surge365.MassEmailReact.Domain.Enums;
using Surge365.MassEmailReact.Domain.Enums.Extensions;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Surge365.MassEmailReact.Infrastructure.Repositories
{
public class ServerRepository : IServerRepository
{
private IConfiguration _config;
private const string _connectionStringName = "MassEmail.ConnectionString";
private string? ConnectionString
{
get
{
return _config.GetConnectionString(_connectionStringName);
}
}
public ServerRepository(IConfiguration config)
{
_config = config;
#if DEBUG
if (!FluentMapper.EntityMaps.ContainsKey(typeof(Server)))
{
throw new InvalidOperationException("Server dapper mapping is missing. Make sure ConfigureMappings() is called inside program.cs (program startup).");
}
#endif
}
public async Task<Server?> GetByIdAsync(int serverKey, bool returnPassword = false)
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
Server? server = (await conn.QueryAsync<Server>("mem_get_server_by_id", new { server_key = serverKey }, commandType: CommandType.StoredProcedure)).FirstOrDefault();
if (server != null && !returnPassword)
{
server.Password = "";
}
return server;
}
public async Task<List<Server>> GetAllAsync(bool activeOnly = true, bool returnPassword = false)
{
ArgumentNullException.ThrowIfNull(_config);
ArgumentNullException.ThrowIfNull(_connectionStringName);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
List<Server> servers = (await conn.QueryAsync<Server>("mem_get_server_all", new { active_only = activeOnly }, commandType: CommandType.StoredProcedure)).ToList();
if (!returnPassword)
{
foreach (Server server in servers)
server.Password = "";
}
return servers;
}
public async Task<bool> UpdateAsync(Server server)
{
ArgumentNullException.ThrowIfNull(server);
ArgumentNullException.ThrowIfNull(server.Id);
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
var parameters = new DynamicParameters();
parameters.Add("@server_key", server.Id, DbType.Int32);
parameters.Add("@name", server.Name, DbType.String);
parameters.Add("@server_name", server.ServerName, DbType.String);
parameters.Add("@port", server.Port, DbType.Int32);
parameters.Add("@username", server.Username, DbType.String);
parameters.Add("@password", server.Password, DbType.String);
// Output parameter
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_save_server", parameters, commandType: CommandType.StoredProcedure);
// Retrieve the output parameter value
bool success = parameters.Get<bool>("@success");
return success;
}
//public void Add(Server server)
//{
// Servers.Add(server);
//}
}
}

View File

@ -56,6 +56,37 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
//return conn.Query<Target>("SELECT * FROM mem_target WHERE is_active = @active_only", new { active_only = activeOnly }).ToList();
}
public async Task<int?> CreateAsync(Target target)
{
ArgumentNullException.ThrowIfNull(target);
if (target.Id != null && target.Id > 0)
throw new Exception("ID must be null");
using SqlConnection conn = new SqlConnection(_config.GetConnectionString(_connectionStringName));
var parameters = new DynamicParameters();
parameters.Add("@target_key", dbType: DbType.Int32, direction: ParameterDirection.Output);
parameters.Add("@server_key", target.ServerId, DbType.Int32);
parameters.Add("@name", target.Name, DbType.String);
parameters.Add("@database_name", target.DatabaseName, DbType.String);
parameters.Add("@view_name", target.ViewName, DbType.String);
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);
// Output parameter
parameters.Add("@success", dbType: DbType.Boolean, direction: ParameterDirection.Output);
await conn.ExecuteAsync("mem_save_target", parameters, commandType: CommandType.StoredProcedure);
// Retrieve the output parameter value
bool success = parameters.Get<bool>("@success");
if(success)
return parameters.Get<int>("@target_key");
return null;
}
public async Task<bool> UpdateAsync(Target target)
{
ArgumentNullException.ThrowIfNull(target);

View File

@ -101,7 +101,7 @@ namespace Surge365.MassEmailReact.Infrastructure.Repositories
DataAccess da = new DataAccess(_config, _connectionStringName);
DataSet ds = await da.CallRetrievalProcedureAsync(pms, "adm_get_adm_login_all");
if (ds.Tables.Count == 0 || ds.Tables[0].Rows.Count == 0)
return null;
throw new Exception("No users returned");
return LoadFromDataRow(ds.Tables[0]);
}

View File

@ -0,0 +1,53 @@
using Surge365.MassEmailReact.Application.Interfaces;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using System.IdentityModel.Tokens.Jwt;
using Microsoft.IdentityModel.Tokens;
using Microsoft.Extensions.Configuration;
using Surge365.MassEmailReact.Domain.Entities;
using System.Security.Cryptography;
namespace Surge365.MassEmailReact.Infrastructure.Services
{
public class ServerService : IServerService
{
private readonly IServerRepository _serverRepository;
private readonly IConfiguration _config;
public ServerService(IServerRepository serverRepository, IConfiguration config)
{
_serverRepository = serverRepository;
_config = config;
}
public async Task<Server?> GetByIdAsync(int id, bool returnPassword = false)
{
return await _serverRepository.GetByIdAsync(id, returnPassword);
}
public async Task<List<Server>> GetAllAsync(bool activeOnly = true, bool returnPassword = false)
{
return await _serverRepository.GetAllAsync(activeOnly, returnPassword);
}
public async Task<bool> UpdateAsync(ServerUpdateDto serverDto)
{
ArgumentNullException.ThrowIfNull(serverDto, nameof(serverDto));
ArgumentNullException.ThrowIfNull(serverDto.Id, nameof(serverDto.Id));
var server = await _serverRepository.GetByIdAsync(serverDto.Id.Value);
if (server == null || server.Id == null) return false;
server.Name = serverDto.Name;
server.ServerName = serverDto.ServerName;
server.Port = serverDto.Port;
server.Username = serverDto.Username;
server.Password = serverDto.Password;
return await _serverRepository.UpdateAsync(server);
}
}
}

View File

@ -33,6 +33,24 @@ namespace Surge365.MassEmailReact.Infrastructure.Services
{
return await _targetRepository.GetAllAsync(activeOnly);
}
public async Task<int?> CreateAsync(TargetUpdateDto targetDto)
{
ArgumentNullException.ThrowIfNull(targetDto, nameof(targetDto));
if (targetDto.Id != null && targetDto.Id > 0)
throw new Exception("ID must be null");
var target = new Target();
target.ServerId = targetDto.ServerId;
target.Name = targetDto.Name;
target.DatabaseName = targetDto.DatabaseName;
target.ViewName = targetDto.ViewName;
target.FilterQuery = targetDto.FilterQuery;
target.AllowWriteBack = targetDto.AllowWriteBack;
target.IsActive = targetDto.IsActive;
return await _targetRepository.CreateAsync(target);
}
public async Task<bool> UpdateAsync(TargetUpdateDto targetDto)
{
ArgumentNullException.ThrowIfNull(targetDto, nameof(targetDto));

View File

@ -11,6 +11,7 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.1.1",
"@hookform/resolvers": "^4.1.2",
"@mui/icons-material": "^6.4.5",
"@mui/material": "^6.4.5",
"@mui/x-charts": "^7.27.1",
@ -23,8 +24,10 @@
"react": "^19.0.0",
"react-bootstrap": "^2.10.9",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-icons": "^5.3.0",
"react-router-dom": "^7.0.1"
"react-router-dom": "^7.0.1",
"yup": "^1.6.1"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
@ -1063,6 +1066,18 @@
"integrity": "sha512-XwVVXtERDQIM7HPUIbyDe0FP4SRovpjF7zMI8M7pbqFp3ahLJsJTd18h+E6pkar6UbV3btbwkKjYARr5M+SQow==",
"license": "Apache-2.0"
},
"node_modules/@hookform/resolvers": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.2.tgz",
"integrity": "sha512-wl6H9c9wLOZMJAqGLEVKzbCkxJuV+BYuLFZFCQtCwMe0b3qQk4kUBd/ZAj13SwcSqcx86rCgSCyngQfmA6DOWg==",
"license": "MIT",
"dependencies": {
"@standard-schema/utils": "^0.3.0"
},
"peerDependencies": {
"react-hook-form": "^7.0.0"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -1990,6 +2005,12 @@
"win32"
]
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@stencil/core": {
"version": "4.26.0",
"resolved": "https://registry.npmjs.org/@stencil/core/-/core-4.26.0.tgz",
@ -3959,6 +3980,12 @@
"react": ">=0.14.0"
}
},
"node_modules/property-expr": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz",
"integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -4042,6 +4069,22 @@
"react": "^19.0.0"
}
},
"node_modules/react-hook-form": {
"version": "7.54.2",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz",
"integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
@ -4358,6 +4401,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tiny-case": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
"integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==",
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -4371,6 +4420,12 @@
"node": ">=8.0"
}
},
"node_modules/toposort": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz",
"integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==",
"license": "MIT"
},
"node_modules/ts-api-utils": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.0.1.tgz",
@ -4409,6 +4464,18 @@
"node": ">= 0.8.0"
}
},
"node_modules/type-fest": {
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz",
"integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
@ -4659,6 +4726,18 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yup": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/yup/-/yup-1.6.1.tgz",
"integrity": "sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==",
"license": "MIT",
"dependencies": {
"property-expr": "^2.0.5",
"tiny-case": "^1.0.3",
"toposort": "^2.0.2",
"type-fest": "^2.19.0"
}
}
}
}

View File

@ -14,6 +14,7 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@fontsource/roboto": "^5.1.1",
"@hookform/resolvers": "^4.1.2",
"@mui/icons-material": "^6.4.5",
"@mui/material": "^6.4.5",
"@mui/x-charts": "^7.27.1",
@ -26,8 +27,10 @@
"react": "^19.0.0",
"react-bootstrap": "^2.10.9",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-icons": "^5.3.0",
"react-router-dom": "^7.0.1"
"react-router-dom": "^7.0.1",
"yup": "^1.6.1"
},
"devDependencies": {
"@eslint/js": "^9.19.0",

View File

@ -1,5 +1,7 @@
// src/components/layouts/Layout.tsx
import React, { ReactNode } from 'react';
import React, { ReactNode, useEffect } from 'react';
import { useTheme, useMediaQuery } from '@mui/material';
import { styled, useColorScheme } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Drawer from '@mui/material/Drawer';
@ -16,14 +18,26 @@ import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import DashboardIcon from '@mui/icons-material/Dashboard';
import DirectionsCarIcon from '@mui/icons-material/DirectionsCar';
import PeopleIcon from '@mui/icons-material/People';
import DnsIcon from '@mui/icons-material/Dns';
import TargetIcon from '@mui/icons-material/TrackChanges';
import MarkEmailReadIcon from '@mui/icons-material/MarkEmailRead';
import BlockIcon from '@mui/icons-material/Block';
import LinkOffIcon from '@mui/icons-material/LinkOff';
import EmailIcon from '@mui/icons-material/Email';
import SendIcon from '@mui/icons-material/Send';
import ScheduleSendIcon from '@mui/icons-material/ScheduleSend';
import AutorenewIcon from '@mui/icons-material/Autorenew';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import { Link as RouterLink } from 'react-router-dom';
import Select, { SelectChangeEvent } from '@mui/material/Select';
import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import { useTitle } from "@/context/TitleContext";
// Constants
const drawerWidth = 240;
@ -56,26 +70,26 @@ interface LayoutProps {
}
const Layout = ({ children }: LayoutProps) => {
const [open, setOpen] = React.useState(false);
const [open, setOpen] = React.useState(true);
const { mode, setMode } = useColorScheme(); // MUI v6 hook for theme switching
const iconButtonRef = React.useRef<HTMLButtonElement>(null)
const iconButtonRef = React.useRef<HTMLButtonElement>(null);
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); //TODO: Move this to shared utils?
const { title } = useTitle();
const handleDrawerOpen = () => {
setOpen(true);
sendResize();
};
const handleDrawerClose = () => {
setOpen(false);
sendResize();
};
useEffect(() => {
if (isMobile) {
setOpen(false);
}
}, [isMobile]);
const sendResize = () => {
//// Force window resize event after drawer state changes
//setTimeout(() => {
// window.dispatchEvent(new Event("resize"));
//}); // Delay slightly to ensure UI updates
};
const handleThemeChange = (event: SelectChangeEvent) => {
setMode(event.target.value as 'light' | 'dark');
if (iconButtonRef.current) {
@ -102,7 +116,7 @@ const Layout = ({ children }: LayoutProps) => {
duration: theme.transitions.duration.leavingScreen,
}),
...(open && {
width: `calc(100% - ${drawerWidth}px)`,
width: isMobile ? "100%" : `calc(100% - ${drawerWidth}px)`,
ml: `${drawerWidth}px`,
transition: theme.transitions.create(['width', 'margin', 'padding'], {
easing: theme.transitions.easing.easeInOut,
@ -112,6 +126,17 @@ const Layout = ({ children }: LayoutProps) => {
})}
>
<Toolbar>
{isMobile && open ?
<IconButton
color="inherit"
aria-label="close drawer"
onClick={handleDrawerClose}
edge="start"
ref={iconButtonRef}
sx={{ mr: 2 }}
>
<ChevronLeftIcon />
</IconButton> :
<IconButton
color="inherit"
aria-label="open drawer"
@ -122,8 +147,9 @@ const Layout = ({ children }: LayoutProps) => {
>
<MenuIcon />
</IconButton>
}
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
Surge365 Dashboard
{title}
</Typography>
<FormControl sx={{ minWidth: 120 }} size="small">
<InputLabel
@ -145,14 +171,14 @@ const Layout = ({ children }: LayoutProps) => {
color: 'white', // White text
'& .MuiSvgIcon-root': { color: 'white' }, // White dropdown arrow
'& .MuiOutlinedInput-notchedOutline': {
borderColor: 'white', // Gray in light, white in dark
borderColor: 'white',
},
'&:hover .MuiOutlinedInput-notchedOutline': {
borderColor: 'white', // Darker gray on hover in light
borderColor: 'white',
borderWidth: 2
},
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
borderColor: 'white', // Even darker gray when focused in light
borderColor: 'white',
borderWidth: 2
},
}}
@ -166,18 +192,19 @@ const Layout = ({ children }: LayoutProps) => {
{/* Sidebar */}
<Drawer
variant="persistent"
variant={isMobile ? "temporary" : "persistent"}
anchor="left"
open={open}
sx={{
width: open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
width: isMobile ? "100%" : open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
width: isMobile ? "100%" : open ? `var(--mui-drawer-width, ${drawerWidth}px)` : 0,
boxSizing: 'border-box',
},
}}
>
<DrawerHeader>
<IconButton onClick={handleDrawerClose}>
<ChevronLeftIcon />
@ -187,8 +214,16 @@ const Layout = ({ children }: LayoutProps) => {
<List>
{[
{ text: 'Home', icon: <DashboardIcon />, path: '/home' },
{ text: 'Targets', icon: <DirectionsCarIcon />, path: '/targets' },
{ text: 'Templates', icon: <PeopleIcon />, path: '/templates' },
{ text: 'Servers', icon: <DnsIcon />, path: '/servers' },
{ text: 'Targets', icon: <TargetIcon />, path: '/targets' },
{ text: 'Test Lists', icon: <MarkEmailReadIcon />, path: '/testLists' },
{ text: 'Blocked Emails', icon: <BlockIcon />, path: '/blockedEmails' },
{ text: 'Unsubscribe Urls', icon: <LinkOffIcon />, path: '/unsubscribeUrls' },
{ text: 'Templates', icon: <EmailIcon />, path: '/templates' },
{ text: 'New Mailings', icon: <SendIcon />, path: '/newMailings' }, //TODO: Maybe move all mailings to same page? Mailing stats on dashboard?
{ text: 'Scheduled Mailings', icon: <ScheduleSendIcon />, path: '/scheduledMailings' }, //
{ text: 'Active Mailings', icon: <AutorenewIcon />, path: '/activeMailings' },
{ text: 'Completed Mailings', icon: <CheckCircleIcon />, path: '/completedMailings' },
].map((item) => (
<ListItem key={item.text} disablePadding>
<ListItemButton component={RouterLink} to={item.path}>

View File

@ -0,0 +1,107 @@
import { useState, useEffect } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
DialogActions,
Button,
} from "@mui/material";
import Server from "@/types/server";
type ServerEditProps = {
open: boolean;
server: Server;
onClose: () => void;
onSave: (updatedServer: Server) => void;
};
const ServerEdit = ({ open, server, onClose, onSave }: ServerEditProps) => {
const [formData, setFormData] = useState<Server>({ ...server });
//const [serverError, setServerError] = useState(false); // Track validation
const [loading, setLoading] = useState(false);
useEffect(() => { //Reset form to unedited state on open or server change
if (open) {
setFormData(server);
}
}, [open, server]);
const handleChange = (field: string, value: any) => {
setFormData((prev) => ({ ...prev, [field]: value || "" }));
//if (field === "serverId" && value) setServerError(false);
};
const handleSave = async () => {
setLoading(true);
try {
const response = await fetch(`/api/servers/${formData.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) throw new Error("Failed to update");
const updatedServer = await response.json();
onSave(updatedServer); // Update UI optimistically
onClose();
} catch (error) {
console.error("Update error:", error);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Edit Server - id={formData.id}</DialogTitle>
<DialogContent>
<TextField
label="Name"
fullWidth
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
margin="dense"
/>
<TextField
label="Server Name"
fullWidth
value={formData.serverName}
onChange={(e) => handleChange("serverName", e.target.value)}
margin="dense"
/>
<TextField
label="Port"
fullWidth
value={formData.port}
onChange={(e) => handleChange("port", e.target.value)}
margin="dense"
/>
<TextField
label="Username"
fullWidth
value={formData.username}
onChange={(e) => handleChange("username", e.target.value)}
margin="dense"
/>
<TextField
label="Password"
fullWidth
value={formData.password}
onChange={(e) => handleChange("password", e.target.value)}
margin="dense"
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>Cancel</Button>
<Button onClick={handleSave} color="primary" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</DialogActions>
</Dialog>
);
};
export default ServerEdit;

View File

@ -1,44 +1,105 @@
import { useState } from "react";
import { useState, useEffect, useMemo } from "react";
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
Autocomplete,
DialogActions,
Button,
Switch,
FormControlLabel,
} from "@mui/material";
import { Target } from "@/types/target";
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";
type TargetEditProps = {
open: boolean;
target: Target;
target: Target | null;
onClose: () => void;
onSave: (updatedTarget: Target) => void;
};
const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
const [formData, setFormData] = useState<Target>({ ...target });
const [loading, setLoading] = useState(false);
const schema = yup.object().shape({
id: yup.number(),
serverId: yup.number().typeError("Server is required").required("Server is required").moreThan(0, "Server is required"),
name: yup
.string()
.required("Name is required")
.test("unique-name", "Name must be unique", function (value) {
const setupData = this.options.context?.setupData as { targets: Target[] };
if (!setupData) return true;
const handleChange = (field: keyof Target, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
return !setupData.targets.some(
(t) => t.name.toLowerCase() === value?.toLowerCase() && (t.id === 0 || t.id !== this.parent.id)
);
}),
databaseName: yup.string().required("Database name is required"),
viewName: yup.string().required("View name is required"),
filterQuery: yup.string().nullable(),
allowWriteBack: yup.boolean().default(false),
isActive: yup.boolean().default(true),
});
//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: "",
serverId: 0,
databaseName: "",
viewName: "",
filterQuery: "",
allowWriteBack: false,
isActive: true,
};
const handleSave = async () => {
const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
const isNew = !target || target.id === 0;
const setupData: SetupData = useSetupData();
const { register, trigger, control, handleSubmit, reset, formState: { errors } } = useForm<Target>({
mode: "onBlur",
defaultValues: target || defaultTarget,
resolver: yupResolver(schema) as Resolver<Target>,
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
if (open) {
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";
setLoading(true);
try {
const response = await fetch(`/api/targets/${formData.id}`, {
method: "PUT",
const response = await fetch(apiUrl, {
method: method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (!response.ok) throw new Error("Failed to update");
if (!response.ok) throw new Error(isNew ? "Failed to create" : "Failed to update");
const updatedTarget = await response.json();
onSave(updatedTarget); // Update UI optimistically
onSave(updatedTarget);
onClose();
} catch (error) {
console.error("Update error:", error);
@ -49,74 +110,98 @@ const TargetEdit = ({ open, target, onClose, onSave }: TargetEditProps) => {
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Edit Target</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}
options={setupData.servers}
getOptionLabel={(option) => option.name}
value={setupData.servers.find((s) => s.id === Number(field.value)) || null}
onChange={(_, newValue) => {
field.onChange(newValue ? newValue.id : null);
trigger("serverId");
}}
renderInput={(params) => (
<TextField
label="Target Key"
{...params}
label="Server"
fullWidth
value={formData.id}
onChange={(e) => handleChange("id", e.target.value)}
margin="dense"
/>
<TextField
label="Server Key"
fullWidth
value={formData.serverId}
onChange={(e) => handleChange("serverId", e.target.value)}
margin="dense"
error={!!errors.serverId}
helperText={errors.serverId?.message}
/>
)}
/>
)}
/>
<TextField
{...register("name")}
label="Name"
fullWidth
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
margin="dense"
error={!!errors.name}
helperText={errors.name?.message}
/>
<TextField
{...register("databaseName")}
label="Database Name"
fullWidth
value={formData.databaseName}
onChange={(e) => handleChange("databaseName", e.target.value)}
margin="dense"
error={!!errors.databaseName}
helperText={errors.databaseName?.message}
/>
<TextField
{...register("viewName")}
label="View Name"
fullWidth
value={formData.viewName}
onChange={(e) => handleChange("viewName", e.target.value)}
margin="dense"
error={!!errors.viewName}
helperText={errors.viewName?.message}
/>
<TextField
{...register("filterQuery")}
label="Filter Query"
fullWidth
value={formData.filterQuery}
onChange={(e) => handleChange("filterQuery", e.target.value)}
margin="dense"
error={!!errors.filterQuery}
helperText={errors.filterQuery?.message}
/>
<FormControlLabel
control={
<Switch
checked={formData.allowWriteBack}
onChange={(e) => handleChange("allowWriteBack", e.target.checked)}
{...register("allowWriteBack")}
/>
}
label="Allow Write Back"
/>
<Controller
name="isActive"
control={control}
render={({ field }) => (
<FormControlLabel
control={
<Switch
checked={formData.isActive}
onChange={(e) => handleChange("isActive", e.target.checked)}
{...field}
checked={field.value}
onChange={(e) => field.onChange(e.target.checked)}
/>
}
label="Active"
/>
)}
/>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={loading}>Cancel</Button>
<Button onClick={handleSave} color="primary" disabled={loading}>
<Button onClick={handleSubmit(handleSave)} color="primary" disabled={loading}>
{loading ? "Saving..." : "Save"}
</Button>
</DialogActions>

View File

@ -1,18 +1,35 @@
// App.tsx or main routing component
import React from 'react';
import React, { useEffect, ReactNode } from "react";
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Layout from '@/components/layouts/Layout';
import LayoutLogin from '@/components/layouts/LayoutLogin';
import Home from '@/components/pages/Home';
import Login from '@/components/pages/Login';
import Servers from '@/components/pages/Servers';
import Targets from '@/components/pages/Targets';
import Templates from '@/components/pages/Templates';
import { ColorModeContext } from '@/theme/theme';
import { SetupDataProvider } from '@/context/SetupDataContext';
import { useTitle } from "@/context/TitleContext";
import { createTheme, ThemeProvider } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
interface PageWrapperProps {
title: string;
children: ReactNode;
}
const PageWrapper: React.FC<PageWrapperProps> = ({ title, children }) => {
const { setTitle } = useTitle();
useEffect(() => {
setTitle(title);
}, [title, setTitle]);
return <>{children}</>;
};
const App = () => {
const [mode, setMode] = React.useState<'light' | 'dark'>('light');
@ -38,25 +55,41 @@ const App = () => {
<Route
path="/home"
element={
<PageWrapper title="Dashboard">
<Layout>
<Home />
</Layout>
</PageWrapper>
}
/>
<Route
path="/servers"
element={
<PageWrapper title="Servers">
<Layout>
<Servers />
</Layout>
</PageWrapper>
}
/>
<Route
path="/targets"
element={
<PageWrapper title="Targets">
<Layout>
<Targets />
</Layout>
</PageWrapper>
}
/>
<Route
path="/templates"
element={
<PageWrapper title="Templates">
<Layout>
<Templates />
</Layout>
</PageWrapper>
}
/>
<Route

View File

@ -4,6 +4,7 @@ import { createTheme, ThemeProvider } from '@mui/material/styles';
import '@/css/main.css'
import App from '@/components/pages/App'
import '@/config/constants';
import { TitleProvider } from "@/context/TitleContext";
//DEFAULT THEMES
@ -70,7 +71,9 @@ if (rootElement) {
createRoot(rootElement).render(
<React.StrictMode>
<ThemeProvider theme={theme} defaultMode="system">
<TitleProvider>
<App />
</TitleProvider>
</ThemeProvider>
</React.StrictMode>
);

View File

@ -0,0 +1,170 @@
import { useState, useRef, useEffect } from 'react';
import { useSetupData, SetupData } from "@/context/SetupDataContext";
//import Typography from '@mui/material/Typography';
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress, IconButton } from '@mui/material';
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
import { Lock, LockOpen } from "@mui/icons-material";
//import utils from '@/ts/utils';
import Server from '@/types/server';
import ServerEdit from "@/components/modals/ServerEdit";
function Servers() {
const theme = useTheme();
const setupData: SetupData = useSetupData();
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
const [servers, setServers] = useState<Server[] | null>(null);
const gridContainerRef = useRef<HTMLDivElement | null>(null);
const [selectedRow, setSelectedRow] = useState<Server | null>(null);
const [open, setOpen] = useState<boolean>(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const togglePasswordVisibility = async () => {
if (isPasswordVisible) {
setIsPasswordVisible(false);
setServers(setupData.servers);
}
else {
try {
setIsPasswordVisible(true);
const serversResponse = await fetch("/api/servers/GetAll?activeOnly=false&returnPassword=true");
const serversData = await serversResponse.json();
setServers(serversData);
}
catch (error) {
console.error("Error fetching servers:", error);
}
}
};
const columns: GridColDef<Server>[] = [
{
field: "actions",
headerName: "Actions",
sortable: false,
renderCell: (params: GridRenderCellParams<Server>) => (
<Button variant="contained" color="primary" size="small" onClick={() => handleEdit(params.row)}>
Edit
</Button>
),
},
{ field: "id", headerName: "ID", width: 60 },
{ field: "name", headerName: "Name", flex: 1, minWidth: 160 },
{ field: "serverName", headerName: "Database", flex: 1, minWidth: 160 },
{ field: "port", headerName: "Port", width: 75 },
{ field: "username", headerName: "Username", flex: 1, minWidth: 100 },
{
field: "password",
headerName: "Password",
width: 175,
renderHeader: () => (
<div style={{ display: "flex", alignItems: "center" }}>
Password
<IconButton size="small" onClick={togglePasswordVisibility} sx={{ marginLeft: 1 }}>
{isPasswordVisible ? <LockOpen /> : <Lock />}
</IconButton>
</div>
),
renderCell: (params: GridRenderCellParams<Server>) =>
isPasswordVisible ? params.value : "••••••••",
},
];
const handleEdit = (row: GridRowModel<Server>) => {
setSelectedRow(row);
setOpen(true);
};
const handleUpdateRow = (updatedRow: Server) => {
setupData.setServers(updatedRow);
updateServers(updatedRow);
};
const updateServers = (updatedServer: Server) => {
setServers((prevServers) => {
if (prevServers == null) return null;
return prevServers.map((server) => (server.id === updatedServer.id ? updatedServer : server))
});
};
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 }}>
{isMobile ? (
<List>
{ (!isPasswordVisible ? setupData.servers : servers)?.map((row) => (
<Card key={row.id} sx={{ marginBottom: 2 }}>
<CardContent>
<Typography variant="h6">{row.name}</Typography>
<Typography variant="body2">Name: {row.name}</Typography>
<Typography variant="body2">Server Name: {row.serverName}</Typography>
<Typography variant="body2">Port: {row.port}</Typography>
<Typography variant="body2">Username: {row.username}</Typography>
<Typography variant="body2">Password: {row.password}</Typography>
</CardContent>
</Card>
))}
</List>
) : (
<DataGrid
rows={(!isPasswordVisible ? setupData.servers : servers)!}
columns={columns}
autoPageSize
sx={{ minWidth: "600px" }}
//getRowId={(row) => row.id!}//Set this if object doesn't have id as unique id (like serverKey etc)
slots={{
toolbar: () => (
<GridToolbarContainer sx={{ display: "flex", alignItems: "center" }}>
<Button
variant="contained"
color="primary"
onClick={() => setupData.reloadServers()} // Refresh only active servers
sx={{ marginRight: 2 }}
>
{setupData.serversLoading ? <CircularProgress size={24} color="inherit" /> : "Refresh"}
</Button>
<GridToolbarColumnsButton />
<GridToolbarDensitySelector />
<GridToolbarExport />
<GridToolbarQuickFilter sx={{ ml: "auto" }} /> {/* Keeps the search filter in the toolbar */}
</GridToolbarContainer>
),
}}
slotProps={{
toolbar: {
showQuickFilter: true,
quickFilterProps: { /*debounceMs: 500/*Optional: Adds debounce to search*/ },
},
}}
initialState={{
pagination: {
paginationModel: {
pageSize: 20,
},
},
}}
pageSizeOptions={[10, 20, 50, 100]}
/>
)}
</Box>
{/* Server Edit Modal */}
{selectedRow && (
<ServerEdit
open={open}
server={selectedRow}
onClose={() => setOpen(false)}
onSave={handleUpdateRow}
/>
)}
</Box>
);
}
export default Servers;

View File

@ -4,7 +4,7 @@ import { useSetupData, SetupData } from "@/context/SetupDataContext";
import { List, Card, CardContent, Typography, Box, useTheme, useMediaQuery, Button, CircularProgress } from '@mui/material';
import { DataGrid, GridColDef, GridRenderCellParams, GridRowModel, GridToolbarContainer, GridToolbarQuickFilter, GridToolbarExport, GridToolbarDensitySelector, GridToolbarColumnsButton } from '@mui/x-data-grid';
//import utils from '@/ts/utils';
import { Target } from '@/types/target';
import Target from '@/types/target';
import TargetEdit from "@/components/modals/TargetEdit";
@ -67,6 +67,10 @@ function Targets() {
// if (hadError)
// alert(hadErrorMessage); //TODO: Make this look better. MUI Alert popup?
//}
const handleNew = () => {
setSelectedRow(null);
setOpen(true);
};
const handleEdit = (row: GridRowModel<Target>) => {
setSelectedRow(row);
setOpen(true);
@ -114,7 +118,15 @@ function Targets() {
<Button
variant="contained"
color="primary"
onClick={() => setupData.reloadTargets()} // Refresh only active targets
onClick={() => handleNew()}
sx={{ marginRight: 2 }}
>
{setupData.targetsLoading ? <CircularProgress size={24} color="inherit" /> : "Add New"}
</Button>
<Button
variant="contained"
color="primary"
onClick={() => setupData.reloadTargets()}
sx={{ marginRight: 2 }}
>
{setupData.targetsLoading ? <CircularProgress size={24} color="inherit" /> : "Refresh"}
@ -145,7 +157,7 @@ function Targets() {
</Box>
{/* Target Edit Modal */}
{selectedRow && (
{open && (
<TargetEdit
open={open}
target={selectedRow}

View File

@ -8,6 +8,7 @@ export type SetupData = {
setTargets: (updatedTarget: Target) => void;
servers: Server[];
reloadServers: () => void;
setServers: (updatedServer: Server) => void;
reloadSetupData: () => void;
targetsLoading: boolean;
serversLoading: boolean;
@ -46,10 +47,9 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
const targetsData = await targetsResponse.json();
setTargets(targetsData);
setTargetsLoading(false);
//const serversResponse = await fetch("/api/setup/servers"); //TODO: call once setup
//const serversData = await serversResponse.json();
const serversResponse = await fetch("/api/servers/GetAll?activeOnly=false&returnPassword=false");
const serversData = await serversResponse.json();
const serversData: Server[] = [];
setServers(serversData);
setServersLoading(false);
setDataLoading(false);
@ -63,8 +63,20 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
};
const updateTargetCache = (updatedTarget: Target) => {
setTargets((prevTargets) =>
prevTargets.map((target) => (target.id === updatedTarget.id ? updatedTarget : target))
setTargets((prevTargets) => {
const targetExists = prevTargets.some((target) => target.id === updatedTarget.id);
return targetExists
? prevTargets.map((target) => (target.id === updatedTarget.id ? updatedTarget : target))
: [...prevTargets, updatedTarget]; // Push new target if not found
});
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets }));
};
const updateServerCache = (updatedServer: Server) => {
updatedServer.password = "";
setServers((prevServers) =>
prevServers.map((server) => (server.id === updatedServer.id ? updatedServer : server))
);
sessionStorage.setItem("setupData", JSON.stringify({ servers, targets }));
};
@ -73,7 +85,7 @@ export const SetupDataProvider = ({ children }: { children: React.ReactNode }) =
}, []);
return (
<SetupDataContext.Provider value={{ targets, reloadTargets: reloadSetupData, setTargets: updateTargetCache, servers, reloadServers: reloadSetupData, reloadSetupData: reloadSetupData, targetsLoading, serversLoading, dataLoading }}>
<SetupDataContext.Provider value={{ targets, reloadTargets: reloadSetupData, setTargets: updateTargetCache, servers, reloadServers: reloadSetupData, setServers: updateServerCache, reloadSetupData: reloadSetupData, targetsLoading, serversLoading, dataLoading }}>
{children}
</SetupDataContext.Provider>
);

View File

@ -0,0 +1,26 @@
import { createContext, useContext, useState, ReactNode } from "react";
interface TitleContextType {
title: string;
setTitle: (title: string) => void;
}
const TitleContext = createContext<TitleContextType | undefined>(undefined);
export const TitleProvider = ({ children }: { children: ReactNode }) => {
const [title, setTitle] = useState<string>("Default Title");
return (
<TitleContext.Provider value={{ title, setTitle }}>
{children}
</TitleContext.Provider>
);
};
export const useTitle = (): TitleContextType => {
const context = useContext(TitleContext);
if (!context) {
throw new Error("useTitle must be used within a TitleProvider");
}
return context;
};

View File

@ -1,5 +1,10 @@
export interface Server {
id?: number;
name: string;
serverName: string;
port: number;
username: string;
password: string;
}
export default Server;

View File

@ -9,3 +9,4 @@ export interface Target {
isActive: boolean;
}
export default Target;