Finora la foto si poteva togliere solo prima di salvare la scheda. - La X compare su tutte le foto (non in sola lettura, non mentre la foto e' ancora in elaborazione). L'eliminazione avviene al salvataggio: uscendo senza salvare le foto restano. - Una foto gia' inviata si toglie prima dal server (nuovo endpoint steup/removeAttachment), poi dal dispositivo e da Scheda.ImageNames. Senza rete la X avvisa e non la toglie; se il server non conferma al salvataggio la foto resta ovunque e compare un avviso. - Una foto salvata ma non ancora inviata si elimina dal dispositivo (con la rete si prova anche a toglierla dal server, senza bloccare). - Togliere una foto salvata non la cancellava mai: il flag ToRemove finiva su un elemento appena rimosso dall'elenco. Ora le foto tolte stanno in una lista a parte fino al salvataggio; ToRemove e' stato eliminato. - FileManager.GetInspectionFiles: le foto in /toUpload si filtravano per nome, ma ImageNames contiene il percorso completo, quindi quelle salvate senza rete non ricomparivano mai riaprendo la scheda. Ora si accetta sia il percorso sia il nome, e il DTO porta anche i byte del file per l'invio. Richiede i Servizi con steup/removeAttachment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1165 lines
44 KiB
Plaintext
1165 lines
44 KiB
Plaintext
@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<ModalFormScheda> 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.
|
|
*@
|
|
|
|
<MudDialog Class="customDialog-form">
|
|
<DialogContent>
|
|
<MudForm ReadOnly="ReadOnly" @ref="_form">
|
|
<div class="app-shell">
|
|
|
|
<HeaderLayout Title="@(IsNew ? "Nuova scheda" : ReadOnly ? "Scheda" : "Modifica scheda")"
|
|
Subtitle="@Scheda.DescrizioneReparto"
|
|
Cancel="true" OnCancel="@Cancel" Compact="true"/>
|
|
|
|
<div class="app-scroll">
|
|
<div class="page-width stack stack-10" style="padding-bottom: var(--space-lg);">
|
|
|
|
@if (IsBozza)
|
|
{
|
|
<div class="feedback feedback--warning" role="status">
|
|
<i class="feedback__icon ri-edit-line" aria-hidden="true"></i>
|
|
<span>Questa scheda è una bozza: completala per poterla inviare.</span>
|
|
</div>
|
|
}
|
|
|
|
<CardFormModal Title="Reparto" Loading="SteupDataService.Reparti.IsNullOrEmpty()"
|
|
Class="@(_showErrors && RepartoMancante ? "form-group--invalid" : "")">
|
|
<SelectField TValue="JtbFasiDto" Title="Reparto"
|
|
Items="SteupDataService.Reparti"
|
|
ItemLabel="@(x => x.Descrizione)"
|
|
Value="Scheda.Reparto" ValueChanged="OnRepartoChanged"
|
|
Placeholder="Scegli il reparto" ReadOnly="ReadOnly"/>
|
|
|
|
@if (_showErrors && RepartoMancante)
|
|
{
|
|
<p class="form-error" role="alert">
|
|
<i class="ri-error-warning-line" aria-hidden="true"></i>
|
|
Indica il reparto
|
|
</p>
|
|
}
|
|
</CardFormModal>
|
|
|
|
<CardFormModal Title="Motivo" Loading="SteupDataService.TipiAttività.IsNullOrEmpty()"
|
|
Class="@(_showErrors && MotivoMancante ? "form-group--invalid" : "")">
|
|
@if (Scheda.CodJfas.IsNullOrEmpty())
|
|
{
|
|
<p class="t-meta">Scegli prima il reparto.</p>
|
|
}
|
|
else
|
|
{
|
|
<SelectField TValue="string" Title="Motivo"
|
|
Subtitle="@Scheda.DescrizioneReparto"
|
|
Items="MotiviDelReparto"
|
|
ItemLabel="@(x => x)"
|
|
Value="Scheda.ActivityTypeId" ValueChanged="OnMotivoChanged"
|
|
Placeholder="Scegli il motivo" ReadOnly="ReadOnly"/>
|
|
|
|
@if (_showErrors && MotivoMancante)
|
|
{
|
|
<p class="form-error" role="alert">
|
|
<i class="ri-error-warning-line" aria-hidden="true"></i>
|
|
Indica il motivo
|
|
</p>
|
|
}
|
|
}
|
|
</CardFormModal>
|
|
|
|
@* --- Articoli: il compito primario della schermata --- *@
|
|
<CardFormModal Title="Articoli" Count="@ArticoliCount">
|
|
@if (!ReadOnly)
|
|
{
|
|
<div class="barcode-field">
|
|
<input type="text" inputmode="numeric" placeholder="Digita o scansiona un codice"
|
|
aria-label="Codice a barre"
|
|
value="@ManualBarcode" @oninput="OnManualBarcodeInput"
|
|
@onkeydown="OnManualBarcodeKey"/>
|
|
<button type="button" class="barcode-field__send"
|
|
disabled="@(ManualBarcode.IsNullOrEmpty())"
|
|
aria-label="Aggiungi il codice" @onclick="OnManualBarcodeSet">
|
|
<i class="ri-arrow-right-line" aria-hidden="true"></i>
|
|
</button>
|
|
</div>
|
|
}
|
|
|
|
@if (!Scheda.Articoli.IsNullOrEmpty())
|
|
{
|
|
<div class="art-list" style="margin-top: var(--space-sm);">
|
|
@foreach (var articolo in Scheda.Articoli)
|
|
{
|
|
<div class="art-item" @key="articolo.Barcode">
|
|
<div class="art-item__text">
|
|
<div class="art-item__name">@articolo.Descrizione</div>
|
|
<div class="art-item__code selectable">@articolo.Barcode</div>
|
|
</div>
|
|
@if (!ReadOnly)
|
|
{
|
|
<button type="button" class="art-item__remove"
|
|
aria-label="@($"Rimuovi {articolo.Descrizione}")"
|
|
@onclick="@(() => RemoveArt(articolo.Barcode))">
|
|
<i class="ri-close-line" aria-hidden="true"></i>
|
|
</button>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
else if (ReadOnly)
|
|
{
|
|
<p class="t-meta">Nessun articolo.</p>
|
|
}
|
|
|
|
@if (NetworkService.ConnectionAvailable && !ReadOnly)
|
|
{
|
|
<button type="button" class="action-tile" style="margin-top: var(--space-sm);"
|
|
@onclick="@OpenSelectArt">
|
|
<span class="action-tile__icon action-tile__icon--neutral">
|
|
<i class="ri-list-check-2" aria-hidden="true"></i>
|
|
</span>
|
|
<span class="action-tile__label">Scegli dalla griglia</span>
|
|
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
|
|
</button>
|
|
}
|
|
</CardFormModal>
|
|
|
|
@* --- Foto --- *@
|
|
<CardFormModal Title="Foto" Count="@FotoCount" Loading="FileLoading">
|
|
@if (!AttachedList.IsNullOrEmpty())
|
|
{
|
|
<div class="attach-strip">
|
|
@foreach (var item in AttachedList!.Select((p, index) => new { p, index }))
|
|
{
|
|
<div class="attach-item">
|
|
@if (!item.p.ThumbPath.IsNullOrEmpty())
|
|
{
|
|
<img class="attach-item__img" src="@item.p.ThumbPath"
|
|
alt="@item.p.Name"/>
|
|
}
|
|
else
|
|
{
|
|
<div class="attach-item__placeholder">
|
|
<span class="spinner" aria-label="Elaborazione"></span>
|
|
</div>
|
|
}
|
|
|
|
@* Rimovibile anche se gia' salvata o inviata. Non mentre e'
|
|
in elaborazione: l'indice della foto serve ancora al
|
|
caricamento in background. *@
|
|
@if (!ReadOnly && !item.p.ThumbPath.IsNullOrEmpty())
|
|
{
|
|
<button type="button" class="attach-item__remove"
|
|
aria-label="@($"Rimuovi la foto {item.p.Name}")"
|
|
@onclick="@(() => OnRemoveAttached(item.index))">
|
|
<i class="ri-close-line" aria-hidden="true"></i>
|
|
</button>
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
else if (ReadOnly)
|
|
{
|
|
<p class="t-meta">Nessuna foto.</p>
|
|
}
|
|
|
|
@if (!ReadOnly)
|
|
{
|
|
<button type="button" class="action-tile" style="margin-top: var(--space-sm);"
|
|
@onclick="@OpenAddAttached">
|
|
<span class="action-tile__icon action-tile__icon--primary">
|
|
<i class="ri-camera-line" aria-hidden="true"></i>
|
|
</span>
|
|
<span class="action-tile__label">Aggiungi una foto</span>
|
|
</button>
|
|
}
|
|
</CardFormModal>
|
|
|
|
@* --- Urgenza: pillole invece di un menu a tendina --- *@
|
|
<CardFormModal Title="Entro quando">
|
|
<div class="option-row">
|
|
@foreach (var opzione in ScadenzaOptions)
|
|
{
|
|
<button type="button" class="option"
|
|
disabled="@ReadOnly"
|
|
aria-pressed="@(Scheda.Scadenza == opzione.Value ? "true" : "false")"
|
|
@onclick="@(() => SetScadenza(opzione.Value))">
|
|
@opzione.Label
|
|
</button>
|
|
}
|
|
</div>
|
|
</CardFormModal>
|
|
|
|
<CardFormModal Title="Persona di riferimento">
|
|
<MudTextField FullWidth="true" T="string?" Variant="Variant.Text"
|
|
Placeholder="Nome di chi ha seguito il controllo"
|
|
@bind-Value="Scheda.Responsabile" @bind-Value:after="OnAfterChangeValue"
|
|
DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/>
|
|
</CardFormModal>
|
|
|
|
<CardFormModal Title="Note">
|
|
<MudTextField @key="_noteFieldKey" T="string?" Variant="Variant.Text" Lines="3"
|
|
Placeholder="Cosa hai rilevato"
|
|
@bind-Value="Scheda.Note" @bind-Value:after="OnAfterChangeValue"
|
|
DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/>
|
|
|
|
@if (!ReadOnly)
|
|
{
|
|
<button type="button" class="action-tile" style="margin-top: var(--space-sm);"
|
|
@onclick="@SuggestActivityDescription">
|
|
<span class="action-tile__icon action-tile__icon--info">
|
|
<i class="ri-lightbulb-line" aria-hidden="true"></i>
|
|
</span>
|
|
<span class="action-tile__label">Usa una nota ricorrente</span>
|
|
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
|
|
</button>
|
|
}
|
|
</CardFormModal>
|
|
</div>
|
|
</div>
|
|
|
|
@if (!ReadOnly)
|
|
{
|
|
@* Barra azioni ancorata: il salvataggio non si cerca mai. *@
|
|
<div class="action-bar">
|
|
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
|
|
Disabled="@(!IsDirty)" OnClick="@Submit">
|
|
@(LabelSave ?? (IsNew ? "Salva" : "Aggiorna"))
|
|
</MudButton>
|
|
</div>
|
|
}
|
|
</div>
|
|
</MudForm>
|
|
|
|
<ConfirmUpdateActivity @ref="_confirmUpdateMessage"/>
|
|
</DialogContent>
|
|
</MudDialog>
|
|
|
|
<SpinnerOverlay VisibleOverlay="VisibleOverlay" SuccessAnimation="SuccessAnimation"/>
|
|
<SpinnerOverlay VisibleOverlay="OnLoading" Message="@TextLoading"/>
|
|
|
|
@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<string> _originalBarcodes = [];
|
|
// Gli allegati vivono in AttachedList finche' non si salva: li tracciamo con un flag.
|
|
private bool _attachmentsDirty;
|
|
private List<AttachedDto>? AttachedList { get; set; }
|
|
// Foto gia' salvate tolte dal rilevatore: si eliminano davvero solo al salvataggio,
|
|
// cosi' uscendo senza salvare restano dove sono.
|
|
private readonly List<AttachedDto> _removedAttachments = [];
|
|
|
|
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;
|
|
|
|
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)
|
|
{
|
|
var ispezione = new Ispezione
|
|
{
|
|
CodMdep = CodMdep,
|
|
Data = Data,
|
|
Rilevatore = UserSession.User.Username
|
|
};
|
|
|
|
// Prima di toccare l'ActivityId: le foto gia' inviate vanno tolte dall'attivita'
|
|
// su cui sono state caricate.
|
|
await RemoveDeletedAttachments(ispezione);
|
|
|
|
if (!AttachedList.IsNullOrEmpty())
|
|
{
|
|
foreach (var attached in AttachedList!.Where(x => !x.SavedOnAppData))
|
|
{
|
|
var fileNameAdded = await FileManager.SaveInspectionFile(
|
|
ispezione,
|
|
attached.FileBytes!,
|
|
attached.Name!
|
|
);
|
|
|
|
Scheda.ImageNames ??= [];
|
|
if (fileNameAdded != null)
|
|
Scheda.ImageNames.Add(fileNameAdded);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Elimina le foto salvate che il rilevatore ha tolto dalla scheda: dal server se
|
|
/// erano gia' state inviate, poi dal dispositivo e dall'elenco della scheda.
|
|
/// Se il server non conferma, la foto resta (sul server e sul dispositivo) invece
|
|
/// di sparire solo da qui.
|
|
/// </summary>
|
|
private async Task RemoveDeletedAttachments(Ispezione ispezione)
|
|
{
|
|
if (_removedAttachments.Count == 0) return;
|
|
|
|
var nonEliminate = 0;
|
|
|
|
foreach (var attached in _removedAttachments)
|
|
{
|
|
if (attached.Name.IsNullOrEmpty()) continue;
|
|
|
|
var giaInviata = !attached.ToUpload;
|
|
|
|
// Una foto ancora "da inviare" di norma non e' sul server, ma l'invio dalla
|
|
// card o dalla chiusura ispezione non la sposta fra le inviate: con la rete
|
|
// si prova comunque a toglierla, senza bloccare l'eliminazione se non c'e'.
|
|
if (Scheda.ActivityId.IsValorized() && (giaInviata || NetworkService.ConnectionAvailable))
|
|
{
|
|
try
|
|
{
|
|
await IntegrySteupService.RemoveFile(Scheda.ActivityId!, attached.Name!);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
if (giaInviata)
|
|
{
|
|
Logger.LogError(e, "Eliminazione della foto {FileName} dal server non riuscita", attached.Name);
|
|
nonEliminate++;
|
|
continue;
|
|
}
|
|
|
|
Logger.LogWarning(e, "Foto {FileName} non trovata sul server", attached.Name);
|
|
}
|
|
}
|
|
|
|
try
|
|
{
|
|
FileManager.RemoveInspectionFile(ispezione, attached.Name!);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
// Il file orfano non fa danni: conta che la foto esca dalla scheda.
|
|
Logger.LogError(e, "Eliminazione del file {FileName} non riuscita", attached.Name);
|
|
}
|
|
|
|
Scheda.ImageNames?.RemoveAll(x => System.IO.Path.GetFileName(x) == attached.Name);
|
|
}
|
|
|
|
_removedAttachments.Clear();
|
|
|
|
if (nonEliminate > 0)
|
|
{
|
|
Snackbar.Add(nonEliminate == 1
|
|
? "Una foto già inviata non è stata eliminata: riprova con la connessione"
|
|
: $"{nonEliminate} foto già inviate non sono state eliminate: riprova con la connessione",
|
|
Severity.Warning);
|
|
}
|
|
}
|
|
|
|
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<string> 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;
|
|
}
|
|
}
|
|
|
|
/// <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 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Salva il lavoro in corso solo sul dispositivo, marcato come da completare.
|
|
/// Niente salvataggio sul server e niente validazione: una bozza può essere incompleta.
|
|
/// Unica eccezione: le foto già inviate tolte dal rilevatore si eliminano anche dal server.
|
|
/// </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()
|
|
{
|
|
// 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
|
|
|
|
// Dopo un rifiuto definitivo Android non ripropone la richiesta: il
|
|
// permesso si riattiva solo dalle impostazioni, quindi le apriamo noi.
|
|
private void ShowCameraPermissionDenied()
|
|
{
|
|
Snackbar.Clear();
|
|
Snackbar.Add("Per scattare le foto serve il permesso della fotocamera", Severity.Warning, config =>
|
|
{
|
|
config.Action = "Impostazioni";
|
|
config.OnClick = _ =>
|
|
{
|
|
AttachedService.OpenAppSettings();
|
|
return Task.CompletedTask;
|
|
};
|
|
});
|
|
}
|
|
|
|
private async Task OpenAddAttached()
|
|
{
|
|
List<AttachedDto>? attachedList;
|
|
|
|
if (AttachedService.IsCameraAvailable)
|
|
{
|
|
if (!await AttachedService.RequestCameraPermissions())
|
|
{
|
|
ShowCameraPermissionDenied();
|
|
return;
|
|
}
|
|
|
|
AttachedDto? selectImageFromCamera;
|
|
try
|
|
{
|
|
selectImageFromCamera = await AttachedService.SelectImageFromCamera();
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Gia' registrato e inviato a Sentry dal servizio
|
|
Snackbar.Clear();
|
|
Snackbar.Add("Non è stato possibile scattare la foto, riprova", Severity.Error);
|
|
return;
|
|
}
|
|
|
|
if (selectImageFromCamera == null) return;
|
|
attachedList = [selectImageFromCamera];
|
|
}
|
|
else
|
|
{
|
|
// Senza fotocamera "Aggiungi una foto" non avrebbe altra strada:
|
|
// si passa alla galleria invece di lasciare il rilevatore fermo.
|
|
Snackbar.Clear();
|
|
Snackbar.Add("Fotocamera non disponibile: scegli la foto dalla galleria", Severity.Warning);
|
|
|
|
attachedList = await AttachedService.SelectImageFromGallery();
|
|
if (attachedList is null || attachedList.Count == 0) return;
|
|
}
|
|
|
|
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<ArticoliInGrigliaDto>? 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));
|
|
|
|
// Gli articoli si aggiungono sul thread della UI, come fanno le scansioni:
|
|
// toccare Scheda.Articoli da qui mentre arriva una lettura (o mentre la
|
|
// lista viene disegnata) puo' far perdere articoli.
|
|
await InvokeAsync(() =>
|
|
{
|
|
OnLoading = false;
|
|
TextLoading = null;
|
|
|
|
if (modal is { Canceled: false, Data: List<ArticoliInGrigliaDto> articoliSelezionati })
|
|
{
|
|
// Quelli gia' sparati non si aggiungono di nuovo.
|
|
Scheda.Articoli.InsertRange(0, articoliSelezionati
|
|
.DistinctBy(x => x.Barcode)
|
|
.Where(x => !ContainsBarcode(x.Barcode))
|
|
.Select(x => new SchedaArticolo
|
|
{
|
|
Barcode = x.Barcode,
|
|
Descrizione = x.Descrizione
|
|
})
|
|
);
|
|
|
|
RecalcDirty();
|
|
}
|
|
|
|
StateHasChanged();
|
|
});
|
|
});
|
|
}
|
|
|
|
private void OnRemoveAttached(int index)
|
|
{
|
|
if (AttachedList is null || index < 0 || index >= AttachedList.Count) return;
|
|
|
|
var attached = AttachedList[index];
|
|
|
|
// Le foto appena scattate stanno solo in memoria: basta toglierle dall'elenco.
|
|
if (attached.SavedOnAppData)
|
|
{
|
|
// Una foto gia' inviata va tolta anche dal server: senza rete resterebbe
|
|
// nella scheda inviata pur sparendo dal dispositivo.
|
|
if (!attached.ToUpload && !NetworkService.ConnectionAvailable)
|
|
{
|
|
Snackbar.Clear();
|
|
Snackbar.Add("Per eliminare una foto già inviata serve la connessione", Severity.Warning);
|
|
return;
|
|
}
|
|
|
|
_removedAttachments.Add(attached);
|
|
}
|
|
|
|
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 (ContainsBarcode(value)) return;
|
|
|
|
await InvokeAsync(() =>
|
|
{
|
|
OnLoading = true;
|
|
StateHasChanged();
|
|
});
|
|
|
|
var art = await IntegrySteupService.RetrieveArtFromBarcode(value);
|
|
|
|
await InvokeAsync(() =>
|
|
{
|
|
OnLoading = false;
|
|
StateHasChanged();
|
|
|
|
// Il controllo va ripetuto sul barcode restituito: il server puo' rispondere
|
|
// con un barcode diverso da quello letto (lo stesso gia' scelto dalla griglia),
|
|
// e due letture ravvicinate superano entrambe il controllo iniziale.
|
|
if (art != null && ContainsBarcode(art.Barcode)) return;
|
|
|
|
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);
|
|
|
|
private bool ContainsBarcode(string barcode) => Scheda.Articoli.Any(x => x.Barcode.Equals(barcode));
|
|
|
|
#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();
|
|
}
|
|
|
|
} |