From c90b07386d64d3b091ed3e00e11ee14aee6ff7e7 Mon Sep 17 00:00:00 2001 From: Anatolii Grynchuk Date: Sat, 2 May 2026 02:43:59 +0300 Subject: [PATCH] feat: polished admin UI styles + email channels admin CRUD - Extract inline styles to wwwroot/css/admin.css - Bootstrap Icons for nav and buttons - Styled page headers, table, empty state, readonly fields - Email Channels admin: list, create, edit, delete - GetAllEmailChannelsQuery + handler - AdminChannelsController with full CRUD - form id + form= attribute pattern for EditorLayout footer buttons Ref: IT-628 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Repositories/IEmailChannelRepository.cs | 1 + .../Repositories/EmailChannelRepository.cs | 10 ++ .../GetAll/GetAllEmailChannelsHandler.cs | 30 ++++ .../GetAll/GetAllEmailChannelsQuery.cs | 7 + .../Admin/AdminChannelsController.cs | 153 ++++++++++++++++ .../ViewModels/EmailChannelEditViewModel.cs | 57 ++++++ .../Views/AdminChannels/Edit.cshtml | 141 +++++++++++++++ .../Views/AdminChannels/Index.cshtml | 93 ++++++++++ .../Views/AdminTemplates/Edit.cshtml | 22 ++- .../Views/AdminTemplates/Index.cshtml | 28 ++- .../Views/Shared/_EditorLayout.cshtml | 5 +- .../Views/Shared/_Layout.cshtml | 22 ++- .../wwwroot/css/admin.css | 167 ++++++++++++++++++ 13 files changed, 706 insertions(+), 30 deletions(-) create mode 100644 HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs create mode 100644 HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsQuery.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs create mode 100644 HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailChannelEditViewModel.cs create mode 100644 HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml create mode 100644 HrynCo.NotificationService.Web/Views/AdminChannels/Index.cshtml create mode 100644 HrynCo.NotificationService.Web/wwwroot/css/admin.css diff --git a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs index e1bfdc6..89e984b 100644 --- a/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs +++ b/HrynCo.NotificationService.DAL.Abstract/Repositories/IEmailChannelRepository.cs @@ -4,6 +4,7 @@ namespace HrynCo.NotificationService.DAL.Abstract.Repositories; public interface IEmailChannelRepository { + Task> GetAllAsync(CancellationToken ct = default); Task> GetByServiceAsync(string serviceName, CancellationToken ct = default); Task GetByIdAsync(Guid id, CancellationToken ct = default); Task AddAsync(EmailChannel channel, CancellationToken ct = default); diff --git a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs index 1fd914a..840bd74 100644 --- a/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs +++ b/HrynCo.NotificationService.DAL.EF/Repositories/EmailChannelRepository.cs @@ -13,6 +13,16 @@ internal sealed class EmailChannelRepository : EfRepository, { } + public async Task> GetAllAsync(CancellationToken ct = default) + { + var entities = await DbSet + .OrderBy(x => x.ServiceName) + .ThenBy(x => x.Priority) + .ToListAsync(ct); + + return entities.Select(MapToDomain).ToList(); + } + public async Task> GetByServiceAsync(string serviceName, CancellationToken ct = default) { var entities = await DbSet diff --git a/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs b/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs new file mode 100644 index 0000000..91299ef --- /dev/null +++ b/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsHandler.cs @@ -0,0 +1,30 @@ +using HrynCo.NotificationService.DAL.Abstract; +using HrynCo.NotificationService.DAL.Abstract.Providers; +using HrynCo.NotificationService.DAL.Abstract.Repositories; +using HrynCo.NotificationService.Services.Core; +using HrynCo.NotificationService.Services.Logging; +using static HrynCo.NotificationService.Services.Core.ServiceResultHelper; + +namespace HrynCo.NotificationService.Services.EmailChannels.GetAll; + +internal sealed class GetAllEmailChannelsHandler + : RequestHandler>> +{ + private readonly IEmailChannelRepository _channels; + + public GetAllEmailChannelsHandler( + IContextualSerilogLogger logger, + IUnitOfWork unitOfWork, + IEmailChannelRepository channels) + : base(logger, unitOfWork) + { + _channels = channels; + } + + protected override async Task>> DoOnHandle( + GetAllEmailChannelsQuery request, CancellationToken cancellationToken) + { + var channels = await _channels.GetAllAsync(cancellationToken); + return Success(channels); + } +} diff --git a/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsQuery.cs b/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsQuery.cs new file mode 100644 index 0000000..d9eb447 --- /dev/null +++ b/HrynCo.NotificationService.Services/EmailChannels/GetAll/GetAllEmailChannelsQuery.cs @@ -0,0 +1,7 @@ +using HrynCo.NotificationService.DAL.Abstract.Providers; +using MediatR; +using HrynCo.NotificationService.Services.Core; + +namespace HrynCo.NotificationService.Services.EmailChannels.GetAll; + +public sealed record GetAllEmailChannelsQuery : IRequest>>; diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs b/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs new file mode 100644 index 0000000..2f0cd56 --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Admin/AdminChannelsController.cs @@ -0,0 +1,153 @@ +using HrynCo.NotificationService.DAL.Abstract.Providers; +using HrynCo.NotificationService.Services.EmailChannels.Create; +using HrynCo.NotificationService.Services.EmailChannels.Delete; +using HrynCo.NotificationService.Services.EmailChannels.Get; +using HrynCo.NotificationService.Services.EmailChannels.GetAll; +using HrynCo.NotificationService.Services.EmailChannels.Update; +using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace HrynCo.NotificationService.Web.Controllers.Admin; + +[Route("admin/channels")] +public class AdminChannelsController : Controller +{ + private readonly IMediator _mediator; + + public AdminChannelsController(IMediator mediator) + { + _mediator = mediator; + } + + // GET /admin/channels + [HttpGet("")] + public async Task Index(CancellationToken ct) + { + var result = await _mediator.Send(new GetAllEmailChannelsQuery(), ct); + if (!result.IsSuccess) + { + ModelState.AddModelError("", result.Error?.Message ?? "Failed to load channels."); + return View(Array.Empty()); + } + + return View(result.Result); + } + + // GET /admin/channels/create + [HttpGet("create")] + public IActionResult Create() + { + return View("Edit", new EmailChannelEditViewModel()); + } + + // GET /admin/channels/{id} + [HttpGet("{id:guid}")] + public async Task Edit(Guid id, CancellationToken ct) + { + var result = await _mediator.Send(new GetEmailChannelQuery(id), ct); + if (!result.IsSuccess || result.Result is null) + return NotFound(); + + var channel = result.Result; + var smtp = channel.Settings as SmtpChannelSettings ?? new SmtpChannelSettings + { + Host = "", Username = "", Password = "", + FromEmail = "", FromName = "", AppDisplayName = "", AppBaseUrl = "" + }; + + var vm = new EmailChannelEditViewModel + { + Id = channel.Id, + ServiceName = channel.ServiceName, + Priority = channel.Priority, + EmailChannelType = channel.EmailChannelType, + DailyLimit = channel.DailyLimit, + MonthlyLimit = channel.MonthlyLimit, + WarnThresholdPercent = channel.WarnThresholdPercent, + IsActive = channel.IsActive, + Host = smtp.Host, + Port = smtp.Port, + Username = smtp.Username, + Password = smtp.Password, + UseSsl = smtp.UseSsl, + FromEmail = smtp.FromEmail, + FromName = smtp.FromName, + AppDisplayName = smtp.AppDisplayName, + AppBaseUrl = smtp.AppBaseUrl + }; + + return View(vm); + } + + // POST /admin/channels/save + [HttpPost("save")] + [ValidateAntiForgeryToken] + public async Task Save(EmailChannelEditViewModel model, CancellationToken ct) + { + if (!ModelState.IsValid) + return View("Edit", model); + + var smtpSettings = new SmtpChannelSettings + { + Host = model.Host, + Port = model.Port, + Username = model.Username, + Password = model.Password, + UseSsl = model.UseSsl, + FromEmail = model.FromEmail, + FromName = model.FromName, + AppDisplayName = model.AppDisplayName, + AppBaseUrl = model.AppBaseUrl + }; + + if (model.IsNew) + { + var command = new CreateEmailChannelCommand( + model.ServiceName, + model.Priority, + EmailChannelType.Smtp, + smtpSettings, + model.DailyLimit, + model.MonthlyLimit, + model.WarnThresholdPercent, + model.IsActive); + + var result = await _mediator.Send(command, ct); + if (!result.IsSuccess) + { + ModelState.AddModelError("", result.Error?.Message ?? "Failed to create channel."); + return View("Edit", model); + } + } + else + { + var command = new UpdateEmailChannelCommand( + model.Id, + model.Priority, + smtpSettings, + model.DailyLimit, + model.MonthlyLimit, + model.WarnThresholdPercent, + model.IsActive); + + var result = await _mediator.Send(command, ct); + if (!result.IsSuccess) + { + ModelState.AddModelError("", result.Error?.Message ?? "Failed to update channel."); + return View("Edit", model); + } + } + + return RedirectToAction(nameof(Index)); + } + + // POST /admin/channels/{id}/delete + [HttpPost("{id:guid}/delete")] + [ValidateAntiForgeryToken] + public async Task Delete(Guid id, CancellationToken ct) + { + await _mediator.Send(new DeleteEmailChannelCommand(id), ct); + return RedirectToAction(nameof(Index)); + } +} diff --git a/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailChannelEditViewModel.cs b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailChannelEditViewModel.cs new file mode 100644 index 0000000..1810baa --- /dev/null +++ b/HrynCo.NotificationService.Web/Controllers/Admin/ViewModels/EmailChannelEditViewModel.cs @@ -0,0 +1,57 @@ +using System.ComponentModel.DataAnnotations; +using HrynCo.NotificationService.DAL.Abstract.Providers; + +namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels; + +public class EmailChannelEditViewModel +{ + public Guid Id { get; set; } + + // ── Channel fields ───────────────────────────────────────────────── + [Required] + public string ServiceName { get; set; } = ""; + + public int Priority { get; set; } = 1; + + public EmailChannelType EmailChannelType { get; set; } = EmailChannelType.Smtp; + + public int? DailyLimit { get; set; } + + public int? MonthlyLimit { get; set; } + + [Range(1, 100)] + public int WarnThresholdPercent { get; set; } = 90; + + public bool IsActive { get; set; } = true; + + // ── SMTP Settings ────────────────────────────────────────────────── + [Required] + public string Host { get; set; } = ""; + + [Range(1, 65535)] + public int Port { get; set; } = 587; + + [Required] + public string Username { get; set; } = ""; + + [Required] + public string Password { get; set; } = ""; + + public bool UseSsl { get; set; } = true; + + [Required, EmailAddress] + public string FromEmail { get; set; } = ""; + + [Required] + public string FromName { get; set; } = ""; + + [Required] + public string AppDisplayName { get; set; } = ""; + + [Required] + public string AppBaseUrl { get; set; } = ""; + + // ── Computed ─────────────────────────────────────────────────────── + public bool IsNew => Id == Guid.Empty; + public string PageTitle => IsNew ? "Create Channel" : "Edit Channel"; +} diff --git a/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml b/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml new file mode 100644 index 0000000..ded7506 --- /dev/null +++ b/HrynCo.NotificationService.Web/Views/AdminChannels/Edit.cshtml @@ -0,0 +1,141 @@ +@using HrynCo.NotificationService.DAL.Abstract.Providers +@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels +@model EmailChannelEditViewModel +@{ + Layout = "~/Views/Shared/_EditorLayout.cshtml"; + ViewData["Title"] = Model.PageTitle; + ViewData["EditorTitle"] = Model.PageTitle; +} + +
+ @Html.AntiForgeryToken() + + + @if (!ViewData.ModelState.IsValid) + { +
+ @foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors)) + { +
@error.ErrorMessage
+ } +
+ } + + @* Channel Settings section *@ +
Channel Settings
+ +
+ + + +
+ +
+
+ + + +
+
+ + +
+
+
+ + +
+
+
+ +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + @* SMTP Settings section *@ +
SMTP Settings
+ +
+
+ + + +
+
+ + + +
+
+ +
+
+ + + +
+
+ + + +
+
+ +
+
+ + +
+
+ +
+
+ + + +
+
+ + + +
+
+ +
+
+ + + +
+
+ + + +
+
+ + @section FormActions { + + + Cancel + + } +
diff --git a/HrynCo.NotificationService.Web/Views/AdminChannels/Index.cshtml b/HrynCo.NotificationService.Web/Views/AdminChannels/Index.cshtml new file mode 100644 index 0000000..8ac187d --- /dev/null +++ b/HrynCo.NotificationService.Web/Views/AdminChannels/Index.cshtml @@ -0,0 +1,93 @@ +@using HrynCo.NotificationService.DAL.Abstract.Providers +@model IReadOnlyList +@{ + ViewData["Title"] = "Email Channels"; +} + + + +@if (!ViewData.ModelState.IsValid) +{ +
+ @foreach (var error in ViewData.ModelState.Values.SelectMany(v => v.Errors)) + { +
@error.ErrorMessage
+ } +
+} + +@if (Model is null || Model.Count == 0) +{ +
+
+ +

No email channels found.

+ + Create First Channel + +
+
+} +else +{ +
+
+ + + + + + + + + + + + + + @foreach (var c in Model) + { + + + + + + + + + + } + +
Service NameTypePriorityStatusDaily LimitMonthly LimitActions
@c.ServiceName@c.EmailChannelType@c.Priority + @if (c.IsActive) + { + Active + } + else + { + Inactive + } + @(c.DailyLimit.HasValue ? c.DailyLimit.ToString() : "—")@(c.MonthlyLimit.HasValue ? c.MonthlyLimit.ToString() : "—") + + Edit + +
+ @Html.AntiForgeryToken() + +
+
+
+
+} diff --git a/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml b/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml index 27103ef..5344878 100644 --- a/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminTemplates/Edit.cshtml @@ -21,42 +21,42 @@ } -
+
-
+
-
+
-
+
-
+
-
+
-
+
@@ -65,7 +65,11 @@
@section FormActions { - - ✖ Cancel + + + Cancel + } diff --git a/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml b/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml index ae1f80e..ccc62f6 100644 --- a/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml +++ b/HrynCo.NotificationService.Web/Views/AdminTemplates/Index.cshtml @@ -4,9 +4,11 @@ ViewData["Title"] = "Email Templates"; } -
-

📋 Email Templates

- + Create New Template + @if (!ViewData.ModelState.IsValid) @@ -21,13 +23,21 @@ @if (Model is null || Model.Count == 0) { -
No email templates found.
+
+
+ +

No email templates found.

+ + Create First Template + +
+
} else { -
+
- +
@@ -47,7 +57,9 @@ else diff --git a/HrynCo.NotificationService.Web/Views/Shared/_EditorLayout.cshtml b/HrynCo.NotificationService.Web/Views/Shared/_EditorLayout.cshtml index 9595b2c..442cf97 100644 --- a/HrynCo.NotificationService.Web/Views/Shared/_EditorLayout.cshtml +++ b/HrynCo.NotificationService.Web/Views/Shared/_EditorLayout.cshtml @@ -3,7 +3,10 @@ }
-
@ViewData["EditorTitle"]
+
+ + @ViewData["EditorTitle"] +
@RenderBody() diff --git a/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml b/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml index 8bc8b6f..39b28bd 100644 --- a/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml +++ b/HrynCo.NotificationService.Web/Views/Shared/_Layout.cshtml @@ -13,32 +13,30 @@ rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous" /> - + + -
Service Name@t.Subject ✏️ Edit + class="btn btn-sm btn-outline-primary me-1"> + Edit +
@@ -55,7 +67,7 @@ else