Files
SteUP_Dotnet/SteUp.Shared/Components/Pages/IspezionePage.razor
T
MarcoE 70a0d0f5fa Rimetti i reparti richiudibili e il "+" sul reparto
Le schede di un reparto erano un elenco fisso: con quattro reparti aperti
la pagina diventava un rotolo, e per aggiungere una scheda a un reparto
preciso bisognava passare dal comando generale e ridichiarare il reparto.

La testata del reparto torna a essere un comando: si tocca per richiudere
il gruppo, e la freccia dice da che parte sta. I conteggi salgono nella
testata ("3 schede · 1 da inviare · 1 da completare") invece di stare nel
corpo: un reparto compattato non deve nascondere quello che gli manca.

Accanto torna il "+", che apre la scheda nuova con il reparto gia'
scelto. E' l'unica differenza con il "+" della barra, ed e' quella che
toglie un passaggio a chi sta lavorando su un reparto alla volta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:45:50 +02:00

670 lines
26 KiB
Plaintext

@page "/ispezione"
@using Microsoft.Extensions.Logging
@using SteUp.Shared.Components.Layout
@using SteUp.Shared.Components.Layout.Overlay
@using SteUp.Shared.Components.Layout.Sheet
@using SteUp.Shared.Components.SingleElements
@using SteUp.Shared.Components.SingleElements.Card
@using SteUp.Shared.Components.SingleElements.MessageBox
@using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Entities
@using SteUp.Shared.Core.Enum
@using SteUp.Shared.Core.Helpers
@using SteUp.Shared.Core.Interface.IntegryApi
@using SteUp.Shared.Core.Interface.LocalDb
@using SteUp.Shared.Core.Interface.System
@using SteUp.Shared.Core.Messages.System
@inject IIspezioniService IspezioniService
@inject IDialogService Dialog
@inject IIntegrySteupService IntegrySteupService
@inject IFileManager FileManager
@inject ILogger<IspezionePage> Logger
@inject PageActionsService PageActions
@implements IDisposable
@* Le azioni non stanno nella testata: le apre il comando al centro della
barra in basso, che finche' questa schermata e' a video significa "azioni
di questa ispezione" (vedi PageActionsService). Su un'ispezione conclusa
non si registra nulla e resta solo la consultazione. *@
<HeaderLayout Title="@Titolo" Subtitle="@Sottotitolo" Back="true" Compact="true"/>
<ActionSheet @bind-Open="_actionsOpen" Title="Azioni ispezione" Subtitle="@Ispezione.CodMdep">
<div class="stack stack-xs">
<button type="button" class="action-tile" disabled="@(!NetworkService.IsNetworkAvailable())"
@onclick="NewScheda">
<span class="action-tile__icon action-tile__icon--primary">
<i class="ri-add-line" aria-hidden="true"></i>
</span>
<span class="action-tile__label">Nuova scheda</span>
</button>
@if (!Ispezione.ActivityId.IsNullOrEmpty())
{
<button type="button" class="action-tile" disabled="@(!NetworkService.IsNetworkAvailable())"
@onclick="ConfirmCompleteInspection">
<span class="action-tile__icon action-tile__icon--success">
<i class="ri-check-double-line" aria-hidden="true"></i>
</span>
<span class="action-tile__label">Concludi ispezione</span>
</button>
}
<button type="button" class="action-tile" disabled="@(!NetworkService.IsNetworkAvailable())"
@onclick="ConfirmDeleteInspection">
<span class="action-tile__icon action-tile__icon--danger">
<i class="ri-delete-bin-line" aria-hidden="true"></i>
</span>
<span class="action-tile__label">Elimina ispezione</span>
</button>
@if (!NetworkService.IsNetworkAvailable())
{
<div class="feedback feedback--warning" role="status">
<i class="feedback__icon ri-wifi-off-line" aria-hidden="true"></i>
<span>Senza connessione queste azioni non sono disponibili. Il lavoro gia' fatto resta sul dispositivo.</span>
</div>
}
</div>
</ActionSheet>
<div class="app-scroll">
<div class="page-width page-body stack stack-lg">
@* §6.6 Pannello di riepilogo: primo elemento della pagina, con lo stato
del documento e i conteggi sempre a vista. Superficie bianca come
ogni altro pannello: era l'unico blocco tinto dell'app, e quel
colore non appartiene a nessuno stato. *@
<section class="panel panel--lg">
@* Il nome del punto vendita e' gia' nella testata: qui basta la data
e lo stato del documento, che la testata non porta. *@
<div class="summary__head">
<div class="section-header__text">
<p class="summary__subtitle" style="margin: 0;">@Ispezione.Data.ToString("d MMMM yyyy")</p>
</div>
<Badge Spec="@Ispezione.Stato.ToBadge()"/>
</div>
<div class="summary__metrics">
<div class="summary__metric">
<i class="summary__metric-icon ri-file-list-3-line" aria-hidden="true"></i>
<div class="summary__metric-value">@SchedeList.Count</div>
<div class="summary__metric-label">@(SchedeList.Count == 1 ? "scheda" : "schede")</div>
</div>
<div class="summary__metric">
<i class="summary__metric-icon ri-smartphone-line" aria-hidden="true"></i>
<div class="summary__metric-value">@DaInviare</div>
<div class="summary__metric-label">da inviare</div>
</div>
<div class="summary__metric">
<i class="summary__metric-icon ri-edit-line" aria-hidden="true"></i>
<div class="summary__metric-value">@Bozze</div>
<div class="summary__metric-label">da completare</div>
</div>
</div>
@if (SchedeList.Count > 0)
{
<div class="summary__progress-head">
<span class="summary__progress-count">@Inviate di @SchedeList.Count inviate</span>
<span class="summary__progress-pct">@PercentualeInviate%</span>
</div>
<div class="progress-track" role="progressbar" aria-valuenow="@PercentualeInviate"
aria-valuemin="0" aria-valuemax="100" aria-label="Schede inviate al server">
<div class="progress-fill" style="--progress: @Progresso"></div>
</div>
}
</section>
@if (Bozze > 0)
{
@* Feedback inline: resta a video finche' il problema esiste, a
differenza del toast che sparisce. *@
<div class="feedback feedback--warning" role="status" aria-live="polite">
<i class="feedback__icon ri-edit-line" aria-hidden="true"></i>
<span>
@(Bozze == 1 ? "1 scheda è rimasta a metà" : $"{Bozze} schede sono rimaste a metà").
Completale o eliminale prima di concludere l'ispezione.
</span>
</div>
}
@if (SchedeGrouped.Count == 0)
{
<div class="empty-state">
<i class="empty-state__icon ri-clipboard-line" aria-hidden="true"></i>
<p class="empty-state__title">Nessuna scheda</p>
@if (!IsCompleted)
{
<p class="empty-state__text">
Aggiungi la prima scheda di questa ispezione.
</p>
<div class="empty-state__action">
<MudButton Variant="Variant.Filled" Color="Color.Primary"
StartIcon="@Icons.Material.Rounded.Add"
Disabled="@(!NetworkService.IsNetworkAvailable())"
OnClick="NewScheda">
Nuova scheda
</MudButton>
</div>
}
</div>
}
else
{
@* Un reparto per volta: la testata si tocca per richiudere il
gruppo, e i conteggi restano scritti li' anche da chiuso — un
reparto compattato non deve nascondere quello che gli manca. *@
@foreach (var group in SchedeGrouped)
{
var reparto = group.Key;
var chiuso = IsCollapsed(reparto);
var daInviare = group.Value.CountDaInviare();
<section class="reparto @(chiuso ? "reparto--collapsed" : "")">
<div class="reparto__head">
<button type="button" class="reparto__toggle" @onclick="@(() => Toggle(reparto))"
aria-expanded="@((!chiuso).ToString().ToLowerInvariant())">
<i class="reparto__chevron ri-arrow-down-s-line" aria-hidden="true"></i>
<span class="reparto__text">
<span class="reparto__title">@reparto.Descrizione</span>
<span class="reparto__meta">@Conteggi(group.Value)</span>
</span>
</button>
<div class="reparto__actions">
@if (!IsCompleted)
{
@* Da qui la scheda nasce gia' con il suo reparto: e'
l'unica differenza con il "+" della barra. *@
<button type="button" class="reparto__add"
disabled="@(!NetworkService.IsNetworkAvailable())"
aria-label="@($"Nuova scheda nel reparto {reparto.Descrizione}")"
title="@($"Nuova scheda in {reparto.Descrizione}")"
@onclick="@(() => CreateNewScheda(reparto))">
<i class="ri-add-line" aria-hidden="true"></i>
</button>
@if (NetworkService.IsNetworkAvailable() && daInviare > 0)
{
<MudButton Variant="Variant.Outlined" Size="Size.Small"
StartIcon="@Icons.Material.Rounded.CloudUpload"
aria-label="@($"Invia le schede del reparto {reparto.Descrizione}")"
OnClick="@(() => ExportReparto(reparto))">
Invia @daInviare
</MudButton>
}
}
</div>
</div>
@if (!chiuso)
{
<div class="card-list reparto__body">
@foreach (var scheda in group.Value)
{
<SchedaCard Scheda="scheda" Ispezione="Ispezione"
OnSchedaDeleted="OnSchedaDeleted"
OnSchedaModified="OnSchedaModified"
OnBusyChanged="@(b => VisibleOverlay = b)"/>
}
</div>
}
</section>
}
}
</div>
</div>
<SpinnerOverlay VisibleOverlay="VisibleOverlay" SuccessAnimation="_success" Message="@_progressMessage"/>
<ConfirmMessageBox @ref="_completeBox" Title="Concludere l'ispezione?" NoText="Annulla"
YesText="Concludi" YesColor="Color.Primary"
Message="Una volta conclusa, l'ispezione non sarà più modificabile e non potrai aggiungere altre schede."/>
<ConfirmMessageBox @ref="_deleteBox" Title="Eliminare l'ispezione?" NoText="Annulla"
YesText="Elimina" YesColor="Color.Error"
Message="L'ispezione e tutte le sue schede verranno eliminate dal dispositivo. L'operazione non è reversibile."/>
<ConfirmMessageBox @ref="_exportRepartoBox" Title="Inviare le schede del reparto?" NoText="Annulla"
YesText="Invia" YesColor="Color.Primary"
Message="Le schede complete di questo reparto verranno inviate al server. Procedere?"/>
@code {
private List<Scheda> SchedeList { get; set; } = [];
private Dictionary<JtbFasiDto, List<Scheda>> SchedeGrouped { get; set; } = [];
private bool VisibleOverlay { get; set; }
private bool _isBusy;
private bool _success;
private string? _progressMessage;
private ConfirmMessageBox _exportRepartoBox = null!;
private ConfirmMessageBox _completeBox = null!;
private ConfirmMessageBox _deleteBox = null!;
private bool _actionsOpen;
private Ispezione Ispezione => SteupDataService.InspectionPageState.Ispezione;
private bool IsCompleted => Ispezione.Stato == StatusEnum.Completata;
private int DaInviare => SchedeList.CountDaInviare();
private int Bozze => SchedeList.CountBozze();
private int Inviate => SchedeList.Count(s => s.GetSyncState() == SyncState.Inviato);
private int PercentualeInviate => SchedeList.Count == 0
? 0
: (int)Math.Round(Inviate * 100.0 / SchedeList.Count);
private string Progresso => (PercentualeInviate / 100.0).ToString("0.###",
System.Globalization.CultureInfo.InvariantCulture);
private PuntoVenditaDto? PuntoVendita => SteupDataService.PuntiVenditaList
.Find(x => x.CodMdep != null && x.CodMdep.EqualsIgnoreCase(Ispezione.CodMdep));
// Il codice del punto vendita sta davanti al nome, come su ogni documento
// cartaceo dell'ispezione: e' quello che si confronta, il nome serve a
// riconoscerlo.
private string Titolo => PuntoVendita?.Descrizione is { Length: > 0 } descrizione
? $"{Ispezione.CodMdep} - {descrizione}"
: Ispezione.CodMdep;
private string? Sottotitolo => PuntoVendita?.Indirizzo;
private IDisposable? _actionsRegistration;
protected override void OnInitialized()
{
LoadSchede();
// Su un'ispezione conclusa non c'e' niente da fare: il comando della
// barra si tiene il suo significato di default invece di aprire uno
// sheet di azioni tutte spente.
if (!IsCompleted)
_actionsRegistration = PageActions.Register(() => InvokeAsync(OpenActions));
}
private void OpenActions()
{
_actionsOpen = true;
StateHasChanged();
}
public void Dispose() => _actionsRegistration?.Dispose();
private void LoadSchede()
{
var ispezione = Ispezione;
InvokeAsync(async () =>
{
SchedeList = await IspezioniService.GetAllSchedeOfIspezioneAsync(
ispezione.CodMdep, ispezione.Data, ispezione.Rilevatore
);
GroupSchede();
StateHasChanged();
});
}
private async Task NewScheda()
{
_actionsOpen = false;
var modal = await Dialog.OpenFormScheda(Ispezione.CodMdep, Ispezione.Data, true);
if (modal is { Canceled: false }) LoadSchede();
}
/// Stessa scheda nuova, ma con il reparto gia' scelto: partendo da un
/// reparto non ha senso richiederlo.
private async Task CreateNewScheda(JtbFasiDto reparto)
{
var modal = await Dialog.OpenFormScheda(
Ispezione.CodMdep, Ispezione.Data, true, new Scheda { Reparto = reparto }
);
if (modal is { Canceled: false }) LoadSchede();
}
// I reparti richiusi stanno qui e non nell'entita': e' uno stato della
// schermata, non del documento.
private readonly HashSet<string> _collapsed = new(StringComparer.OrdinalIgnoreCase);
private bool IsCollapsed(JtbFasiDto reparto) =>
reparto.CodJfas is not null && _collapsed.Contains(reparto.CodJfas);
private void Toggle(JtbFasiDto reparto)
{
if (reparto.CodJfas is null) return;
if (!_collapsed.Remove(reparto.CodJfas)) _collapsed.Add(reparto.CodJfas);
}
private static string Conteggi(List<Scheda> schede)
{
var parti = new List<string> { schede.Count == 1 ? "1 scheda" : $"{schede.Count} schede" };
var daInviare = schede.CountDaInviare();
if (daInviare > 0) parti.Add($"{daInviare} da inviare");
var bozze = schede.CountBozze();
if (bozze > 0) parti.Add($"{bozze} da completare");
return string.Join(" · ", parti);
}
private async Task ConfirmCompleteInspection()
{
_actionsOpen = false;
var confirmed = await _completeBox.ShowAsync();
if (confirmed is true) HandleCompleteInspection();
}
private async Task ConfirmDeleteInspection()
{
_actionsOpen = false;
// Prima non c'era conferma: un tocco cancellava l'ispezione e tutte le
// sue schede senza appello.
var confirmed = await _deleteBox.ShowAsync();
if (confirmed is not true) return;
try
{
var ispezione = Ispezione;
await IspezioniService.DeleteIspezioneAsync(ispezione.CodMdep, ispezione.Data, ispezione.Rilevatore);
NavigationManager.NavigateTo("/ispezioni");
}
catch (Exception e)
{
OnError(e, e.Message);
}
}
private async void HandleCompleteInspection()
{
// Guardia anti doppio-invio: il completamento è irreversibile.
if (_isBusy) return;
_isBusy = true;
try
{
await InvokeAsync(() =>
{
VisibleOverlay = true;
StateHasChanged();
});
var ispezione = Ispezione;
// Le bozze non sono inviabili: l'ispezione non si chiude finché ci sono.
var bozze = SchedeList.CountBozze();
if (bozze > 0)
{
await InvokeAsync(() =>
{
VisibleOverlay = false;
StateHasChanged();
});
await Dialog.ShowWarning(
bozze == 1
? "C'è 1 scheda da completare. Completala o eliminala prima di concludere l'ispezione."
: $"Ci sono {bozze} schede da completare. Completale o eliminale prima di concludere l'ispezione."
);
return;
}
var daInviare = ispezione.Schede.Where(x => x.ActivityId == null && !x.Bozza).ToList();
var totale = daInviare.Count;
var indice = 0;
var fallite = 0;
foreach (var scheda in daInviare)
{
indice++;
await SetProgress($"Invio scheda {indice} di {totale}…");
var apiResponse = await IntegrySteupService.SaveScheda(
new SaveRequestDto
{
LocalIdScheda = scheda.Id,
ActivityTypeId = scheda.ActivityTypeId,
CodJfas = scheda.CodJfas,
CodMdep = ispezione.CodMdep,
DataCreazione = ispezione.Data,
Note = scheda.Note,
PersonaRif = scheda.Responsabile,
Barcodes = scheda.Articoli.ConvertAll(x => x.Barcode),
Scandeza = (ScadenzaEnum)scheda.Scadenza,
// Aggancia all'ispezione esistente: dopo la prima scheda l'id e' noto,
// così le successive non creano nuove ispezioni.
ParentActivityId = ispezione.ActivityId
}
);
// Fallita: resta "Da inviare". Non completeremo l'ispezione finché tutte non sono arrivate.
if (apiResponse == null)
{
fallite++;
continue;
}
scheda.ActivityId = apiResponse.ActivityIdScheda;
await IspezioniService.UpdateSchedaAsync(scheda);
await IspezioniService.UpdateActivityIdIspezioneAsync(
ispezione.CodMdep,
ispezione.Data,
ispezione.Rilevatore,
apiResponse.ActivityIdIspezione
);
// Aggiorna l'id in memoria per l'iterazione successiva.
ispezione.ActivityId = apiResponse.ActivityIdIspezione;
if (scheda.ImageNames == null) continue;
var fileList = (await FileManager.GetInspectionFiles(ispezione, scheda.ImageNames))?
.Where(x => x.ToUpload).ToList();
if (fileList == null) continue;
var totFoto = fileList.Count;
var indiceFoto = 0;
foreach (var file in fileList)
{
indiceFoto++;
await SetProgress($"Scheda {indice}/{totale} · foto {indiceFoto}/{totFoto}…");
await IntegrySteupService.UploadFile(scheda.ActivityId!, file.FileBytes!, file.Name!);
}
}
// Integrità dati: se anche una sola scheda non è arrivata al server, NON completare.
// L'ispezione resta editabile e le schede fallite restano "Da inviare".
if (fallite > 0)
{
await InvokeAsync(() =>
{
_progressMessage = null;
StateHasChanged();
});
LoadSchede(); // rinfresca i badge: riuscite → "Inviato", fallite → restano "Da inviare"
await Dialog.ShowWarning(
fallite == 1
? "1 scheda non è stata inviata e resta salvata sul dispositivo. Controlla la connessione e riprova prima di concludere l'ispezione."
: $"{fallite} schede non sono state inviate e restano salvate sul dispositivo. Controlla la connessione e riprova prima di concludere l'ispezione."
);
return;
}
await SetProgress("Completamento ispezione…");
await IntegrySteupService.CompleteInspection(ispezione.ActivityId!);
await IspezioniService.UpdateStatoIspezioneAsync(
ispezione.CodMdep,
ispezione.Data,
ispezione.Rilevatore,
StatusEnum.Completata
);
ispezione.Stato = StatusEnum.Completata;
// Riscontro al momento più importante: la spunta di successo prima di chiudere l'overlay.
await InvokeAsync(() =>
{
_progressMessage = "Ispezione inviata";
_success = true;
StateHasChanged();
});
await Task.Delay(1800);
}
catch (Exception e)
{
OnError(e, e.Message);
}
finally
{
_isBusy = false;
_success = false;
_progressMessage = null;
await InvokeAsync(() =>
{
VisibleOverlay = false;
StateHasChanged();
});
}
}
private Task SetProgress(string message) =>
InvokeAsync(() =>
{
_progressMessage = message;
StateHasChanged();
});
private void GroupSchede()
{
SchedeGrouped = SchedeList
.Where(s => s.Reparto != null)
.GroupBy(s => s.CodJfas)
.ToDictionary(
g => g.First().Reparto!,
g => g.ToList()
);
}
private void OnSchedaModified(Scheda obj)
{
var index = SchedeList.FindIndex(x => x.Id.Equals(obj.Id));
if (index >= 0) SchedeList[index] = obj;
GroupSchede();
StateHasChanged();
}
private void OnSchedaDeleted(Scheda obj)
{
SchedeList.Remove(obj);
GroupSchede();
StateHasChanged();
}
private async Task ExportReparto(JtbFasiDto jtbFasi)
{
// Guardia anti doppio-tap: un secondo tocco durante l'invio non ri-esporta il batch.
if (_isBusy) return;
var confirmed = await _exportRepartoBox.ShowAsync();
if (confirmed is not true) return;
_isBusy = true;
VisibleOverlay = true;
StateHasChanged();
try
{
var ispezione = Ispezione;
// Le bozze restano sul dispositivo: si esporta solo ciò che è completo.
var daEsportare = SchedeGrouped[jtbFasi].FindAll(x => !x.Bozza);
if (daEsportare.Count == 0)
{
Snackbar.Add("Nessuna scheda completa da inviare in questo reparto", Severity.Info);
return;
}
var saveRequest = daEsportare.ConvertAll(x =>
{
return new SaveRequestDto
{
LocalIdScheda = x.Id,
ActivityTypeId = x.ActivityTypeId,
CodJfas = x.CodJfas,
CodMdep = ispezione.CodMdep,
DataCreazione = ispezione.Data,
Note = x.Note,
PersonaRif = x.Responsabile,
Barcodes = x.Articoli.ConvertAll(y => y.Barcode),
Scandeza = (ScadenzaEnum)x.Scadenza,
// Aggancia all'ispezione esistente sul server (evita di crearne una nuova a ogni export).
ParentActivityId = ispezione.ActivityId
};
});
var apiResponse = await IntegrySteupService.SaveMultipleSchede(saveRequest);
if (apiResponse == null)
{
Snackbar.Add("Invio non riuscito. Le schede restano sul dispositivo, riprova.", Severity.Error);
return;
}
ispezione.ActivityId = apiResponse.ActivityIdIspezione;
await IspezioniService.UpdateActivityIdIspezioneAsync(ispezione.CodMdep, ispezione.Data,
UserSession.User.Username, apiResponse.ActivityIdIspezione
);
if (!apiResponse.ActivityIdSchedaList.IsNullOrEmpty())
{
foreach (var scheda in daEsportare)
{
var activityId = apiResponse.ActivityIdSchedaList!.Find(x =>
x.LocalId != null && x.LocalId == scheda.Id
)?.ActivityId;
if (activityId == null) continue;
scheda.ActivityId = activityId;
await IspezioniService.UpdateActivityIdSchedaAsync(scheda.Id, scheda.ActivityId);
}
}
Snackbar.Add(daEsportare.Count == 1 ? "Scheda inviata" : $"{daEsportare.Count} schede inviate",
Severity.Success);
}
catch (Exception e)
{
OnError(e, e.Message);
}
finally
{
_isBusy = false;
VisibleOverlay = false;
StateHasChanged();
}
}
private void OnError(Exception? e, string? technicalMessage)
{
// Il dettaglio tecnico va nei log, non davanti al rilevatore in campo.
if (e != null) Logger.LogError(e, technicalMessage);
_ = Dialog.ShowError(
"Invio non riuscito. Il lavoro resta salvato sul dispositivo: controlla la connessione e riprova."
);
}
}