@using Microsoft.Extensions.Logging @using SteUp.Shared.Components.Layout @using SteUp.Shared.Components.Layout.Overlay @using SteUp.Shared.Components.Layout.Spinner @using SteUp.Shared.Components.SingleElements.Card.ModalForm @using SteUp.Shared.Components.SingleElements.MessageBox @using SteUp.Shared.Components.SingleElements.Picker @using SteUp.Shared.Core.Dto @using SteUp.Shared.Core.Entities @using SteUp.Shared.Core.Enum @using SteUp.Shared.Core.Interface.IntegryApi @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 @inject IFileManager FileManager @inject IIspezioniService IspezioniService @inject IIntegrySteupService IntegrySteupService @inject OnScannerService OnScannerService @inject IAttachedService AttachedService @inject ILogger Logger @implements IDisposable @* Form scheda: la schermata dove il rilevatore passa la maggior parte del turno. Struttura a shell — testata, corpo scorrevole, barra azioni ancorata in fondo (STILE_UI §6.13) — così il salvataggio resta sempre sotto il pollice invece di stare in fondo a uno scroll lungo. *@
@if (IsBozza) { } @if (_showErrors && RepartoMancante) { } @if (Scheda.CodJfas.IsNullOrEmpty()) {

Scegli prima il reparto.

} else { @if (_showErrors && MotivoMancante) { } }
@* --- Articoli: il compito primario della schermata --- *@ @if (!ReadOnly) {
} @if (!Scheda.Articoli.IsNullOrEmpty()) {
@foreach (var articolo in Scheda.Articoli) {
@articolo.Descrizione
@articolo.Barcode
@if (!ReadOnly) { }
}
} else if (ReadOnly) {

Nessun articolo.

} @if (NetworkService.ConnectionAvailable && !ReadOnly) { }
@* --- Foto --- *@ @if (!AttachedList.IsNullOrEmpty()) {
@foreach (var item in AttachedList!.Select((p, index) => new { p, index })) {
@if (!item.p.ThumbPath.IsNullOrEmpty()) { @item.p.Name } else {
} @if (item.p.ToUpload && !ReadOnly) { }
}
} else if (ReadOnly) {

Nessuna foto.

} @if (!ReadOnly) { }
@* --- Urgenza: pillole invece di un menu a tendina --- *@
@foreach (var opzione in ScadenzaOptions) { }
@if (!ReadOnly) { }
@if (!ReadOnly) { @* Barra azioni ancorata: il salvataggio non si cerca mai. *@
@(LabelSave ?? (IsNew ? "Salva" : "Aggiorna"))
}
@code { [CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!; [Parameter] public required string CodMdep { get; set; } [Parameter] public required DateTime Data { get; set; } [Parameter] public bool IsNew { get; set; } [Parameter] public bool ReadOnly { get; set; } [Parameter] public Scheda Scheda { get; set; } = new(); private string? ManualBarcode { get; set; } private bool IsBozza => Scheda.Bozza; private string ArticoliCount => Scheda.Articoli.Count == 0 ? "nessuno" : Scheda.Articoli.Count == 1 ? "1" : Scheda.Articoli.Count.ToString(); private string FotoCount => AttachedList is null or { Count: 0 } ? "nessuna" : AttachedList.Count.ToString(); // I valori sono quelli di ScadenzaEnum. Come pillole invece che in un menu // a tendina: si scelgono con un tocco solo e si vede subito quale e' attiva. private static readonly (int Value, string Label)[] ScadenzaOptions = [ (3, "24 ore"), (2, "1 settimana"), (1, "1 mese"), (0, "2 mesi") ]; private void SetScadenza(int value) { if (ReadOnly) return; Scheda.Scadenza = value; OnAfterChangeValue(); } private void OnManualBarcodeInput(ChangeEventArgs e) { ManualBarcode = e.Value?.ToString(); } // Invio da tastiera: il codice digitato si conferma senza cercare il pulsante. private void OnManualBarcodeKey(KeyboardEventArgs e) { if (e.Key is "Enter" or "NumpadEnter") OnManualBarcodeSet(); } //Overlay private bool VisibleOverlay { get; set; } private bool SuccessAnimation { get; set; } private bool OnLoading { get; set; } private string? TextLoading { get; set; } private bool FileLoading { get; set; } private ConfirmUpdateActivity _confirmUpdateMessage = null!; private MudForm _form = null!; private string? LabelSave { get; set; } // Cambiare questa chiave forza Blazor a ricreare il MudTextField delle note, // così una nota impostata da codice (suggerimenti) compare subito a video // nonostante il DebounceInterval del campo. private int _noteFieldKey; private bool IsDirty { get; set; } private Scheda _originalScheda = null!; // Clone() e' un MemberwiseClone, quindi _originalScheda condivide la stessa lista // Articoli: per accorgerci degli articoli aggiunti/rimossi serve uno snapshot a parte. private List _originalBarcodes = []; // Gli allegati vivono in AttachedList finche' non si salva: li tracciamo con un flag. private bool _attachmentsDirty; private List? 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 WithChildDialog(Func> open) { _childDialogs++; try { return await open(); } finally { _childDialogs--; } } protected override void OnInitialized() { Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter; OnScannerService.OnNewScanSuccessful += HandleNewScanSuccessful; OnScannerService.OnErrorScan += OnErrorScan; _originalScheda = Scheda.Clone(); _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(); } private void LoadAttached() { if (Scheda.ImageNames == null) return; FileLoading = true; StateHasChanged(); Task.Run(async () => { var fileList = await FileManager.GetInspectionFiles( new Ispezione { CodMdep = CodMdep, Data = Data, Rilevatore = UserSession.User.Username }, Scheda.ImageNames ); await InvokeAsync(() => { AttachedList = fileList; FileLoading = false; StateHasChanged(); }); }); } private async Task Save() { VisibleOverlay = true; StateHasChanged(); // Salvataggio completo: la scheda non è più una bozza. Scheda.Bozza = false; SaveSchedaResponseDto? apiResponse = null; try { if (NetworkService.ConnectionAvailable) { apiResponse = await IntegrySteupService.SaveScheda( new SaveRequestDto { LocalIdScheda = Scheda.Id, ActivityId = Scheda.ActivityId, ActivityTypeId = Scheda.ActivityTypeId, CodJfas = Scheda.CodJfas, CodMdep = CodMdep, DataCreazione = Data, Note = Scheda.Note, PersonaRif = Scheda.Responsabile, Barcodes = Scheda.Articoli.ConvertAll(x => x.Barcode), Scandeza = (ScadenzaEnum)Scheda.Scadenza, ParentActivityId = SteupDataService.InspectionPageState.Ispezione.ActivityId } ); } } catch (Exception e) { var message = e.Message; Logger.LogError(e, message); Console.WriteLine(message); await Dialog.ShowError(message); } if (IsNew) await NewSave(apiResponse); else await Update(apiResponse); if (Scheda.ActivityId.IsValorized()) await UploadFile(Scheda); await FileManager.CleanTempStorageAsync(); SuccessAnimation = true; StateHasChanged(); await Task.Delay(1250); DisposeMessage(); MudDialog.Close(Scheda); } private async Task NewSave(SaveSchedaResponseDto? apiResponse) { if (!AttachedList.IsNullOrEmpty()) { foreach (var attached in AttachedList!) { var fileNameAdded = await FileManager.SaveInspectionFile( new Ispezione { CodMdep = CodMdep, Data = Data, Rilevatore = UserSession.User.Username }, attached.FileBytes!, attached.Name! ); Scheda.ImageNames ??= []; if (fileNameAdded != null) Scheda.ImageNames.Add(fileNameAdded); } } if (apiResponse != null) { await IspezioniService.UpdateActivityIdIspezioneAsync(CodMdep, Data, UserSession.User.Username, apiResponse.ActivityIdIspezione ); // Tiene in memoria l'ActivityId dell'ispezione: il prossimo salvataggio nella stessa // sessione lo userà come ParentActivityId invece di creare una nuova ispezione. SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione; Scheda.ActivityId = apiResponse.ActivityIdScheda; } await IspezioniService.AddSchedaAsync(CodMdep, Data, UserSession.User.Username, Scheda); } private async Task Update(SaveSchedaResponseDto? apiResponse, bool preserveActivityId = false) { if (!AttachedList.IsNullOrEmpty()) { foreach (var attached in AttachedList!.Where(x => !x.SavedOnAppData)) { var ispezione = new Ispezione { CodMdep = CodMdep, Data = Data, Rilevatore = UserSession.User.Username }; if (!attached.ToRemove) { var fileNameAdded = await FileManager.SaveInspectionFile( ispezione, attached.FileBytes!, attached.Name! ); Scheda.ImageNames ??= []; if (fileNameAdded != null) Scheda.ImageNames.Add(fileNameAdded); } else _ = FileManager.RemoveInspectionFile(ispezione, attached.Name!); } } // 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) { await IspezioniService.UpdateActivityIdIspezioneAsync(CodMdep, Data, UserSession.User.Username, apiResponse.ActivityIdIspezione ); SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione; } } private async Task UploadFile(Scheda scheda) { if (AttachedList.IsNullOrEmpty()) return; var ispezione = new Ispezione { CodMdep = CodMdep, Data = Data, Rilevatore = UserSession.User.Username }; foreach (var file in AttachedList!.Where(x => x.ToUpload)) { if (file.FileBytes == null || file.Name == null) continue; await IntegrySteupService.UploadFile(scheda.ActivityId!, file.FileBytes, file.Name); var newPath = await FileManager.MoveInspectionFileFromToUploadToFinal(ispezione, file.Name); if (newPath == null) continue; var filePathToRemove = FileManager.GetFileToUploadDir(ispezione, file.Name); scheda.ImageNames!.Remove(filePathToRemove); scheda.ImageNames.Add(newPath); } await IspezioniService.UpdateFileListSchedaAsync(scheda.Id, scheda.ImageNames); } private async Task Cancel() { if (_closing) return; _closing = true; try { if (await CheckSavePreAction()) { await FileManager.CleanTempStorageAsync(); DisposeMessage(); MudDialog.Cancel(); } } finally { _closing = false; } } #region Form /// Gli obbligatori si segnalano solo dopo il primo tentativo di salvataggio: /// un form che si tinge di rosso prima che tu abbia fatto qualcosa accusa /// l'utente di un errore che non ha ancora commesso. private bool _showErrors; private bool RepartoMancante => Scheda.Reparto is null; private bool MotivoMancante => Scheda.ActivityTypeId.IsNullOrEmpty(); /// I motivi disponibili dipendono dal reparto scelto. private List MotiviDelReparto => SteupDataService.TipiAttività .Where(x => x.CodJfas.EqualsIgnoreCase(Scheda.CodJfas!)) .Select(x => x.ActivityTypeId) .Where(x => !x.IsNullOrEmpty()) .Select(x => x!) .ToList(); private void OnRepartoChanged(JtbFasiDto? reparto) { if (reparto is null) return; Scheda.Reparto = reparto; // Cambiando reparto il motivo precedente non e' piu' fra quelli validi. Scheda.ActivityTypeId = null; OnAfterChangeValue(); } private void OnMotivoChanged(string? motivo) { Scheda.ActivityTypeId = motivo; OnAfterChangeValue(); } private void OnAfterChangeValue() { RecalcDirty(); StateHasChanged(); } private void RecalcDirty() { IsDirty = _attachmentsDirty || !ValueComparer.AreEqual(Scheda, _originalScheda) || !BarcodesUnchanged(); if (IsDirty) LabelSave = !IsNew ? "Aggiorna" : "Salva"; else LabelSave = null; } private bool BarcodesUnchanged() { if (Scheda.Articoli.Count != _originalBarcodes.Count) return false; for (var i = 0; i < _originalBarcodes.Count; i++) { if (!Scheda.Articoli[i].Barcode.Equals(_originalBarcodes[i], StringComparison.Ordinal)) return false; } return true; } private static class ValueComparer { public static bool AreEqual(Scheda? a, Scheda? b) { if (a is null || b is null) return a == b; return a.CodJfas == b.CodJfas && a.DescrizioneReparto == b.DescrizioneReparto && a.ActivityTypeId == b.ActivityTypeId && a.Note == b.Note && a.Responsabile == b.Responsabile && a.Scadenza == b.Scadenza; } } /// /// 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. /// private async Task CheckSavePreAction() { if (!IsDirty) 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(); if (result is true) await Submit(); else await SaveDraft(); VisibleOverlay = false; StateHasChanged(); return false; } /// /// Salva il lavoro in corso solo sul dispositivo, marcato come da completare. /// Niente chiamate al server e niente validazione: una bozza può essere incompleta. /// 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() { // Reparto e motivo non passano piu' dalla validazione di MudForm: sono // campi tappabili, non input. Si controllano qui, e l'errore si vede sul // gruppo interessato oltre che nel messaggio. _showErrors = true; await _form.Validate(); if (!RepartoMancante && !MotivoMancante && _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); StateHasChanged(); } #endregion #region File private async Task OpenAddAttached() { var selectImageFromCamera = await AttachedService.SelectImageFromCamera(); if (selectImageFromCamera == null) return; var attachedList = new List { selectImageFromCamera }; OnLoading = true; await InvokeAsync(StateHasChanged); await Task.Yield(); // prepara placeholder in UI subito (così vedi le card con spinner) AttachedList ??= []; foreach (var a in attachedList) AttachedList.Add(new AttachedDto { Name = a.Name, MimeType = a.MimeType, FileBytes = a.FileBytes }); await InvokeAsync(StateHasChanged); _attachmentsDirty = true; RecalcDirty(); // Processa in background e aggiorna UI man mano (o a blocchi) _ = Task.Run(async () => { for (var i = 0; i < attachedList.Count; i++) { var a = attachedList[i]; if (a.FileBytes is null || a.Name is null) continue; var (origUrl, thumbUrl) = await FileManager.SaveAndCreateThumbAsync(a.FileBytes, a.Name); await InvokeAsync(() => { var target = AttachedList![AttachedList.Count - attachedList.Count + i]; target.TempPath = origUrl; target.ThumbPath = thumbUrl; target.ToUpload = true; StateHasChanged(); }); } await InvokeAsync(() => { OnLoading = false; StateHasChanged(); }); }); } private void OpenSelectArt() { if (Scheda.CodJfas.IsNullOrEmpty()) { Snackbar.Clear(); Snackbar.Add("Selezionare prima il reparto", Severity.Error); return; } OnLoading = true; TextLoading = "Download articoli in griglia"; StateHasChanged(); _ = Task.Run(async () => { List? articoli; try { articoli = await IntegrySteupService.RetrieveGrigliaPlu( new RetrieveGrigliaPluRequestDto { CodMdep = CodMdep, ActivityTypeId = Scheda.ActivityTypeId, CodJfas = Scheda.CodJfas } ); } catch (Exception e) { Console.WriteLine(e.Message); await InvokeAsync(() => { OnLoading = false; TextLoading = null; StateHasChanged(); OnError(e, e.Message); }); return; } var modal = await WithChildDialog(() => Dialog.OpenSelectArt(articoli)); await InvokeAsync(() => { OnLoading = false; TextLoading = null; StateHasChanged(); }); if (modal is { Canceled: false, Data: List articoliSelezionati }) { Scheda.Articoli.InsertRange(0, articoliSelezionati.ConvertAll(x => new SchedaArticolo { Barcode = x.Barcode, Descrizione = x.Descrizione }) ); await InvokeAsync(() => { RecalcDirty(); StateHasChanged(); }); } }); } private void OnRemoveAttached(int index) { if (AttachedList is null || index < 0 || index >= AttachedList.Count) return; if (AttachedList[index].SavedOnAppData) AttachedList[index].ToRemove = true; AttachedList.RemoveAt(index); _attachmentsDirty = true; RecalcDirty(); StateHasChanged(); } #endregion private async Task SuggestActivityDescription() { if (Scheda.ActivityTypeId == null) { Snackbar.Add("Indicare prima il motivo", Severity.Error); return; } VisibleOverlay = true; StateHasChanged(); try { var activityDescriptions = await IntegryApiService.SuggestActivityDescription(Scheda.ActivityTypeId); var modal = await WithChildDialog(() => Dialog.OpenSuggestActivityDescription(activityDescriptions)); if (modal is { Canceled: false, Data: string descrizione } && !string.IsNullOrWhiteSpace(descrizione)) { Scheda.Note = descrizione; _noteFieldKey++; // ricrea il campo note così la nota selezionata compare subito a video // Aggiorna anche lo stato (dirty → Salva/Aggiorna) come farebbe l'input manuale. OnAfterChangeValue(); } } finally { VisibleOverlay = false; StateHasChanged(); } } #region Scanner private void OnManualBarcodeSet() { if (ManualBarcode == null) return; HandleNewScanSuccessful(ManualBarcode); ManualBarcode = null; } private async void HandleNewScanSuccessful(string? value) { try { if (value is null) return; if (Scheda.Articoli.Any(x => x.Barcode.Equals(value))) return; await InvokeAsync(() => { OnLoading = true; StateHasChanged(); }); var art = await IntegrySteupService.RetrieveArtFromBarcode(value); await InvokeAsync(() => { OnLoading = false; StateHasChanged(); if (art != null) { Scheda.Articoli.Insert(0, new SchedaArticolo { Barcode = art.Barcode, Descrizione = art.Descrizione }); RecalcDirty(); StateHasChanged(); } else { OnError(null, "Nessun articolo trovato"); } }); } catch (Exception e) { Console.WriteLine(e.Message); await InvokeAsync(() => { OnLoading = false; StateHasChanged(); }); OnError(e, e.Message); } } private void OnErrorScan(string? value) => OnError(new Exception(value), value); #endregion private void OnError(Exception? e, string? errorMessage) { if (e != null) Logger.LogError(e, errorMessage); if (errorMessage == null) return; _ = Dialog.ShowError(errorMessage); } private void RemoveArt(string barcode) { var index = Scheda.Articoli.FindIndex(x => x.Barcode.Equals(barcode)); if (index < 0) return; Scheda.Articoli.RemoveAt(index); RecalcDirty(); StateHasChanged(); } private void DisposeMessage() { 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(); } }