diff --git a/API/API.csproj b/API/API.csproj index 2e0fe3b..7f730cb 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -25,6 +25,11 @@ + + + + + @@ -36,6 +41,7 @@ + diff --git a/API/Controllers/ClientController.cs b/API/Controllers/ClientController.cs index 4fe6061..d32df58 100644 --- a/API/Controllers/ClientController.cs +++ b/API/Controllers/ClientController.cs @@ -52,6 +52,18 @@ public async Task CreateClient(ClientDto dto) return Ok(); } + /// + /// Create several clients at once + /// + /// + /// + [HttpPost("create-bulk")] + public async Task CreateClientBulk(IList dtos) + { + await clientService.CreateClients(dtos); + return Ok(); + } + /// /// Update an existing client /// diff --git a/API/Data/Repositories/ProductRepository.cs b/API/Data/Repositories/ProductRepository.cs index 6433ff8..32a1ffc 100644 --- a/API/Data/Repositories/ProductRepository.cs +++ b/API/Data/Repositories/ProductRepository.cs @@ -1,6 +1,5 @@ using API.DTOs; using API.Entities; -using API.Entities.Enums; using API.Extensions; using AutoMapper; using AutoMapper.QueryableExtensions; @@ -71,31 +70,31 @@ public async Task> GetDtoByIds(IEnumerable ids) public async Task> GetAll(bool onlyEnabled = false) { - return await ctx.Products - .WhereIf(onlyEnabled, p => p.Enabled) + return await QueryableExtensions + .WhereIf(ctx.Products, onlyEnabled, p => p.Enabled) .ToListAsync(); } public async Task> GetAllDto(bool onlyEnabled = false) { - return await ctx.Products - .WhereIf(onlyEnabled, p => p.Enabled) + return await QueryableExtensions + .WhereIf(ctx.Products, onlyEnabled, p => p.Enabled) .ProjectTo(mapper.ConfigurationProvider) .ToListAsync(); } public async Task> GetAllCategories(bool onlyEnabled = false) { - return await ctx.ProductCategories - .WhereIf(onlyEnabled, p => p.Enabled) + return await QueryableExtensions + .WhereIf(ctx.ProductCategories, onlyEnabled, p => p.Enabled) .OrderBy(p => p.SortValue) .ToListAsync(); } public async Task> GetAllCategoriesDtos(bool onlyEnabled = false) { - return await ctx.ProductCategories - .WhereIf(onlyEnabled, p => p.Enabled) + return await QueryableExtensions + .WhereIf(ctx.ProductCategories, onlyEnabled, p => p.Enabled) .OrderBy(p => p.SortValue) .ProjectTo(mapper.ConfigurationProvider) .ToListAsync(); diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index ab97e25..d197ba0 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -39,7 +39,8 @@ public static void AddApplicationServices(this IServiceCollection services, ICon private static void AddPostgres(this IServiceCollection services, IConfiguration configuration) { - var pgConnectionString = configuration.GetConnectionString("Postgres"); + var pgConnectionString = Environment.GetEnvironmentVariable("CONNECTION_STRING") + ?? configuration.GetConnectionString("Postgres"); services.AddDbContextPool(options => { diff --git a/API/Entities/QueryableExtensions.cs b/API/Extensions/QueryableExtensions.cs similarity index 91% rename from API/Entities/QueryableExtensions.cs rename to API/Extensions/QueryableExtensions.cs index fc01b98..67ec950 100644 --- a/API/Entities/QueryableExtensions.cs +++ b/API/Extensions/QueryableExtensions.cs @@ -1,6 +1,6 @@ using System.Linq.Expressions; -namespace API.Entities; +namespace API.Extensions; public static class QueryableExtensions { diff --git a/API/Helpers/Telemetry/OperationTracker.cs b/API/Helpers/Telemetry/OperationTracker.cs new file mode 100644 index 0000000..71c3af4 --- /dev/null +++ b/API/Helpers/Telemetry/OperationTracker.cs @@ -0,0 +1,30 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace API.Helpers.Telemetry; + +public class OperationTracker: IDisposable +{ + private readonly Stopwatch _stopwatch; + private readonly Histogram _histogram; + private readonly Dictionary _tags; + private bool _disposed; + + internal OperationTracker(Histogram histogram, string operationName, Dictionary? tags) + { + _histogram = histogram; + _tags = tags ?? []; + _stopwatch = Stopwatch.StartNew(); + + _tags["operation"] = operationName; + } + + public void Dispose() + { + if (_disposed) return; + + _stopwatch.Stop(); + _histogram.Record(_stopwatch.Elapsed.TotalMilliseconds, _tags.ToArray()); + _disposed = true; + } +} \ No newline at end of file diff --git a/API/Helpers/Telemetry/TelemetryHelper.cs b/API/Helpers/Telemetry/TelemetryHelper.cs new file mode 100644 index 0000000..31d7074 --- /dev/null +++ b/API/Helpers/Telemetry/TelemetryHelper.cs @@ -0,0 +1,46 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; +using Flurl.Util; + +namespace API.Helpers.Telemetry; + +public static class TelemetryHelper +{ + + private static readonly ActivitySource ActivitySource; + private static readonly Meter Meter; + + private static readonly Histogram MethodTiming; + private static readonly Counter InvalidOperationCounts; + + static TelemetryHelper() + { + var serviceName = BuildInfo.AppName; + + ActivitySource = new ActivitySource(serviceName); + Meter = new Meter(serviceName); + + MethodTiming = Meter.CreateHistogram( + "method_timing_duration_ms", + "ms", + "Duration of specific method, see method tag" + ); + + InvalidOperationCounts = Meter.CreateCounter( + "invalid_operations_counter", + "absolute", + "Amount of invalid operations performed by users"); + } + + public static OperationTracker TrackOperation(string operationName, Dictionary? tags = null) + { + return new OperationTracker(MethodTiming, operationName, tags); + } + + public static void InvalidOperation(string operationName, Dictionary? tags = null) + { + tags ??= []; + tags.Add("operation", operationName); + InvalidOperationCounts.Add(1, tags.ToArray()); + } +} \ No newline at end of file diff --git a/API/Services/ClientService.cs b/API/Services/ClientService.cs index 2a4d44b..c6ff431 100644 --- a/API/Services/ClientService.cs +++ b/API/Services/ClientService.cs @@ -10,6 +10,7 @@ namespace API.Services; public interface IClientService { Task CreateClient(ClientDto dto); + Task CreateClients(IList dtos); Task UpdateClient(ClientDto dto); Task DeleteClient(int id); } @@ -18,6 +19,22 @@ public class ClientService(IUnitOfWork unitOfWork, ILogger logger { public async Task CreateClient(ClientDto dto) + { + await CreateClientFromDto(dto); + await unitOfWork.CommitAsync(); + } + + public async Task CreateClients(IList dtos) + { + foreach (var dto in dtos) + { + await CreateClientFromDto(dto); + } + + await unitOfWork.CommitAsync(); + } + + private async Task CreateClientFromDto(ClientDto dto) { if (!string.IsNullOrWhiteSpace(dto.CompanyNumber)) { @@ -39,7 +56,6 @@ public async Task CreateClient(ClientDto dto) }; unitOfWork.ClientRepository.Add(client); - await unitOfWork.CommitAsync(); } public async Task UpdateClient(ClientDto dto) diff --git a/API/Services/DeliveryService.cs b/API/Services/DeliveryService.cs index 222afc7..82247c9 100644 --- a/API/Services/DeliveryService.cs +++ b/API/Services/DeliveryService.cs @@ -7,6 +7,7 @@ using API.Entities.Enums; using API.Exceptions; using API.Extensions; +using API.Helpers.Telemetry; namespace API.Services; @@ -31,6 +32,11 @@ public async Task CreateDelivery(int userId, DeliveryDto dto) var client = await unitOfWork.ClientRepository.GetClientById(dto.ClientId); if (client == null) throw new InOutException("errors.client-not-found"); + + using var tracker = TelemetryHelper.TrackOperation("create_client", new Dictionary + { + ["user_id"] = userId, + }); var lines = dto.Lines.GroupBy(l => l.ProductId) .Select(g => new DeliveryLineDto @@ -95,6 +101,11 @@ public async Task UpdateDelivery(ClaimsPrincipal actor, DeliveryDto dt var user = await userService.GetUser(actor); if (delivery.UserId != user.Id && !actor.IsInRole(PolicyConstants.CreateForOthers)) throw new UnauthorizedAccessException(); + + using var tracker = TelemetryHelper.TrackOperation("update_client", new Dictionary + { + ["user_id"] = user.Id, + }); delivery.Message = dto.Message; @@ -205,6 +216,12 @@ public async Task TransitionDelivery(ClaimsPrincipal actor, int deliveryId, Deli throw new InOutException("errors.invalid-next-state"); delivery.State = nextState; + + if (delivery.State == DeliveryState.Cancelled) + { + // TODO: Refund stock + } + await unitOfWork.CommitAsync(); } diff --git a/API/Services/LocalizationService.cs b/API/Services/LocalizationService.cs index 581d169..cc78737 100644 --- a/API/Services/LocalizationService.cs +++ b/API/Services/LocalizationService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using API.Data; +using API.Helpers.Telemetry; using Microsoft.Extensions.Caching.Memory; namespace API.Services; @@ -78,6 +79,11 @@ public LocalizationService(ILogger logger, IDirectoryServic public async Task?> LoadLanguage(string languageCode) { + using var tracker = TelemetryHelper.TrackOperation("load_language", new Dictionary + { + ["language"] = languageCode, + }); + if (string.IsNullOrWhiteSpace(languageCode)) { languageCode = DefaultLocale; @@ -105,6 +111,12 @@ public LocalizationService(ILogger logger, IDirectoryServic public async Task Get(string locale, string key, params object[] args) { + using var tracker = TelemetryHelper.TrackOperation("get_translations", new Dictionary + { + ["locale"] = locale, + ["key"] = key, + }); + var cacheKey = $"{locale}_{key}"; if (!_memoryCache.TryGetValue(cacheKey, out string? translatedString)) { diff --git a/API/Services/StockService.cs b/API/Services/StockService.cs index 5dd10f2..52df099 100644 --- a/API/Services/StockService.cs +++ b/API/Services/StockService.cs @@ -3,8 +3,8 @@ using API.DTOs; using API.Entities; using API.Entities.Enums; -using API.Extensions; using API.Helpers; +using API.Helpers.Telemetry; namespace API.Services; @@ -25,12 +25,18 @@ public class StockService(ILogger logger, IUnitOfWork unitOfWork, public async Task>> UpdateStockBulkAsync(User user, IList dtos) { - if (dtos == null || !dtos.Any()) + dtos = dtos.Where(dto => dto.Value != 0 || dto.Operation == StockOperation.Set).ToList(); + + if (!dtos.Any()) { return Result>.Failure(await localization.Translate(user.Id, "stock-bulk-empty-list")); } - - dtos = dtos.Where(dto => dto.Value != 0 || dto.Operation == StockOperation.Set).ToList(); + + using var tracker = TelemetryHelper.TrackOperation("bulk_stock_update", new Dictionary + { + ["user_id"] = user.Id, + ["update_operations"] = dtos.Count, + }); return await unitOfWork.ExecuteWithRetryAsync(async () => { diff --git a/API/Startup.cs b/API/Startup.cs index 485289f..b293e3b 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -8,8 +8,12 @@ using API.Middleware; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.Extensions.Primitives; using Microsoft.Net.Http.Headers; using Microsoft.OpenApi.Models; +using Npgsql; +using OpenTelemetry.Metrics; +using OpenTelemetry.Resources; using Serilog; using Serilog.Events; @@ -85,6 +89,16 @@ public void ConfigureServices(IServiceCollection services) }); services.AddResponseCaching(); // TODO: Rate limitter + + services.AddOpenTelemetry() + .ConfigureResource(src => src + .AddService(BuildInfo.AppName)) + .WithMetrics(metrics => metrics + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation() + .AddNpgsqlInstrumentation() + .AddMeter(BuildInfo.AppName) + .AddPrometheusExporter()); } public void Configure(IApplicationBuilder app, IServiceProvider serviceProvider, IHostApplicationLifetime applicationLifetime) @@ -158,12 +172,33 @@ public void Configure(IApplicationBuilder app, IServiceProvider serviceProvider, ctx.Context.Response.Headers["X-Robots-Tag"] = "noindex,nofollow"; } }); + + + + var apiKey = cfg.GetValue("ApiKey"); + app.Use(async (ctx, next) => + { + if (ctx.Request.Path.StartsWithSegments("/metrics")) + { + if (!ctx.Request.Query.TryGetValue("api-key", out var key) || + key != apiKey || string.IsNullOrWhiteSpace(apiKey)) + { + ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; + await ctx.Response.WriteAsync("Unauthorized"); + return; + } + } + + await next(); + }); + app.UseEndpoints(endpoints => { endpoints.MapControllers(); //endpoints.MapHub("hubs/messages"); //endpoints.MapHub("hubs/logs"); endpoints.MapFallbackToController("Index", "Fallback"); + endpoints.MapPrometheusScrapingEndpoint(); }); applicationLifetime.ApplicationStarted.Register(() => diff --git a/API/config/appsettings.json b/API/config/appsettings.json index 10f68b8..e04ad28 100644 --- a/API/config/appsettings.json +++ b/API/config/appsettings.json @@ -5,5 +5,12 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "OpenIdConnect": { + "Authority": "", + "ClientId": "" + }, + "ConnectionStrings": { + "Postgres": "" + }, + "ApiKey": "" } diff --git a/In-Out.sln.DotSettings.user b/In-Out.sln.DotSettings.user index f8ae1c9..71401df 100644 --- a/In-Out.sln.DotSettings.user +++ b/In-Out.sln.DotSettings.user @@ -1,5 +1,12 @@  + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded + ForceIncluded + ForceIncluded ForceIncluded <AssemblyExplorer> <Assembly Path="/Users/amelia/.nuget/packages/testcontainers/4.6.0/lib/net9.0/Testcontainers.dll" /> diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index dd04610..c286309 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -16,6 +16,7 @@ "@angular/forms": "^20.1.1", "@angular/platform-browser": "^20.1.1", "@angular/router": "^20.1.1", + "@iplab/ngx-file-upload": "^20.0.0", "@jsverse/transloco": "^7.6.1", "@ng-bootstrap/ng-bootstrap": "^19.0.1", "@popperjs/core": "^2.11.8", @@ -23,6 +24,7 @@ "bootstrap": "^5.3.6", "luxon": "^3.7.1", "ngx-toastr": "^19.0.0", + "papaparse": "^5.5.3", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -34,6 +36,7 @@ "@angular/localize": "^20.1.1", "@types/jasmine": "~5.1.0", "@types/luxon": "^3.6.2", + "@types/papaparse": "^5.3.16", "jasmine-core": "~5.7.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", @@ -1744,6 +1747,22 @@ } } }, + "node_modules/@iplab/ngx-file-upload": { + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@iplab/ngx-file-upload/-/ngx-file-upload-20.0.0.tgz", + "integrity": "sha512-cBUg9Y3WgIWcumS6ei6JX77EaZL9lR/jsu9T4FEE0kbZd6N3xcwFYscWcqNHHRhImeKHPHFMfVU4oW9CuiDFxQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/animations": "^20.0.0", + "@angular/common": "^20.0.0", + "@angular/core": "^20.0.0", + "@angular/forms": "^20.0.0", + "rxjs": "^7.0.0" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3891,6 +3910,16 @@ "undici-types": "~7.8.0" } }, + "node_modules/@types/papaparse": { + "version": "5.3.16", + "resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.16.tgz", + "integrity": "sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-basic-ssl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz", @@ -8137,6 +8166,12 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/papaparse": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz", + "integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==", + "license": "MIT" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index b45d0a3..caecbef 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -28,6 +28,7 @@ "@angular/forms": "^20.1.1", "@angular/platform-browser": "^20.1.1", "@angular/router": "^20.1.1", + "@iplab/ngx-file-upload": "^20.0.0", "@jsverse/transloco": "^7.6.1", "@ng-bootstrap/ng-bootstrap": "^19.0.1", "@popperjs/core": "^2.11.8", @@ -35,6 +36,7 @@ "bootstrap": "^5.3.6", "luxon": "^3.7.1", "ngx-toastr": "^19.0.0", + "papaparse": "^5.5.3", "rxjs": "~7.8.0", "tslib": "^2.3.0", "zone.js": "~0.15.0" @@ -46,6 +48,7 @@ "@angular/localize": "^20.1.1", "@types/jasmine": "~5.1.0", "@types/luxon": "^3.6.2", + "@types/papaparse": "^5.3.16", "jasmine-core": "~5.7.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", diff --git a/UI/Web/public/assets/i18n/en.json b/UI/Web/public/assets/i18n/en.json index f16214c..fa85b47 100644 --- a/UI/Web/public/assets/i18n/en.json +++ b/UI/Web/public/assets/i18n/en.json @@ -58,10 +58,31 @@ "invoiceEmail-tooltip": "Email to receive invoices and billing-related communications" }, + "import-client-modal": { + "title": "Import clients from CSV", + "cancel": "{{common.cancel}}", + "create": "{{common.create}}", + "close": "{{common.close}}", + "import": "Import", + "next": "Next", + "import-description": "Select your csv export, to mass import clients from a known source" + }, + + "client-field-pipe": { + "name": "Name", + "address": "Address", + "company-number": "Company Number", + "contact-email": "Contact Email", + "contact-name": "Contact Name", + "contact-number": "Contact Number", + "invoice-email": "Invoice email" + }, + "manage-delivery": { "loading": "Loading...", "new-delivery": "New Delivery", - "edit-delivery-with-id": "Edit Delivery #{{id}}", + "edit-delivery-with-id": "Edit Delivery #{{id}} to {{name}}", + "unknown": "unknown", "items-selected": "{{count}} items selected", "save-delivery": "Save Delivery", "save": "{{common.save}}", @@ -71,7 +92,9 @@ "from": "From", "success": "Successfully updated your delivery", "failed": "Failed to submit your delivery, check your input", - "delivery-locked": "This delivery cannot be changed in its current state" + "delivery-locked": "This delivery cannot be changed in its current state", + "message-label": "Message", + "message-tooltip": "An optional message, as extra information" }, "filter": { @@ -89,6 +112,7 @@ "from": "From", "to": "Recipient", "state": "Delivery state", + "size": "Size", "created": "Created on", "actions": "Actions", "transition": "Transition" @@ -304,7 +328,10 @@ "companyNumber": "Company number", "contactEmail": "Email", "contactNumber": "Phone number", - "actions": "{{common.actions}}" + "actions": "{{common.actions}}", + "address": "Address", + "invoiceEmail": "Invoice Email", + "contactName": "Contact Name" } } } diff --git a/UI/Web/src/app/_pipes/client-field-pipe.ts b/UI/Web/src/app/_pipes/client-field-pipe.ts new file mode 100644 index 0000000..5519a94 --- /dev/null +++ b/UI/Web/src/app/_pipes/client-field-pipe.ts @@ -0,0 +1,31 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import { + ClientField +} from '../management/management-clients/_components/import-client-modal/import-client-modal.component'; +import {translate} from '@jsverse/transloco'; + +@Pipe({ + name: 'clientField' +}) +export class ClientFieldPipe implements PipeTransform { + + transform(value: ClientField): string { + switch (value) { + case ClientField.Name: + return translate('client-field-pipe.name'); + case ClientField.Address: + return translate('client-field-pipe.address'); + case ClientField.CompanyNumber: + return translate('client-field-pipe.company-number'); + case ClientField.ContactEmail: + return translate('client-field-pipe.contact-email'); + case ClientField.ContactName: + return translate('client-field-pipe.contact-name'); + case ClientField.ContactNumber: + return translate('client-field-pipe.contact-number'); + case ClientField.InvoiceEmail: + return translate('client-field-pipe.invoice-email'); + } + } + +} diff --git a/UI/Web/src/app/_services/client.service.ts b/UI/Web/src/app/_services/client.service.ts index b972d68..69f5349 100644 --- a/UI/Web/src/app/_services/client.service.ts +++ b/UI/Web/src/app/_services/client.service.ts @@ -31,6 +31,10 @@ export class ClientService { return this.http.post(this.baseUrl, dto); } + createBulk(dtos: Client[]): Observable { + return this.http.post(this.baseUrl+"create-bulk", dtos); + } + update(dto: Client): Observable { return this.http.put(this.baseUrl, dto); } 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 c29ea71..18a8810 100644 --- a/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html +++ b/UI/Web/src/app/browse-deliveries/browse-deliveries.component.html @@ -12,6 +12,7 @@ {{t('from')}} {{t('to')}} {{t('state')}} + {{t('size')}} {{t('created')}} {{t('actions')}} @@ -23,16 +24,25 @@ {{delivery.state | deliveryState}} + + {{delivery.lines.length}} + {{ delivery.createdUtc ?? '—' | utcToLocalTime:'shortDate' }} - + + +
+ - + + + - - - + +
diff --git a/UI/Web/src/app/manage-delivery/manage-delivery.component.html b/UI/Web/src/app/manage-delivery/manage-delivery.component.html index 73536f5..54e96f3 100644 --- a/UI/Web/src/app/manage-delivery/manage-delivery.component.html +++ b/UI/Web/src/app/manage-delivery/manage-delivery.component.html @@ -16,7 +16,7 @@

@if (delivery().id === -1) { {{ t('new-delivery') }} } @else { - {{ t('edit-delivery-with-id', { id: delivery().id }) }} + {{ t('edit-delivery-with-id', { id: delivery().id, name: selectedClient()?.name ?? t('unknown') }) }} }

@if (totalItems() > 0) { @@ -169,6 +169,16 @@
} } + +
+ @if (deliveryForm.get('message'); as formControl) { + + + + + + } +
diff --git a/UI/Web/src/app/manage-delivery/manage-delivery.component.ts b/UI/Web/src/app/manage-delivery/manage-delivery.component.ts index 06ae610..26ed3ff 100644 --- a/UI/Web/src/app/manage-delivery/manage-delivery.component.ts +++ b/UI/Web/src/app/manage-delivery/manage-delivery.component.ts @@ -20,10 +20,11 @@ import { } from '../browse-deliveries/_components/transition-delivery-modal/transition-delivery-modal.component'; import {DefaultModalOptions} from '../_models/default-modal-options'; import {ModalService} from '../_services/modal.service'; +import {SettingsItemComponent} from '../shared/components/settings-item/settings-item.component'; @Component({ selector: 'app-manage-delivery', - imports: [CommonModule, ReactiveFormsModule, TranslocoDirective, TypeaheadComponent, RouterLink], + imports: [CommonModule, ReactiveFormsModule, TranslocoDirective, TypeaheadComponent, RouterLink, SettingsItemComponent], templateUrl: './manage-delivery.component.html', styleUrl: './manage-delivery.component.scss', changeDetection: ChangeDetectionStrategy.OnPush diff --git a/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.html b/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.html index 86903a5..e000fad 100644 --- a/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.html +++ b/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.html @@ -22,7 +22,7 @@ @if (clientForm.get('name'); as control) { - {{control.value}} + {{control.value | defaultValue}} @@ -35,7 +35,7 @@ @if (clientForm.get('address'); as control) { - {{control.value}} + {{control.value | defaultValue}} @@ -48,7 +48,7 @@ @if (clientForm.get('companyNumber'); as control) { - {{control.value}} + {{control.value | defaultValue}} @@ -61,7 +61,7 @@ @if (clientForm.get('contactEmail'); as control) { - {{control.value}} + {{control.value | defaultValue}} @@ -74,7 +74,7 @@ @if (clientForm.get('contactName'); as control) { - {{control.value}} + {{control.value | defaultValue}} @@ -87,7 +87,7 @@ @if (clientForm.get('contactNumber'); as control) { - {{control.value}} + {{control.value | defaultValue}} @@ -100,7 +100,7 @@ @if (clientForm.get('invoiceEmail'); as control) { - {{control.value}} + {{control.value | defaultValue}} diff --git a/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.ts b/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.ts index ad3210a..916dfc4 100644 --- a/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.ts +++ b/UI/Web/src/app/management/management-clients/_components/client-modal/client-modal.component.ts @@ -6,6 +6,7 @@ import {FormControl, FormGroup, FormsModule, ReactiveFormsModule, Validators} fr import {ModalDismissReasons, NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; import {ClientService} from '../../../../_services/client.service'; import {SettingsItemComponent} from '../../../../shared/components/settings-item/settings-item.component'; +import {DefaultValuePipe} from '../../../../_pipes/default-value.pipe'; @Component({ selector: 'app-client-modal', @@ -14,7 +15,8 @@ import {SettingsItemComponent} from '../../../../shared/components/settings-item LoadingSpinnerComponent, FormsModule, ReactiveFormsModule, - SettingsItemComponent + SettingsItemComponent, + DefaultValuePipe ], templateUrl: './client-modal.component.html', styleUrl: './client-modal.component.scss', diff --git a/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.html b/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.html new file mode 100644 index 0000000..7353244 --- /dev/null +++ b/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.html @@ -0,0 +1,66 @@ + + + + + {{t('header.name')}} + + + {{t('header.address')}} + + + {{t('header.companyNumber')}} + + + {{t('header.invoiceEmail')}} + + + {{t('header.contactName')}} + + + {{t('header.contactEmail')}} + + + {{t('header.contactNumber')}} + + @if (showActions()) { + + {{t('header.actions')}} + + } + + + + + {{item.name}} + + + {{item.address | defaultValue}} + + + {{item.companyNumber | defaultValue}} + + + @if (item.invoiceEmail) { + {{item.invoiceEmail}} + } @else { + {{null | defaultValue}} + } + + + {{item.contactName | defaultValue}} + + + @if (item.contactEmail) { + {{item.contactEmail}} + } @else { + {{null | defaultValue}} + } + + + {{item.contactNumber | defaultValue}} + + @if (showActions()) { + + } + + diff --git a/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.scss b/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.ts b/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.ts new file mode 100644 index 0000000..c85ad5b --- /dev/null +++ b/UI/Web/src/app/management/management-clients/_components/clients-table/clients-table.component.ts @@ -0,0 +1,31 @@ +import {ChangeDetectionStrategy, Component, ContentChild, input, TemplateRef, ViewChild} from '@angular/core'; +import {TableComponent} from '../../../../shared/components/table/table.component'; +import {Client} from '../../../../_models/client'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {DefaultValuePipe} from '../../../../_pipes/default-value.pipe'; +import {NgTemplateOutlet} from '@angular/common'; + +@Component({ + selector: 'app-clients-table', + imports: [ + TableComponent, + TranslocoDirective, + DefaultValuePipe, + NgTemplateOutlet + ], + templateUrl: './clients-table.component.html', + styleUrl: './clients-table.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ClientsTableComponent { + + @ContentChild("actions") actionsRef!: TemplateRef; + + showActions = input.required(); + clients = input.required(); + + clientTracker(idx: number, client: Client): string { + return `${client.id}` + } + +} diff --git a/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.html b/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.html new file mode 100644 index 0000000..0a93e45 --- /dev/null +++ b/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.html @@ -0,0 +1,61 @@ + + + diff --git a/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.scss b/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.ts b/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.ts new file mode 100644 index 0000000..3dfe792 --- /dev/null +++ b/UI/Web/src/app/management/management-clients/_components/import-client-modal/import-client-modal.component.ts @@ -0,0 +1,224 @@ +import {ChangeDetectionStrategy, Component, computed, inject, signal} from '@angular/core'; +import {LoadingSpinnerComponent} from '../../../../shared/components/loading-spinner/loading-spinner.component'; +import {translate, TranslocoDirective} from '@jsverse/transloco'; +import {ClientService} from '../../../../_services/client.service'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {FormArray, FormControl, FormGroup, NonNullableFormBuilder, ReactiveFormsModule} from '@angular/forms'; +import {FileUploadComponent, FileUploadValidators} from '@iplab/ngx-file-upload'; +import {map} from 'rxjs'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {ToastrService} from 'ngx-toastr'; +import Papa from 'papaparse'; +import {SettingsItemComponent} from '../../../../shared/components/settings-item/settings-item.component'; +import {ClientFieldPipe} from '../../../../_pipes/client-field-pipe'; +import {Client} from '../../../../_models/client'; +import {TableComponent} from '../../../../shared/components/table/table.component'; +import {DefaultValuePipe} from '../../../../_pipes/default-value.pipe'; +import {ClientsTableComponent} from '../clients-table/clients-table.component'; + +enum StageId { + FileImport = 'file-import', + HeaderMatch = 'header-match', + Confirm = 'confirm', +} + +export enum ClientField { + Name = 0, + Address = 1, + CompanyNumber = 2, + InvoiceEmail = 3, + ContactName = 4, + ContactEmail = 5, + ContactNumber = 6, +} + +const fields: ClientField[] = [ClientField.Name, ClientField.Address, ClientField.CompanyNumber, +ClientField.InvoiceEmail, ClientField.ContactName, ClientField.ContactEmail, ClientField.ContactNumber]; + +type HeaderMappingControl = FormGroup<{ + header: FormControl, + field: FormControl, + index: FormControl +}>; + +@Component({ + selector: 'app-import-client-modal', + imports: [ + LoadingSpinnerComponent, + TranslocoDirective, + ReactiveFormsModule, + FileUploadComponent, + SettingsItemComponent, + ClientFieldPipe, + TableComponent, + DefaultValuePipe, + ClientsTableComponent, + ], + templateUrl: './import-client-modal.component.html', + styleUrl: './import-client-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class ImportClientModalComponent { + + private readonly clientService = inject(ClientService); + protected readonly modal = inject(NgbActiveModal); + private readonly toastr = inject(ToastrService); + private readonly fb = inject(NonNullableFormBuilder); + + fileUploadControl = new FormControl>(undefined, [ + FileUploadValidators.accept(['.csv']), FileUploadValidators.filesLimit(1) + ]); + + uploadForm = new FormGroup({ + files: this.fileUploadControl, + }); + + headerMatchForm: FormGroup<{headers: FormArray}> = new FormGroup({ + headers: new FormArray([]) + }); + + isSaving = signal(false); + currentStage = signal(StageId.FileImport); + // CSV data without header + data = signal([]); + clients = signal([]); + + isFileSelected = toSignal(this.uploadForm.get('files')!.valueChanges + .pipe(map((files) => !!files && files.length == 1)), {initialValue: false}); + + buttonLabel = computed(() => { + switch (this.currentStage()) { + case StageId.FileImport: + return translate('import-client-modal.next'); + case StageId.HeaderMatch: + return translate('import-client-modal.next'); + case StageId.Confirm : + return translate('import-client-modal.import'); + } + }); + canMoveToNext = computed(() => { + switch (this.currentStage()) { + case StageId.FileImport: + return this.isFileSelected(); + case StageId.HeaderMatch: + return this.headerMatchForm.valid; + case StageId.Confirm : + return true; + } + }); + + get headerArray(): FormArray { + return this.headerMatchForm.get('headers')! as FormArray; + } + + async nextStep() { + switch (this.currentStage()) { + case StageId.FileImport: + await this.handleFileImport(); + break; + case StageId.HeaderMatch: + await this.constructClients(); + break; + case StageId.Confirm: + this.import(); + break; + } + } + + private async handleFileImport() { + const files = this.fileUploadControl.value; + if (!files || files.length === 0) { + this.toastr.error(translate('import-client-modal.select-files-warning')); + return; + } + + const file = files[0]; + const text = await file.text(); + const res = Papa.parse(text, {header: false, delimiter: ','}); + if (res.errors.length > 0) { + console.log(res); + this.toastr.error(translate('import-client-modal.parse-error')); + return; + } + + const data = res.data; + if (data.length < 2) { + this.toastr.error(translate('import-client-modal.header-only-or-none')); + return; + } + + const headers = data[0]; + this.headerArray.setValue([]); + + headers.forEach((header, idx) => { + this.headerArray.push(this.fb.group({ + header: this.fb.control(header), + field: this.fb.control(fields[idx % fields.length]), + index: this.fb.control(idx), + })); + }); + + this.data.set(res.data.slice(1).filter(d => d.length === headers.length)); + this.currentStage.set(StageId.HeaderMatch); + } + + private async constructClients() { + const mappings = this.headerArray.controls.map(c => c.value); + + const clients: Client[] = []; + + for (const dataRow of this.data()) { + + const client: Partial = {}; + + + for (const field of fields) { + const mapping = mappings.find(mapping => mapping.field === field); + const value = (mapping !== undefined && mapping.index !== undefined) ? dataRow[mapping.index] : ''; + + switch (field) { + case ClientField.Name: + client.name = value; + break; + case ClientField.Address: + client.address = value; + break; + case ClientField.ContactEmail: + client.contactEmail = value; + break; + case ClientField.CompanyNumber: + client.companyNumber = value; + break; + case ClientField.InvoiceEmail: + client.invoiceEmail = value; + break; + case ClientField.ContactNumber: + client.contactNumber = value; + break; + case ClientField.ContactName: + client.contactName = value; + break; + } + } + + clients.push(client as Client); + } + + this.clients.set(clients); + this.currentStage.set(StageId.Confirm); + } + + private import() { + this.clientService.createBulk(this.clients()).subscribe({ + next: () => this.close(), + }); + } + + close() { + this.modal.close(); + } + + protected readonly StageId = StageId; + protected readonly fields = fields; + protected readonly JSON = JSON; +} diff --git a/UI/Web/src/app/management/management-clients/management-clients.component.html b/UI/Web/src/app/management/management-clients/management-clients.component.html index 87e5f52..f0808e7 100644 --- a/UI/Web/src/app/management/management-clients/management-clients.component.html +++ b/UI/Web/src/app/management/management-clients/management-clients.component.html @@ -5,7 +5,8 @@

{{t('title')}}

@@ -42,39 +43,8 @@

{{t('no-clients.title')}}

} @else {
- - - - - {{t('header.name')}} - - - {{t('header.companyNumber')}} - - - {{t('header.contactEmail')}} - - - {{t('header.contactNumber')}} - - - {{t('header.actions')}} - - - - - - {{item.name}} - - - {{item.companyNumber}} - - - {{item.contactEmail}} - - - {{item.contactNumber}} - + +
- + } diff --git a/UI/Web/src/app/management/management-clients/management-clients.component.ts b/UI/Web/src/app/management/management-clients/management-clients.component.ts index 8b1daf8..add2a3a 100644 --- a/UI/Web/src/app/management/management-clients/management-clients.component.ts +++ b/UI/Web/src/app/management/management-clients/management-clients.component.ts @@ -7,6 +7,9 @@ import {LoadingSpinnerComponent} from '../../shared/components/loading-spinner/l import {TableComponent} from '../../shared/components/table/table.component'; import {ClientModalComponent} from './_components/client-modal/client-modal.component'; import {DefaultModalOptions} from '../../_models/default-modal-options'; +import {DefaultValuePipe} from '../../_pipes/default-value.pipe'; +import {ImportClientModalComponent} from './_components/import-client-modal/import-client-modal.component'; +import {ClientsTableComponent} from './_components/clients-table/clients-table.component'; @Component({ selector: 'app-management-clients', @@ -14,6 +17,8 @@ import {DefaultModalOptions} from '../../_models/default-modal-options'; TranslocoDirective, LoadingSpinnerComponent, TableComponent, + DefaultValuePipe, + ClientsTableComponent, ], templateUrl: './management-clients.component.html', styleUrl: './management-clients.component.scss', @@ -45,13 +50,19 @@ export class ManagementClientsComponent implements OnInit { return `${client.id}` } + importClients() { + const [modal, component] = this.modalService.open(ImportClientModalComponent, DefaultModalOptions); + + modal.closed.subscribe(() => this.loadClients()); + } + createOrUpdateClient(client?: Client) { const [modal, component] = this.modalService.open(ClientModalComponent, DefaultModalOptions); if (client) { component.client.set(client); } - modal.closed.subscribe(() => this.loadClients()) + modal.closed.subscribe(() => this.loadClients()); } async deleteClient(client: Client) { diff --git a/UI/Web/src/theme/components/table.scss b/UI/Web/src/theme/components/table.scss index 2f22de8..8e9d32e 100644 --- a/UI/Web/src/theme/components/table.scss +++ b/UI/Web/src/theme/components/table.scss @@ -39,3 +39,9 @@ font-weight: 500; } } + +.table-header-cell:last-child, +.table-cell:last-child { + width: 1%; + white-space: nowrap; +}