Salvataggio come bozza: il lavoro in corso non si perde piu'

Uscendo da una scheda a meta' (tasto indietro del dispositivo o "Annulla")
il lavoro veniva scartato. Il tasto indietro non era nemmeno gestito: non
arriva alla WebView e non c'e' stack di navigazione, quindi mandava l'app
in background con i dati solo in memoria.

Nuovo stato Bozza ("Da completare"): salvata in locale, mai inviabile
finche' non viene conclusa.

- Scheda.Bozza + migration additiva (default false: le schede esistenti
  non cambiano stato).
- SchedaSyncHelper: terzo SyncState, CountBozze(). CountDaInviare()
  esclude le bozze, quindi il badge "da inviare" resta corretto.
- ModalFormScheda: SaveDraft() salva solo in locale senza validazione;
  Save() azzera il flag; Submit() avvisa quando il form e' invalido
  (prima il tocco su "Salva" sembrava non fare nulla se i campi mancanti
  erano fuori schermo); Update() prende preserveActivityId cosi' una
  bozza di scheda gia' inviata mantiene il link al server e al
  completamento lo aggiorna invece di duplicarlo.
- ConfirmUpdateActivity: il pulsante bozza sta su NoText, non CancelText
  (CancelText ritorna null, NoText ritorna false).
- BackButtonService + MainActivity.OnBackPressed: il tasto indietro passa
  dallo stesso percorso di "Annulla". Ignorato mentre e' aperto un dialog
  figlio, per non mostrare il prompt sotto un altro dialog.
- IspezionePage: "Concludi ispezione" si blocca se ci sono bozze,
  "Esporta reparto" le salta, chip "N da completare" per reparto.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 13:24:35 +02:00
parent e3942d00cc
commit d076a4b77a
11 changed files with 433 additions and 22 deletions
@@ -40,6 +40,7 @@
<TitleContent>
@{
var daInviare = group.Value.CountDaInviare();
var daCompletare = group.Value.CountBozze();
}
<div class="header-scheda-group">
<div class="title">
@@ -54,6 +55,13 @@
@($"{daInviare} da inviare")
</MudChip>
}
@if (daCompletare > 0)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
Color="Color.Warning" Icon="@Icons.Material.Rounded.EditNote">
@($"{daCompletare} da completare")
</MudChip>
}
</div>
@if (SteupDataService.InspectionPageState.Ispezione.Stato != StatusEnum.Completata)
{
@@ -156,7 +164,25 @@
var ispezione = SteupDataService.InspectionPageState.Ispezione;
var daInviare = ispezione.Schede.Where(x => x.ActivityId == null).ToList();
// 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;
@@ -341,7 +367,16 @@
{
var ispezione = SteupDataService.InspectionPageState.Ispezione;
var saveRequest = SchedeGrouped[jtbFasi].ConvertAll(x =>
// 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 esportare in questo reparto", Severity.Info);
return;
}
var saveRequest = daEsportare.ConvertAll(x =>
{
return new SaveRequestDto
{
@@ -375,7 +410,7 @@
if (!apiResponse.ActivityIdSchedaList.IsNullOrEmpty())
{
foreach (var scheda in SchedeGrouped[jtbFasi])
foreach (var scheda in daEsportare)
{
var activityId = apiResponse.ActivityIdSchedaList!.Find(x =>
x.LocalId != null && x.LocalId == scheda.Id
@@ -1,6 +1,9 @@
<MudMessageBox @ref="_confirmSave" Title="Attenzione!" CancelText="Non salvare">
@* NoText -> false (salva bozza), YesText -> true (salva), chiusura senza scelta -> null.
Niente CancelText: da qui non si esce buttando via il lavoro. *@
<MudMessageBox @ref="_confirmSave" Title="Lavoro non salvato" NoText="Salva come bozza">
<MessageContent>
Sono state apportate delle modifiche. Vuoi salvarle prima di continuare?
Hai delle modifiche non salvate. Salva la scheda, oppure tienila come bozza
da completare più tardi: in ogni caso non perdi nulla.
</MessageContent>
<YesButton>
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary">
@@ -11,6 +11,8 @@
@using SteUp.Shared.Core.Interface.LocalDb
@using SteUp.Shared.Core.Interface.System
@using SteUp.Shared.Core.Messages.Scanner
@using SteUp.Shared.Core.Messages.System
@inject BackButtonService BackButton
@inject INetworkService NetworkService
@inject IDialogService Dialog
@inject IIntegryApiService IntegryApiService
@@ -20,6 +22,7 @@
@inject OnScannerService OnScannerService
@inject IAttachedService AttachedService
@inject ILogger<ModalFormScheda> Logger
@implements IDisposable
<MudDialog Class="customDialog-form">
<DialogContent>
@@ -237,6 +240,34 @@
private bool _attachmentsDirty;
private List<AttachedDto>? AttachedList { get; set; }
private IDisposable? _backRegistration;
// Il tasto indietro puo' arrivare mentre il prompt di uscita e' gia' a video.
private bool _closing;
// Dialog aperti sopra al form (articoli, suggerimenti, errori).
private int _childDialogs;
private Task HandleBackPressed()
{
// Con un dialog figlio a video il back non deve agire sul form sottostante:
// meglio ignorarlo che far comparire il prompt di uscita sotto un altro dialog.
if (_childDialogs > 0) return Task.CompletedTask;
return Cancel();
}
private async Task<T> WithChildDialog<T>(Func<Task<T>> open)
{
_childDialogs++;
try
{
return await open();
}
finally
{
_childDialogs--;
}
}
protected override void OnInitialized()
{
Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter;
@@ -248,6 +279,10 @@
_originalBarcodes = Scheda.Articoli.ConvertAll(x => x.Barcode);
Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter;
// Il tasto indietro del dispositivo deve passare da qui, non mandare l'app
// in background lasciando il lavoro non salvato solo in memoria.
_backRegistration = BackButton.Register(() => InvokeAsync(HandleBackPressed));
LoadAttached();
}
@@ -284,7 +319,10 @@
{
VisibleOverlay = true;
StateHasChanged();
// Salvataggio completo: la scheda non è più una bozza.
Scheda.Bozza = false;
SaveSchedaResponseDto? apiResponse = null;
try
{
@@ -369,7 +407,7 @@
await IspezioniService.AddSchedaAsync(CodMdep, Data, UserSession.User.Username, Scheda);
}
private async Task Update(SaveSchedaResponseDto? apiResponse)
private async Task Update(SaveSchedaResponseDto? apiResponse, bool preserveActivityId = false)
{
if (!AttachedList.IsNullOrEmpty())
{
@@ -399,7 +437,10 @@
}
}
Scheda.ActivityId = apiResponse?.ActivityIdScheda;
// Salvando una bozza non tocchiamo l'ActivityId: se la scheda era già sul server,
// al completamento va aggiornata, non ricreata.
if (!preserveActivityId) Scheda.ActivityId = apiResponse?.ActivityIdScheda;
await IspezioniService.UpdateSchedaAsync(Scheda);
if (apiResponse != null)
@@ -440,11 +481,21 @@
private async Task Cancel()
{
if (await CheckSavePreAction())
if (_closing) return;
_closing = true;
try
{
await FileManager.CleanTempStorageAsync();
DisposeMessage();
MudDialog.Cancel();
if (await CheckSavePreAction())
{
await FileManager.CleanTempStorageAsync();
DisposeMessage();
MudDialog.Cancel();
}
}
finally
{
_closing = false;
}
}
@@ -501,17 +552,25 @@
}
}
/// <summary>
/// Ritorna true se il form può chiudersi scartando (nessuna modifica in sospeso).
/// Con del lavoro in corso non si perde mai nulla: o si salva per intero, o si
/// salva come bozza da riprendere.
/// </summary>
private async Task<bool> CheckSavePreAction()
{
if (!IsDirty) return true;
var resul = await _confirmUpdateMessage.ShowAsync();
if (resul is not true) return true;
var result = await WithChildDialog(() => _confirmUpdateMessage.ShowAsync());
// Prompt chiuso senza scegliere (tap fuori / indietro): si resta sulla scheda.
if (result is null) return false;
VisibleOverlay = true;
StateHasChanged();
await Submit();
if (result is true) await Submit();
else await SaveDraft();
VisibleOverlay = false;
StateHasChanged();
@@ -519,10 +578,48 @@
return false;
}
/// <summary>
/// Salva il lavoro in corso solo sul dispositivo, marcato come da completare.
/// Niente chiamate al server e niente validazione: una bozza può essere incompleta.
/// </summary>
private async Task SaveDraft()
{
try
{
Scheda.Bozza = true;
if (IsNew) await NewSave(null);
else await Update(null, preserveActivityId: true);
await FileManager.CleanTempStorageAsync();
Snackbar.Clear();
Snackbar.Add("Scheda salvata come bozza, la ritrovi nell'ispezione", Severity.Success);
DisposeMessage();
MudDialog.Close(Scheda);
}
catch (Exception e)
{
Scheda.Bozza = false;
OnError(e, e.Message);
}
}
private async Task Submit()
{
await _form.Validate();
if (_form.IsValid) await Save();
if (_form.IsValid)
{
await Save();
return;
}
// Il form resta aperto sugli errori: senza un avviso il tocco su "Salva"
// sembra non fare nulla se i campi mancanti sono fuori schermo.
Snackbar.Clear();
Snackbar.Add("Compila i campi obbligatori, oppure esci salvando come bozza", Severity.Warning);
}
#endregion
@@ -621,7 +718,7 @@
return;
}
var modal = await Dialog.OpenSelectArt(articoli);
var modal = await WithChildDialog(() => Dialog.OpenSelectArt(articoli));
await InvokeAsync(() =>
{
@@ -679,7 +776,7 @@
{
var activityDescriptions = await IntegryApiService.SuggestActivityDescription(Scheda.ActivityTypeId);
var modal = await Dialog.OpenSuggestActivityDescription(activityDescriptions);
var modal = await WithChildDialog(() => Dialog.OpenSuggestActivityDescription(activityDescriptions));
if (modal is { Canceled: false, Data: string descrizione } && !string.IsNullOrWhiteSpace(descrizione))
{
@@ -783,6 +880,16 @@
{
OnScannerService.OnNewScanSuccessful -= HandleNewScanSuccessful;
OnScannerService.OnErrorScan -= OnErrorScan;
_backRegistration?.Dispose();
_backRegistration = null;
}
// Rete di sicurezza: se il dialog viene chiuso per altre vie, il tasto indietro
// non deve restare agganciato a un form che non esiste più.
void IDisposable.Dispose()
{
DisposeMessage();
}
}
+6
View File
@@ -20,6 +20,12 @@ public class Scheda : EntityBase<Scheda>
public List<SchedaArticolo> Articoli { get; set; } = [];
public List<string>? ImageNames { get; set; }
/// <summary>
/// Scheda salvata in locale ma non ancora completata (es. uscita dal form con lavoro in corso).
/// Non viene mai inviata al server finché il rilevatore non la conclude.
/// </summary>
public bool Bozza { get; set; }
public string? DescrizioneReparto { get; set; }
public string? ActivityTypeId { get; set; }
+18 -3
View File
@@ -14,19 +14,27 @@ public enum SyncState
DaInviare = 0,
/// Inviata al server (ha un ActivityId).
Inviato = 1
Inviato = 1,
/// Salvata in locale ma non completata: da riprendere, non inviabile.
Bozza = 2
}
public static class SchedaSyncHelper
{
public static SyncState GetSyncState(this Scheda scheda) =>
string.IsNullOrEmpty(scheda.ActivityId) ? SyncState.DaInviare : SyncState.Inviato;
scheda.Bozza
? SyncState.Bozza
: string.IsNullOrEmpty(scheda.ActivityId)
? SyncState.DaInviare
: SyncState.Inviato;
public static string ToLabel(this SyncState state) =>
state switch
{
SyncState.DaInviare => "Da inviare",
SyncState.Inviato => "Inviato",
SyncState.Bozza => "Da completare",
_ => string.Empty
};
@@ -39,6 +47,7 @@ public static class SchedaSyncHelper
{
SyncState.DaInviare => Color.Primary,
SyncState.Inviato => Color.Success,
SyncState.Bozza => Color.Warning,
_ => Color.Default
};
@@ -47,10 +56,16 @@ public static class SchedaSyncHelper
{
SyncState.DaInviare => Icons.Material.Rounded.PhonelinkOff,
SyncState.Inviato => Icons.Material.Rounded.CloudDone,
SyncState.Bozza => Icons.Material.Rounded.EditNote,
_ => string.Empty
};
/// Numero di schede non ancora inviate in una lista.
/// Numero di schede non ancora inviate in una lista. Le bozze non contano:
/// non sono inviabili finché non vengono completate.
public static int CountDaInviare(this IEnumerable<Scheda> schede) =>
schede.Count(s => s.GetSyncState() == SyncState.DaInviare);
/// Numero di schede lasciate a metà, da riprendere.
public static int CountBozze(this IEnumerable<Scheda> schede) =>
schede.Count(s => s.GetSyncState() == SyncState.Bozza);
}
@@ -0,0 +1,42 @@
namespace SteUp.Shared.Core.Messages.System;
/// <summary>
/// Ponte fra il tasto indietro del dispositivo (host nativo) e la UI Blazor.
/// Il form scheda si registra mentre è aperto così il back non manda l'app in
/// background buttando via il lavoro non salvato: passa invece dallo stesso
/// percorso del pulsante "Annulla", che propone il salvataggio come bozza.
/// </summary>
public class BackButtonService
{
private Func<Task>? _handler;
/// Registra il gestore corrente. Ritorna l'azione di deregistrazione.
public IDisposable Register(Func<Task> handler)
{
_handler = handler;
return new Registration(this, handler);
}
/// <summary>
/// Invocato dall'host nativo. Ritorna true se la UI Blazor ha preso in carico
/// il tasto indietro: in quel caso l'host non deve fare altro.
/// </summary>
public bool TryHandleBackPressed()
{
var handler = _handler;
if (handler is null) return false;
// Fire-and-forget: il gestore rientra nel dispatcher Blazor da solo.
_ = handler();
return true;
}
private sealed class Registration(BackButtonService owner, Func<Task> handler) : IDisposable
{
public void Dispose()
{
// Non azzerare se nel frattempo si è registrato qualcun altro.
if (ReferenceEquals(owner._handler, handler)) owner._handler = null;
}
}
}