Files
maraphon-app/src/Marathon.UI/Components/ExportDialog.razor
T
alexei.dolgolyov fed3a09695 refactor: hoist Moscow offset + sport labels into shared helpers (HIGH)
* New Marathon.Domain.ValueObjects.MoscowTime with Offset, Now, and
  EndOfMoscowDay(DateOnly) — replaces ~15 inline TimeSpan.FromHours(3)
  literals across Domain/Application/Infrastructure/UI.
* New Marathon.UI.Services.SportLabels.Resolve(IStringLocalizer, int) —
  replaces 6 near-identical SportLabel switch bodies in EventListShell,
  Events/Detail, Anomalies/AnomalyFeed, Results/ResultsList,
  Results/ResultsLoader, and AnomalyCard. Single source of truth for the
  6/11/22723/43658 sport-code mapping. Pages keep a one-liner wrapper so
  the call sites stay terse.
2026-05-09 15:40:35 +03:00

174 lines
5.7 KiB
Plaintext

@*
ExportDialog — modal that collects a DateRange + ExportKind, then calls
ExportToExcelUseCase. Reports back via DialogResult so the caller can
show a snackbar with the file path. Keyboard: Esc to cancel, Enter to
submit.
*@
@using Marathon.Application.UseCases
@using AppDateRange = Marathon.Application.Storage.DateRange
@using ExportKind = Marathon.Application.Storage.ExportKind
@inject IStringLocalizer<SharedResource> L
@inject ExportToExcelUseCase ExportUseCase
@inject ILogger<ExportDialog> Logger
<MudDialog @key="@(_kind.ToString())" Class="m-export-dialog">
<TitleContent>
<span class="m-kicker">@L["Export.Kicker"]</span>
<h2 style="font-family: var(--m-font-display); font-weight: 400; font-size: 1.5rem; margin: var(--m-space-2) 0 0;">
@L["Export.Title"]
</h2>
</TitleContent>
<DialogContent>
<div style="display: grid; gap: var(--m-space-4); padding: var(--m-space-2) 0;" @onkeydown="HandleKeyDown">
<div class="m-field-row">
<div>
<label style="font-weight: 500;">@L["Export.DateRange.From"]</label>
</div>
<div>
<MudDatePicker
@bind-Date="_from"
Label="@L["Export.DateRange.From"]"
DateFormat="yyyy-MM-dd"
Variant="Variant.Outlined" />
</div>
</div>
<div class="m-field-row">
<div>
<label style="font-weight: 500;">@L["Export.DateRange.To"]</label>
</div>
<div>
<MudDatePicker
@bind-Date="_to"
Label="@L["Export.DateRange.To"]"
DateFormat="yyyy-MM-dd"
Variant="Variant.Outlined" />
</div>
</div>
<div class="m-field-row">
<div>
<label style="font-weight: 500;">@L["Export.Kind.Label"]</label>
</div>
<div>
<MudRadioGroup T="ExportKind" @bind-Value="_kind">
<MudRadio Value="@(ExportKind.PreMatch)" Color="Color.Primary">@L["Export.Kind.PreMatch"]</MudRadio>
<MudRadio Value="@(ExportKind.Live)" Color="Color.Primary">@L["Export.Kind.Live"]</MudRadio>
<MudRadio Value="@(ExportKind.Combined)" Color="Color.Primary">@L["Export.Kind.Combined"]</MudRadio>
</MudRadioGroup>
</div>
</div>
@if (_error is not null)
{
<div role="alert" class="m-export-dialog__error">
@_error
</div>
}
</div>
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Variant="Variant.Text">@L["Export.Cancel"]</MudButton>
<MudButton OnClick="Submit"
Color="Color.Primary"
Variant="Variant.Filled"
Disabled="@_busy">
@if (_busy)
{
<MudProgressCircular Indeterminate="true" Size="Size.Small" Color="Color.Inherit" />
<span style="margin-left: 8px;">@L["Common.Loading"]</span>
}
else
{
@L["Export.Submit"]
}
</MudButton>
</DialogActions>
</MudDialog>
<style>
.m-export-dialog__error {
background: rgba(220, 38, 38, 0.08);
border-left: 3px solid var(--m-c-anomaly);
padding: var(--m-space-3);
font-family: var(--m-font-mono);
font-size: 0.8125rem;
color: var(--m-c-anomaly);
}
</style>
@code {
[CascadingParameter] private MudDialogInstance Dialog { get; set; } = default!;
[Parameter] public DateTime? InitialFrom { get; set; }
[Parameter] public DateTime? InitialTo { get; set; }
[Parameter] public ExportKind InitialKind { get; set; } = ExportKind.Combined;
private DateTime? _from;
private DateTime? _to;
private ExportKind _kind;
private bool _busy;
private string? _error;
protected override void OnInitialized()
{
var moscow = MoscowTime.Now;
_from = InitialFrom ?? moscow.AddDays(-1).Date;
_to = InitialTo ?? moscow.AddDays(1).Date;
_kind = InitialKind;
}
private void Cancel() => Dialog.Cancel();
private async Task Submit()
{
if (_from is null || _to is null)
{
_error = L["Export.Error.MissingDates"];
return;
}
if (_from > _to)
{
_error = L["Export.Error.InvalidRange"];
return;
}
_error = null;
_busy = true;
StateHasChanged();
try
{
// Use Moscow offset to match domain ScheduledAt invariant.
var range = new AppDateRange(
new DateTimeOffset(_from.Value.Date, MoscowTime.Offset),
MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(_to.Value.Date)));
var path = await ExportUseCase.ExecuteAsync(range, _kind, CancellationToken.None);
Dialog.Close(DialogResult.Ok(path));
}
catch (Exception ex)
{
Logger.LogError(ex, "Export failed");
_error = L["Export.Error.Failed"].Value + " — " + ex.Message;
}
finally
{
_busy = false;
StateHasChanged();
}
}
private async Task HandleKeyDown(Microsoft.AspNetCore.Components.Web.KeyboardEventArgs e)
{
if (e.Key == "Enter" && !_busy)
{
await Submit();
}
else if (e.Key == "Escape")
{
Cancel();
}
}
}