Migliora IspezionePage e form scheda (design review impeccable)

Ispezione:
- Stato sync visibile: badge per-scheda (Da inviare/Inviato) e contatore
  "N da inviare" per reparto (nuovo SchedaSyncHelper)
- Completamento: progresso reale nell'overlay + spunta di successo;
  NON completa se una scheda fallisce (resta editabile, schede "Da inviare")
- Export reparto/completamento blindati: conferma + guardia anti doppio-tap,
  try/finally che libera sempre l'overlay
- Accento corallo unico per l'azione "invia"; target di tocco >=44px;
  empty state; solo il primo pannello espanso di default
- Tasto "Invia" nascosto sulle schede gia inviate
- Messaggio d'errore comprensibile in campo (dettaglio tecnico nei log)
- Fix OnSchedaModified (index >= 0)

Aspetto card:
- SchedaCard e InspectionCard: superficie Carta + hairline border + ombra
  morbida (nuovo token --card-box-shadow); pagina con tinta Grigio Campo
- SchedaCard compattata

Form scheda (ModalFormScheda):
- Tasto Salva/Aggiorna full-width
- Fix "Suggerisci note descrittive": la nota selezionata ora viene inserita
  nel campo (metodo async sul thread UI + OnAfterChangeValue)

StatusEnum: etichette in sentence-case

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 16:39:20 +02:00
parent 9f4088eff3
commit c454474f49
13 changed files with 647 additions and 152 deletions
+176 -70
View File
@@ -3,6 +3,7 @@
@using SteUp.Shared.Components.Layout
@using SteUp.Shared.Components.Layout.Overlay
@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
@@ -28,36 +29,47 @@
@if (!SchedeGrouped.IsNullOrEmpty())
{
<MudExpansionPanels MultiExpansion="true">
@{
var panelIndex = 0;
}
@foreach (var group in SchedeGrouped)
{
<MudExpansionPanel Class="expansion-panel" Expanded="true" Text="@group.Key.Descrizione">
var isFirstPanel = panelIndex == 0;
panelIndex++;
<MudExpansionPanel Class="expansion-panel" Expanded="isFirstPanel" Text="@group.Key.Descrizione">
<TitleContent>
@{
var daInviare = group.Value.CountDaInviare();
}
<div class="header-scheda-group">
<div class="title">
<MudText Typo="Typo.h6"><b>@group.Key.Descrizione</b></MudText>
<MudChip T="string" Size="Size.Small" Color="Color.Default">
@($"{group.Value.Count} sched{(group.Value.Count == 1 ? "a" : "e")}")
</MudChip>
@if (daInviare > 0)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
Color="Color.Primary" Icon="@Icons.Material.Rounded.PhonelinkOff">
@($"{daInviare} da inviare")
</MudChip>
}
</div>
@if (SteupDataService.InspectionPageState.Ispezione.Stato != StatusEnum.Completata)
{
<div class="action-scheda-group">
@if (NetworkService.IsNetworkAvailable())
{
<MudFab StartIcon="@Icons.Material.Rounded.Add"
Color="Color.Warning" Size="Size.Small"
OnClick="@(() => CreateNewScheda(group.Key))"/>
<MudFab StartIcon="@Icons.Material.Rounded.Add"
Color="Color.Default" Size="Size.Medium"
Disabled="!NetworkService.IsNetworkAvailable()"
aria-label="@($"Nuova scheda su {group.Key.Descrizione}")"
OnClick="@(() => CreateNewScheda(group.Key))"/>
<MudFab StartIcon="@Icons.Material.Rounded.CloudSync" Label="Esporta reparto"
Color="Color.Success" Size="Size.Small"
OnClick="@(() => ExportReparto(group.Key))"/>
}
else
@if (NetworkService.IsNetworkAvailable() && daInviare > 0)
{
<MudFab StartIcon="@Icons.Material.Rounded.Add"
Label="@($"Nuova scheda su {group.Key.Descrizione}")"
Color="Color.Warning" Size="Size.Medium"
OnClick="@(() => CreateNewScheda(group.Key))"/>
<MudFab StartIcon="@Icons.Material.Rounded.CloudUpload" Label="Esporta reparto"
Color="Color.Primary" Size="Size.Medium"
aria-label="@($"Esporta il reparto {group.Key.Descrizione} al server")"
OnClick="@(() => ExportReparto(group.Key))"/>
}
</div>
}
@@ -75,15 +87,35 @@
}
</MudExpansionPanels>
}
else
{
<div class="empty-state">
<MudIcon Icon="@Icons.Material.Rounded.AssignmentLate" Class="empty-state-icon" Size="Size.Large"/>
<p class="empty-state-title">Nessuna scheda ancora</p>
@if (SteupDataService.InspectionPageState.Ispezione.Stato != StatusEnum.Completata)
{
<p class="empty-state-text">
Tocca <b>+</b> in basso per creare la prima scheda di questa ispezione.
</p>
}
</div>
}
</div>
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
<SpinnerOverlay VisibleOverlay="VisibleOverlay" SuccessAnimation="_success" Message="@_progressMessage"/>
<ConfirmMessageBox @ref="_exportRepartoBox" NoText="Annulla" YesText="Esporta" YesColor="Color.Primary"
Message="Le schede 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!;
protected override void OnInitialized()
{
@@ -110,6 +142,10 @@
private async void HandleCompleteInspection()
{
// Guardia anti doppio-invio: il completamento è irreversibile.
if (_isBusy) return;
_isBusy = true;
try
{
await InvokeAsync(() =>
@@ -120,8 +156,16 @@
var ispezione = SteupDataService.InspectionPageState.Ispezione;
foreach (var scheda in ispezione.Schede.Where(x => x.ActivityId == null))
var daInviare = ispezione.Schede.Where(x => x.ActivityId == null).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
{
@@ -138,7 +182,12 @@
}
);
if (apiResponse == null) continue;
// 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);
@@ -149,20 +198,47 @@
ispezione.Rilevatore,
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,
@@ -170,29 +246,42 @@
ispezione.Rilevatore,
StatusEnum.Completata
);
SteupDataService.InspectionPageState.Ispezione.Stato = StatusEnum.Completata;
// Riscontro al momento più importante: la spunta di successo prima di chiudere l'overlay.
await InvokeAsync(() =>
{
VisibleOverlay = false;
_progressMessage = "Ispezione inviata";
_success = true;
StateHasChanged();
});
await Task.Delay(1800);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
OnError(e, e.Message);
}
finally
{
_isBusy = false;
_success = false;
_progressMessage = null;
await InvokeAsync(() =>
{
VisibleOverlay = false;
StateHasChanged();
});
OnError(e, e.Message);
}
}
private Task SetProgress(string message) =>
InvokeAsync(() =>
{
_progressMessage = message;
StateHasChanged();
});
private void GroupSchede()
{
SchedeGrouped = SchedeList
@@ -207,7 +296,7 @@
private void OnSchedaModified(Scheda obj)
{
var index = SchedeList.FindIndex(x => x.Id.Equals(obj.Id));
if (index > 0) SchedeList[index] = obj;
if (index >= 0) SchedeList[index] = obj;
GroupSchede();
StateHasChanged();
@@ -234,66 +323,83 @@
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();
var ispezione = SteupDataService.InspectionPageState.Ispezione;
var saveRequest = SchedeGrouped[jtbFasi].ConvertAll(x =>
try
{
return new SaveRequestDto
var ispezione = SteupDataService.InspectionPageState.Ispezione;
var saveRequest = SchedeGrouped[jtbFasi].ConvertAll(x =>
{
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,
ParentActivityId = x.Ispezione?.ActivityId
};
});
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,
ParentActivityId = x.Ispezione?.ActivityId
};
});
var apiResponse = await IntegrySteupService.SaveMultipleSchede(saveRequest);
var apiResponse = await IntegrySteupService.SaveMultipleSchede(saveRequest);
if (apiResponse != null)
{
SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione;
await IspezioniService.UpdateActivityIdIspezioneAsync(ispezione.CodMdep, ispezione.Data,
UserSession.User.Username, apiResponse.ActivityIdIspezione
);
if (apiResponse.ActivityIdSchedaList.IsNullOrEmpty()) return;
foreach (var scheda in SchedeGrouped[jtbFasi])
if (apiResponse != null)
{
scheda.ActivityId = apiResponse.ActivityIdScheda;
await IspezioniService.UpdateActivityIdSchedaAsync(scheda.Id, scheda.ActivityId);
SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione;
var activityId = apiResponse.ActivityIdSchedaList!.Find(x =>
x.LocalId != null && x.LocalId == scheda.Id
)?.ActivityId;
await IspezioniService.UpdateActivityIdIspezioneAsync(ispezione.CodMdep, ispezione.Data,
UserSession.User.Username, apiResponse.ActivityIdIspezione
);
if (activityId == null) return;
if (!apiResponse.ActivityIdSchedaList.IsNullOrEmpty())
{
foreach (var scheda in SchedeGrouped[jtbFasi])
{
var activityId = apiResponse.ActivityIdSchedaList!.Find(x =>
x.LocalId != null && x.LocalId == scheda.Id
)?.ActivityId;
scheda.ActivityId = activityId;
await IspezioniService.UpdateActivityIdSchedaAsync(scheda.Id, scheda.ActivityId);
if (activityId == null) continue;
scheda.ActivityId = activityId;
await IspezioniService.UpdateActivityIdSchedaAsync(scheda.Id, scheda.ActivityId);
}
}
}
}
VisibleOverlay = false;
StateHasChanged();
catch (Exception e)
{
OnError(e, e.Message);
}
finally
{
_isBusy = false;
VisibleOverlay = false;
StateHasChanged();
}
}
private void OnError(Exception? e, string? errorMessage)
private void OnError(Exception? e, string? technicalMessage)
{
if (e != null) Logger.LogError(e, errorMessage);
if (errorMessage == null) return;
// Il dettaglio tecnico va nei log, non davanti al rilevatore in campo.
if (e != null) Logger.LogError(e, technicalMessage);
_ = Dialog.ShowError(errorMessage);
_ = Dialog.ShowError(
"Invio non riuscito. Il lavoro resta salvato sul dispositivo: controlla la connessione e riprova."
);
}
void IDisposable.Dispose()
@@ -1,4 +1,9 @@
.ispezione ::deep .expansion-panel .mud-expand-panel-content {
/* Tinta di fondo tenue: le card e i pannelli bianchi (Carta) risaltano sul Grigio Campo */
.ispezione {
background-color: var(--light-card-background);
}
.ispezione ::deep .expansion-panel .mud-expand-panel-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
@@ -7,5 +12,36 @@
.action-scheda-group {
display: flex;
align-items: center;
justify-content: space-around;
justify-content: flex-end;
gap: .75rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: .75rem;
margin-top: 3rem;
padding: 0 1.5rem;
}
.empty-state .empty-state-icon {
color: var(--mud-palette-primary);
opacity: .85;
}
.empty-state .empty-state-title {
margin: 0;
font-weight: 800;
font-size: 1.25rem;
color: var(--mud-palette-text-primary);
}
.empty-state .empty-state-text {
margin: 0;
max-width: 34ch;
font-weight: 600;
line-height: 1.5;
color: var(--mud-palette-gray-darker);
}