diff --git a/API/API.csproj b/API/API.csproj index 8c54c5c..bd6e907 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -23,6 +23,7 @@ + diff --git a/API/Controllers/ExportController.cs b/API/Controllers/ExportController.cs new file mode 100644 index 0000000..33e51e9 --- /dev/null +++ b/API/Controllers/ExportController.cs @@ -0,0 +1,43 @@ +using API.Constants; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Extensions; +using API.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +public class ExportController(ILogger logger, IExportService exportService, IUnitOfWork unitOfWork): BaseApiController +{ + + [HttpPost] + [Authorize(PolicyConstants.HandleDeliveries)] + public async Task> Export(ExportRequestDto dto) + { + logger.LogInformation("Creating export on behalf of {UserId}", User.GetUserId()); + + var deliveries = + await unitOfWork.DeliveryRepository.GetDeliveryByIds(dto.DeliveryIds, DeliveryIncludes.Complete); + if (deliveries.Count == 0) + { + return BadRequest(); + } + + + var uuid = await exportService.Export(deliveries, dto); + return Ok(uuid); + } + + [HttpGet("{uuid}")] + [Authorize(PolicyConstants.HandleDeliveries)] + public async Task GetExport(string uuid) + { + var export = await exportService.GetExport(uuid); + if (export == null) return NotFound(); + + return export; + } + +} \ No newline at end of file diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs new file mode 100644 index 0000000..a2efcb5 --- /dev/null +++ b/API/Controllers/SettingsController.cs @@ -0,0 +1,27 @@ +using API.Constants; +using API.DTOs; +using API.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +[Authorize(PolicyConstants.ManageApplication)] +public class SettingsController(ILogger logger, ISettingsService settingsService): BaseApiController +{ + + [HttpGet] + public async Task> GetSettings() + { + var settings = await settingsService.GetSettingsAsync(); + return Ok(settings); + } + + [HttpPost] + public async Task SaveSettings(ServerSettingsDto settings) + { + await settingsService.SaveSettingsAsync(settings); + return Accepted(); + } + +} \ No newline at end of file diff --git a/API/DTOs/Enum/DeliveryExportField.cs b/API/DTOs/Enum/DeliveryExportField.cs new file mode 100644 index 0000000..9585b89 --- /dev/null +++ b/API/DTOs/Enum/DeliveryExportField.cs @@ -0,0 +1,56 @@ +using API.Entities; + +namespace API.DTOs.Enum; + +public enum DeliveryExportField +{ + /// + /// + /// + Id = 0, + /// + /// + /// + State = 1, + /// + /// + /// + FromId = 2, + /// + /// + /// + From = 3, + /// + /// + /// + RecipientId = 4, + /// + /// + /// + RecipientName = 5, + /// + /// + /// + RecipientEmail = 6, + /// + /// + /// + CompanyNumber = 7, + /// + /// + /// + Message = 8, + /// + /// + /// All products accross exported deliveries will be added as headers. Each row will have 0 or n as value + /// + Products = 9, + /// + /// + /// + CreatedUtc = 10, + /// + /// + /// + LastModifiedUtc = 11, +} \ No newline at end of file diff --git a/API/DTOs/Enum/ExportKind.cs b/API/DTOs/Enum/ExportKind.cs new file mode 100644 index 0000000..7187c97 --- /dev/null +++ b/API/DTOs/Enum/ExportKind.cs @@ -0,0 +1,9 @@ +using System.ComponentModel; + +namespace API.DTOs.Enum; + +public enum ExportKind +{ + [Description("csv")] + Csv = 0, +} \ No newline at end of file diff --git a/API/DTOs/Export/CsvExportConfigurationDto.cs b/API/DTOs/Export/CsvExportConfigurationDto.cs new file mode 100644 index 0000000..0a46ee4 --- /dev/null +++ b/API/DTOs/Export/CsvExportConfigurationDto.cs @@ -0,0 +1,13 @@ +using API.DTOs.Enum; + +namespace API.DTOs; + +public class CsvExportConfigurationDto +{ + /// + /// The order in which to export fields, if empty or unset exports in the natural enum order + /// + public IList HeaderOrder { get; set; } = []; + + public IList HeaderNames { get; set; } = []; +} \ No newline at end of file diff --git a/API/DTOs/Export/ExportRequestDto.cs b/API/DTOs/Export/ExportRequestDto.cs new file mode 100644 index 0000000..291b668 --- /dev/null +++ b/API/DTOs/Export/ExportRequestDto.cs @@ -0,0 +1,12 @@ +using API.DTOs.Enum; + +namespace API.DTOs; + +public class ExportRequestDto +{ + + public ExportKind Kind { get; set; } + + public IList DeliveryIds { get; set; } + +} \ No newline at end of file diff --git a/API/DTOs/ServerSettingDto.cs b/API/DTOs/ServerSettingDto.cs deleted file mode 100644 index 0df8560..0000000 --- a/API/DTOs/ServerSettingDto.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace API.DTOs; - -public sealed record ServerSettingDto -{ - -} \ No newline at end of file diff --git a/API/DTOs/ServerSettingsDto.cs b/API/DTOs/ServerSettingsDto.cs new file mode 100644 index 0000000..7a9237b --- /dev/null +++ b/API/DTOs/ServerSettingsDto.cs @@ -0,0 +1,10 @@ +using Serilog.Events; + +namespace API.DTOs; + +public sealed record ServerSettingsDto +{ + public CsvExportConfigurationDto CsvExportConfiguration { get; set; } + + public LogEventLevel LogLevel { get; set; } +} \ No newline at end of file diff --git a/API/Data/Repositories/DeliveryRepository.cs b/API/Data/Repositories/DeliveryRepository.cs index f197c88..1d2f602 100644 --- a/API/Data/Repositories/DeliveryRepository.cs +++ b/API/Data/Repositories/DeliveryRepository.cs @@ -26,6 +26,7 @@ public interface IDeliveryRepository { public Task> GetDeliveries(FilterDto filter, PaginationParams pagination, DeliveryIncludes includes = DeliveryIncludes.None); public Task GetDeliveryById(int deliveryId, DeliveryIncludes includes = DeliveryIncludes.None); + public Task> GetDeliveryByIds(IEnumerable deliveryIds, DeliveryIncludes includes = DeliveryIncludes.None); public Task GetDelivery(int deliveryId, DeliveryIncludes includes = DeliveryIncludes.From); public Task> GetDeliveriesForClient(int clientId, IList states, DeliveryIncludes includes = DeliveryIncludes.None); @@ -65,6 +66,14 @@ public async Task> GetDeliveries(FilterDto filter, Pagination .FirstOrDefaultAsync(); } + public async Task> GetDeliveryByIds(IEnumerable deliveryIds, DeliveryIncludes includes = DeliveryIncludes.None) + { + return await ctx.Deliveries + .Where(d => deliveryIds.Contains(d.Id)) + .Includes(includes) + .ToListAsync(); + } + public async Task GetDelivery(int deliveryId, DeliveryIncludes includes = DeliveryIncludes.From) { return mapper.Map(await ctx.Deliveries diff --git a/API/Data/Repositories/SettingsRepository.cs b/API/Data/Repositories/SettingsRepository.cs index 21fe529..250a5a9 100644 --- a/API/Data/Repositories/SettingsRepository.cs +++ b/API/Data/Repositories/SettingsRepository.cs @@ -1,4 +1,6 @@ +using API.DTOs; using API.Entities; +using API.Entities.Enums; using AutoMapper; using Microsoft.EntityFrameworkCore; @@ -9,6 +11,7 @@ public interface ISettingsRepository void Update(ServerSetting settings); void Remove(ServerSetting setting); + Task GetSettingsAsync(ServerSettingKey key); Task> GetSettingsAsync(); } @@ -25,6 +28,13 @@ public void Remove(ServerSetting setting) ctx.Remove(setting); } + public async Task GetSettingsAsync(ServerSettingKey key) + { + return await ctx.ServerSettings + .Where(x => x.Key == key) + .FirstAsync(); + } + public async Task> GetSettingsAsync() { return await ctx.ServerSettings.ToListAsync(); diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs new file mode 100644 index 0000000..a63345d --- /dev/null +++ b/API/Data/Seed.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using Serilog.Events; + +namespace API.Data; + +public static class Seed +{ + + private static readonly IList DefaultServerSettings = [ + new() { Key = ServerSettingKey.CsvExportConfiguration, Value = JsonSerializer.Serialize(new CsvExportConfigurationDto()) }, + new () { Key = ServerSettingKey.LogLevel, Value = nameof(LogEventLevel.Information) } + ]; + + public static async Task Run(DataContext ctx) + { + foreach (var defaultServerSetting in DefaultServerSettings) + { + var existing = ctx.ServerSettings.FirstOrDefault(s => s.Key == defaultServerSetting.Key); + if (existing == null) + { + ctx.ServerSettings.Add(defaultServerSetting); + } + } + + + await ctx.SaveChangesAsync(); + } + +} \ No newline at end of file diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index 81fa9a7..83a68c3 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -1,6 +1,14 @@ +using System.ComponentModel; + namespace API.Entities.Enums; public enum ServerSettingKey { - + /** + * Json object of type + */ + [Description("Csv Export Configuration")] + CsvExportConfiguration = 0, + [Description("Log Level")] + LogLevel = 1, } \ No newline at end of file diff --git a/API/Entities/SystemMessage.cs b/API/Entities/SystemMessage.cs index 963e159..6133c08 100644 --- a/API/Entities/SystemMessage.cs +++ b/API/Entities/SystemMessage.cs @@ -2,6 +2,6 @@ namespace API.Entities; public class SystemMessage { - public string Message; + public string Message { get; set; } public DateTime CreatedUtc { get; set; } } \ No newline at end of file diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 64836d9..af23470 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -2,8 +2,10 @@ using System.Reflection; using API.Data; using API.Data.Repositories; +using API.DTOs.Enum; using API.Helpers; using API.Services; +using API.Services.Exporters; using API.Services.Store; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.EntityFrameworkCore; @@ -31,6 +33,11 @@ public static void AddApplicationServices(this IServiceCollection services, ICon services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + + // Exporters + services.AddScoped(); + services.AddKeyedScoped(nameof(ExportKind.Csv)); services.AddSignalR(opt => opt.EnableDetailedErrors = true); diff --git a/API/Extensions/DistributedCacheExtensions.cs b/API/Extensions/DistributedCacheExtensions.cs new file mode 100644 index 0000000..4541046 --- /dev/null +++ b/API/Extensions/DistributedCacheExtensions.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; + +namespace API.Extensions; + +public static class DistributedCacheExtensions +{ + + public static Task SetAsJsonAsync( + this IDistributedCache cache, + string key, + T value, + DistributedCacheEntryOptions options, + CancellationToken token = default (CancellationToken) + ) + { + var bytes = JsonSerializer.SerializeToUtf8Bytes(value); + return cache.SetAsync(key, bytes, options, token); + } + + public static async Task GetAsJsonAsync( + this IDistributedCache cache, + string key, + CancellationToken token = default(CancellationToken)) + { + var bytes = await cache.GetAsync(key, token); + if (bytes == null) return default; + + return JsonSerializer.Deserialize(bytes); + } + +} \ No newline at end of file diff --git a/API/Helpers/Telemetry/TelemetryHelper.cs b/API/Helpers/Telemetry/TelemetryHelper.cs index 31d7074..0cce8d0 100644 --- a/API/Helpers/Telemetry/TelemetryHelper.cs +++ b/API/Helpers/Telemetry/TelemetryHelper.cs @@ -21,7 +21,7 @@ static TelemetryHelper() Meter = new Meter(serviceName); MethodTiming = Meter.CreateHistogram( - "method_timing_duration_ms", + "method_timing_duration", "ms", "Duration of specific method, see method tag" ); diff --git a/API/Logging/LogLevelOptions.cs b/API/Logging/LogLevelOptions.cs index d9a6b67..85ae9ab 100644 --- a/API/Logging/LogLevelOptions.cs +++ b/API/Logging/LogLevelOptions.cs @@ -30,6 +30,7 @@ public static LoggerConfiguration CreateConfig(LoggerConfiguration configuration .Enrich.WithCaller() .WriteTo.Console(new ExpressionTemplate(outputTemplate)) .WriteTo.File(LogFile, shared: true, rollingInterval: RollingInterval.Day, outputTemplate: outputTemplate) + .Filter.ByExcluding("RequestPath like '/metric%'") .Filter.ByIncludingOnly(ShouldIncludeLogStatement); } @@ -44,40 +45,46 @@ private static bool ShouldIncludeLogStatement(LogEvent e) return LogLevelSwitch.MinimumLevel <= LogEventLevel.Information; } - public static void SwitchLogLevel(string level) + public static void SwitchLogLevel(LogEventLevel level) { switch (level) { - case "Debug": + case LogEventLevel.Verbose: + LogLevelSwitch.MinimumLevel = LogEventLevel.Verbose; + MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Information; + MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Debug; + AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Information; + break; + case LogEventLevel.Debug: LogLevelSwitch.MinimumLevel = LogEventLevel.Debug; MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Warning; MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Information; AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Warning; break; - case "Information": + case LogEventLevel.Information: LogLevelSwitch.MinimumLevel = LogEventLevel.Information; MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Error; MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error; AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; break; - case "Trace": - LogLevelSwitch.MinimumLevel = LogEventLevel.Verbose; - MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Information; - MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Debug; - AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Information; - break; - case "Warning": + case LogEventLevel.Warning: LogLevelSwitch.MinimumLevel = LogEventLevel.Warning; MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Error; MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error; AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; break; - case "Critical": - LogLevelSwitch.MinimumLevel = LogEventLevel.Fatal; + case LogEventLevel.Error: + LogLevelSwitch.MinimumLevel = LogEventLevel.Error; MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Error; MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Error; AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Error; break; + case LogEventLevel.Fatal: + LogLevelSwitch.MinimumLevel = LogEventLevel.Fatal; + MicrosoftLogLevelSwitch.MinimumLevel = LogEventLevel.Fatal; + MicrosoftHostingLifetimeLogLevelSwitch.MinimumLevel = LogEventLevel.Fatal; + AspNetCoreLogLevelSwitch.MinimumLevel = LogEventLevel.Fatal; + break; } } } \ No newline at end of file diff --git a/API/Middleware/ExceptionMiddleware.cs b/API/Middleware/ExceptionMiddleware.cs index e946b55..e121ba1 100644 --- a/API/Middleware/ExceptionMiddleware.cs +++ b/API/Middleware/ExceptionMiddleware.cs @@ -26,6 +26,10 @@ public async Task InvokeAsync(HttpContext context) { statusCode = (int)HttpStatusCode.BadRequest; } + else if (ex is UnauthorizedAccessException) + { + statusCode = (int)HttpStatusCode.Unauthorized; + } else { logger.LogError(ex, "An exception occurred while handling an http request."); diff --git a/API/Program.cs b/API/Program.cs index 47a86d4..7079cb8 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,8 +1,11 @@ using API.Data; +using API.Entities.Enums; using API.Logging; +using API.Services; using Microsoft.AspNetCore.Server.Kestrel.Core; using Microsoft.EntityFrameworkCore; using Serilog; +using Serilog.Events; namespace API; @@ -29,12 +32,15 @@ public static async Task Main(string[] args) var logger = services.GetRequiredService>(); var context = services.GetRequiredService(); - logger.LogDebug("Migrating database"); + logger.LogInformation("Migrating database"); await context.Database.MigrateAsync(); - logger.LogDebug("Seeding database"); - - + logger.LogInformation("Seeding database"); + await Seed.Run(context); + + var service = services.GetRequiredService(); + var logLevel = await service.GetSettingsAsync(ServerSettingKey.LogLevel); + LogLevelOptions.SwitchLogLevel(logLevel); } catch (Exception ex) diff --git a/API/Services/ClientService.cs b/API/Services/ClientService.cs index c6ff431..8bd5250 100644 --- a/API/Services/ClientService.cs +++ b/API/Services/ClientService.cs @@ -82,7 +82,7 @@ public async Task UpdateClient(ClientDto dto) } logger.LogDebug("Updating CompanyNumber for {ClientId} - {ClientName}, adding note to outgoing deliveries", client.Id, client.Name); - AddSystemNote(await localizationService.Translate("client-update-company-number-note", client.CompanyNumber, dto.CompanyNumber.Trim())); + AddSystemNote(await localizationService.DefaultTranslate("client-update-company-number-note", client.CompanyNumber, dto.CompanyNumber.Trim())); client.CompanyNumber = dto.CompanyNumber; } @@ -90,7 +90,7 @@ public async Task UpdateClient(ClientDto dto) if (client.Address != dto.Address.Trim()) { logger.LogDebug("Updating Address for {ClientId} - {ClientName}, adding note to outgoing deliveries", client.Id, client.Name); - AddSystemNote(await localizationService.Translate("client-update-address-note", client.Address, dto.Address.Trim())); + AddSystemNote(await localizationService.DefaultTranslate("client-update-address-note", client.Address, dto.Address.Trim())); client.Address = dto.Address.Trim(); } @@ -98,7 +98,7 @@ public async Task UpdateClient(ClientDto dto) if (client.InvoiceEmail != dto.InvoiceEmail.Trim()) { logger.LogDebug("Updating invoice email for {ClientId} - {ClientName}, adding note to outgoing deliveries", client.Id, client.Name); - AddSystemNote(await localizationService.Translate("client-update-invoice-email", client.InvoiceEmail, dto.InvoiceEmail.Trim())); + AddSystemNote(await localizationService.DefaultTranslate("client-update-invoice-email", client.InvoiceEmail, dto.InvoiceEmail.Trim())); client.InvoiceEmail = dto.InvoiceEmail.Trim(); } diff --git a/API/Services/ExportService.cs b/API/Services/ExportService.cs new file mode 100644 index 0000000..f777298 --- /dev/null +++ b/API/Services/ExportService.cs @@ -0,0 +1,83 @@ +using System.Text; +using System.Text.Json; +using API.DTOs; +using API.DTOs.Enum; +using API.Entities; +using API.Exceptions; +using API.Extensions; +using API.Helpers.Telemetry; +using API.Services.Exporters; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Distributed; + +namespace API.Services; + +public interface IExportService +{ + Task Export(IList deliveries, ExportRequestDto request); + Task GetExport(string uuid); +} + +public class ExportService: IExportService +{ + + private readonly Dictionary _exporters = []; + private readonly ILogger _logger; + private readonly IDistributedCache _cache; + + private readonly DistributedCacheEntryOptions _options = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(15), + }; + + public ExportService(ILogger logger, IServiceProvider keyedServiceProvider, IDistributedCache cache) + { + _logger = logger; + _cache = cache; + + foreach (var kind in Enum.GetValues()) + { + var exporter = keyedServiceProvider.GetRequiredKeyedService(kind.ToString()); + _exporters[kind] = exporter; + } + } + + public async Task Export(IList deliveries, ExportRequestDto request) + { + if (!_exporters.TryGetValue(request.Kind, out var exporter)) + { + throw new InOutException($"No exporter found for {request.Kind}"); + } + + using var tracker = TelemetryHelper.TrackOperation("export_deliveries", new Dictionary + { + ["kind"] = request.Kind.ToString(), + }); + + var (metadata, content) = await exporter.Export(deliveries, request); + + var uuid = Guid.NewGuid().ToString(); + await _cache.SetAsync($"{uuid}_content", content, _options); + await _cache.SetAsJsonAsync($"{uuid}_metadata", metadata, _options); + return uuid; + } + + public async Task GetExport(string uuid) + { + if (!Guid.TryParse(uuid, out _)) + { + return null; + } + + var metadata = await _cache.GetAsJsonAsync($"{uuid}_metadata"); + if (metadata == null) return null; + + var fileContent = await _cache.GetAsync($"{uuid}_content"); + if (fileContent == null) return null; + + return new FileContentResult(fileContent, metadata.FileContentType) + { + FileDownloadName = metadata.FileName, + }; + } +} \ No newline at end of file diff --git a/API/Services/Exporters/CsvExporter.cs b/API/Services/Exporters/CsvExporter.cs new file mode 100644 index 0000000..2ca7d7d --- /dev/null +++ b/API/Services/Exporters/CsvExporter.cs @@ -0,0 +1,117 @@ +using System.Globalization; +using System.Text; +using API.Data; +using API.DTOs; +using API.DTOs.Enum; +using API.Entities; +using API.Entities.Enums; +using API.Exceptions; +using CsvHelper; + +namespace API.Services.Exporters; + +public class CsvExportContext : ExportContext +{ + public required IList SortedProducts { get; init; } + + public required CsvExportConfigurationDto Configuration { get; init; } + + public CsvWriter Writer { get; set; } +} + +public class CsvExporter(IUnitOfWork unitOfWork, ISettingsService settingsService) : BaseExporter(unitOfWork) +{ + + private static readonly IList DefaultHeaderNames = Enum.GetNames(); + private static readonly IList DefaultExportFields = Enum.GetValues().ToList(); + + protected override async Task ConstructContext(ExportContext ctx) + { + var sorted = ctx.AllProducts.Values.OrderBy(product => product.Id).ToList(); + var config = + await settingsService.GetSettingsAsync(ServerSettingKey.CsvExportConfiguration); + + return new CsvExportContext + { + Request = ctx.Request, + Deliveries = ctx.Deliveries, + AllProducts = ctx.AllProducts, + AllProductCategories = ctx.AllProductCategories, + SortedProducts = sorted, + Configuration = config, + }; + } + + protected override async Task<(ExportMetadata, byte[])> ConstructExportFile(CsvExportContext ctx) + { + if (ctx.Configuration == null) + { + throw new InOutException("CSV export configuration is missing"); + } + + await using var writer = new StringWriter(); + await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); + + ctx.Writer = csv; + + var headers = ctx.Configuration.HeaderNames.Count > 0 ? ctx.Configuration.HeaderNames : DefaultHeaderNames; + var headerOrder = ctx.Configuration.HeaderOrder.Count > 0 ? ctx.Configuration.HeaderOrder : DefaultExportFields; + + var productPlace = headerOrder.IndexOf(DeliveryExportField.Products); + + var idx = 0; + foreach (var headerName in headers) + { + if (idx != productPlace) + { + csv.WriteField(headerName); + } + else + { + foreach (var sortedProduct in ctx.SortedProducts) + { + csv.WriteField(sortedProduct.Name); + } + } + + idx++; + } + + await csv.NextRecordAsync(); + + + foreach (var delivery in ctx.Deliveries) + { + foreach (var header in headerOrder) + { + var s = GetDeliveryFieldAsString(ctx, delivery, header); + if (!header.Equals(DeliveryExportField.Products)) + { + // Products are written as a side effect of GetDeliveryFieldAsString + csv.WriteField(s); + } + } + + await csv.NextRecordAsync(); + } + + var content = Encoding.UTF8.GetBytes(writer.ToString()); + var metadata = new ExportMetadata + { + FileContentType = "text/csv", + FileName = $"deliveries_{DateTime.Now:yyyyMMdd_HHmmss}.csv" + }; + + return (metadata, content); + } + + protected override string ExportLines(CsvExportContext ctx, IList lines) + { + foreach (var quantity in ctx.SortedProducts.Select(product => lines.FirstOrDefault(line => line.ProductId == product.Id)?.Quantity ?? 0)) + { + ctx.Writer.WriteField(quantity); + } + + return ""; + } +} \ No newline at end of file diff --git a/API/Services/Exporters/IExporter.cs b/API/Services/Exporters/IExporter.cs new file mode 100644 index 0000000..daa8be2 --- /dev/null +++ b/API/Services/Exporters/IExporter.cs @@ -0,0 +1,94 @@ +using System.Globalization; +using API.Data; +using API.DTOs; +using API.DTOs.Enum; +using API.Entities; + +namespace API.Services.Exporters; + +public interface IExporter +{ + Task<(ExportMetadata, byte[])> Export(IList deliveries, ExportRequestDto request); +} + +public class ExportMetadata +{ + public string FileName { get; set; } + public string FileContentType { get; set; } +} + +public class ExportContext +{ + public required ExportRequestDto Request { get; init; } + + public required IList Deliveries { get; init; } + + public required Dictionary AllProducts { get; init; } + + public required Dictionary AllProductCategories { get; init; } +} + +public abstract class BaseExporter(IUnitOfWork unitOfWork) : IExporter where T : ExportContext +{ + public async Task<(ExportMetadata, byte[])> Export(IList deliveries, ExportRequestDto request) + { + var products = await unitOfWork.ProductRepository.GetAll(); + var categories = await unitOfWork.ProductRepository.GetAllCategories(); + + var ctx = new ExportContext + { + Request = request, + Deliveries = deliveries, + AllProducts = products.ToDictionary(x => x.Id, x => x), + AllProductCategories = categories.ToDictionary(x => x.Id, x => x), + }; + + + return await ConstructExportFile(await ConstructContext(ctx)); + } + + protected abstract Task ConstructContext(ExportContext ctx); + protected abstract Task<(ExportMetadata, byte[])> ConstructExportFile(T ctx); + protected abstract string ExportLines(T ctx, IList lines); + + protected object GetDeliveryField(T ctx, Delivery delivery, DeliveryExportField field) + { + return field switch + { + DeliveryExportField.Id => delivery.Id, + DeliveryExportField.State => delivery.State, + DeliveryExportField.FromId => delivery.From.Id, + DeliveryExportField.From => delivery.From.Name, + DeliveryExportField.RecipientId => delivery.Recipient.Id, + DeliveryExportField.RecipientName => delivery.Recipient.Name, + DeliveryExportField.RecipientEmail => delivery.Recipient.InvoiceEmail, + DeliveryExportField.CompanyNumber => delivery.Recipient.CompanyNumber, + DeliveryExportField.Message => delivery.Message, + DeliveryExportField.Products => delivery.Lines.Select(l => new {l.ProductId, l.Quantity}), + DeliveryExportField.CreatedUtc => delivery.CreatedUtc, + DeliveryExportField.LastModifiedUtc => delivery.LastModifiedUtc, + _ => throw new ArgumentOutOfRangeException(nameof(field), field, null) + }; + } + + protected string GetDeliveryFieldAsString(T ctx, Delivery delivery, DeliveryExportField field) + { + return field switch + { + DeliveryExportField.Id => delivery.Id.ToString(), + DeliveryExportField.State => delivery.State.ToString(), + DeliveryExportField.FromId => delivery.From.Id.ToString(), + DeliveryExportField.From => delivery.From.Name, + DeliveryExportField.RecipientId => delivery.Recipient.Id.ToString(), + DeliveryExportField.RecipientName => delivery.Recipient.Name, + DeliveryExportField.RecipientEmail => delivery.Recipient.InvoiceEmail, + DeliveryExportField.CompanyNumber => delivery.Recipient.CompanyNumber, + DeliveryExportField.Products => ExportLines(ctx, delivery.Lines), + DeliveryExportField.Message => delivery.Message, + DeliveryExportField.CreatedUtc => delivery.CreatedUtc.ToString(CultureInfo.InvariantCulture), + DeliveryExportField.LastModifiedUtc => delivery.LastModifiedUtc.ToString(CultureInfo.InvariantCulture), + _ => throw new ArgumentOutOfRangeException(nameof(field), field, null) + }; + } + +} \ No newline at end of file diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs index cc78737..72628a3 100644 --- a/API/Services/LocalizationService.cs +++ b/API/Services/LocalizationService.cs @@ -38,7 +38,7 @@ public interface ILocalizationService /// Key to translate /// Argument to format the translation with /// Formated translated string - Task Translate(string key, params object[] args); + Task DefaultTranslate(string key, params object[] args); IEnumerable GetLocales(); string DefaultLocale { get; } @@ -157,7 +157,7 @@ public async Task Translate(int userId, string key, params object[] args return await Get(userLocale ?? DefaultLocale, key, args); } - public Task Translate(string key, params object[] args) + public Task DefaultTranslate(string key, params object[] args) { return Get(DefaultLocale, key, args); } diff --git a/API/Services/OidcService.cs b/API/Services/OidcService.cs index e947467..356f2cd 100644 --- a/API/Services/OidcService.cs +++ b/API/Services/OidcService.cs @@ -65,7 +65,7 @@ public async Task RefreshCookieToken(CookieValidatePrincipalContext ctx) ctx.Properties.UpdateTokenValue(IdToken, tokenResponse.IdToken); ctx.ShouldRenew = true; - logger.LogTrace("Automatically refreshed token for user {UserId}", ctx.Principal.GetUserId()); + logger.LogDebug("Automatically refreshed token for user {UserId}", ctx.Principal.GetUserId()); } finally { diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs new file mode 100644 index 0000000..8868155 --- /dev/null +++ b/API/Services/SettingsService.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using API.Data; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using API.Logging; +using Serilog.Events; + +namespace API.Services; + +public interface ISettingsService +{ + /// + /// You will be required to specify the correct type, there is no compile time checks. Only run time! + /// + /// + /// + /// + Task GetSettingsAsync(ServerSettingKey key); + Task GetSettingsAsync(); + Task SaveSettingsAsync(ServerSettingsDto settings); +} + +public class SettingsService(ILogger logger, IUnitOfWork unitOfWork): ISettingsService +{ + public async Task GetSettingsAsync(ServerSettingKey key) + { + if (!ServerSettingTypeMap.KeyToType.TryGetValue(key, out var expectedType) || expectedType != typeof(T)) + { + throw new ArgumentException($"Invalid type {typeof(T).Name} for key {key}. Expected {expectedType?.Name ?? "unknown"}"); + } + + var setting = await unitOfWork.SettingsRepository.GetSettingsAsync(key); + return DeserializeSetting(setting); + } + + private static T DeserializeSetting(ServerSetting setting) + { + object? result = setting.Key switch + { + ServerSettingKey.CsvExportConfiguration => JsonSerializer.Deserialize(setting.Value), + ServerSettingKey.LogLevel => Enum.Parse(setting.Value), + _ => default(T), + }; + + return result switch + { + null => throw new ArgumentException($"No converter found for key {setting.Key}"), + T typedResult => typedResult, + _ => throw new ArgumentException($"Failed to convert {setting.Key} - {setting.Value} to type {typeof(T).Name}") + }; + } + + private static async Task SerializeSetting(ServerSettingKey key, object setting) + { + return key switch + { + ServerSettingKey.CsvExportConfiguration => JsonSerializer.Serialize(setting), + ServerSettingKey.LogLevel => setting.ToString(), + _ => throw new ArgumentException($"No converter found for key {key}"), + } ?? string.Empty; + } + + public async Task GetSettingsAsync() + { + var settings = await unitOfWork.SettingsRepository.GetSettingsAsync(); + var dto = new ServerSettingsDto(); + + foreach (var serverSetting in settings) + { + switch (serverSetting.Key) + { + case ServerSettingKey.CsvExportConfiguration: + dto.CsvExportConfiguration = DeserializeSetting(serverSetting); + break; + case ServerSettingKey.LogLevel: + dto.LogLevel = DeserializeSetting(serverSetting); + break; + default: + throw new ArgumentOutOfRangeException(nameof(serverSetting.Key), serverSetting.Key, "Unknown server settings key"); + } + } + + return dto; + } + + private async Task UpdateIfDifferent(ServerSetting setting, object value) + { + var serialized = await SerializeSetting(setting.Key, value); + if (setting.Value != serialized) + { + setting.Value = serialized; + unitOfWork.SettingsRepository.Update(setting); + return true; + } + + return false; + } + + public async Task SaveSettingsAsync(ServerSettingsDto dto) + { + var settings = await unitOfWork.SettingsRepository.GetSettingsAsync(); + + foreach (var serverSetting in settings) + { + object value = serverSetting.Key switch + { + ServerSettingKey.CsvExportConfiguration => dto.CsvExportConfiguration, + ServerSettingKey.LogLevel => dto.LogLevel, + _ => throw new ArgumentOutOfRangeException(nameof(serverSetting.Key), serverSetting.Key, "Unknown server settings key"), + }; + + var updated = await UpdateIfDifferent(serverSetting, value); + + if (updated && serverSetting.Key == ServerSettingKey.LogLevel) + { + LogLevelOptions.SwitchLogLevel(dto.LogLevel); + } + } + + if (unitOfWork.HasChanges()) + { + await unitOfWork.CommitAsync(); + } + } + + private static class ServerSettingTypeMap + { + public static readonly Dictionary KeyToType = new() + { + { ServerSettingKey.CsvExportConfiguration, typeof(CsvExportConfigurationDto) }, + { ServerSettingKey.LogLevel, typeof(LogEventLevel) }, + }; + } +} \ No newline at end of file diff --git a/API/Startup.cs b/API/Startup.cs index be3910e..955c322 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -232,7 +232,7 @@ private void OverrideFaviconIfSet(ILogger logger) var data = Convert.FromBase64String(favicon);; File.WriteAllBytes(filePath, data); - logger.LogDebug("Overwriting favicon from configuration: {Data}", favicon); + logger.LogDebug("Overwritten favicon from configuration"); } catch (Exception ex) { diff --git a/In-Out.sln.DotSettings.user b/In-Out.sln.DotSettings.user index 71401df..a6397a4 100644 --- a/In-Out.sln.DotSettings.user +++ b/In-Out.sln.DotSettings.user @@ -1,12 +1,21 @@  ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded <AssemblyExplorer> <Assembly Path="/Users/amelia/.nuget/packages/testcontainers/4.6.0/lib/net9.0/Testcontainers.dll" /> diff --git a/UI/Web/public/assets/i18n/en.json b/UI/Web/public/assets/i18n/en.json index fa85b47..cc02c0c 100644 --- a/UI/Web/public/assets/i18n/en.json +++ b/UI/Web/public/assets/i18n/en.json @@ -115,7 +115,8 @@ "size": "Size", "created": "Created on", "actions": "Actions", - "transition": "Transition" + "transition": "Transition", + "no-deliveries": "No deliveries where found for your filter" }, "transition-delivery-modal": { @@ -128,6 +129,39 @@ "no-transition-possible": "You cannot change the state of this delivery" }, + "view-delivery-modal": { + "title": "Delivery {{number}} to {{to}}", + "close": "{{common.close}}", + "participants": { + "label": "Participants", + "from": "From", + "to": "To" + }, + "message": { + "label": "Message" + }, + "systemMessages": { + "label": "System Messages" + }, + "lines": { + "category": "Category", + "label": "Items", + "product": "Product", + "quantity": "Quantity" + }, + "dates": { + "label": "Timestamps", + "created": "Created", + "modified": "Last Modified" + } + }, + + "under-construction": { + "title": "Under Construction", + "message": "We’re working hard to bring you something amazing. Please check back soon!", + "back": "Go Back Home" + }, + "delivery-state-tooltips": { "in-progress": "The delivery is still being worked on and should not be processed", "completed": "The delivery is ready to be processed", @@ -264,13 +298,6 @@ }, "management": { - "dashboard": { - "title": "Management dashboard", - "sub-title": "Contact your idp manager if you're missing perms" - }, - "overview": { - "title": "Overview" - }, "products": { "title": "Product Management", "products-table-label": "Products", @@ -334,6 +361,20 @@ "contactName": "Contact Name" } } + }, + "server": { + "title": "Server Management", + "general": "General", + "export": "Export", + "general-title": "General server settings", + "log-level-label": "Log level", + "log-level-tooltip": "Enable debug when issues arise, otherwise keep higher to ensure disk space is saved", + "export-settings": { + "csv-export-title": "Csv Export configuration", + "available-fields": "All available (unused) fields", + "selected-fields": "Selected fields", + "drag-fields-here": "Drag fields here to customise your export" + } } }, @@ -342,6 +383,30 @@ "consumable": "Multiple times" }, + "delivery-export-field-pipe": { + "id": "ID", + "state": "State", + "from-id": "Sender ID", + "from": "Sender Name", + "recipient-id": "Recipient ID", + "recipient-name": "Recipient Name", + "recipient-email": "Recipient Email", + "company-number": "Company Number", + "message": "Message", + "products": "Products", + "created-utc": "Created Date (UTC)", + "last-modified-utc": "Last Modified Date (UTC)" + }, + + "log-level-pipe": { + "verbose": "Verbose", + "debug": "Debug", + "information": "Information", + "warning": "Warning", + "error": "Error", + "fatal": "Fatal" + }, + "product-modal": { "title": "Product", "description": "Products are anything that may appear in a delivery, group them together by using categories", diff --git a/UI/Web/src/app/_models/delivery.ts b/UI/Web/src/app/_models/delivery.ts index 6c6e874..7fcddfa 100644 --- a/UI/Web/src/app/_models/delivery.ts +++ b/UI/Web/src/app/_models/delivery.ts @@ -12,13 +12,18 @@ export type Delivery = { recipient?: Client, message: string, - systemMessages: string[], + systemMessages: SystemMessage[], lines: DeliveryLine[], createdUtc?: Date, lastModifiedUtc?: Date, } +export type SystemMessage = { + message: string, + createdUtc: Date, +} + export type DeliveryLine = { productId: number, quantity: number, diff --git a/UI/Web/src/app/_models/export.ts b/UI/Web/src/app/_models/export.ts new file mode 100644 index 0000000..813ad2c --- /dev/null +++ b/UI/Web/src/app/_models/export.ts @@ -0,0 +1,9 @@ + +export enum ExportKind { + Csv = 0, +} + +export interface ExportRequest { + kind: ExportKind; + deliveryIds: number[]; +} diff --git a/UI/Web/src/app/_models/settings.ts b/UI/Web/src/app/_models/settings.ts new file mode 100644 index 0000000..9486d37 --- /dev/null +++ b/UI/Web/src/app/_models/settings.ts @@ -0,0 +1,61 @@ + +export enum LogLevel { + Verbose = 0, + Debug = 1, + Information = 2, + Warning = 3, + Error = 4, + Fatal = 5 +} + +export const LogLevelValues = [ + LogLevel.Verbose, + LogLevel.Debug, + LogLevel.Information, + LogLevel.Warning, + LogLevel.Error, + LogLevel.Fatal +]; + + +export enum DeliveryExportField { + Id = 0, + State = 1, + FromId = 2, + From = 3, + RecipientId = 4, + RecipientName = 5, + RecipientEmail = 6, + CompanyNumber = 7, + Message = 8, + Products = 9, + CreatedUtc = 10, + LastModifiedUtc = 11 +} + +export const DeliveryExportFieldValues = [ + DeliveryExportField.Id, + DeliveryExportField.State, + DeliveryExportField.FromId, + DeliveryExportField.From, + DeliveryExportField.RecipientId, + DeliveryExportField.RecipientName, + DeliveryExportField.RecipientEmail, + DeliveryExportField.CompanyNumber, + DeliveryExportField.Message, + DeliveryExportField.Products, + DeliveryExportField.CreatedUtc, + DeliveryExportField.LastModifiedUtc +]; + + + +export type CsvExportConfiguration = { + headerNames: string[], + headerOrder: DeliveryExportField[], +} + +export type Settings = { + logLevel: LogLevel; + csvExportConfiguration: CsvExportConfiguration +} diff --git a/UI/Web/src/app/_pipes/delivery-export-field-pipe.ts b/UI/Web/src/app/_pipes/delivery-export-field-pipe.ts new file mode 100644 index 0000000..074d4fb --- /dev/null +++ b/UI/Web/src/app/_pipes/delivery-export-field-pipe.ts @@ -0,0 +1,41 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { translate } from '@jsverse/transloco'; +import {DeliveryExportField} from '../_models/settings'; + +@Pipe({ + name: 'deliveryExportField' +}) +export class DeliveryExportFieldPipe implements PipeTransform { + + transform(value: DeliveryExportField): string { + switch (value) { + case DeliveryExportField.Id: + return translate('delivery-export-field-pipe.id'); + case DeliveryExportField.State: + return translate('delivery-export-field-pipe.state'); + case DeliveryExportField.FromId: + return translate('delivery-export-field-pipe.from-id'); + case DeliveryExportField.From: + return translate('delivery-export-field-pipe.from'); + case DeliveryExportField.RecipientId: + return translate('delivery-export-field-pipe.recipient-id'); + case DeliveryExportField.RecipientName: + return translate('delivery-export-field-pipe.recipient-name'); + case DeliveryExportField.RecipientEmail: + return translate('delivery-export-field-pipe.recipient-email'); + case DeliveryExportField.CompanyNumber: + return translate('delivery-export-field-pipe.company-number'); + case DeliveryExportField.Message: + return translate('delivery-export-field-pipe.message'); + case DeliveryExportField.Products: + return translate('delivery-export-field-pipe.products'); + case DeliveryExportField.CreatedUtc: + return translate('delivery-export-field-pipe.created-utc'); + case DeliveryExportField.LastModifiedUtc: + return translate('delivery-export-field-pipe.last-modified-utc'); + default: + return ''; + } + } + +} diff --git a/UI/Web/src/app/_pipes/log-level-pipe.ts b/UI/Web/src/app/_pipes/log-level-pipe.ts new file mode 100644 index 0000000..8689c31 --- /dev/null +++ b/UI/Web/src/app/_pipes/log-level-pipe.ts @@ -0,0 +1,29 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { translate } from '@jsverse/transloco'; +import {LogLevel} from '../_models/settings'; + +@Pipe({ + name: 'logLevel' +}) +export class LogLevelPipe implements PipeTransform { + + transform(value: LogLevel): string { + switch (value) { + case LogLevel.Verbose: + return translate('log-level-pipe.verbose'); + case LogLevel.Debug: + return translate('log-level-pipe.debug'); + case LogLevel.Information: + return translate('log-level-pipe.information'); + case LogLevel.Warning: + return translate('log-level-pipe.warning'); + case LogLevel.Error: + return translate('log-level-pipe.error'); + case LogLevel.Fatal: + return translate('log-level-pipe.fatal'); + default: + return ''; + } + } + +} diff --git a/UI/Web/src/app/_services/export.service.ts b/UI/Web/src/app/_services/export.service.ts new file mode 100644 index 0000000..133d5b2 --- /dev/null +++ b/UI/Web/src/app/_services/export.service.ts @@ -0,0 +1,19 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {environment} from '../../environments/environment'; +import {ExportRequest} from '../_models/export'; + +@Injectable({ + providedIn: 'root' +}) +export class ExportService { + + private readonly httpClient = inject(HttpClient); + + private readonly baseUrl = environment.apiUrl; + + export(req: ExportRequest) { + return this.httpClient.post(this.baseUrl + "export", req, {responseType: 'text'}) + } + +} diff --git a/UI/Web/src/app/_services/settings.service.ts b/UI/Web/src/app/_services/settings.service.ts new file mode 100644 index 0000000..b75509c --- /dev/null +++ b/UI/Web/src/app/_services/settings.service.ts @@ -0,0 +1,22 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import {environment} from '../../environments/environment'; +import {Settings} from '../_models/settings'; + +@Injectable({ + providedIn: 'root' +}) +export class SettingsService { + + private readonly httpClient = inject(HttpClient); + private readonly baseUrl = environment.apiUrl; + + getSettings() { + return this.httpClient.get(this.baseUrl + 'settings'); + } + + updateSettings(settings: Settings) { + return this.httpClient.post(this.baseUrl + 'settings', settings); + } + +} diff --git a/UI/Web/src/app/browse-deliveries/_components/view-delivery-modal/view-delivery-modal.component.html b/UI/Web/src/app/browse-deliveries/_components/view-delivery-modal/view-delivery-modal.component.html new file mode 100644 index 0000000..8931cd2 --- /dev/null +++ b/UI/Web/src/app/browse-deliveries/_components/view-delivery-modal/view-delivery-modal.component.html @@ -0,0 +1,113 @@ + + + diff --git a/UI/Web/src/app/browse-deliveries/_components/view-delivery-modal/view-delivery-modal.component.scss b/UI/Web/src/app/browse-deliveries/_components/view-delivery-modal/view-delivery-modal.component.scss new file mode 100644 index 0000000..2dbfe8f --- /dev/null +++ b/UI/Web/src/app/browse-deliveries/_components/view-delivery-modal/view-delivery-modal.component.scss @@ -0,0 +1,42 @@ +.modal-body { + background-color: var(--background-color); + color: var(--text-secondary); +} + +.modal-body h6 { + color: var(--text-primary); + font-weight: 600; + font-family: 'Spectral', serif; +} + +.table { + border: 1px solid var(--surface-border); + background-color: var(--surface-card); + color: var(--text-secondary); +} + +.table thead { + background-color: var(--primary-color-light); + color: var(--text-light); +} + +.table tbody tr:hover { + background-color: var(--surface-hover); +} + +.table td, +.table th { + border-color: var(--surface-border); +} + +.modal-body strong { + color: var(--text-primary); +} + +.modal-body ul { + color: var(--text-secondary); +} + +.modal-body li::marker { + color: var(--secondary-color); +} diff --git a/UI/Web/src/app/browse-deliveries/_components/view-delivery-modal/view-delivery-modal.component.ts b/UI/Web/src/app/browse-deliveries/_components/view-delivery-modal/view-delivery-modal.component.ts new file mode 100644 index 0000000..88b282d --- /dev/null +++ b/UI/Web/src/app/browse-deliveries/_components/view-delivery-modal/view-delivery-modal.component.ts @@ -0,0 +1,46 @@ +import {ChangeDetectionStrategy, Component, computed, inject, model} from '@angular/core'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {Delivery} from '../../../_models/delivery'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {DatePipe} from '@angular/common'; +import {DeliveryStatePipe} from '../../../_pipes/delivery-state-pipe'; +import {Product, ProductCategory, ProductType} from '../../../_models/product'; +import {DefaultValuePipe} from '../../../_pipes/default-value.pipe'; + +@Component({ + selector: 'app-view-delivery-modal', + imports: [ + TranslocoDirective, + DatePipe, + DefaultValuePipe + ], + templateUrl: './view-delivery-modal.component.html', + styleUrl: './view-delivery-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ViewDeliveryModalComponent { + + private readonly modal = inject(NgbActiveModal); + + delivery = model.required(); + products = model.required(); + categories = model.required(); + + protected productLookup = computed(() => { + const map = new Map(); + this.products().forEach(p => map.set(p.id, p)); + return map; + }); + + protected categoryLookup = computed(() => { + const map = new Map(); + this.categories().forEach(c => map.set(c.id, c)); + return map; + }); + + close() { + this.modal.close(); + } + + protected readonly ProductType = ProductType; +} diff --git a/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html index 18a8810..3d22eed 100644 --- a/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html +++ b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html @@ -2,51 +2,81 @@ - - - - {{t('from')}} - {{t('to')}} - {{t('state')}} - {{t('size')}} - {{t('created')}} - {{t('actions')}} - - - - - {{ delivery.from.name }} - {{ delivery.recipient.name }} - - {{delivery.state | deliveryState}} - - - {{delivery.lines.length}} - - {{ delivery.createdUtc ?? '—' | utcToLocalTime:'shortDate' }} - - -
- - - - - - - -
- - -
-
+ @if (deliveries().length === 0) { +
+
+ +
+ {{ t('no-deliveries') }} +
+
+
+ } @else { + + + + {{t('from')}} + {{t('to')}} + {{t('state')}} + {{t('size')}} + {{t('created')}} + +
+ {{t('actions')}} + + @if (authService.roles().includes(Role.HandleDeliveries)) { + + } +
+ + +
+ + + {{ delivery.from.name }} + {{ delivery.recipient.name }} + + {{delivery.state | deliveryState}} + + + {{delivery.lines.length}} + + {{ delivery.createdUtc ?? '—' | utcToLocalTime:'shortDate' }} + + +
+ + + + + + + + + @if (authService.roles().includes(Role.HandleDeliveries)) { + @if (tracker.lookup().has(delivery.id)) { + + } @else { + + } + } + +
+ + +
+
+ } diff --git a/UI/Web/src/app/browse-deliveries/browse-deliveries.component.ts b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.ts index 202356a..1c30503 100644 --- a/UI/Web/src/app/browse-deliveries/browse-deliveries.component.ts +++ b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.ts @@ -1,4 +1,4 @@ -import {ChangeDetectionStrategy, Component, inject, input, signal} from '@angular/core'; +import {ChangeDetectionStrategy, Component, inject, input, OnInit, signal} from '@angular/core'; import {ReactiveFormsModule} from '@angular/forms'; import {Filter,} from '../_models/filter'; import {Delivery, DeliveryState} from '../_models/delivery'; @@ -17,6 +17,13 @@ import { } from './_components/transition-delivery-modal/transition-delivery-modal.component'; import {DefaultModalOptions} from '../_models/default-modal-options'; import {tap} from 'rxjs'; +import {ViewDeliveryModalComponent} from './_components/view-delivery-modal/view-delivery-modal.component'; +import {Product, ProductCategory} from '../_models/product'; +import {ProductService} from '../_services/product.service'; +import {Tracker} from '../shared/tracker'; +import {ExportService} from '../_services/export.service'; +import {ExportKind} from '../_models/export'; +import {AuthService, Role} from '../_services/auth.service'; @Component({ selector: 'app-browse-deliveries', @@ -34,20 +41,36 @@ import {tap} from 'rxjs'; styleUrl: './browse-deliveries.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) -export class BrowseDeliveriesComponent { +export class BrowseDeliveriesComponent implements OnInit { private readonly deliveryService = inject(DeliveryService); + private readonly productService = inject(ProductService); private readonly toastr = inject(ToastrService); private readonly modalService = inject(ModalService); + private readonly exportService = inject(ExportService); + protected readonly authService = inject(AuthService); showNavbar = input(true); deliveries = signal([]); + products = signal([]); + categories = signal([]); + tracker = new Tracker((d) => d.id) + + ngOnInit() { + this.productService.allProducts().subscribe(products => { + this.products.set(products); + }); + this.productService.getCategories().subscribe(categories => { + this.categories.set(categories); + }); + } loadFilter(filter: Filter) { this.deliveryService.filter(filter).subscribe({ next: (data: Delivery[]) => { this.deliveries.set(data); + this.tracker.reset(); }, error: (err) => { console.log(err); @@ -91,5 +114,25 @@ export class BrowseDeliveriesComponent { } } + showInfo(delivery: Delivery) { + const [_, component] = this.modalService.open(ViewDeliveryModalComponent, DefaultModalOptions); + component.delivery.set(delivery); + component.products.set(this.products()); + component.categories.set(this.categories()); + } + + export() { + if (!this.authService.roles().includes(Role.HandleDeliveries)) return; + + + this.exportService.export({ + kind: ExportKind.Csv, + deliveryIds: this.tracker.ids(), + }).subscribe((id) => { + window.open(`/api/export/${id}`, '_blank'); + this.tracker.reset(); + }); + } + protected readonly Role = Role; } diff --git a/UI/Web/src/app/management/management-server/_components/export-settings/export-settings.component.html b/UI/Web/src/app/management/management-server/_components/export-settings/export-settings.component.html new file mode 100644 index 0000000..7003cb9 --- /dev/null +++ b/UI/Web/src/app/management/management-server/_components/export-settings/export-settings.component.html @@ -0,0 +1,47 @@ + +
+

{{ t('csv-export-title') }}

+ +
+

{{ t('available-fields') }}

+
+ + @for (field of availableFields(); track field) { +
+ {{ field | deliveryExportField }} +
+ } +
+
+ +
+

{{ t('selected-fields') }}

+
+ + @for (field of selectedFields(); track field; let i = $index) { +
+ {{ field | deliveryExportField }} +
+ } @empty { +
+ {{ t('drag-fields-here') }} +
+ } +
+
+
+
diff --git a/UI/Web/src/app/management/management-server/_components/export-settings/export-settings.component.scss b/UI/Web/src/app/management/management-server/_components/export-settings/export-settings.component.scss new file mode 100644 index 0000000..d921112 --- /dev/null +++ b/UI/Web/src/app/management/management-server/_components/export-settings/export-settings.component.scss @@ -0,0 +1,137 @@ +.export-settings-container { + font-family: 'Spectral', serif; + padding: 2rem; + background-color: var(--background-color); + min-height: 100vh; + + .section-title { + color: var(--text-primary); + font-size: 2rem; + font-weight: 600; + margin-bottom: 2rem; + text-align: center; + letter-spacing: 1px; + } + + .options-section, + .selections-section { + margin-bottom: 3rem; + + .row-title { + color: var(--text-primary); + font-size: 1.5rem; + font-weight: 500; + margin-bottom: 1.5rem; + padding-left: 0.5rem; + border-left: 4px solid var(--secondary-color); + } + } + + .options-row, + .selections-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + padding: 1.5rem; + background-color: var(--surface-card); + border-radius: 12px; + box-shadow: 0 4px 12px var(--shadow-light); + border: 2px solid var(--surface-border); + min-height: 120px; + transition: all 0.3s ease; + + &:hover { + box-shadow: 0 6px 20px var(--shadow-medium); + } + + &.cdk-drop-list-receiving { + background-color: var(--surface-hover); + border-color: var(--secondary-color); + transform: scale(1.02); + } + } + + .field-item { + background-color: var(--background-color); + border: 2px solid var(--surface-border); + border-radius: 8px; + padding: 1rem; + cursor: move; + transition: all 0.3s ease; + width: 200px; + box-shadow: 0 2px 8px var(--shadow-light); + + &:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px var(--shadow-medium); + border-color: var(--secondary-color-light); + } + + &.cdk-drag-preview { + box-shadow: 0 8px 24px var(--shadow-dark); + transform: rotate(5deg); + border-color: var(--secondary-color); + background-color: var(--surface-hover); + } + + &.cdk-drag-placeholder { + opacity: 0.5; + border: 2px dashed var(--secondary-color); + background-color: transparent; + } + + &.selected-item { + cursor: pointer; + + .field-content { + display: flex; + flex-direction: column; + gap: 0.5rem; + + .field-name { + font-weight: 600; + color: var(--text-primary); + font-size: 1rem; + } + + .field-type { + font-size: 0.85rem; + color: var(--text-secondary); + font-style: italic; + } + } + } + } + + .empty-placeholder { + width: 100%; + text-align: center; + color: var(--text-secondary); + font-style: italic; + font-size: 1.1rem; + padding: 2rem; + border: 2px dashed var(--surface-border); + border-radius: 8px; + background-color: var(--background-secondary); + display: flex; + align-items: center; + justify-content: center; + min-height: 80px; + transition: all 0.3s ease; + + &:hover { + border-color: var(--secondary-color-light); + background-color: var(--surface-hover); + } + } + + .cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + } + + .cdk-drop-list { + &.cdk-drop-list-dragging .field-item:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); + } + } +} diff --git a/UI/Web/src/app/management/management-server/_components/export-settings/export-settings.component.ts b/UI/Web/src/app/management/management-server/_components/export-settings/export-settings.component.ts new file mode 100644 index 0000000..7326f30 --- /dev/null +++ b/UI/Web/src/app/management/management-server/_components/export-settings/export-settings.component.ts @@ -0,0 +1,109 @@ +import {ChangeDetectionStrategy, Component, input, signal, computed, OnInit, inject} from '@angular/core'; +import {Settings, DeliveryExportField, DeliveryExportFieldValues} from '../../../../_models/settings'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {CdkDragDrop, CdkDrag, CdkDropList, moveItemInArray} from '@angular/cdk/drag-drop'; +import {DeliveryExportFieldPipe} from '../../../../_pipes/delivery-export-field-pipe'; +import {SettingsService} from '../../../../_services/settings.service'; + +@Component({ + selector: 'app-export-settings', + imports: [ + TranslocoDirective, + CdkDropList, + CdkDrag, + DeliveryExportFieldPipe + ], + templateUrl: './export-settings.component.html', + styleUrl: './export-settings.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ExportSettingsComponent implements OnInit { + + private readonly settingsService = inject(SettingsService); + + settings = input.required(); + + selectedFields = signal([]); + headerNames = signal([]); + + availableFields = computed(() => { + const selected = this.selectedFields(); + return DeliveryExportFieldValues.filter(field => !selected.includes(field)); + }); + + drop(event: CdkDragDrop) { + if (event.previousContainer === event.container) { + const fields = [...this.selectedFields()]; + const names = [...this.headerNames()]; + + moveItemInArray(fields, event.previousIndex, event.currentIndex); + moveItemInArray(names, event.previousIndex, event.currentIndex); + + this.selectedFields.set(fields); + this.headerNames.set(names); + this.updateSettings(); + return; + } + + if (event.container.id === 'selectedList' || event.container.element.nativeElement.classList.contains('selections-row')) { + const field = event.item.data as DeliveryExportField; + const newSelected = [...this.selectedFields()]; + const newNames = [...this.headerNames()]; + + newSelected.splice(event.currentIndex, 0, field); + newNames.splice(event.currentIndex, 0, this.getDefaultFieldName(field)); + + this.selectedFields.set(newSelected); + this.headerNames.set(newNames); + this.updateSettings(); + return; + } + + const newSelected = [...this.selectedFields()]; + const newNames = [...this.headerNames()]; + + newSelected.splice(event.previousIndex, 1); + newNames.splice(event.previousIndex, 1); + + this.selectedFields.set(newSelected); + this.headerNames.set(newNames); + this.updateSettings(); + } + + private getDefaultFieldName(field: DeliveryExportField): string { + const defaultNames: Record = { + [DeliveryExportField.Id]: 'ID', + [DeliveryExportField.State]: 'State', + [DeliveryExportField.FromId]: 'From ID', + [DeliveryExportField.From]: 'From', + [DeliveryExportField.RecipientId]: 'Recipient ID', + [DeliveryExportField.RecipientName]: 'Recipient Name', + [DeliveryExportField.RecipientEmail]: 'Recipient Email', + [DeliveryExportField.CompanyNumber]: 'Company Number', + [DeliveryExportField.Message]: 'Message', + [DeliveryExportField.Products]: 'Products', + [DeliveryExportField.CreatedUtc]: 'Created', + [DeliveryExportField.LastModifiedUtc]: 'Last Modified' + }; + + return defaultNames[field] || 'Unknown Field'; + } + + private updateSettings() { + const updatedSettings: Settings = { + ...this.settings(), + csvExportConfiguration: { + headerNames: this.headerNames(), + headerOrder: this.selectedFields() + } + }; + + this.settingsService.updateSettings(updatedSettings).subscribe(); + } + + ngOnInit(): void { + const currentSettings = this.settings(); + this.selectedFields.set([...currentSettings.csvExportConfiguration.headerOrder]); + this.headerNames.set([...currentSettings.csvExportConfiguration.headerNames]); + } +} diff --git a/UI/Web/src/app/management/management-server/management-server.component.html b/UI/Web/src/app/management/management-server/management-server.component.html index 8b7c286..726d956 100644 --- a/UI/Web/src/app/management/management-server/management-server.component.html +++ b/UI/Web/src/app/management/management-server/management-server.component.html @@ -1 +1,55 @@ -

management-server works!

+ +
+
+
+

{{t('title')}}

+
+
+ + + + @if (settings()) { +
+ } +
+ diff --git a/UI/Web/src/app/management/management-server/management-server.component.scss b/UI/Web/src/app/management/management-server/management-server.component.scss index e69de29..72b9406 100644 --- a/UI/Web/src/app/management/management-server/management-server.component.scss +++ b/UI/Web/src/app/management/management-server/management-server.component.scss @@ -0,0 +1 @@ +@use "../shared/management"; diff --git a/UI/Web/src/app/management/management-server/management-server.component.ts b/UI/Web/src/app/management/management-server/management-server.component.ts index 178fc7b..e161799 100644 --- a/UI/Web/src/app/management/management-server/management-server.component.ts +++ b/UI/Web/src/app/management/management-server/management-server.component.ts @@ -1,12 +1,98 @@ -import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {ChangeDetectionStrategy, Component, DestroyRef, inject, OnInit, signal} from '@angular/core'; +import {UnderConstructionComponent} from '../../shared/components/under-construction/under-construction.component'; +import {TranslocoDirective} from '@jsverse/transloco'; +import { + NgbNav, + NgbNavContent, + NgbNavItem, + NgbNavLink, + NgbNavLinkButton, + NgbNavOutlet +} from '@ng-bootstrap/ng-bootstrap'; +import {SettingsService} from '../../_services/settings.service'; +import {DeliveryExportField, LogLevel, LogLevelValues, Settings} from '../../_models/settings'; +import {FormArray, FormControl, FormGroup, NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; +import {SettingsItemComponent} from '../../shared/components/settings-item/settings-item.component'; +import {LogLevelPipe} from '../../_pipes/log-level-pipe'; +import {debounceTime, distinctUntilChanged, filter, switchMap, tap} from 'rxjs'; +import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; +import {ToastrService} from 'ngx-toastr'; +import {ExportSettingsComponent} from './_components/export-settings/export-settings.component'; + +enum NavId { + General = 'general', + Export = 'export', +} + +type SettingsForm = FormGroup<{ + logLevel: FormControl, + csvExportConfiguration: FormGroup<{ + headerNames: FormArray>, + headerOrder: FormArray>, + }> +}> @Component({ selector: 'app-management-server', - imports: [], + imports: [ + TranslocoDirective, + NgbNav, + NgbNavItem, + NgbNavContent, + NgbNavOutlet, + NgbNavLink, + ReactiveFormsModule, + SettingsItemComponent, + LogLevelPipe, + ExportSettingsComponent + ], templateUrl: './management-server.component.html', styleUrl: './management-server.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) -export class ManagementServerComponent { +export class ManagementServerComponent implements OnInit { + + protected readonly NavId = NavId; + + private readonly settingsService = inject(SettingsService); + private readonly fb = inject(NonNullableFormBuilder); + private readonly destroyRef = inject(DestroyRef); + private readonly toastR = inject(ToastrService); + + activeId = NavId.General; + + settings = signal(undefined); + settingsForm!: SettingsForm; + + ngOnInit(): void { + this.settingsService.getSettings().subscribe(settings => { + this.settings.set(settings); + this.setupForm(settings); + }); + } + + private setupForm(settings: Settings) { + const headerNames = settings.csvExportConfiguration.headerNames + .map(hn => this.fb.control(hn)); + const headerOrder = settings.csvExportConfiguration.headerOrder + .map(ho => this.fb.control(ho)); + + this.settingsForm = this.fb.group({ + logLevel: this.fb.control(settings.logLevel), + csvExportConfiguration: this.fb.group({ + headerNames: this.fb.array(headerNames), + headerOrder: this.fb.array(headerOrder) + }), + }); + + this.settingsForm.valueChanges.pipe( + distinctUntilChanged(), + debounceTime(100), + takeUntilDestroyed(this.destroyRef), + filter(() => this.settingsForm.valid), + switchMap(() => this.settingsService.updateSettings(this.settingsForm.value as Settings)), + ).subscribe(); + } + protected readonly LogLevelValues = LogLevelValues; } diff --git a/UI/Web/src/app/shared/components/under-construction/under-construction.component.html b/UI/Web/src/app/shared/components/under-construction/under-construction.component.html new file mode 100644 index 0000000..aeec163 --- /dev/null +++ b/UI/Web/src/app/shared/components/under-construction/under-construction.component.html @@ -0,0 +1,16 @@ + +
+
+
🚧
+

+ {{ t('title') }} +

+

+ {{ t('message') }} +

+ + {{ t('back') }} + +
+
+
diff --git a/UI/Web/src/app/shared/components/under-construction/under-construction.component.scss b/UI/Web/src/app/shared/components/under-construction/under-construction.component.scss new file mode 100644 index 0000000..bb00a9e --- /dev/null +++ b/UI/Web/src/app/shared/components/under-construction/under-construction.component.scss @@ -0,0 +1,36 @@ +.construction-card { + background-color: var(--surface-card); + border: 1px solid var(--surface-border); + border-radius: 1rem; + box-shadow: 0 4px 10px var(--shadow-light); + max-width: 500px; + width: 100%; +} + +/* Icon */ +.construction-icon { + font-size: 4rem; + color: var(--secondary-color); +} + +/* Title */ +.construction-title { + font-weight: 600; + color: var(--primary-color); +} + +/* Text */ +.construction-text { + color: var(--text-secondary); +} + +/* Branded button overrides */ +.btn-primary { + background-color: var(--primary-color); + border-color: var(--primary-color); +} + +.btn-primary:hover { + background-color: var(--primary-color-light); + border-color: var(--primary-color-light); +} diff --git a/UI/Web/src/app/shared/components/under-construction/under-construction.component.ts b/UI/Web/src/app/shared/components/under-construction/under-construction.component.ts new file mode 100644 index 0000000..f12b3e5 --- /dev/null +++ b/UI/Web/src/app/shared/components/under-construction/under-construction.component.ts @@ -0,0 +1,17 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import {RouterLink} from "@angular/router"; +import {TranslocoDirective} from "@jsverse/transloco"; + +@Component({ + selector: 'app-under-construction', + imports: [ + RouterLink, + TranslocoDirective + ], + templateUrl: './under-construction.component.html', + styleUrl: './under-construction.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class UnderConstructionComponent { + +} diff --git a/UI/Web/src/app/shared/tracker.ts b/UI/Web/src/app/shared/tracker.ts new file mode 100644 index 0000000..810f56e --- /dev/null +++ b/UI/Web/src/app/shared/tracker.ts @@ -0,0 +1,75 @@ +import {computed, signal} from '@angular/core'; + + +export class Tracker { + + private readonly idFunc: (t: T) => S; + private readonly comparator: (a: T, b: T) => boolean; + + private readonly _items = signal([]); + private readonly _lookup= signal(new Map()); + + public readonly items = this._items.asReadonly(); + public readonly ids = computed(() => this.items().map(x => this.idFunc(x))); + public readonly lookup = this._lookup.asReadonly(); + public readonly empty = computed(() => this._items().length === 0); + + /** + * + * @param idFunc Return the identifier for the items + * @param comparator If required, pass a custom comparator. Default to === between ids + */ + constructor(idFunc: (t: T) => S, comparator?: (a: T, b: T) => boolean) { + this.idFunc = idFunc; + this.comparator = comparator ?? ((a: T, b: T) => this.idFunc(a) === this.idFunc(b)); + } + + add(item: T) { + const id = this.idFunc(item); + if (this._lookup().has(id)) return false; + + this._items.update(i => { + i.push(item); + return i; + }); + + this._lookup.update(m => { + m.set(id, item); + return m; + }); + + return true; + } + + addAll(items: T[]) { + items.forEach(this.add) + } + + remove(item: T) { + const id = this.idFunc(item); + if (!this._lookup().has(id)) return false; + + this._items.update(i => i.filter(t => !this.comparator(t, item))); + this._lookup.update(m => { + m.delete(id); + return m; + }); + + return true; + } + + removeAll(items: T[]) { + items.forEach(this.remove) + } + + reset() { + this._items.set([]); + this._lookup.set(new Map()); + } + + + + + + +}