Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/Turnierplan.App/Client/src/app/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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<DocumentConfiguration> {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<div class="modal-header">
<div class="modal-title" translate="Portal.ViewPlanningRealm.ExportApplications.DialogTitle"></div>
<button type="button" class="btn-close" (click)="modal.dismiss()"></button>
</div>
<div class="modal-body">
<div class="mb-2" [translate]="'Portal.ViewPlanningRealm.ExportApplications.InfoTest'"></div>
<div [formGroup]="form">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="includeApplicationTeams"
formControlName="includeApplicationTeams" />
<label
class="form-check-label"
for="includeApplicationTeams"
translate="Portal.ViewPlanningRealm.ExportApplications.IncludeApplicationTeams"></label>
</div>
</div>
</div>
<div class="modal-footer">
@if (isDownloading) {
<tp-small-spinner />
}
<tp-action-button [type]="'outline-dark'" [title]="'Portal.General.Cancel'" [disabled]="isDownloading" (buttonClick)="modal.dismiss()" />
<tp-action-button
[type]="'outline-primary'"
[title]="'Portal.ViewPlanningRealm.ExportApplications.Download'"
[icon]="'download'"
[disabled]="isDownloading"
(buttonClick)="exportApplications()" />
</div>
Original file line number Diff line number Diff line change
@@ -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 });
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const makeSafeFileName = (input: string): string => {
return input.replaceAll(/[^.A-Za-z0-9Ä-Öä-öß _-]/g, '_');
};
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@
}
@case (2) {
<!-- Applications page -->
<ng-container *tpIsActionAllowed="[planningRealm.id, Actions.ApplicationsRead]">
<tp-action-button
[title]="'Portal.ViewPlanningRealm.ExportApplications.Button'"
[type]="'outline-secondary'"
[icon]="'filetype-csv'"
(click)="exportApplications()" />
</ng-container>
<ng-container *tpIsActionAllowed="[planningRealm.id, Actions.ApplicationsWrite]">
@let canAddApplications = !_hasUnsavedChanges && planningRealm.tournamentClasses.length > 0;
@if (!canAddApplications) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
21 changes: 21 additions & 0 deletions src/Turnierplan.App/Csv/CsvResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace Turnierplan.App.Csv;

internal sealed class CsvResult : IResult
{
private readonly Func<CsvWriter, Task> _generate;

public CsvResult(Func<CsvWriter, Task> 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);
}
}
114 changes: 114 additions & 0 deletions src/Turnierplan.App/Csv/CsvWriter.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading