From ec7257d1b4cbfd9e26860e87d113bbd18ea9f90d Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 30 Aug 2025 01:50:10 +0200 Subject: [PATCH 01/11] Fix metrics being logged by serilog --- API/Logging/LogLevelOptions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/API/Logging/LogLevelOptions.cs b/API/Logging/LogLevelOptions.cs index d9a6b67..b58630d 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); } From b6a9f01eab7469a9abb00d3aa62ab71542bde911 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 30 Aug 2025 02:36:43 +0200 Subject: [PATCH 02/11] BVDK-49 in UI overview of delivery --- API/Entities/SystemMessage.cs | 2 +- API/Services/ClientService.cs | 6 +- API/Services/LocalizationService.cs | 4 +- UI/Web/public/assets/i18n/en.json | 27 +++++ UI/Web/src/app/_models/delivery.ts | 7 +- .../view-delivery-modal.component.html | 113 ++++++++++++++++++ .../view-delivery-modal.component.scss | 42 +++++++ .../view-delivery-modal.component.ts | 46 +++++++ .../browse-deliveries.component.html | 2 +- .../browse-deliveries.component.ts | 29 ++++- 10 files changed, 267 insertions(+), 11 deletions(-) create mode 100644 UI/Web/src/app/browse-deliveries/_components/view-delivery-modal/view-delivery-modal.component.html create mode 100644 UI/Web/src/app/browse-deliveries/_components/view-delivery-modal/view-delivery-modal.component.scss create mode 100644 UI/Web/src/app/browse-deliveries/_components/view-delivery-modal/view-delivery-modal.component.ts 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/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/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/UI/Web/public/assets/i18n/en.json b/UI/Web/public/assets/i18n/en.json index fa85b47..c9498bb 100644 --- a/UI/Web/public/assets/i18n/en.json +++ b/UI/Web/public/assets/i18n/en.json @@ -128,6 +128,33 @@ "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" + } + }, + "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", 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/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..36c8805 100644 --- a/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html +++ b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html @@ -39,7 +39,7 @@ - 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..51bf851 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'; @@ -16,7 +16,12 @@ import { TransitionDeliveryModalComponent } from './_components/transition-delivery-modal/transition-delivery-modal.component'; import {DefaultModalOptions} from '../_models/default-modal-options'; -import {tap} from 'rxjs'; +import {filter, tap} from 'rxjs'; +import {ViewDeliveryModalComponent} from './_components/view-delivery-modal/view-delivery-modal.component'; +import {Product, ProductCategory} from '../_models/product'; +import * as console from 'node:console'; +import {state} from '@angular/animations'; +import {ProductService} from '../_services/product.service'; @Component({ selector: 'app-browse-deliveries', @@ -34,15 +39,27 @@ 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); showNavbar = input(true); deliveries = signal([]); + products = signal([]); + categories = signal([]); + + 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({ @@ -92,4 +109,10 @@ 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()); + } } From 01fa8e538c919613cfa60e0b0c182b11ecf5099c Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 30 Aug 2025 02:39:13 +0200 Subject: [PATCH 03/11] Update metric name --- API/Helpers/Telemetry/TelemetryHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" ); From 2ca93105c52d7b0e2f55082e53c917abafb8d9dc Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 30 Aug 2025 02:44:50 +0200 Subject: [PATCH 04/11] Under construction page --- UI/Web/public/assets/i18n/en.json | 6 ++++ .../management-server.component.html | 2 +- .../management-server.component.ts | 5 ++- .../under-construction.component.html | 16 +++++++++ .../under-construction.component.scss | 36 +++++++++++++++++++ .../under-construction.component.ts | 17 +++++++++ 6 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 UI/Web/src/app/shared/components/under-construction/under-construction.component.html create mode 100644 UI/Web/src/app/shared/components/under-construction/under-construction.component.scss create mode 100644 UI/Web/src/app/shared/components/under-construction/under-construction.component.ts diff --git a/UI/Web/public/assets/i18n/en.json b/UI/Web/public/assets/i18n/en.json index c9498bb..4365677 100644 --- a/UI/Web/public/assets/i18n/en.json +++ b/UI/Web/public/assets/i18n/en.json @@ -155,6 +155,12 @@ } }, + "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", 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..27d2c14 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 @@ -

management-server works!

+ 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..08674f9 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,8 +1,11 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {UnderConstructionComponent} from '../../shared/components/under-construction/under-construction.component'; @Component({ selector: 'app-management-server', - imports: [], + imports: [ + UnderConstructionComponent + ], templateUrl: './management-server.component.html', styleUrl: './management-server.component.scss', changeDetection: ChangeDetectionStrategy.OnPush 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 { + +} From 76d6aa79b72207777ad0ca8b8712e7441a1bf92a Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sat, 30 Aug 2025 18:33:33 +0200 Subject: [PATCH 05/11] Backend logic for server settings, entity for exports, log levels --- API/Controllers/SettingsController.cs | 6 ++ API/DTOs/CsvExportConfigurationDto.cs | 11 +++ API/DTOs/Enum/DeliveryExportField.cs | 56 +++++++++++++ API/DTOs/ServerSettingDto.cs | 4 + API/Data/Repositories/SettingsRepository.cs | 10 +++ API/Data/Seed.cs | 32 +++++++ API/Entities/Enums/ServerSettingKey.cs | 10 ++- .../ApplicationServiceExtensions.cs | 1 + API/Logging/LogLevelOptions.cs | 30 ++++--- API/Program.cs | 14 +++- API/Services/ServerSettingsService.cs | 83 +++++++++++++++++++ API/Startup.cs | 2 +- In-Out.sln.DotSettings.user | 2 + 13 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 API/Controllers/SettingsController.cs create mode 100644 API/DTOs/CsvExportConfigurationDto.cs create mode 100644 API/DTOs/Enum/DeliveryExportField.cs create mode 100644 API/Data/Seed.cs create mode 100644 API/Services/ServerSettingsService.cs diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs new file mode 100644 index 0000000..ae39f0d --- /dev/null +++ b/API/Controllers/SettingsController.cs @@ -0,0 +1,6 @@ +namespace API.Controllers; + +public class SettingsController +{ + +} \ No newline at end of file diff --git a/API/DTOs/CsvExportConfigurationDto.cs b/API/DTOs/CsvExportConfigurationDto.cs new file mode 100644 index 0000000..9249447 --- /dev/null +++ b/API/DTOs/CsvExportConfigurationDto.cs @@ -0,0 +1,11 @@ +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; } = []; +} \ 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/ServerSettingDto.cs b/API/DTOs/ServerSettingDto.cs index 0df8560..4a44d0a 100644 --- a/API/DTOs/ServerSettingDto.cs +++ b/API/DTOs/ServerSettingDto.cs @@ -1,6 +1,10 @@ +using Serilog.Events; + namespace API.DTOs; public sealed record ServerSettingDto { + public CsvExportConfigurationDto CsvExportConfiguration { get; set; } + public LogEventLevel LogLevel { get; set; } } \ No newline at end of file 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/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 64836d9..79575bd 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -31,6 +31,7 @@ public static void AddApplicationServices(this IServiceCollection services, ICon services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddSignalR(opt => opt.EnableDetailedErrors = true); diff --git a/API/Logging/LogLevelOptions.cs b/API/Logging/LogLevelOptions.cs index b58630d..85ae9ab 100644 --- a/API/Logging/LogLevelOptions.cs +++ b/API/Logging/LogLevelOptions.cs @@ -45,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/Program.cs b/API/Program.cs index 47a86d4..f8843a9 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/ServerSettingsService.cs b/API/Services/ServerSettingsService.cs new file mode 100644 index 0000000..5fe2611 --- /dev/null +++ b/API/Services/ServerSettingsService.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using API.Data; +using API.DTOs; +using API.Entities; +using API.Entities.Enums; +using Serilog.Events; + +namespace API.Services; + +public interface IServerSettingsService +{ + /// + /// You will be required to specify the correct type, there is no compile time checks. Only run time! + /// + /// + /// + /// + Task GetSettingsAsync(ServerSettingKey key); + Task GetSettingsAsync(); +} + +public class ServerSettingsService(ILogger logger, IUnitOfWork unitOfWork): IServerSettingsService +{ + 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 ConvertSetting(setting); + } + + private static T ConvertSetting(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}") + }; + } + + public async Task GetSettingsAsync() + { + var settings = await unitOfWork.SettingsRepository.GetSettingsAsync(); + var dto = new ServerSettingDto(); + + foreach (var serverSetting in settings) + { + switch (serverSetting.Key) + { + case ServerSettingKey.CsvExportConfiguration: + dto.CsvExportConfiguration = ConvertSetting(serverSetting); + break; + case ServerSettingKey.LogLevel: + dto.LogLevel = ConvertSetting(serverSetting); + break; + default: + throw new ArgumentOutOfRangeException(nameof(serverSetting.Key), serverSetting.Key, "Unknown server settings key"); + } + } + + return dto; + } + + 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..e325cd8 100644 --- a/In-Out.sln.DotSettings.user +++ b/In-Out.sln.DotSettings.user @@ -1,9 +1,11 @@  ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded From f075ca0e8a388de63c01cb60d61b13543b8273ac Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sun, 31 Aug 2025 18:35:36 +0200 Subject: [PATCH 06/11] Csv Export --- API/API.csproj | 1 + API/Controllers/ExportController.cs | 33 +++++++ API/DTOs/Enum/ExportKind.cs | 9 ++ .../{ => Export}/CsvExportConfigurationDto.cs | 2 + API/DTOs/Export/ExportRequestDto.cs | 14 +++ API/Data/Repositories/DeliveryRepository.cs | 9 ++ .../ApplicationServiceExtensions.cs | 6 ++ API/Middleware/ExceptionMiddleware.cs | 4 + API/Services/ExportService.cs | 39 ++++++++ API/Services/Exporters/CsvExporter.cs | 81 +++++++++++++++++ API/Services/Exporters/IExporter.cs | 89 +++++++++++++++++++ API/Services/OidcService.cs | 2 +- In-Out.sln.DotSettings.user | 7 ++ 13 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 API/Controllers/ExportController.cs create mode 100644 API/DTOs/Enum/ExportKind.cs rename API/DTOs/{ => Export}/CsvExportConfigurationDto.cs (82%) create mode 100644 API/DTOs/Export/ExportRequestDto.cs create mode 100644 API/Services/ExportService.cs create mode 100644 API/Services/Exporters/CsvExporter.cs create mode 100644 API/Services/Exporters/IExporter.cs 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..9fbb430 --- /dev/null +++ b/API/Controllers/ExportController.cs @@ -0,0 +1,33 @@ +using API.Constants; +using API.Data; +using API.Data.Repositories; +using API.DTOs; +using API.Extensions; +using API.Services.Exporters; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace API.Controllers; + +public class ExportController(ILogger logger, IExporter exporter, 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 NotFound(); + } + + + var result = await exporter.Export(deliveries, dto); + return Ok(result); + } + +} \ 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/CsvExportConfigurationDto.cs b/API/DTOs/Export/CsvExportConfigurationDto.cs similarity index 82% rename from API/DTOs/CsvExportConfigurationDto.cs rename to API/DTOs/Export/CsvExportConfigurationDto.cs index 9249447..0a46ee4 100644 --- a/API/DTOs/CsvExportConfigurationDto.cs +++ b/API/DTOs/Export/CsvExportConfigurationDto.cs @@ -8,4 +8,6 @@ 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..55c42ec --- /dev/null +++ b/API/DTOs/Export/ExportRequestDto.cs @@ -0,0 +1,14 @@ +using API.DTOs.Enum; + +namespace API.DTOs; + +public class ExportRequestDto +{ + + public ExportKind Kind { get; set; } + + public IList DeliveryIds { get; set; } + + public CsvExportConfigurationDto? CsvExportConfigurationDto { 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/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 79575bd..9e8440a 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; @@ -32,6 +34,10 @@ public static void AddApplicationServices(this IServiceCollection services, ICon services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // Exporters + services.AddScoped(); + services.AddKeyedScoped(nameof(ExportKind.Csv)); services.AddSignalR(opt => opt.EnableDetailedErrors = true); 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/Services/ExportService.cs b/API/Services/ExportService.cs new file mode 100644 index 0000000..7533fa4 --- /dev/null +++ b/API/Services/ExportService.cs @@ -0,0 +1,39 @@ +using API.DTOs; +using API.DTOs.Enum; +using API.Entities; +using API.Exceptions; +using API.Helpers.Telemetry; +using API.Services.Exporters; +using Microsoft.AspNetCore.Mvc; + +namespace API.Services; + +public class ExportService: IExporter +{ + + private readonly Dictionary _exporters = []; + + public ExportService(ILogger logger, IServiceProvider keyedServiceProvider) + { + 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(), + }); + + return await exporter.Export(deliveries, request); + } +} \ 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..4148b39 --- /dev/null +++ b/API/Services/Exporters/CsvExporter.cs @@ -0,0 +1,81 @@ +using System.Globalization; +using System.Text; +using API.Data; +using API.Entities; +using API.Exceptions; +using CsvHelper; +using Microsoft.AspNetCore.Mvc; + +namespace API.Services.Exporters; + +public class CsvExportContext : ExportContext +{ + public IList SortedProducts { get; init; } +} + +public class CsvExporter(IUnitOfWork unitOfWork) : BaseExporter(unitOfWork) +{ + protected override Task ConstructContext(ExportContext ctx) + { + var sorted = ctx.AllProducts.Values.OrderBy(product => product.Id).ToList(); + + return Task.FromResult(new CsvExportContext + { + Request = ctx.Request, + Deliveries = ctx.Deliveries, + AllProducts = ctx.AllProducts, + AllProductCategories = ctx.AllProductCategories, + SortedProducts = sorted, + }); + } + + protected override async Task ConstructExportFile(CsvExportContext ctx) + { + if (ctx.Request.CsvExportConfigurationDto == null) + { + throw new InOutException("CSV export configuration is missing"); + } + + await using var writer = new StringWriter(); + await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); + + foreach (var headerName in ctx.Request.CsvExportConfigurationDto.HeaderNames) + { + csv.WriteField(headerName); + } + + await csv.NextRecordAsync(); + + foreach (var delivery in ctx.Deliveries) + { + foreach (var header in ctx.Request.CsvExportConfigurationDto.HeaderOrder) + { + csv.WriteField(GetDeliveryFieldAsString(ctx, delivery, header)); + } + + await csv.NextRecordAsync(); + } + + var csvContent = writer.ToString(); + var bytes = Encoding.UTF8.GetBytes(csvContent); + + var fileName = $"deliveries_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv"; + + return new FileContentResult(bytes, "text/csv") + { + FileDownloadName = fileName + }; + } + + protected override string ExportLines(CsvExportContext ctx, IList lines) + { + var builder = new StringBuilder(); + + foreach (var quantity in ctx.SortedProducts.Select(product => lines.FirstOrDefault(line => line.ProductId == product.Id)?.Quantity ?? 0)) + { + builder.Append(quantity).Append(','); + } + + return builder.ToString(); + } +} \ 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..0ec5705 --- /dev/null +++ b/API/Services/Exporters/IExporter.cs @@ -0,0 +1,89 @@ +using System.Globalization; +using API.Data; +using API.DTOs; +using API.DTOs.Enum; +using API.Entities; +using Microsoft.AspNetCore.Mvc; + +namespace API.Services.Exporters; + +public interface IExporter +{ + Task Export(IList deliveries, ExportRequestDto request); +} + +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 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 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/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/In-Out.sln.DotSettings.user b/In-Out.sln.DotSettings.user index e325cd8..a6397a4 100644 --- a/In-Out.sln.DotSettings.user +++ b/In-Out.sln.DotSettings.user @@ -2,13 +2,20 @@ 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" /> From f200c0608fef32a22456b38603793f2aaca1ca24 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Sun, 31 Aug 2025 20:26:53 +0200 Subject: [PATCH 07/11] 2 step exporting, CSV fixes --- API/Controllers/ExportController.cs | 22 +++-- API/DTOs/Export/ExportRequestDto.cs | 2 - .../ApplicationServiceExtensions.cs | 2 +- API/Extensions/DistributedCacheExtensions.cs | 32 ++++++++ API/Services/ExportService.cs | 52 +++++++++++- API/Services/Exporters/CsvExporter.cs | 82 +++++++++++++------ API/Services/Exporters/IExporter.cs | 13 ++- UI/Web/src/app/_models/export.ts | 9 ++ UI/Web/src/app/_services/export.service.ts | 19 +++++ .../browse-deliveries.component.html | 16 +++- .../browse-deliveries.component.ts | 21 ++++- UI/Web/src/app/shared/tracker.ts | 75 +++++++++++++++++ 12 files changed, 300 insertions(+), 45 deletions(-) create mode 100644 API/Extensions/DistributedCacheExtensions.cs create mode 100644 UI/Web/src/app/_models/export.ts create mode 100644 UI/Web/src/app/_services/export.service.ts create mode 100644 UI/Web/src/app/shared/tracker.ts diff --git a/API/Controllers/ExportController.cs b/API/Controllers/ExportController.cs index 9fbb430..33e51e9 100644 --- a/API/Controllers/ExportController.cs +++ b/API/Controllers/ExportController.cs @@ -3,18 +3,18 @@ using API.Data.Repositories; using API.DTOs; using API.Extensions; -using API.Services.Exporters; +using API.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace API.Controllers; -public class ExportController(ILogger logger, IExporter exporter, IUnitOfWork unitOfWork): BaseApiController +public class ExportController(ILogger logger, IExportService exportService, IUnitOfWork unitOfWork): BaseApiController { [HttpPost] [Authorize(PolicyConstants.HandleDeliveries)] - public async Task> Export(ExportRequestDto dto) + public async Task> Export(ExportRequestDto dto) { logger.LogInformation("Creating export on behalf of {UserId}", User.GetUserId()); @@ -22,12 +22,22 @@ public async Task> Export(ExportRequestDto dto) await unitOfWork.DeliveryRepository.GetDeliveryByIds(dto.DeliveryIds, DeliveryIncludes.Complete); if (deliveries.Count == 0) { - return NotFound(); + return BadRequest(); } - var result = await exporter.Export(deliveries, dto); - return Ok(result); + 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/DTOs/Export/ExportRequestDto.cs b/API/DTOs/Export/ExportRequestDto.cs index 55c42ec..291b668 100644 --- a/API/DTOs/Export/ExportRequestDto.cs +++ b/API/DTOs/Export/ExportRequestDto.cs @@ -9,6 +9,4 @@ public class ExportRequestDto public IList DeliveryIds { get; set; } - public CsvExportConfigurationDto? CsvExportConfigurationDto { get; set; } - } \ No newline at end of file diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index 9e8440a..f2ca043 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -36,7 +36,7 @@ public static void AddApplicationServices(this IServiceCollection services, ICon services.AddScoped(); // Exporters - services.AddScoped(); + 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/Services/ExportService.cs b/API/Services/ExportService.cs index 7533fa4..f777298 100644 --- a/API/Services/ExportService.cs +++ b/API/Services/ExportService.cs @@ -1,20 +1,40 @@ +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 class ExportService: IExporter +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; - public ExportService(ILogger logger, IServiceProvider keyedServiceProvider) + 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()); @@ -22,7 +42,7 @@ public ExportService(ILogger logger, IServiceProvider keyedServic } } - public async Task Export(IList deliveries, ExportRequestDto request) + public async Task Export(IList deliveries, ExportRequestDto request) { if (!_exporters.TryGetValue(request.Kind, out var exporter)) { @@ -34,6 +54,30 @@ public ExportService(ILogger logger, IServiceProvider keyedServic ["kind"] = request.Kind.ToString(), }); - return await exporter.Export(deliveries, request); + 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 index 4148b39..33e198b 100644 --- a/API/Services/Exporters/CsvExporter.cs +++ b/API/Services/Exporters/CsvExporter.cs @@ -1,37 +1,50 @@ 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; -using Microsoft.AspNetCore.Mvc; namespace API.Services.Exporters; public class CsvExportContext : ExportContext { - public IList SortedProducts { get; init; } + public required IList SortedProducts { get; init; } + + public required CsvExportConfigurationDto Configuration { get; init; } + + public CsvWriter Writer { get; set; } } -public class CsvExporter(IUnitOfWork unitOfWork) : BaseExporter(unitOfWork) +public class CsvExporter(IUnitOfWork unitOfWork, IServerSettingsService settingsService) : BaseExporter(unitOfWork) { - protected override Task ConstructContext(ExportContext ctx) + + 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 Task.FromResult(new CsvExportContext + return new CsvExportContext { Request = ctx.Request, Deliveries = ctx.Deliveries, AllProducts = ctx.AllProducts, AllProductCategories = ctx.AllProductCategories, SortedProducts = sorted, - }); + Configuration = config, + }; } - protected override async Task ConstructExportFile(CsvExportContext ctx) + protected override async Task<(ExportMetadata, byte[])> ConstructExportFile(CsvExportContext ctx) { - if (ctx.Request.CsvExportConfigurationDto == null) + if (ctx.Configuration == null) { throw new InOutException("CSV export configuration is missing"); } @@ -39,43 +52,66 @@ protected override Task ConstructContext(ExportContext ctx) await using var writer = new StringWriter(); await using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture); - foreach (var headerName in ctx.Request.CsvExportConfigurationDto.HeaderNames) + 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) { - csv.WriteField(headerName); + 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 ctx.Request.CsvExportConfigurationDto.HeaderOrder) + foreach (var header in headerOrder) { - csv.WriteField(GetDeliveryFieldAsString(ctx, delivery, header)); + 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 csvContent = writer.ToString(); - var bytes = Encoding.UTF8.GetBytes(csvContent); - - var fileName = $"deliveries_export_{DateTime.Now:yyyyMMdd_HHmmss}.csv"; - - return new FileContentResult(bytes, "text/csv") + var content = Encoding.UTF8.GetBytes(writer.ToString()); + var metadata = new ExportMetadata { - FileDownloadName = fileName + FileContentType = "text/csv", + FileName = $"deliveries_{DateTime.Now:yyyyMMdd_HHmmss}.csv" }; + + return (metadata, content); } protected override string ExportLines(CsvExportContext ctx, IList lines) { - var builder = new StringBuilder(); - foreach (var quantity in ctx.SortedProducts.Select(product => lines.FirstOrDefault(line => line.ProductId == product.Id)?.Quantity ?? 0)) { - builder.Append(quantity).Append(','); + ctx.Writer.WriteField(quantity); } - return builder.ToString(); + return ""; } } \ No newline at end of file diff --git a/API/Services/Exporters/IExporter.cs b/API/Services/Exporters/IExporter.cs index 0ec5705..daa8be2 100644 --- a/API/Services/Exporters/IExporter.cs +++ b/API/Services/Exporters/IExporter.cs @@ -3,13 +3,18 @@ using API.DTOs; using API.DTOs.Enum; using API.Entities; -using Microsoft.AspNetCore.Mvc; namespace API.Services.Exporters; public interface IExporter { - Task Export(IList deliveries, ExportRequestDto request); + Task<(ExportMetadata, byte[])> Export(IList deliveries, ExportRequestDto request); +} + +public class ExportMetadata +{ + public string FileName { get; set; } + public string FileContentType { get; set; } } public class ExportContext @@ -25,7 +30,7 @@ public class ExportContext public abstract class BaseExporter(IUnitOfWork unitOfWork) : IExporter where T : ExportContext { - public async Task Export(IList deliveries, ExportRequestDto request) + public async Task<(ExportMetadata, byte[])> Export(IList deliveries, ExportRequestDto request) { var products = await unitOfWork.ProductRepository.GetAll(); var categories = await unitOfWork.ProductRepository.GetAllCategories(); @@ -43,7 +48,7 @@ public abstract class BaseExporter(IUnitOfWork unitOfWork) : IExporter where } protected abstract Task ConstructContext(ExportContext ctx); - protected abstract Task ConstructExportFile(T 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) 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/_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/browse-deliveries/browse-deliveries.component.html b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html index 36c8805..8f2e286 100644 --- a/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html +++ b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html @@ -14,7 +14,14 @@ {{t('state')}} {{t('size')}} {{t('created')}} - {{t('actions')}} + +
+ {{t('actions')}} + +
+ @@ -42,6 +49,13 @@ + + @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 51bf851..aba1a5d 100644 --- a/UI/Web/src/app/browse-deliveries/browse-deliveries.component.ts +++ b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.ts @@ -16,12 +16,13 @@ import { TransitionDeliveryModalComponent } from './_components/transition-delivery-modal/transition-delivery-modal.component'; import {DefaultModalOptions} from '../_models/default-modal-options'; -import {filter, tap} from 'rxjs'; +import {tap} from 'rxjs'; import {ViewDeliveryModalComponent} from './_components/view-delivery-modal/view-delivery-modal.component'; import {Product, ProductCategory} from '../_models/product'; -import * as console from 'node:console'; -import {state} from '@angular/animations'; import {ProductService} from '../_services/product.service'; +import {Tracker} from '../shared/tracker'; +import {ExportService} from '../_services/export.service'; +import {ExportKind} from '../_models/export'; @Component({ selector: 'app-browse-deliveries', @@ -45,12 +46,14 @@ export class BrowseDeliveriesComponent implements OnInit { private readonly productService = inject(ProductService); private readonly toastr = inject(ToastrService); private readonly modalService = inject(ModalService); + private readonly exportService = inject(ExportService); showNavbar = input(true); deliveries = signal([]); products = signal([]); categories = signal([]); + tracker = new Tracker((d) => d.id) ngOnInit() { this.productService.allProducts().subscribe(products => { @@ -65,6 +68,7 @@ export class BrowseDeliveriesComponent implements OnInit { this.deliveryService.filter(filter).subscribe({ next: (data: Delivery[]) => { this.deliveries.set(data); + this.tracker.reset(); }, error: (err) => { console.log(err); @@ -108,11 +112,20 @@ export class BrowseDeliveriesComponent implements OnInit { } } - 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() { + this.exportService.export({ + kind: ExportKind.Csv, + deliveryIds: this.tracker.ids(), + }).subscribe((id) => { + window.open(`/api/export/${id}`, '_blank'); + this.tracker.reset(); + }); + } } 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()); + } + + + + + + +} From 44c4c8c3ae688b15f78a88f81df552ee3b3beec6 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Mon, 1 Sep 2025 20:18:53 +0200 Subject: [PATCH 08/11] Saving settings --- API/Controllers/SettingsController.cs | 23 ++++++- ...rverSettingDto.cs => ServerSettingsDto.cs} | 2 +- .../ApplicationServiceExtensions.cs | 2 +- API/Program.cs | 2 +- API/Services/Exporters/CsvExporter.cs | 2 +- API/Services/ServerSettingsService.cs | 63 ++++++++++++++++--- 6 files changed, 79 insertions(+), 15 deletions(-) rename API/DTOs/{ServerSettingDto.cs => ServerSettingsDto.cs} (82%) diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index ae39f0d..a2efcb5 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -1,6 +1,27 @@ +using API.Constants; +using API.DTOs; +using API.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + namespace API.Controllers; -public class SettingsController +[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/ServerSettingDto.cs b/API/DTOs/ServerSettingsDto.cs similarity index 82% rename from API/DTOs/ServerSettingDto.cs rename to API/DTOs/ServerSettingsDto.cs index 4a44d0a..7a9237b 100644 --- a/API/DTOs/ServerSettingDto.cs +++ b/API/DTOs/ServerSettingsDto.cs @@ -2,7 +2,7 @@ namespace API.DTOs; -public sealed record ServerSettingDto +public sealed record ServerSettingsDto { public CsvExportConfigurationDto CsvExportConfiguration { get; set; } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index f2ca043..af23470 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -33,7 +33,7 @@ public static void AddApplicationServices(this IServiceCollection services, ICon services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); // Exporters services.AddScoped(); diff --git a/API/Program.cs b/API/Program.cs index f8843a9..7079cb8 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -38,7 +38,7 @@ public static async Task Main(string[] args) logger.LogInformation("Seeding database"); await Seed.Run(context); - var service = services.GetRequiredService(); + var service = services.GetRequiredService(); var logLevel = await service.GetSettingsAsync(ServerSettingKey.LogLevel); LogLevelOptions.SwitchLogLevel(logLevel); diff --git a/API/Services/Exporters/CsvExporter.cs b/API/Services/Exporters/CsvExporter.cs index 33e198b..2ca7d7d 100644 --- a/API/Services/Exporters/CsvExporter.cs +++ b/API/Services/Exporters/CsvExporter.cs @@ -19,7 +19,7 @@ public class CsvExportContext : ExportContext public CsvWriter Writer { get; set; } } -public class CsvExporter(IUnitOfWork unitOfWork, IServerSettingsService settingsService) : BaseExporter(unitOfWork) +public class CsvExporter(IUnitOfWork unitOfWork, ISettingsService settingsService) : BaseExporter(unitOfWork) { private static readonly IList DefaultHeaderNames = Enum.GetNames(); diff --git a/API/Services/ServerSettingsService.cs b/API/Services/ServerSettingsService.cs index 5fe2611..5a2c3e7 100644 --- a/API/Services/ServerSettingsService.cs +++ b/API/Services/ServerSettingsService.cs @@ -7,7 +7,7 @@ namespace API.Services; -public interface IServerSettingsService +public interface ISettingsService { /// /// You will be required to specify the correct type, there is no compile time checks. Only run time! @@ -16,10 +16,11 @@ public interface IServerSettingsService /// /// Task GetSettingsAsync(ServerSettingKey key); - Task GetSettingsAsync(); + Task GetSettingsAsync(); + Task SaveSettingsAsync(ServerSettingsDto settings); } -public class ServerSettingsService(ILogger logger, IUnitOfWork unitOfWork): IServerSettingsService +public class SettingsService(ILogger logger, IUnitOfWork unitOfWork): ISettingsService { public async Task GetSettingsAsync(ServerSettingKey key) { @@ -29,10 +30,10 @@ public async Task GetSettingsAsync(ServerSettingKey key) } var setting = await unitOfWork.SettingsRepository.GetSettingsAsync(key); - return ConvertSetting(setting); + return DeserializeSetting(setting); } - private static T ConvertSetting(ServerSetting setting) + private static T DeserializeSetting(ServerSetting setting) { object? result = setting.Key switch { @@ -48,21 +49,31 @@ private static T ConvertSetting(ServerSetting setting) _ => 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() + public async Task GetSettingsAsync() { var settings = await unitOfWork.SettingsRepository.GetSettingsAsync(); - var dto = new ServerSettingDto(); + var dto = new ServerSettingsDto(); foreach (var serverSetting in settings) { switch (serverSetting.Key) { case ServerSettingKey.CsvExportConfiguration: - dto.CsvExportConfiguration = ConvertSetting(serverSetting); + dto.CsvExportConfiguration = DeserializeSetting(serverSetting); break; case ServerSettingKey.LogLevel: - dto.LogLevel = ConvertSetting(serverSetting); + dto.LogLevel = DeserializeSetting(serverSetting); break; default: throw new ArgumentOutOfRangeException(nameof(serverSetting.Key), serverSetting.Key, "Unknown server settings key"); @@ -71,7 +82,39 @@ public async Task GetSettingsAsync() 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); + } + } + + 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"), + }; + + await UpdateIfDifferent(serverSetting, value); + } + + if (unitOfWork.HasChanges()) + { + await unitOfWork.CommitAsync(); + } + } + private static class ServerSettingTypeMap { public static readonly Dictionary KeyToType = new() From 91589c9dfdff368938e548db642a57191cda9593 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Mon, 1 Sep 2025 20:22:42 +0200 Subject: [PATCH 09/11] Hide export beyond role check --- ...rSettingsService.cs => SettingsService.cs} | 0 .../browse-deliveries.component.html | 19 ++++++++++++------- .../browse-deliveries.component.ts | 7 +++++++ 3 files changed, 19 insertions(+), 7 deletions(-) rename API/Services/{ServerSettingsService.cs => SettingsService.cs} (100%) diff --git a/API/Services/ServerSettingsService.cs b/API/Services/SettingsService.cs similarity index 100% rename from API/Services/ServerSettingsService.cs rename to API/Services/SettingsService.cs 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 8f2e286..eac1af7 100644 --- a/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html +++ b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html @@ -17,9 +17,12 @@
{{t('actions')}} - + + @if (authService.roles().includes(Role.HandleDeliveries)) { + + }
@@ -50,10 +53,12 @@ - @if (tracker.lookup().has(delivery.id)) { - - } @else { - + @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 aba1a5d..1c30503 100644 --- a/UI/Web/src/app/browse-deliveries/browse-deliveries.component.ts +++ b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.ts @@ -23,6 +23,7 @@ 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', @@ -47,6 +48,7 @@ export class BrowseDeliveriesComponent implements OnInit { private readonly toastr = inject(ToastrService); private readonly modalService = inject(ModalService); private readonly exportService = inject(ExportService); + protected readonly authService = inject(AuthService); showNavbar = input(true); @@ -120,6 +122,9 @@ export class BrowseDeliveriesComponent implements OnInit { } export() { + if (!this.authService.roles().includes(Role.HandleDeliveries)) return; + + this.exportService.export({ kind: ExportKind.Csv, deliveryIds: this.tracker.ids(), @@ -128,4 +133,6 @@ export class BrowseDeliveriesComponent implements OnInit { this.tracker.reset(); }); } + + protected readonly Role = Role; } From 48856099fe7ebfc281fab3194150e90ab85d6505 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:58:10 +0200 Subject: [PATCH 10/11] Csv Export settings --- API/Services/SettingsService.cs | 13 +- UI/Web/public/assets/i18n/en.json | 45 +++++- UI/Web/src/app/_models/settings.ts | 61 ++++++++ .../app/_pipes/delivery-export-field-pipe.ts | 41 ++++++ UI/Web/src/app/_pipes/log-level-pipe.ts | 29 ++++ UI/Web/src/app/_services/settings.service.ts | 22 +++ .../export-settings.component.html | 47 ++++++ .../export-settings.component.scss | 137 ++++++++++++++++++ .../export-settings.component.ts | 109 ++++++++++++++ .../management-server.component.html | 56 ++++++- .../management-server.component.scss | 1 + .../management-server.component.ts | 89 +++++++++++- 12 files changed, 637 insertions(+), 13 deletions(-) create mode 100644 UI/Web/src/app/_models/settings.ts create mode 100644 UI/Web/src/app/_pipes/delivery-export-field-pipe.ts create mode 100644 UI/Web/src/app/_pipes/log-level-pipe.ts create mode 100644 UI/Web/src/app/_services/settings.service.ts create mode 100644 UI/Web/src/app/management/management-server/_components/export-settings/export-settings.component.html create mode 100644 UI/Web/src/app/management/management-server/_components/export-settings/export-settings.component.scss create mode 100644 UI/Web/src/app/management/management-server/_components/export-settings/export-settings.component.ts diff --git a/API/Services/SettingsService.cs b/API/Services/SettingsService.cs index 5a2c3e7..8868155 100644 --- a/API/Services/SettingsService.cs +++ b/API/Services/SettingsService.cs @@ -3,6 +3,7 @@ using API.DTOs; using API.Entities; using API.Entities.Enums; +using API.Logging; using Serilog.Events; namespace API.Services; @@ -83,14 +84,17 @@ public async Task GetSettingsAsync() return dto; } - private async Task UpdateIfDifferent(ServerSetting setting, object value) + 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) @@ -106,7 +110,12 @@ public async Task SaveSettingsAsync(ServerSettingsDto dto) _ => throw new ArgumentOutOfRangeException(nameof(serverSetting.Key), serverSetting.Key, "Unknown server settings key"), }; - await UpdateIfDifferent(serverSetting, value); + var updated = await UpdateIfDifferent(serverSetting, value); + + if (updated && serverSetting.Key == ServerSettingKey.LogLevel) + { + LogLevelOptions.SwitchLogLevel(dto.LogLevel); + } } if (unitOfWork.HasChanges()) diff --git a/UI/Web/public/assets/i18n/en.json b/UI/Web/public/assets/i18n/en.json index 4365677..b9182ce 100644 --- a/UI/Web/public/assets/i18n/en.json +++ b/UI/Web/public/assets/i18n/en.json @@ -297,13 +297,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", @@ -367,6 +360,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" + } } }, @@ -375,6 +382,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/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/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/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 27d2c14..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 @@ - + +
+
+
+

{{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 08674f9..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,15 +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: [ - UnderConstructionComponent + 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; } From db7991efc0ecba5681dbca1ce47bfa4f73efc7b8 Mon Sep 17 00:00:00 2001 From: Amelia <77553571+Fesaa@users.noreply.github.com> Date: Wed, 3 Sep 2025 17:01:26 +0200 Subject: [PATCH 11/11] No deliveries message --- UI/Web/public/assets/i18n/en.json | 3 +- .../browse-deliveries.component.html | 121 ++++++++++-------- 2 files changed, 68 insertions(+), 56 deletions(-) diff --git a/UI/Web/public/assets/i18n/en.json b/UI/Web/public/assets/i18n/en.json index b9182ce..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": { 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 eac1af7..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,70 +2,81 @@ - - - - {{t('from')}} - {{t('to')}} - {{t('state')}} - {{t('size')}} - {{t('created')}} - -
- {{t('actions')}} + @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)) { - - } -
- - -
+ @if (authService.roles().includes(Role.HandleDeliveries)) { + + } +
+ + +
- - {{ delivery.from.name }} - {{ delivery.recipient.name }} - - {{delivery.state | deliveryState}} - - - {{delivery.lines.length}} - - {{ delivery.createdUtc ?? '—' | utcToLocalTime:'shortDate' }} - + + {{ 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 { - + @if (authService.roles().includes(Role.HandleDeliveries)) { + @if (tracker.lookup().has(delivery.id)) { + + } @else { + + } } - } -
+
- -
-
+ + + + }