diff --git a/src/Turnierplan.App/Client/src/app/i18n/de.ts b/src/Turnierplan.App/Client/src/app/i18n/de.ts index 954eacee..02536fe9 100644 --- a/src/Turnierplan.App/Client/src/app/i18n/de.ts +++ b/src/Turnierplan.App/Client/src/app/i18n/de.ts @@ -1040,6 +1040,14 @@ export const de = { CheckboxLabel: 'Ich habe den Hinweis gelesen und möchte die Anmeldung erstellen' } }, + ExportApplications: { + Button: 'Export (.csv)', + DialogTitle: 'Anmeldungen exportieren', + InfoTest: 'Nachfolgend können Sie eine CSV-Tabelle mit allen Anmeldungen in diesem Turnierplaner herunterladen.', + IncludeApplicationTeams: 'Mannschaften auflisten', + Download: 'Herunterladen', + FileName: 'Anmeldungen {{planningRealmName}}' + }, TournamentClasses: { Name: 'Name', InvitationLinkCount: 'Anmeldelinks', diff --git a/src/Turnierplan.App/Client/src/app/portal/components/document-manager/document-manager.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/document-manager/document-manager.component.ts index 91065b77..07581d75 100644 --- a/src/Turnierplan.App/Client/src/app/portal/components/document-manager/document-manager.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/components/document-manager/document-manager.component.ts @@ -27,6 +27,7 @@ import { setMatchPlanDocumentConfiguration } from '../../../api/fn/documents/set import { MatchPlanDocumentConfiguration } from '../../../api/models/match-plan-document-configuration'; import { setReceiptsDocumentConfiguration } from '../../../api/fn/documents/set-receipts-document-configuration'; import { ReceiptsDocumentConfiguration } from '../../../api/models/receipts-document-configuration'; +import { makeSafeFileName } from '../../helpers/file-name'; @Component({ selector: 'tp-document-manager', @@ -244,7 +245,7 @@ export class DocumentManagerComponent { } private getDocumentFileName(name: string): string { - return `${name} - ${this.tournamentName}.pdf`.replaceAll(/[^.A-Za-z0-9Ä-Öä-öß _-]/g, '_'); + return `${makeSafeFileName(`${name} - ${this.tournamentName}`)}.pdf`; } private getDocumentConfig(document: DocumentDto): Observable { diff --git a/src/Turnierplan.App/Client/src/app/portal/components/export-applications-dialog/export-applications-dialog.component.html b/src/Turnierplan.App/Client/src/app/portal/components/export-applications-dialog/export-applications-dialog.component.html new file mode 100644 index 00000000..0a58922d --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/export-applications-dialog/export-applications-dialog.component.html @@ -0,0 +1,33 @@ + + + diff --git a/src/Turnierplan.App/Client/src/app/portal/components/export-applications-dialog/export-applications-dialog.component.ts b/src/Turnierplan.App/Client/src/app/portal/components/export-applications-dialog/export-applications-dialog.component.ts new file mode 100644 index 00000000..84bcce08 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/components/export-applications-dialog/export-applications-dialog.component.ts @@ -0,0 +1,69 @@ +import { Component } from '@angular/core'; +import { TranslateDirective, TranslateService } from '@ngx-translate/core'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { ActionButtonComponent } from '../action-button/action-button.component'; +import { SmallSpinnerComponent } from '../../../core/components/small-spinner/small-spinner.component'; +import { TurnierplanApi } from '../../../api/turnierplan-api'; +import { exportApplications } from '../../../api/fn/applications/export-applications'; +import { PlanningRealmDto } from '../../../api/models/planning-realm-dto'; +import { makeSafeFileName } from '../../helpers/file-name'; +import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; + +@Component({ + selector: 'tp-export-applications-dialog', + imports: [TranslateDirective, ActionButtonComponent, SmallSpinnerComponent, ReactiveFormsModule], + templateUrl: './export-applications-dialog.component.html' +}) +export class ExportApplicationsDialogComponent { + protected readonly form = new FormGroup({ + includeApplicationTeams: new FormControl(false, { nonNullable: true }) + }); + + protected isDownloading = false; + private planningRealm?: PlanningRealmDto; + + constructor( + protected readonly modal: NgbActiveModal, + private readonly turnierplanApi: TurnierplanApi, + private readonly translateService: TranslateService + ) {} + + public initialize(planningRealm: PlanningRealmDto) { + this.planningRealm = planningRealm; + } + + protected exportApplications(): void { + if (!this.planningRealm) { + return; + } + + const fileName = `${makeSafeFileName( + this.translateService.instant('Portal.ViewPlanningRealm.ExportApplications.FileName', { + planningRealmName: this.planningRealm?.name + }) as string + )}.csv`; + + this.form.disable(); + this.isDownloading = true; + + this.turnierplanApi + .invoke(exportApplications, { + planningRealmId: this.planningRealm.id, + languageCode: this.translateService.getCurrentLang(), + includeApplicationTeams: this.form.getRawValue().includeApplicationTeams + }) + .subscribe({ + next: (result) => { + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([result])); + a.download = fileName; + a.click(); + + this.modal.close(); + }, + error: (error) => { + this.modal.dismiss({ isApiError: true, apiError: error as unknown }); + } + }); + } +} diff --git a/src/Turnierplan.App/Client/src/app/portal/helpers/file-name.ts b/src/Turnierplan.App/Client/src/app/portal/helpers/file-name.ts new file mode 100644 index 00000000..5e652313 --- /dev/null +++ b/src/Turnierplan.App/Client/src/app/portal/helpers/file-name.ts @@ -0,0 +1,3 @@ +export const makeSafeFileName = (input: string): string => { + return input.replaceAll(/[^.A-Za-z0-9Ä-Öä-öß _-]/g, '_'); +}; diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-planning-realm/view-planning-realm.component.html b/src/Turnierplan.App/Client/src/app/portal/pages/view-planning-realm/view-planning-realm.component.html index 3d99d48c..d4f83e26 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-planning-realm/view-planning-realm.component.html +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-planning-realm/view-planning-realm.component.html @@ -39,6 +39,13 @@ } @case (2) { + + + @let canAddApplications = !_hasUnsavedChanges && planningRealm.tournamentClasses.length > 0; @if (!canAddApplications) { diff --git a/src/Turnierplan.App/Client/src/app/portal/pages/view-planning-realm/view-planning-realm.component.ts b/src/Turnierplan.App/Client/src/app/portal/pages/view-planning-realm/view-planning-realm.component.ts index 87c7f4f0..31a06a6a 100644 --- a/src/Turnierplan.App/Client/src/app/portal/pages/view-planning-realm/view-planning-realm.component.ts +++ b/src/Turnierplan.App/Client/src/app/portal/pages/view-planning-realm/view-planning-realm.component.ts @@ -36,6 +36,7 @@ import { createApplication } from '../../../api/fn/applications/create-applicati import { updatePlanningRealm } from '../../../api/fn/planning-realms/update-planning-realm'; import { deletePlanningRealm } from '../../../api/fn/planning-realms/delete-planning-realm'; import { LabelsManagerComponent } from '../../components/labels-manager/labels-manager.component'; +import { ExportApplicationsDialogComponent } from '../../components/export-applications-dialog/export-applications-dialog.component'; export type UpdatePlanningRealmFunc = (modifyFunc: (planningRealm: PlanningRealmDto) => boolean) => void; @@ -303,6 +304,29 @@ export class ViewPlanningRealmComponent implements OnInit, OnDestroy, DiscardCha }); } + protected exportApplications(): void { + if (!this.planningRealm) { + return; + } + + const ref = this.modalService.open(ExportApplicationsDialogComponent, { + centered: true, + size: 'md', + fullscreen: 'md' + }); + + (ref.componentInstance as ExportApplicationsDialogComponent).initialize(this.planningRealm); + + ref.dismissed.subscribe({ + next: (reason?: { isApiError?: boolean; apiError?: unknown }) => { + if (reason?.isApiError === true) { + // If reason is specified, this means an error occurred + this.loadingState = { isLoading: false, error: reason.apiError }; + } + } + }); + } + protected renamePlanningRealm(name: string): void { if (!this.planningRealm) { return; diff --git a/src/Turnierplan.App/Csv/CsvResult.cs b/src/Turnierplan.App/Csv/CsvResult.cs new file mode 100644 index 00000000..64c8c0dd --- /dev/null +++ b/src/Turnierplan.App/Csv/CsvResult.cs @@ -0,0 +1,21 @@ +namespace Turnierplan.App.Csv; + +internal sealed class CsvResult : IResult +{ + private readonly Func _generate; + + public CsvResult(Func generate) + { + _generate = generate; + } + + public async Task ExecuteAsync(HttpContext httpContext) + { + httpContext.Response.StatusCode = 200; + httpContext.Response.Headers.ContentType = "text/csv"; + + await using var csvWriter = new CsvWriter(httpContext.Response.Body); + + await _generate(csvWriter); + } +} diff --git a/src/Turnierplan.App/Csv/CsvWriter.cs b/src/Turnierplan.App/Csv/CsvWriter.cs new file mode 100644 index 00000000..82f732dd --- /dev/null +++ b/src/Turnierplan.App/Csv/CsvWriter.cs @@ -0,0 +1,114 @@ +using System.Text; + +namespace Turnierplan.App.Csv; + +internal sealed class CsvWriter : IDisposable, IAsyncDisposable +{ + private readonly StreamWriter _target; + private bool _isAtNewLine = true; + private bool _headerWritten; + private int? _columnCount; + + public CsvWriter(Stream target) + { + _target = new StreamWriter(target, encoding: Encoding.UTF8, leaveOpen: true); + } + + public async Task WriteHeaderAsync(params string[] header) + { + ArgumentOutOfRangeException.ThrowIfZero(header.Length); + + if (_headerWritten) + { + throw new InvalidOperationException("The CSV header was already written."); + } + + foreach (var cell in header) + { + await WriteCellAsync(cell); + } + + await WriteNewLineAsync(); + + _headerWritten = true; + _columnCount = header.Length; + } + + public async Task WriteRowAsync(params object?[] data) + { + if (!_headerWritten) + { + throw new InvalidOperationException("The CSV header has not been written yet."); + } + + if (data.Length != _columnCount) + { + throw new InvalidOperationException("The row has a different column count than the header."); + } + + foreach (var cell in data) + { + switch (cell) + { + case string cellString: + await WriteCellAsync(cellString); + break; + case DateTime dateTime: + await WriteCellAsync($"{dateTime:O}"); + break; + default: + await WriteCellAsync(cell?.ToString() ?? string.Empty); + break; + } + } + + await WriteNewLineAsync(); + } + + public void Dispose() + { + _target.Dispose(); + } + + public async ValueTask DisposeAsync() + { + await _target.DisposeAsync(); + } + + private async Task WriteCellAsync(string cell) + { + if (!_isAtNewLine) + { + await _target.WriteAsync(","); + } + + _isAtNewLine = false; + + if (cell.Length == 0) + { + return; + } + + await _target.WriteAsync('"'); + + foreach (var c in cell) + { + if (c == '"') + { + await _target.WriteAsync("\"\""); + } + else + { + await _target.WriteAsync(c); + } + } + + await _target.WriteAsync('"'); + } + + private async Task WriteNewLineAsync() + { + await _target.WriteLineAsync(); + _isAtNewLine = true; + } +} diff --git a/src/Turnierplan.App/Endpoints/Applications/ExportApplicationsEndpoint.cs b/src/Turnierplan.App/Endpoints/Applications/ExportApplicationsEndpoint.cs new file mode 100644 index 00000000..0eaac5aa --- /dev/null +++ b/src/Turnierplan.App/Endpoints/Applications/ExportApplicationsEndpoint.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Mvc; +using Turnierplan.App.Extensions; +using Turnierplan.App.OpenApi; +using Turnierplan.App.Security; +using Turnierplan.Core.PublicId; +using Turnierplan.Dal.Repositories; +using Turnierplan.Localization; + +namespace Turnierplan.App.Endpoints.Applications; + +internal sealed class ExportApplicationsEndpoint : EndpointBase +{ + protected override HttpMethod Method => HttpMethod.Get; + + protected override string Route => "/api/planning-realms/{planningRealmId}/applications-export"; + + protected override Delegate Handler => Handle; + + protected override void ConfigureMetadata(RouteHandlerBuilder builder) + { + builder.ProducesCsv(); + } + + private static async Task Handle( + [FromRoute] PublicId planningRealmId, + [FromQuery] string languageCode, + [FromQuery] bool includeApplicationTeams, + IPlanningRealmRepository planningRealmRepository, + IAccessValidator accessValidator, + ILocalizationProvider localizationProvider) + { + if (!localizationProvider.TryGetLocalization(languageCode, out var localization)) + { + return Results.BadRequest("Invalid language code specified."); + } + + var planningRealm = await planningRealmRepository.GetByPublicIdAsync(planningRealmId, IPlanningRealmRepository.Includes.ApplicationsWithTeams); + + if (planningRealm is null) + { + return Results.NotFound(); + } + + if (!accessValidator.IsActionAllowed(planningRealm, Actions.ApplicationsRead)) + { + return Results.Forbid(); + } + + return Results.Csv(async csv => + { + if (includeApplicationTeams) + { + await csv.WriteHeaderAsync( + localization.Get("ApplicationsExport.Columns.Tag"), + localization.Get("ApplicationsExport.Columns.CreatedAt"), + localization.Get("ApplicationsExport.Columns.ContactPerson"), + localization.Get("ApplicationsExport.Columns.ContactEmail"), + localization.Get("ApplicationsExport.Columns.ContactTelephone"), + localization.Get("ApplicationsExport.Columns.Comment"), + localization.Get("ApplicationsExport.Columns.Notes"), + localization.Get("ApplicationsExport.Columns.TournamentClass"), + localization.Get("ApplicationsExport.Columns.TeamName"), + localization.Get("ApplicationsExport.Columns.TeamLabels") + ); + } + else + { + await csv.WriteHeaderAsync( + localization.Get("ApplicationsExport.Columns.Tag"), + localization.Get("ApplicationsExport.Columns.CreatedAt"), + localization.Get("ApplicationsExport.Columns.NumberOfTeams"), + localization.Get("ApplicationsExport.Columns.ContactPerson"), + localization.Get("ApplicationsExport.Columns.ContactEmail"), + localization.Get("ApplicationsExport.Columns.ContactTelephone"), + localization.Get("ApplicationsExport.Columns.Comment"), + localization.Get("ApplicationsExport.Columns.Notes") + ); + } + + foreach (var application in planningRealm.Applications.OrderBy(x => x.CreatedAt)) + { + if (includeApplicationTeams) + { + foreach (var team in application.Teams.OrderBy(x => x.Name)) + { + await csv.WriteRowAsync( + application.Tag, + application.CreatedAt, + application.Contact, + application.ContactEmail, + application.ContactTelephone, + application.Comment, + application.Notes, + team.Class.Name, + team.Name, + team.Labels.Count == 0 ? string.Empty : string.Join(", ", team.Labels.Select(x => x.Name)) + ); + } + } + else + { + await csv.WriteRowAsync( + application.Tag, + application.CreatedAt, + application.Teams.Count, + application.Contact, + application.ContactEmail, + application.ContactTelephone, + application.Comment, + application.Notes + ); + } + } + }); + } +} diff --git a/src/Turnierplan.App/Extensions/ResultsExtensions.cs b/src/Turnierplan.App/Extensions/ResultsExtensions.cs new file mode 100644 index 00000000..b2498c43 --- /dev/null +++ b/src/Turnierplan.App/Extensions/ResultsExtensions.cs @@ -0,0 +1,14 @@ +using Turnierplan.App.Csv; + +namespace Turnierplan.App.Extensions; + +internal static class ResultsExtensions +{ + extension(Results) + { + public static IResult Csv(Func generate) + { + return new CsvResult(generate); + } + } +} diff --git a/src/Turnierplan.App/OpenApi/PdfResponseOperationTransformer.cs b/src/Turnierplan.App/OpenApi/ResponseOperationTransformer.cs similarity index 54% rename from src/Turnierplan.App/OpenApi/PdfResponseOperationTransformer.cs rename to src/Turnierplan.App/OpenApi/ResponseOperationTransformer.cs index 6c7e4241..c07d6e9f 100644 --- a/src/Turnierplan.App/OpenApi/PdfResponseOperationTransformer.cs +++ b/src/Turnierplan.App/OpenApi/ResponseOperationTransformer.cs @@ -8,13 +8,36 @@ namespace Turnierplan.App.OpenApi; /// content type in such a way that the caller cannot retrieve the Blob returned by the server. This can be /// fixed by modifying the format property and setting it to binary. /// -internal sealed class PdfResponseOperationTransformer : IOpenApiOperationTransformer +internal sealed class ResponseOperationTransformer : IOpenApiOperationTransformer { public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransformerContext context, CancellationToken cancellationToken) { + var csvResponseType = typeof(ICsvResponse); var pdfResponseType = typeof(IPdfResponse); - if (context.Description.SupportedResponseTypes.Any(x => x.Type == pdfResponseType) && operation.Responses is not null) + if (operation.Responses is null) + { + return Task.CompletedTask; + } + + if (context.Description.SupportedResponseTypes.Any(x => x.Type == csvResponseType)) + { + operation.Responses["200"] = new OpenApiResponse + { + Content = new Dictionary + { + ["text/csv"] = new() + { + Schema = new OpenApiSchema + { + Type = JsonSchemaType.String + } + } + } + }; + } + + if (context.Description.SupportedResponseTypes.Any(x => x.Type == pdfResponseType)) { operation.Responses["200"] = new OpenApiResponse { @@ -35,13 +58,20 @@ public Task TransformAsync(OpenApiOperation operation, OpenApiOperationTransform return Task.CompletedTask; } + public interface ICsvResponse; + public interface IPdfResponse; } -internal static class PdfResponseOperationTransformerExtensions +internal static class ResponseOperationTransformerExtensions { + public static void ProducesCsv(this RouteHandlerBuilder builder) + { + builder.Produces(); + } + public static void ProducesPdf(this RouteHandlerBuilder builder) { - builder.Produces(); + builder.Produces(); } } diff --git a/src/Turnierplan.App/Program.cs b/src/Turnierplan.App/Program.cs index c92aeec8..c02b7439 100644 --- a/src/Turnierplan.App/Program.cs +++ b/src/Turnierplan.App/Program.cs @@ -49,7 +49,7 @@ builder.Services.AddOpenApi("turnierplan", options => { - options.AddOperationTransformer(); + options.AddOperationTransformer(); options.AddSchemaTransformer(); options.AddSchemaTransformer(); }); diff --git a/src/Turnierplan.Localization/Resources/i18n/de.json b/src/Turnierplan.Localization/Resources/i18n/de.json index abf8253f..19ccf16a 100644 --- a/src/Turnierplan.Localization/Resources/i18n/de.json +++ b/src/Turnierplan.Localization/Resources/i18n/de.json @@ -1,4 +1,19 @@ { + "ApplicationsExport": { + "Columns": { + "Comment": "Bemerkung", + "ContactEmail": "E-Mail", + "ContactPerson": "Kontaktperson", + "ContactTelephone": "Telefon-Nr.", + "CreatedAt": "Erstellt am", + "Notes": "Notizen", + "NumberOfTeams": "Anzahl Mannschaften", + "Tag": "Tag (Anmeldung)", + "TeamLabels": "Label", + "TeamName": "Mannschaftsname", + "TournamentClass": "Turnierklasse" + } + }, "Documents": { "Types": { "MatchPlan": "Spielplan",