feat StayExport - Stay Export Pages - Initial implementation

This commit is contained in:
2026-09-04 20:38:00 +02:00
parent cf31cbc414
commit 313a714a99
13 changed files with 434 additions and 9 deletions
+1
View File
@@ -23,6 +23,7 @@
<ReconnectModal /> <ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script> <script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script> <script src="@Assets["_content/MudBlazor/MudBlazor.min.js"]"></script>
<script src="js/App.js"></script>
</body> </body>
</html> </html>
@@ -13,4 +13,7 @@
<MudNavLink Href="@GuestConsts.Page_StayEntry_List" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Book"> <MudNavLink Href="@GuestConsts.Page_StayEntry_List" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Book">
@LanguageLocale.navMenu_StayEntryList @LanguageLocale.navMenu_StayEntryList
</MudNavLink> </MudNavLink>
<MudNavLink Href="@GuestConsts.Page_StayExport_List" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ImportExport">
@LanguageLocale.navMenu_StayExport
</MudNavLink>
</MudNavMenu> </MudNavMenu>
@@ -0,0 +1,177 @@
@attribute [Route($"{GuestConsts.Page_StayExport_List}")]
@using FluentStorage;
@using FluentStorage.Storage;
@using System.Text.Json;
@using Microsoft.Extensions.Options;
@using Somnia.GuestLog.Models.DTOs;
@using Somnia.GuestLog.Models.Configuration;
@using Somnia.GuestLog.Services;
@inject IStayExportFileEntityService stayExportEntityService
@inject NavigationManager Navigation
@inject IUserMessageService userMessage
@inject IDialogService DialogService
@inject IDataListService dataListService;
@inject ILocalizerService Localizer;
@inject IOptions<StayExportConfig> exportConfig
@inject IJSRuntime JS
<PageTitle>Somnia - Stay Export Files</PageTitle>
<MudStack Row="true" Class="my-4">
<MudText Typo="Typo.h4" GutterBottom="true">@LanguageLocale.page_StayExportList_pageTitle</MudText>
<MudSpacer />
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.Search" OnClick="() => BtnSearchDataAsync()">
@LanguageLocale.general_btnSearch
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Small" StartIcon="@Icons.Material.Filled.Add"
OnClick="() => BtnNewExport()">
@LanguageLocale.page_StayExportList_btnNewExport
</MudButton>
</MudStack>
@* <MudPaper Class="pa-4 my-4" Elevation="2" Height="100%">
FILTER
</MudPaper> *@
<MudDataGrid @ref="_dataGrid" T="StayExportFileDTO" Items="@stayExportList" Loading="@_dataLoading" ReadOnly="true"
Elevation="2">
<ToolBarContent>
<MudText Typo="Typo.subtitle1">@LanguageLocale.page_StayExportList_exportList</MudText>
</ToolBarContent>
<Columns>
<PropertyColumn Property="x => x.Id" Hideable="false" Title="@LanguageLocale.dto_StayExportFile_Id" />
<PropertyColumn Property="x => Localizer.GetEnumValue(x.ExportFormat)"
Title="@LanguageLocale.dto_StayExportFile_ExportFormat" />
<TemplateColumn Title="@LanguageLocale.dto_StayExportFile_ExportParams">
<CellTemplate>
<MudIconButton Size="@Size.Small" Icon="@Icons.Material.Outlined.Info"
OnClick="() => ShowExportParams(context.Item)" />
</CellTemplate>
</TemplateColumn>
<PropertyColumn Property="x => Localizer.GetEnumValue(x.ExportResult)"
Title="@LanguageLocale.dto_StayExportFile_ExportResult" />
<PropertyColumn Property="x => x.ExportedPath" Title="@LanguageLocale.dto_StayExportFile_ExportedPath" Hidden />
<PropertyColumn Property="x => x.ExportTime" Title="@LanguageLocale.dto_StayExportFile_ExportTime" />
<TemplateColumn CellClass="d-flex justify-between" Title="Options" StickyRight Hideable="false"
ShowColumnOptions="false">
<CellTemplate>
<MudIconButton Size="@Size.Small" Icon="@Icons.Material.Outlined.Download"
OnClick="() => BtnDownloadFile(context.Item)" />
<MudIconButton Size="@Size.Small" Icon="@Icons.Material.Outlined.Delete"
OnClick="() => BtnDeleteItem(context.Item)" />
</CellTemplate>
</TemplateColumn>
</Columns>
<PagerContent>
<MudDataGridPager T="StayExportFileDTO" />
</PagerContent>
</MudDataGrid>
<ConfirmDeleteMsgBox @ref="_confirmDeleteMsgBox" />
@code {
private bool _dataLoading = true;
private const int resultLimit = 200;
private IEnumerable<StayExportFileDTO> stayExportList = [];
private MudDataGrid<StayExportFileDTO> _dataGrid = null!;
private ConfirmDeleteMsgBox _confirmDeleteMsgBox = null!;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync(showMsg: false);
}
private async Task LoadDataAsync(bool showMsg = true)
{
_dataLoading = true;
stayExportList = await stayExportEntityService.GetDtoListAsync(limit: resultLimit);
if (showMsg)
{
userMessage.ShowMessage("Data loaded from DB", MessageKind.Info);
}
_dataLoading = false;
}
#region Event Handlers
private async Task BtnSearchDataAsync()
{
await LoadDataAsync();
}
private void BtnNewExport()
{
Navigation.NavigateTo(GuestConsts.Page_StayExport_New);
}
private async Task ShowExportParams(StayExportFileDTO dto)
{
string jsonParams = JsonSerializer.Serialize(dto.ExportParams, options: new() { WriteIndented = true });
await DialogService.ShowMessageBoxAsync("Details", jsonParams);
}
private async Task BtnDownloadFile(StayExportFileDTO item)
{
if(item.ExportResult == Enums.Models.StayExportFileResult.NoData)
{
userMessage.ShowMessage("A Export without data can't be downloaded!", MessageKind.Warning);
return;
}
if (string.IsNullOrEmpty(item.ExportedPath))
{
userMessage.ShowMessage("File can't be downloaded: no path found!", MessageKind.Warning);
return;
}
IStore store = StorageFactory.FromConnectionString(exportConfig.Value.ExportStorageConnectionString);
using Stream objectStream = new MemoryStream();
await store.GetObject(item.ExportedPath, objectStream);
objectStream.Seek(0, SeekOrigin.Begin);
using var streamRef = new DotNetStreamReference(stream: objectStream);
var fileName = Path.GetFileName(item.ExportedPath);
await JS.InvokeVoidAsync("downloadFileFromStream", fileName, streamRef);
}
private async Task BtnDeleteItem(StayExportFileDTO item)
{
userMessage.ShowMessage("Not Implemented!", MessageKind.Normal);
@* bool? confirmDelete = await _confirmDeleteMsgBox.MsgBox.ShowAsync();
if (confirmDelete is null || confirmDelete.Value == false)
{
return;
}
Result deleteResult = await stayExportEntityService.Delete(item);
if (!deleteResult.IsSuccess)
{
string errorMsg = $"Error deleting Stay {item.Id}";
userMessage.ShowResultWithMessage(deleteResult, errorMsg);
return;
}
userMessage.ShowMessage($"Stay {item.Id} deleted!", MessageKind.Success);
await LoadDataAsync(); *@
}
#endregion
}
@@ -0,0 +1,166 @@
@attribute [Route($"{GuestConsts.Page_StayExport_New}")]
@using FluentValidation;
@using FluentStorage;
@using FluentStorage.Storage;
@using Microsoft.Extensions.Options;
@using Somnia.GuestLog.Models.DTOs;
@using Somnia.GuestLog.Models.Configuration;
@using Somnia.GuestLog.Enums.Models;
@using Somnia.GuestLog.Services;
@using Somnia.GuestLog.Validators;
@using Somnia.GuestLog.Services.StayExport;
@inject IUserMessageService userMessage
@inject IDialogService DialogService
@inject NavigationManager Navigation
@inject IValidator<StayExportParamsDTO> ExportParamsValidator;
@inject ILocalizerService Localizer;
@inject IStayExportServiceFactory ExportServiceFactory;
@inject IOptions<StayExportConfig> exportConfig
@inject IJSRuntime JS
<PageTitle>Somnia - Stay Export - New</PageTitle>
<MudStack Row="true" Class="my-4">
<MudText Typo="Typo.h4" GutterBottom="true">
@LanguageLocale.page_StayExportNew_pageTitle
</MudText>
<MudSpacer />
<MudButton Variant="Variant.Filled" Color="Color.Success" Size="Size.Small" StartIcon="@Icons.Material.Filled.Add"
OnClick="() => BtnCreateExport()" Disabled="@_isExported">
@LanguageLocale.general_btnCreate
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Default" Size="Size.Small"
StartIcon="@Icons.Material.Filled.ArrowBack" OnClick="() => BtnReturn()">
@LanguageLocale.general_btnReturn
</MudButton>
</MudStack>
<MudForm @ref="@_form" Model="@exportParams"
Validation="@((ExportParamsValidator as StayExportParamsDTOValidator)!.ValidateValue)" ReadOnly="@_isExported">
<MudPaper Class="pa-4 my-4" Elevation="3" Height="100%">
<MudStack Row style="margin-block: 0.2em;">
<MudSelect @bind-Value="exportFormat" Label="@LanguageLocale.dto_StayExportFile_ExportFormat" FullWidth
Clearable>
@foreach (StayExportFormat option in Enum.GetValues<StayExportFormat>())
{
<MudSelectItem Value="new Nullable<StayExportFormat>(option)">
@Localizer.GetEnumValue(option)
</MudSelectItem>
}
</MudSelect>
<MudSelect @bind-Value="exportParams.ExportType" For="@(() => exportParams.ExportType)"
Label="@LanguageLocale.dto_StayExportParams_ExportType" FullWidth Clearable>
@foreach (StayExportParamType option in Enum.GetValues<StayExportParamType>())
{
<MudSelectItem Value="new Nullable<StayExportParamType>(option)">
@Localizer.GetEnumValue(option)
</MudSelectItem>
}
</MudSelect>
</MudStack>
</MudPaper>
@if (_showPeriodFields)
{
<MudPaper Class="pa-4" Elevation="3" Height="100%">
<MudStack Row>
<MudDatePicker @bind-Date="exportParams.ExportPeriodStart" For="@(() => exportParams.ExportPeriodStart)"
Label="@LanguageLocale.dto_StayExportParams_ExportPeriodStart" Clearable OpenTo="OpenTo.Date" Editable
AutoClose="true" />
<MudDatePicker @bind-Date="exportParams.ExportPeriodEnd" For="@(() => exportParams.ExportPeriodEnd)"
Label="@LanguageLocale.dto_StayExportParams_ExportPeriodEnd" Clearable OpenTo="OpenTo.Date" Editable
AutoClose="true" />
</MudStack>
</MudPaper>
}
</MudForm>
@if (_isExported && exportedFile != null)
{
<MudPaper Class="pa-4 my-6" Elevation="3" Height="100%">
<MudStack>
<MudText Typo="Typo.h4">File Exported!</MudText>
<MudTextField Value="@Localizer.GetEnumValue(exportedFile.ExportResult)"
Label="@LanguageLocale.dto_StayExportFile_ExportResult" FullWidth ReadOnly="true" />
<MudTextField Value="@Localizer.GetEnumValue(exportedFile.ExportFormat)"
Label="@LanguageLocale.dto_StayExportFile_ExportFormat" FullWidth ReadOnly="true" />
<MudButton OnClick="BtnDownloadExportedFile">Download Exported File</MudButton>
</MudStack>
</MudPaper>
}
@code {
MudForm _form = null!;
StayExportFormat? exportFormat = null;
StayExportParamsDTO exportParams = new();
StayExportFileDTO? exportedFile = null;
bool _showPeriodFields => exportParams.ExportType == StayExportParamType.PeriodDateIn;
bool _isExported => exportedFile != null;
private void BtnReturn()
{
Navigation.NavigateTo(GuestConsts.Page_StayExport_List);
}
private async Task BtnCreateExport()
{
await _form.ValidateAsync();
if (!_form.IsValid || exportFormat == null)
{
userMessage.ShowMessage("Invalid Form!", MessageKind.Warning);
return;
}
var loadingDialog = await DialogService.ShowAsync<LoadingDialog>();
var exportService = ExportServiceFactory.GetService(exportFormat.Value);
var exportResult = await exportService.ExportStaysByParams(exportParams);
exportedFile = exportResult.Value;
userMessage.ShowMessage(
$"Stay exported: {exportResult.ResultCode} with export result: {exportedFile?.ExportResult} to '{exportedFile?.ExportedPath ?? "???"}'",
MessageKind.Info);
loadingDialog.Close();
@* Result createResult = await stayService.CreateEntryAsync(model);
if (!createResult.IsSuccess)
{
userMessage.ShowResultWithMessage(createResult, $"Error creating StayEntry");
return;
} *@
}
private async Task BtnDownloadExportedFile()
{
Debug.Assert(exportedFile != null);
Debug.Assert(exportConfig.Value.ExportStorageConnectionString != null);
if (string.IsNullOrEmpty(exportedFile.ExportedPath))
{
userMessage.ShowMessage("Exported File can't be downloaded as no file was saved", MessageKind.Warning);
return;
}
IStore store = StorageFactory.FromConnectionString(exportConfig.Value.ExportStorageConnectionString);
using Stream objectStream = new MemoryStream();
await store.GetObject(exportedFile.ExportedPath, objectStream);
objectStream.Seek(0, SeekOrigin.Begin);
var fileName = Path.GetFileName(exportedFile.ExportedPath);
using var streamRef = new DotNetStreamReference(stream: objectStream);
await JS.InvokeVoidAsync("downloadFileFromStream", fileName, streamRef);
}
}
@@ -1,2 +1,3 @@
@using Somnia.GuestLog.Components.Controls @using Somnia.GuestLog.Components.Controls
@using Somnia.GuestLog.Services.Entity; @using Somnia.GuestLog.Services.Entity;
@using System.Diagnostics;
+3
View File
@@ -13,6 +13,9 @@ public static class GuestConsts
public const string Page_StayEntry_Detail = $"/stayEntries/d/{Page_StayEntry_Detail_Param_Id}"; public const string Page_StayEntry_Detail = $"/stayEntries/d/{Page_StayEntry_Detail_Param_Id}";
public const string Page_StayEntry_Detail_Param_Id = "{StayId:long}"; public const string Page_StayEntry_Detail_Param_Id = "{StayId:long}";
public const string Page_StayExport_List = "/stayExport";
public const string Page_StayExport_New = "/stayExport/new";
public const string QueryParam_ReturnUrl = "returnUrl"; public const string QueryParam_ReturnUrl = "returnUrl";
+1
View File
@@ -55,6 +55,7 @@ builder.Services.AddHttpClient();
builder.Services.AddSingleton<IValidator<GuestDocumentDTO>, GuestDocumentDTOValidator>(); builder.Services.AddSingleton<IValidator<GuestDocumentDTO>, GuestDocumentDTOValidator>();
builder.Services.AddSingleton<IValidator<GuestDocIdDTO>, GuestDocIdDTOValidator>(); builder.Services.AddSingleton<IValidator<GuestDocIdDTO>, GuestDocIdDTOValidator>();
builder.Services.AddSingleton<IValidator<StayEntryDTO>, StayEntryDTOValidator>(); builder.Services.AddSingleton<IValidator<StayEntryDTO>, StayEntryDTOValidator>();
builder.Services.AddSingleton<IValidator<StayExportParamsDTO>, StayExportParamsDTOValidator>();
// Add Entity Services // Add Entity Services
builder.Services.AddSingleton<IGuestDocumentEntityService, GuestDocumentEntityService>(); builder.Services.AddSingleton<IGuestDocumentEntityService, GuestDocumentEntityService>();
@@ -149,6 +149,17 @@
<data name="dto.StayEntry.CreateTime" xml:space="preserve"><value>Creation Time</value></data> <data name="dto.StayEntry.CreateTime" xml:space="preserve"><value>Creation Time</value></data>
<data name="dto.StayEntry.UpdateTime" xml:space="preserve"><value>Update Time</value></data> <data name="dto.StayEntry.UpdateTime" xml:space="preserve"><value>Update Time</value></data>
<data name="dto.StayExportFile.Id" xml:space="preserve"><value>Id</value></data>
<data name="dto.StayExportFile.ExportFormat" xml:space="preserve"><value>Export Format</value></data>
<data name="dto.StayExportFile.ExportParams" xml:space="preserve"><value>Export Params</value></data>
<data name="dto.StayExportFile.ExportResult" xml:space="preserve"><value>Export Result</value></data>
<data name="dto.StayExportFile.ExportedPath" xml:space="preserve"><value>Export Path</value></data>
<data name="dto.StayExportFile.ExportTime" xml:space="preserve"><value>Export Time</value></data>
<data name="dto.StayExportParams.ExportType" xml:space="preserve"><value>Export Type</value></data>
<data name="dto.StayExportParams.ExportPeriodStart" xml:space="preserve"><value>Date From</value></data>
<data name="dto.StayExportParams.ExportPeriodEnd" xml:space="preserve"><value>Date To</value></data>
<data name="tmp" xml:space="preserve"><value>TMP</value></data> <data name="tmp" xml:space="preserve"><value>TMP</value></data>
<data name="general.actions" xml:space="preserve"><value>Actions</value></data> <data name="general.actions" xml:space="preserve"><value>Actions</value></data>
@@ -188,9 +199,20 @@
<data name="enum.ResultCode.GuestInStay" xml:space="preserve"><value>Guest (ID: '{0}') is in a stay.</value></data> <data name="enum.ResultCode.GuestInStay" xml:space="preserve"><value>Guest (ID: '{0}') is in a stay.</value></data>
<data name="enum.ResultCode.GuestCurrentlyInStay" xml:space="preserve"><value>Guest (ID: '{0}') is already in one stay during the period: {1}</value></data> <data name="enum.ResultCode.GuestCurrentlyInStay" xml:space="preserve"><value>Guest (ID: '{0}') is already in one stay during the period: {1}</value></data>
<data name="enum.StayExportFileResult.OK" xml:space="preserve"><value>OK</value></data>
<data name="enum.StayExportFileResult.NoData" xml:space="preserve"><value>No Data</value></data>
<data name="enum.StayExportFileResult.ExportError" xml:space="preserve"><value>Export Error</value></data>
<data name="enum.StayExportFileResult.UploadError" xml:space="preserve"><value>File Error</value></data>
<data name="enum.StayExportFormat.GuestLogJson" xml:space="preserve"><value>GuestLog JSON</value></data>
<data name="enum.StayExportFormat.SpainParteViajeros" xml:space="preserve"><value>Spain Parte Viajeros</value></data>
<data name="enum.StayExportParamType.PeriodDateIn" xml:space="preserve"><value>Stay 'Date In' Period</value></data>
<data name="navMenu.GuestDocumentList" xml:space="preserve"><value>Guests</value></data> <data name="navMenu.GuestDocumentList" xml:space="preserve"><value>Guests</value></data>
<data name="navMenu.StayEntryList" xml:space="preserve"><value>Stays</value></data> <data name="navMenu.StayEntryList" xml:space="preserve"><value>Stays</value></data>
<data name="navMenu.StayExport" xml:space="preserve"><value>Stay Export</value></data>
<data name="page.GuestDocList.pageTitle" xml:space="preserve"><value>Guest Documents</value></data> <data name="page.GuestDocList.pageTitle" xml:space="preserve"><value>Guest Documents</value></data>
@@ -206,6 +228,13 @@
<data name="page.StayEntryNew.pageTitle" xml:space="preserve"><value>New Stay</value></data> <data name="page.StayEntryNew.pageTitle" xml:space="preserve"><value>New Stay</value></data>
<data name="page.StayExportList.pageTitle" xml:space="preserve"><value>Stay Export Files</value></data>
<data name="page.StayExportList.exportList" xml:space="preserve"><value>Export List</value></data>
<data name="page.StayExportList.btnNewExport" xml:space="preserve"><value>New Export</value></data>
<data name="page.StayExportNew.pageTitle" xml:space="preserve"><value>New Stay Export</value></data>
<data name="control.GuestDocForm.sectionDocument" xml:space="preserve"><value>Document Details</value></data> <data name="control.GuestDocForm.sectionDocument" xml:space="preserve"><value>Document Details</value></data>
<data name="control.GuestDocForm.sectionGuest" xml:space="preserve"><value>Guest Details</value></data> <data name="control.GuestDocForm.sectionGuest" xml:space="preserve"><value>Guest Details</value></data>
<data name="control.GuestDocForm.sectionContact" xml:space="preserve"><value>Contact Details</value></data> <data name="control.GuestDocForm.sectionContact" xml:space="preserve"><value>Contact Details</value></data>
@@ -9,5 +9,5 @@ public interface IStayExportFileEntityService
public Task<StayExportFileDTO?> GetDtoByIdAsync(long id); public Task<StayExportFileDTO?> GetDtoByIdAsync(long id);
//public Task<IEnumerable<StayExportFileDTO>> GetDtoListByFilterAsync(StayExportFileFilterDTO filter, int limit = 0); //public Task<IEnumerable<StayExportFileDTO>> GetDtoListByFilterAsync(StayExportFileFilterDTO filter, int limit = 0);
public Task<IEnumerable<StayExportFileDTO>> GetDtoListAsync(); public Task<IEnumerable<StayExportFileDTO>> GetDtoListAsync(int limit = 0);
} }
@@ -4,6 +4,7 @@ using Somnia.GuestLog.Infrastructure;
using Somnia.GuestLog.Models; using Somnia.GuestLog.Models;
using Somnia.GuestLog.Models.DTOs; using Somnia.GuestLog.Models.DTOs;
using Somnia.GuestLog.Models.Entities; using Somnia.GuestLog.Models.Entities;
using Somnia.GuestLog.Utils;
namespace Somnia.GuestLog.Services.Entity; namespace Somnia.GuestLog.Services.Entity;
@@ -69,13 +70,15 @@ public class StayExportFileEntityService(
return dbItem is not null ? GetDtoFromDb(dbItem) : null; return dbItem is not null ? GetDtoFromDb(dbItem) : null;
} }
public async Task<IEnumerable<StayExportFileDTO>> GetDtoListAsync() public async Task<IEnumerable<StayExportFileDTO>> GetDtoListAsync(int limit = 0)
{ {
using var dbContext = contextFactory.CreateDbContext(); using var dbContext = contextFactory.CreateDbContext();
var dbList = await dbContext.StayExportFiles IQueryable<StayExportFile> query = dbContext.StayExportFiles
.OrderByDescending(e => e.ExportTime) .OrderByDescending(e => e.ExportTime)
.ToListAsync(); .WithLimit(limit);
var dbList = await query.ToListAsync();
return dbList.Select(e => GetDtoFromDb(e)); return dbList.Select(e => GetDtoFromDb(e));
} }
@@ -8,4 +8,9 @@ public static class QueryExtensions
{ {
return condition ? source.Where(predicate) : source; return condition ? source.Where(predicate) : source;
} }
public static IQueryable<T> WithLimit<T>(this IQueryable<T> source, int limit)
{
return limit > 0 ? source.Take(limit) : source;
}
} }
@@ -0,0 +1,23 @@
using FluentValidation;
using Somnia.GuestLog.Enums.Models;
using Somnia.GuestLog.Models.DTOs;
namespace Somnia.GuestLog.Validators;
public class StayExportParamsDTOValidator : ValueValidator<StayExportParamsDTO>
{
public StayExportParamsDTOValidator()
{
RuleFor(e => e.ExportType)
.NotEmpty();
RuleFor(e => e.ExportPeriodStart)
.NotEmpty()
.When(e => e.ExportType == StayExportParamType.PeriodDateIn);
RuleFor(e => e.ExportPeriodEnd)
.NotEmpty()
.When(e => e.ExportType == StayExportParamType.PeriodDateIn);
}
}
+13
View File
@@ -0,0 +1,13 @@
// Function to download files from Blazor pages
window.downloadFileFromStream = async (fileName, contentStreamReference) => {
const arrayBuffer = await contentStreamReference.arrayBuffer();
const blob = new Blob([arrayBuffer]);
const url = URL.createObjectURL(blob);
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = fileName ?? '';
anchorElement.click();
anchorElement.remove();
URL.revokeObjectURL(url);
}