Files
SteUP_Dotnet/SteUp.Shared/Components/Pages/IspezionePage.razor
T
MarcoE b9de075f45 Rimetti codice e indirizzo nella testata dell'ispezione
La testata mostrava il solo nome del punto vendita, con il codice
relegato a sottotitolo e l'indirizzo da nessuna parte. Torna com'era:
"01 - D11-PRIMO PREZZO - TERLIZZI LILLIUM" e sotto la via. Il codice sta
davanti al nome perche' e' quello che si confronta con il documento
cartaceo; il nome serve a riconoscere il negozio, l'indirizzo a essere
sicuri di stare in quello giusto.

Il titolo cosi' e' lungo, quindi la testata passa alla misura compatta e
puo' andare a capo fino a tre righe invece di troncare: un nome tagliato
a meta' non aiuta nessuno.

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

618 lines
24 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
{
@foreach (var group in SchedeGrouped)
{
var daInviare = group.Value.CountDaInviare();
var daCompletare = group.Value.CountBozze();
<section>
<div class="section-header">
<div class="section-header__text">
<h2 class="section-header__title">@group.Key.Descrizione</h2>
<p class="section-header__desc">
@(group.Value.Count == 1 ? "1 scheda" : $"{group.Value.Count} schede")
</p>
</div>
@if (!IsCompleted && NetworkService.IsNetworkAvailable() && daInviare > 0)
{
<div class="section-header__aside">
<MudButton Variant="Variant.Outlined" Size="Size.Small"
StartIcon="@Icons.Material.Rounded.CloudUpload"
aria-label="@($"Invia le schede del reparto {group.Key.Descrizione}")"
OnClick="@(() => ExportReparto(group.Key))">
Invia @daInviare
</MudButton>
</div>
}
</div>
@if (daCompletare > 0)
{
<div class="badge-row" style="margin-bottom: var(--space-10);">
<Badge Spec="@SyncState.Bozza.ToBadge()" Text="@($"{daCompletare} da completare")"/>
</div>
}
<div class="card-list">
@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();
}
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."
);
}
}