Rifacimento della UI secondo STILE_UI.md

Adotta lo stile condiviso delle app Integry: superfici piatte, raggi ampi,
spaziature a scala fissa, gerarchia data dallo spazio prima che dalla
decorazione. La palette storica di SteUP resta intatta; sono stati aggiunti
solo i ruoli che mancavano (workspace, surfaceMuted, divider, inkMuted e le
triplette dei toni semantici).

Fondamenta
- tokens.css: ruoli colore, scale di spazio/raggi/tipografia, curve di
  movimento e scala z-index semantica. Unica fonte di verita': pilotano
  anche le variabili CSS di MudBlazor.
- Inter variabile ospitata in locale (133KB) al posto di Nunito da Google
  Fonts: l'app lavora offline, un font che arriva dalla rete in campo non
  arriva.
- base.css: shell applicativa e comportamenti da app nativa — niente
  selezione testo, niente menu al tocco lungo, niente lampo grigio sul tap,
  niente rimbalzo elastico, niente zoom a doppio tocco, scroll con inerzia.
- mudblazor-overrides.css: MudBlazor appiattito (elevation 0, nessun tint,
  target 44/48, etichette in caso naturale).
- Rimossi Bootstrap (CSS, icone e JS) e i 996 righe di CSS con ambito per
  componente del vecchio sistema: erano tutti inutilizzati.

Struttura
- Navigazione flottante in basso con icona piena sulla voce attiva; da 720px
  diventa colonna laterale. Il MudFabMenu e' sostituito da un bottom sheet:
  stessa funzione, bersagli molto piu' grandi.
- Testata alta col titolo a sinistra, fusa con la pagina.
- Nel form scheda il salvataggio sta in una barra ancorata in fondo, non in
  coda a uno scroll lungo.
- Scelta di punto vendita e articoli a schermo intero con ricerca; la
  selezione multipla usa righe da 56px invece di un menu a tendina.
- Urgenza scelta a pillole invece che da una select.

Stato e riscontro
- StatusBadgeHelper: tono, icona ed etichetta di ogni stato in un punto solo,
  cosi' lo stesso stato si presenta identico ovunque.
- Scheletri al posto degli spinner; l'overlay bloccante dice sempre cosa sta
  facendo; gli stati vuoti suggeriscono la mossa successiva.
- Rimosso il ritardo artificiale di 250ms in InspectionCard, che non
  attendeva nulla.

DESIGN.md riscritto sul nuovo sistema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 11:09:26 +02:00
parent 3a9174f6fc
commit 2a0054aa30
77 changed files with 4595 additions and 3060 deletions
@@ -1,67 +1,56 @@
@inject IJSRuntime Js
<div class="@(Back ? "" : "container") header">
<div class="header-content @(Back ? "with-back" : "no-back")">
@if (!SmallHeader)
{
if (Back)
{
<div class="left-section">
<MudButton StartIcon="@(!Cancel ? Icons.Material.Outlined.ArrowBackIosNew : "")"
OnClick="@GoBack"
Color="Color.Info"
Style="text-transform: none"
Variant="Variant.Text">
@BackTo
</MudButton>
</div>
}
@*
Testata di schermata (STILE_UI §5): alta, titolo a sinistra, sfondo uguale
alla pagina. Non e' una AppBar colorata col titolo centrato: quella e' una
firma Android di serie, e il documento la esclude esplicitamente.
*@
<h3 class="page-title">@Title</h3>
<header class="appbar @(Compact ? "appbar--compact" : "")">
@if (Back || Cancel)
{
<div class="appbar__lead">
<MudIconButton Icon="@(Cancel ? Icons.Material.Rounded.Close : Icons.Material.Rounded.ArrowBack)"
Color="Color.Inherit"
aria-label="@(Cancel ? "Chiudi" : "Indietro")"
title="@(Cancel ? "Chiudi" : "Indietro")"
OnClick="@GoBack"/>
</div>
}
<div class="right-section">
@if (!LabelSave.IsNullOrEmpty())
{
<MudButton OnClick="@OnSave"
Color="Color.Info"
Style="text-transform: none"
Variant="Variant.Text">
@LabelSave
</MudButton>
}
</div>
}
else
<div class="appbar__titles">
<h1 class="appbar__title">@Title</h1>
@if (!string.IsNullOrWhiteSpace(Subtitle))
{
<div class="title">
<MudText Typo="Typo.h6">
<b>@Title</b>
</MudText>
<MudIconButton Icon="@Icons.Material.Rounded.Close" OnClick="@GoBack" />
</div>
<p class="appbar__subtitle">@Subtitle</p>
}
</div>
</div>
@code{
[Parameter] public string? Title { get; set; }
[Parameter] public bool Back { get; set; }
[Parameter] public string BackTo { get; set; } = "";
[Parameter] public EventCallback OnFilterToggle { get; set; }
[Parameter] public bool Cancel { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
[Parameter] public string? LabelSave { get; set; }
[Parameter] public EventCallback OnSave { get; set; }
[Parameter] public bool SmallHeader { get; set; }
protected override void OnParametersSet()
@if (Actions is not null)
{
Back = !Back ? !Back && Cancel : Back;
BackTo = Cancel ? "Annulla" : BackTo;
<div class="appbar__actions">
@Actions
</div>
}
</header>
@code {
[Parameter] public string? Title { get; set; }
[Parameter] public string? Subtitle { get; set; }
/// Mostra la freccia indietro (navigazione tra schermate).
[Parameter] public bool Back { get; set; }
/// Mostra la X di chiusura e delega a OnCancel (schermate modali).
[Parameter] public bool Cancel { get; set; }
[Parameter] public EventCallback OnCancel { get; set; }
/// Testata ridotta per le schermate a compito singolo, dove il titolo
/// non deve rubare spazio al contenuto.
[Parameter] public bool Compact { get; set; }
[Parameter] public RenderFragment? Actions { get; set; }
private async Task GoBack()
{
@@ -73,5 +62,4 @@
await Js.InvokeVoidAsync("goBack");
}
}
}
@@ -1,31 +0,0 @@
.header {
min-height: var(--mh-header);
width: 100%;
display: flex;
align-items: center;
}
.header-content {
width: 100%;
line-height: normal;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
}
.header-content > .title { width: 100%; }
.header-content.with-back .page-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
margin: 0;
font-size: larger;
}
.left-section ::deep button, .right-section ::deep button { font-size: 1.1rem; }
.left-section ::deep .mud-button-icon-start { margin-right: 3px !important; }
.header-content.no-back .page-title { margin: 0; }
+27 -57
View File
@@ -3,30 +3,24 @@
@using SteUp.Shared.Core.Interface.IntegryApi
@using SteUp.Shared.Core.Interface.System.Network
@inherits LayoutComponentBase
@implements IDisposable
@inject INetworkService NetworkService
@inject IIntegryApiService IntegryApiService
<MudThemeProvider Theme="_currentTheme" @ref="@_mudThemeProvider" @bind-IsDarkMode="@IsDarkMode" />
<MudThemeProvider Theme="_theme" IsDarkMode="false"/>
<MudPopoverProvider/>
<MudDialogProvider/>
<MudDialogProvider CloseButton="false" NoHeader="true" BackdropClick="false"/>
<MudSnackbarProvider/>
<ConnectionState IsNetworkAvailable="IsNetworkAvailable" ServicesIsDown="ServicesIsDown" ShowWarning="ShowWarning" />
<ConnectionState IsNetworkAvailable="IsNetworkAvailable" ServicesIsDown="ServicesIsDown" ShowWarning="ShowWarning"/>
<div class="page">
<NavMenu/>
<main>
<article>
@Body
</article>
</main>
<div class="app-shell">
@Body
</div>
<NavMenu/>
@code {
private MudThemeProvider _mudThemeProvider = null!;
private bool IsDarkMode { get; set; }
private string _mainContentClass = "";
//Connection state
private bool _isNetworkAvailable;
@@ -71,58 +65,34 @@
}
}
private readonly MudTheme _currentTheme = new()
// La palette resta quella di SteUP. Forma, ritmo e tipografia arrivano dai
// token CSS (tokens.css), che pilotano anche le variabili di MudBlazor:
// qui si dichiara solo il minimo che Mud deve conoscere lato C#.
private readonly MudTheme _theme = new()
{
PaletteLight = new PaletteLight()
PaletteLight = new PaletteLight
{
Primary = "#ec4c41",
Secondary = "#002339",
Tertiary = "#dff2ff",
TextPrimary = "#000"
Success = "#26b050",
Error = "#e50000",
Warning = "#b26a00",
Info = "#1877d8",
Background = "#e9edf2",
Surface = "#ffffff",
TextPrimary = "#000000",
TextSecondary = "#465563",
LinesDefault = "#d9e0e8",
LinesInputs = "#d9e0e8",
Divider = "#d9e0e8"
},
PaletteDark = new PaletteDark
LayoutProperties = new LayoutProperties
{
Primary = "#ec4c41",
Secondary = "#002339",
Tertiary = "#dff2ff",
Surface = "#000406",
Background = "#000406",
TextPrimary = "#fff",
GrayDark = "#E0E0E0"
DefaultBorderRadius = "16px"
}
};
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// if (firstRender)
// {
// var isDarkMode = LocalStorage.GetString("isDarkMode");
// if (isDarkMode == null && _mudThemeProvider != null)
// {
// IsDarkMode = await _mudThemeProvider.GetSystemPreference();
// await _mudThemeProvider.WatchSystemPreference(OnSystemPreferenceChanged);
// LocalStorage.SetString("isDarkMode", IsDarkMode.ToString());
// StateHasChanged();
// }
// else
// {
// IsDarkMode = bool.Parse(isDarkMode!);
// }
// if (IsDarkMode)
// {
// _mainContentClass += "is-dark";
// StateHasChanged();
// }
// }
}
private async Task OnSystemPreferenceChanged(bool newValue)
{
IsDarkMode = newValue;
}
protected override void OnInitialized()
{
_cts = new CancellationTokenSource();
@@ -170,4 +140,4 @@
_cts?.Dispose();
}
}
}
@@ -1,77 +0,0 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
+153 -99
View File
@@ -1,4 +1,5 @@
@using CommunityToolkit.Mvvm.Messaging
@using SteUp.Shared.Components.Layout.Sheet
@using SteUp.Shared.Components.SingleElements.MessageBox
@using SteUp.Shared.Core.Enum
@using SteUp.Shared.Core.Interface.LocalDb
@@ -9,154 +10,205 @@
@inject IMessenger Messenger
@inject IIspezioniService IspezioniService
@inject IJSRuntime Js
@implements IDisposable
<div
class="container animated-navbar @(IsVisible ? "show-nav" : "hide-nav") @(IsVisible ? PlusVisible ? "with-plus" : "without-plus" : "with-plus")">
<nav class="navbar @(IsVisible ? PlusVisible ? "with-plus" : "without-plus" : "with-plus")">
<div class="container-navbar">
<ul class="navbar-nav flex-row nav-justified align-items-center w-100 text-center">
<li class="nav-item">
<NavLink class="nav-link" href="ispezioni" Match="NavLinkMatch.All">
<div class="d-flex flex-column">
<i class="ri-file-list-3-line"></i>
<span>Ispezioni</span>
</div>
</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="user" Match="NavLinkMatch.All">
<div class="d-flex flex-column">
<i class="ri-user-line"></i>
<span>Profilo</span>
</div>
</NavLink>
</li>
</ul>
</div>
@*
Navigazione flottante in basso (STILE_UI §6.15): il contenuto le scorre
sotto, le destinazioni restano a portata di pollice. Icona outline a
riposo, piena da selezionata, etichetta sempre visibile in entrambe.
Da 720px diventa una colonna laterale (vedi components.css).
*@
@if (PlusVisible)
<nav class="navbar @(IsVisible ? "" : "navbar--hidden")" aria-label="Navigazione principale">
<NavLink class="navbar__item pressable" href="ispezioni" Match="NavLinkMatch.All">
<span class="navbar__icon">
<i class="@(IsOn("ispezioni") ? "ri-file-list-3-fill" : "ri-file-list-3-line")" aria-hidden="true"></i>
</span>
<span class="navbar__label">Ispezioni</span>
</NavLink>
<NavLink class="navbar__item pressable" href="user" Match="NavLinkMatch.All">
<span class="navbar__icon">
<i class="@(IsOn("user") ? "ri-user-fill" : "ri-user-line")" aria-hidden="true"></i>
</span>
<span class="navbar__label">Profilo</span>
</NavLink>
@if (ActionsVisible)
{
<button type="button" class="navbar__fab" @onclick="OpenActions"
aria-label="Azioni" title="Azioni">
<i class="ri-add-line" aria-hidden="true"></i>
</button>
}
</nav>
<ActionSheet @bind-Open="_sheetOpen" Title="@SheetTitle" Subtitle="@SheetSubtitle">
<div class="stack stack-xs">
@if (OnInspectionPage)
{
<MudFabMenu ButtonClass="custom-plus-button" MenuClass="custom-menu-fab" OnClick="@OnOpenMenu"
StartIcon="@Icons.Material.Filled.Add"
IconColor="Color.Primary" Color="Color.Surface" AlignItems="AlignItems.End"
Size="Size.Medium" IconSize="Size.Medium">
@if (SchedaVisible)
{
@if (!_isCompleted)
{
<MudFabMenuItem Disabled="!NetworkService.IsNetworkAvailable()" OnClick="@NewScheda"
Label="Nuova scheda" Color="Color.Surface"/>
}
@if (!_isCompleted)
{
<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>
}
<MudFabMenuItem Disabled="!NetworkService.IsNetworkAvailable()" OnClick="@DeleteInspection"
Label="Cancella ispezione" Color="Color.Surface"/>
@if (ShowCompleteInspection)
{
<button type="button" class="action-tile" disabled="@(!NetworkService.IsNetworkAvailable())"
@onclick="CompleteInspection">
<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>
}
if (ShowCompleteInspection)
{
<MudFabMenuItem Disabled="!NetworkService.IsNetworkAvailable()" OnClick="@CompleteInspection"
Label="Concludi ispezione" Color="Color.Surface"/>
}
}
else
{
<MudFabMenuItem Disabled="!NetworkService.IsNetworkAvailable()" OnClick="@NewInspection"
Label="Nuova ispezione" Color="Color.Surface"/>
}
</MudFabMenu>
<button type="button" class="action-tile" disabled="@(!NetworkService.IsNetworkAvailable())"
@onclick="DeleteInspection">
<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>
}
else
{
<button type="button" class="action-tile" disabled="@(!NetworkService.IsNetworkAvailable())"
@onclick="NewInspection">
<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 ispezione</span>
</button>
}
</nav>
</div>
<ConfirmMessageBox @ref="_messageBox" NoText="No" YesText="Si" YesColor="Color.Primary"
Message="Completando l'ispezione non sarà più possibile editarla o aggiungere ulteriori segnalazioni. Procedere?"/>
@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>
<ConfirmMessageBox @ref="_messageBox" NoText="Annulla" YesText="Concludi" YesColor="Color.Primary"
Message="Una volta conclusa, l'ispezione non sara' piu' modificabile e non potrai aggiungere altre schede. Procedere?"/>
@code
{
private bool IsVisible { get; set; } = true;
private bool PlusVisible { get; set; } = true;
private bool ActionsVisible { get; set; } = true;
private bool OnInspectionPage { get; set; }
private bool ShowCompleteInspection { get; set; }
private bool SchedaVisible { get; set; }
private bool _isCompleted;
private bool _sheetOpen;
private ConfirmMessageBox _messageBox = null!;
private static readonly string[] NavigableRoutes = ["ispezioni", "ispezione", "user"];
private string SheetTitle => OnInspectionPage ? "Azioni ispezione" : "Nuova ispezione";
private string? SheetSubtitle => OnInspectionPage
? SteupDataService.InspectionPageState.Ispezione.CodMdep
: null;
private string CurrentRoute =>
NavigationManager.Uri.Remove(0, NavigationManager.BaseUri.Length).Split('?')[0];
private bool IsOn(string route) => CurrentRoute.EqualsIgnoreCase(route);
protected override Task OnInitializedAsync()
{
NavigationManager.LocationChanged += (_, args) =>
{
var location = args.Location.Remove(0, NavigationManager.BaseUri.Length);
var newIsVisible = new List<string> { "ispezioni", "ispezione", "user" }
.Contains(location);
var newPlusVisible = new List<string> { "ispezioni", "ispezione", "user" }
.Contains(location);
if (IsVisible == newIsVisible && PlusVisible == newPlusVisible) return;
IsVisible = newIsVisible;
PlusVisible = newPlusVisible;
if (location.EqualsIgnoreCase("ispezione"))
{
var ispezione = SteupDataService.InspectionPageState.Ispezione;
if (ispezione.Stato == StatusEnum.Completata) PlusVisible = false;
}
StateHasChanged();
};
NavigationManager.LocationChanged += OnLocationChanged;
Refresh();
return Task.CompletedTask;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
{
// Cambiando schermata un menu aperto non ha piu' senso.
_sheetOpen = false;
Refresh();
}
private void Refresh()
{
var route = CurrentRoute;
IsVisible = NavigableRoutes.Contains(route, StringComparer.OrdinalIgnoreCase);
OnInspectionPage = route.EqualsIgnoreCase("ispezione");
ActionsVisible = IsVisible && !route.EqualsIgnoreCase("user");
if (OnInspectionPage)
{
var ispezione = SteupDataService.InspectionPageState.Ispezione;
_isCompleted = ispezione.Stato == StatusEnum.Completata;
// "Concludi" non ha senso su un'ispezione mai inviata o gia' conclusa.
ShowCompleteInspection = !ispezione.ActivityId.IsNullOrEmpty() && !_isCompleted;
// Su un'ispezione conclusa resta solo la consultazione.
if (_isCompleted) ActionsVisible = false;
}
StateHasChanged();
}
private void OpenActions()
{
Refresh();
_sheetOpen = true;
}
private async Task NewInspection()
{
_sheetOpen = false;
if (await SteupDataService.CanOpenNewInspection())
{
_ = Dialog.OpenSelectShop();
}
else
{
await Dialog.ShowWarning("Per aprire una nuova ispezione è necessario concludere prima tutte le ispezioni aperte più vecchie di 20 giorni!");
await Dialog.ShowWarning(
"Per aprire una nuova ispezione devi prima concludere tutte le ispezioni aperte da piu' di 20 giorni.");
}
}
private async Task CompleteInspection()
{
var result = await _messageBox.ShowAsync();
_sheetOpen = false;
if (result is true)
Messenger.Send(new CompleteInspectionMessage());
var result = await _messageBox.ShowAsync();
if (result is true) Messenger.Send(new CompleteInspectionMessage());
}
private async Task NewScheda()
{
_sheetOpen = false;
var ispezione = SteupDataService.InspectionPageState.Ispezione;
var modal = await Dialog.OpenFormScheda(ispezione.CodMdep, ispezione.Data, true);
if (modal is { Canceled: false })
Messenger.Send(new NewSchedaMessage());
}
private void OnOpenMenu()
{
var location = NavigationManager.Uri.Remove(0, NavigationManager.BaseUri.Length);
SchedaVisible = new List<string> { "ispezione" }.Contains(location);
var ispezione = SteupDataService.InspectionPageState.Ispezione;
_isCompleted = ispezione.Stato == StatusEnum.Completata;
// Concludi non ha senso su un'ispezione gia completata
ShowCompleteInspection = !ispezione.ActivityId.IsNullOrEmpty() && !_isCompleted;
StateHasChanged();
if (modal is { Canceled: false }) Messenger.Send(new NewSchedaMessage());
}
private async Task DeleteInspection()
{
_sheetOpen = false;
try
{
var ispezione = SteupDataService.InspectionPageState.Ispezione;
await IspezioniService.DeleteIspezioneAsync(ispezione.CodMdep, ispezione.Data, ispezione.Rilevatore);
await Js.InvokeVoidAsync("goBack");
}
catch (Exception e)
@@ -164,4 +216,6 @@
await Dialog.ShowError(e.Message);
}
}
}
public void Dispose() => NavigationManager.LocationChanged -= OnLocationChanged;
}
@@ -1,115 +0,0 @@
.animated-navbar {
background: transparent;
position: fixed;
bottom: 0;
width: 100%;
z-index: 1001;
transition: all 0.3s ease-in-out;
}
.animated-navbar.show-nav {
transform: translateY(0);
}
.animated-navbar.hide-nav {
transform: translateY(150%);
}
.animated-navbar.with-plus {
margin-left: 30px;
}
.navbar {
padding-bottom: 1rem;
padding-top: 0 !important;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: end;
transition: all 0.3s ease-in-out;
}
.navbar.with-plus {
transform: translateX(-30px);
}
.navbar.without-plus {
transform: translateX(0);
justify-content: center;
}
.container-navbar {
background: var(--mud-palette-surface);
border-radius: 50px;
padding: 0 10px;
box-shadow: var(--custom-box-shadow);
transition: all 0.3s ease-in-out;
}
.nav-item {
font-size: 0.9rem;
}
.nav-item.plus-button {
position: relative;
bottom: 15px;
}
.navbar ::deep .custom-plus-button {
background: var(--mud-palette-surface);
}
.navbar ::deep .custom-menu-fab .mud-fab-label {
width: max-content;
}
.navbar ::deep .custom-menu-fab .mud-button-root.mud-fab {
background: var(--mud-palette-surface);
}
.nav-item ::deep a {
display: flex;
align-items: center;
line-height: 1.2;
justify-content: center;
padding-top: .25rem !important;
padding-bottom: .25rem !important;
}
.nav-item ::deep a > div {
-webkit-transition: all .1s ease-out;
transition: all .1s ease-out;
min-width: 75px;
}
.nav-item ::deep a.active > div {
color: var(--mud-palette-primary);
}
.nav-item ::deep a.active > div > i {
/*background-color: color-mix(in srgb, var(--mud-palette-primary) 20%, transparent);*/
border-radius: 10px;
}
.nav-item ::deep a.active > div > span {
font-weight: 800;
}
.nav-item ::deep a:not(.active) > div {
color: var(--mud-palette-text-primary);
}
.nav-item ::deep a i {
font-size: 1.65rem;
}
.nav-item ::deep a span {
font-size: 0.8rem;
font-weight: 500;
}
@supports (-webkit-touch-callout: none) {
.navbar {
padding-bottom: env(safe-area-inset-bottom);
}
}
@@ -1,39 +1,36 @@
@using SteUp.Shared.Components.Layout.Spinner
@*
Overlay di operazione bloccante (invio al server, cancellazione).
Mostra sempre cosa sta succedendo: un'attesa muta in campo, con la rete
incerta, e' il momento in cui il rilevatore perde fiducia nello strumento.
*@
<MudOverlay Visible="VisibleOverlay" LightBackground="true">
@if (SuccessAnimation)
{
<div class="overlay-content">
<div class="success-checkmark">
<div class="check-icon">
<span class="icon-line line-tip"></span>
<span class="icon-line line-long"></span>
<div class="icon-circle"></div>
<div class="icon-fix"></div>
</div>
@if (VisibleOverlay)
{
<div class="overlay" role="status" aria-live="polite">
@if (SuccessAnimation)
{
<div class="checkmark">
<svg viewBox="0 0 32 32" aria-hidden="true">
<path d="M6 17 L13 24 L26 9"/>
</svg>
</div>
@if (!string.IsNullOrWhiteSpace(Message))
{
<p class="overlay-message">@Message</p>
}
</div>
}
else
{
<div class="overlay-content">
<SpinnerLayout/>
@if (!string.IsNullOrWhiteSpace(Message))
{
<p class="overlay-message">@Message</p>
}
</div>
}
</MudOverlay>
}
else
{
<span class="spinner spinner--lg" aria-hidden="true"></span>
}
@if (!string.IsNullOrWhiteSpace(Message))
{
<p class="overlay__message">@Message</p>
}
</div>
}
@code {
[Parameter] public required bool SuccessAnimation { get; set; }
[Parameter] public bool SuccessAnimation { get; set; }
[Parameter] public required bool VisibleOverlay { get; set; }
/// Testo opzionale mostrato sotto lo spinner/spunta (es. "Invio scheda 3 di 7…").
/// Testo mostrato sotto lo spinner (es. "Invio scheda 3 di 7…").
[Parameter] public string? Message { get; set; }
}
}
@@ -1,184 +0,0 @@
.overlay-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1.5rem;
padding: 0 2rem;
text-align: center;
}
.overlay-message {
margin: 0;
max-width: 30ch;
font-weight: 700;
font-size: 1.05rem;
line-height: 1.4;
/* Inchiostro su fondo chiaro dell'overlay: leggibile anche al sole */
color: var(--mud-palette-text-primary);
}
.success-checkmark {
width: 80px;
height: 80px;
margin: 0 auto;
}
/* Reduced motion: mostra la spunta di successo statica, senza coreografia */
@media (prefers-reduced-motion: reduce) {
.success-checkmark .check-icon::after,
.icon-line.line-tip,
.icon-line.line-long {
animation: none !important;
}
}
.success-checkmark .check-icon {
width: 80px;
height: 80px;
position: relative;
border-radius: 50%;
box-sizing: content-box;
border: 4px solid var(--mud-palette-success);
}
.success-checkmark .check-icon::before {
top: 3px;
left: -2px;
width: 30px;
transform-origin: 100% 50%;
border-radius: 100px 0 0 100px;
}
.success-checkmark .check-icon::after {
top: 0;
left: 30px;
width: 60px;
transform-origin: 0 50%;
border-radius: 0 100px 100px 0;
animation: rotate-circle 4.25s ease-in;
}
.success-checkmark .check-icon::before,
.success-checkmark .check-icon::after {
content: '';
height: 100px;
position: absolute;
transform: rotate(-45deg);
z-index: 2;
}
.icon-line {
height: 5px;
background-color: var(--mud-palette-success);
display: block;
border-radius: 2px;
position: absolute;
z-index: 10;
}
.icon-line.line-tip {
top: 46px;
left: 14px;
width: 25px;
transform: rotate(45deg);
animation: icon-line-tip 0.75s;
}
.icon-line.line-long {
top: 38px;
right: 8px;
width: 47px;
transform: rotate(-45deg);
animation: icon-line-long 0.75s;
}
.icon-circle {
top: -4px;
left: -4px;
z-index: 10;
width: 80px;
height: 80px;
border-radius: 50%;
position: absolute;
box-sizing: content-box;
border: 4px solid var(--mud-palette-success);
}
.icon-fix {
top: 8px;
width: 5px;
left: 26px;
z-index: 1;
height: 85px;
position: absolute;
transform: rotate(-45deg);
}
@keyframes rotate-circle {
0% { transform: rotate(-45deg); }
5% { transform: rotate(-45deg); }
12% { transform: rotate(-405deg); }
100% { transform: rotate(-405deg); }
}
@keyframes icon-line-tip {
0% {
width: 0;
left: 1px;
top: 19px;
}
54% {
width: 0;
left: 1px;
top: 19px;
}
70% {
width: 50px;
left: -8px;
top: 37px;
}
84% {
width: 17px;
left: 21px;
top: 48px;
}
100% {
width: 25px;
left: 14px;
top: 45px;
}
}
@keyframes icon-line-long {
0% {
width: 0;
right: 46px;
top: 54px;
}
65% {
width: 0;
right: 46px;
top: 54px;
}
84% {
width: 55px;
right: 0px;
top: 35px;
}
100% {
width: 47px;
right: 8px;
top: 38px;
}
}
@@ -0,0 +1,51 @@
@*
Bottom sheet (STILE_UI §6.14): sale dal basso, maniglia di trascinamento,
testata con titolo e chiusura, un'unica linea di struttura, corpo scorrevole.
Sostituisce i menu flottanti: stessa funzione, bersagli molto piu' grandi.
*@
@if (Open)
{
<div class="sheet-scrim" @onclick="Close" aria-hidden="true"></div>
<div class="sheet @(Tall ? "sheet--tall" : "")" role="dialog" aria-modal="true"
aria-label="@Title">
<div class="sheet__handle"></div>
<div class="sheet__head">
<div class="sheet__titles">
<h2 class="sheet__title">@Title</h2>
@if (!string.IsNullOrWhiteSpace(Subtitle))
{
<p class="sheet__subtitle">@Subtitle</p>
}
</div>
<MudIconButton Icon="@Icons.Material.Rounded.Close" Color="Color.Inherit"
aria-label="Chiudi" title="Chiudi" OnClick="Close"/>
</div>
<div class="sheet__divider"></div>
<div class="sheet__body">
@ChildContent
</div>
</div>
}
@code {
[Parameter] public bool Open { get; set; }
[Parameter] public EventCallback<bool> OpenChanged { get; set; }
[Parameter] public string? Title { get; set; }
[Parameter] public string? Subtitle { get; set; }
/// Sheet di compilazione (~92% dello schermo) invece che di consultazione.
[Parameter] public bool Tall { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
private async Task Close()
{
Open = false;
await OpenChanged.InvokeAsync(false);
}
}
@@ -1,13 +0,0 @@
<MudOverlay Visible="Visible" DarkBackground="false">
<div class="overlay-container">
<span>@(Text ?? "Caricamento")</span>
<MudProgressLinear Color="Color.Primary" Rounded="true" Size="Size.Medium" Indeterminate="true" />
</div>
</MudOverlay>
@code
{
[Parameter] public bool Visible { get; set; }
[Parameter] public string? Text { get; set; }
}
@@ -1,18 +0,0 @@
.overlay-container {
background: var(--mud-palette-background);
width: 20rem;
height: 6rem;
padding: 0 1rem;
display: flex;
gap: .5rem;
flex-direction: column;
justify-content: center;
border-radius: 20px;
box-shadow: var(--custom-box-shadow);
}
.overlay-container > span {
text-align: center;
font-size: medium;
font-weight: 600;
}
@@ -1,8 +1,11 @@
<div class="spinner-container @(FullScreen ? "" : "not-fullScreen")">
<span class="loader"></span>
@* Indicatore di attesa. A schermo pieno solo quando non c'e' ancora nulla da
mostrare; dentro una sezione resta contenuto e non fa saltare il layout. *@
<div style="display:flex; align-items:center; justify-content:center; @(FullScreen ? "height:100dvh;" : "padding: var(--space-xl) 0;")">
<span class="spinner @(FullScreen ? "spinner--lg" : "")" role="status" aria-label="Caricamento"></span>
</div>
@code
{
[Parameter] public bool FullScreen { get; set; } = true;
}
}
@@ -1,43 +0,0 @@
.spinner-container {
display: flex;
justify-content: center;
height: calc(100vh - 10.1rem);
align-items: center;
color: var(--mud-palette-primary);
}
.not-fullScreen {
height: auto !important;
padding: 2rem 0 !important;
}
.loader {
width: 50px;
aspect-ratio: 1;
border-radius: 50%;
border: 8px solid #0000;
border-right-color: var(--mud-palette-primary);
position: relative;
animation: l24 1s infinite linear;
}
.loader:before,
.loader:after {
content: "";
position: absolute;
inset: -8px;
border-radius: 50%;
border: inherit;
animation: inherit;
animation-duration: 2s;
}
.loader:after {
animation-duration: 4s;
}
@keyframes l24 {
100% {
transform: rotate(1turn)
}
}
+146 -98
View File
@@ -2,11 +2,13 @@
@using Microsoft.Extensions.Logging
@using SteUp.Shared.Components.Layout
@using SteUp.Shared.Components.Layout.Overlay
@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
@@ -21,98 +23,133 @@
@inject ILogger<IspezionePage> Logger
@implements IDisposable
<HeaderLayout Title="Ispezione" BackTo="Indietro" Back="true"/>
<HeaderLayout Title="@Titolo" Subtitle="@Sottotitolo" Back="true"/>
<div class="container content pb-safe-area ispezione">
<InspectionCard Ispezione="SteupDataService.InspectionPageState.Ispezione"/>
<div class="app-scroll">
<div class="page-width page-body stack stack-lg">
@if (!SchedeGrouped.IsNullOrEmpty())
{
<MudExpansionPanels MultiExpansion="true">
@{
var panelIndex = 0;
@* §6.6 Pannello di riepilogo: primo elemento della pagina, superficie
tenue, con lo stato del documento e i conteggi sempre a vista. *@
<section class="panel panel--info panel--lg">
<div class="summary__head">
<div class="section-header__text">
<h2 class="summary__title">@Titolo</h2>
<p class="summary__subtitle">@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">
Tocca <b>+</b> nella barra in basso per creare la prima scheda di questa ispezione.
</p>
}
</div>
}
else
{
@foreach (var group in SchedeGrouped)
{
var isFirstPanel = panelIndex == 0;
panelIndex++;
<MudExpansionPanel Class="expansion-panel" Expanded="isFirstPanel" Text="@group.Key.Descrizione">
<TitleContent>
@{
var daInviare = group.Value.CountDaInviare();
var daCompletare = group.Value.CountBozze();
}
<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>
}
@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)
{
<div class="action-scheda-group">
<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))"/>
var daInviare = group.Value.CountDaInviare();
var daCompletare = group.Value.CountBozze();
@if (NetworkService.IsNetworkAvailable() && daInviare > 0)
{
<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>
}
<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>
</TitleContent>
<ChildContent>
@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" OnSchedaDeleted="OnSchedaDeleted"
<SchedaCard Scheda="scheda" Ispezione="Ispezione"
OnSchedaDeleted="OnSchedaDeleted"
OnSchedaModified="OnSchedaModified"
Ispezione="@SteupDataService.InspectionPageState.Ispezione"/>
OnBusyChanged="@(b => VisibleOverlay = b)"/>
}
</ChildContent>
</MudExpansionPanel>
</div>
</section>
}
</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>
</div>
<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?"/>
<ConfirmMessageBox @ref="_exportRepartoBox" 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; } = [];
@@ -125,6 +162,28 @@
private ConfirmMessageBox _exportRepartoBox = null!;
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 string Titolo => SteupDataService.PuntiVenditaList
.Find(x => x.CodMdep != null && x.CodMdep.EqualsIgnoreCase(Ispezione.CodMdep))?
.Descrizione is { Length: > 0 } descrizione
? descrizione
: Ispezione.CodMdep;
private string Sottotitolo => Ispezione.CodMdep;
protected override void OnInitialized()
{
NewScheda.OnNewScheda += LoadSchede;
@@ -135,7 +194,7 @@
private void LoadSchede()
{
var ispezione = SteupDataService.InspectionPageState.Ispezione;
var ispezione = Ispezione;
InvokeAsync(async () =>
{
@@ -162,7 +221,7 @@
StateHasChanged();
});
var ispezione = SteupDataService.InspectionPageState.Ispezione;
var ispezione = Ispezione;
// Le bozze non sono inviabili: l'ispezione non si chiude finché ci sono.
var bozze = SchedeList.CountBozze();
@@ -277,7 +336,7 @@
StatusEnum.Completata
);
SteupDataService.InspectionPageState.Ispezione.Stato = StatusEnum.Completata;
ispezione.Stato = StatusEnum.Completata;
// Riscontro al momento più importante: la spunta di successo prima di chiudere l'overlay.
await InvokeAsync(() =>
@@ -339,18 +398,6 @@
StateHasChanged();
}
private async Task CreateNewScheda(JtbFasiDto jtbFasi)
{
var modal = await Dialog.OpenFormScheda(
SteupDataService.InspectionPageState.Ispezione.CodMdep,
SteupDataService.InspectionPageState.Ispezione.Data,
true,
new Scheda { Reparto = jtbFasi }
);
if (modal is { Canceled: false }) LoadSchede();
}
private async Task ExportReparto(JtbFasiDto jtbFasi)
{
// Guardia anti doppio-tap: un secondo tocco durante l'invio non ri-esporta il batch.
@@ -365,14 +412,14 @@
try
{
var ispezione = SteupDataService.InspectionPageState.Ispezione;
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 esportare in questo reparto", Severity.Info);
Snackbar.Add("Nessuna scheda completa da inviare in questo reparto", Severity.Info);
return;
}
@@ -398,11 +445,11 @@
if (apiResponse == null)
{
Snackbar.Add("Esportazione non riuscita. Le schede restano sul dispositivo, riprova.", Severity.Error);
Snackbar.Add("Invio non riuscito. Le schede restano sul dispositivo, riprova.", Severity.Error);
return;
}
SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione;
ispezione.ActivityId = apiResponse.ActivityIdIspezione;
await IspezioniService.UpdateActivityIdIspezioneAsync(ispezione.CodMdep, ispezione.Data,
UserSession.User.Username, apiResponse.ActivityIdIspezione
@@ -423,7 +470,8 @@
}
}
Snackbar.Add("Reparto esportato", Severity.Success);
Snackbar.Add(daEsportare.Count == 1 ? "Scheda inviata" : $"{daEsportare.Count} schede inviate",
Severity.Success);
}
catch (Exception e)
{
@@ -453,4 +501,4 @@
CompleteInspection.OnComplete -= HandleCompleteInspection;
}
}
}
@@ -1,47 +0,0 @@
/* 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;
}
.action-scheda-group {
display: flex;
align-items: center;
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);
}
@@ -1,47 +1,68 @@
@page "/ispezioni"
@attribute [Authorize]
@using SteUp.Shared.Components.Layout
@using SteUp.Shared.Components.Layout.Overlay
@using SteUp.Shared.Components.SingleElements
@using SteUp.Shared.Components.SingleElements.Card
@using SteUp.Shared.Core.Entities
@using SteUp.Shared.Core.Interface.LocalDb
@inject IIspezioniService IspezioniService
<HeaderLayout Title="Ispezioni"/>
<HeaderLayout Title="Ispezioni" Subtitle="@Subtitle"/>
<div class="container ispezioni pb-safe-area">
@if (Ispezioni.IsNullOrEmpty())
{
<NoDataAvailable Text="Nessuna ispezione effettuata"
ImageSource="_content/SteUp.Shared/images/undraw_file-search_cbur.svg"/>
}
else
{
<Virtualize Items="Ispezioni" Context="ispezione">
<InspectionCard Class="no-margin" Ispezione="ispezione" CompactView="true" OnClick="@OnClickIspezione"/>
</Virtualize>
}
<div class="app-scroll">
<div class="page-width page-body">
@if (_loading)
{
@* Scheletri, non uno spinner in mezzo al contenuto: la pagina ha
gia' la forma che avra', e non salta quando i dati arrivano. *@
<div class="card-list" aria-hidden="true">
@for (var i = 0; i < 3; i++)
{
<div class="list-card">
<div class="skeleton skeleton--line-short"></div>
<div class="skeleton skeleton--title" style="margin-top: 10px;"></div>
<div class="skeleton skeleton--line" style="margin-top: 12px;"></div>
<div class="skeleton skeleton--badge" style="margin-top: 14px;"></div>
</div>
}
</div>
}
else if (Ispezioni.Count == 0)
{
<div class="empty-state">
<i class="empty-state__icon ri-clipboard-line" aria-hidden="true"></i>
<p class="empty-state__title">Nessuna ispezione</p>
<p class="empty-state__text">
Tocca <b>+</b> nella barra in basso per aprire la prima ispezione su un punto vendita.
</p>
</div>
}
else
{
<div class="card-list">
<Virtualize Items="Ispezioni" Context="ispezione" OverscanCount="4">
<InspectionCard Ispezione="ispezione" OnClick="@OnClickIspezione"/>
</Virtualize>
</div>
}
</div>
</div>
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
@code{
private List<Ispezione> Ispezioni { get; set; } = [];
private bool _loading = true;
private bool VisibleOverlay { get; set; } = true;
private string? Subtitle => _loading || Ispezioni.Count == 0
? null
: Ispezioni.Count == 1
? "1 ispezione"
: $"{Ispezioni.Count} ispezioni";
protected override async Task OnInitializedAsync()
{
await LoadData();
VisibleOverlay = false;
StateHasChanged();
}
private async Task LoadData()
{
Ispezioni = await IspezioniService.GetAllIspezioniWithSchedeAsync(UserSession.User.Username);
_loading = false;
StateHasChanged();
}
private void OnClickIspezione(Ispezione ispezione)
@@ -49,5 +70,4 @@
SteupDataService.InspectionPageState.Ispezione = ispezione;
NavigationManager.NavigateTo("/ispezione");
}
}
}
@@ -1,5 +0,0 @@
.ispezioni{
display: flex;
flex-direction: column;
gap: 1rem;
}
+85 -73
View File
@@ -1,6 +1,5 @@
@page "/login"
@using Microsoft.Extensions.Logging
@using SteUp.Shared.Components.Layout.Spinner
@using SteUp.Shared.Core.BarcodeReader.Contracts
@using SteUp.Shared.Core.Interface.System
@using SteUp.Shared.Core.Services
@@ -10,68 +9,83 @@
@inject IBarcodeManager BarcodeManager
@inject ILogger<LoginPage> Logger
@if (Spinner)
{
<SpinnerLayout/>
}
else
{
<div class="login-page">
<div class="container container-top-logo">
<img src="_content/SteUp.Shared/images/logo_steup.svg" class="logo" alt="sales book">
</div>
<div class="container container-login">
<div class="login-form-container">
<div class="input-group">
<MudTextField @bind-Value="UserData.Username" Label="Username" Variant="Variant.Outlined"/>
</div>
<div class="input-group">
<MudTextField InputType="@_passwordInput" @bind-Value="UserData.Password" Label="Password" Variant="Variant.Outlined" Adornment="Adornment.End" AdornmentIcon="@_passwordInputIcon" OnAdornmentClick="ShowPassword" AdornmentAriaLabel="Show Password"/>
</div>
<div class="input-group mb-2">
<MudTextField @bind-Value="UserData.CodHash" Label="Profilo azienda" Variant="Variant.Outlined"/>
</div>
@*
Accesso (STILE_UI §7): stessa struttura dell'app, registro diverso.
Niente accento cromatico — l'attenzione sta sul marchio; il corallo torna
dentro l'app, dove significa "qui si agisce".
*@
<MudButton OnClick="@SignInUser" Color="Color.Primary" Variant="Variant.Filled">Login</MudButton>
@if (_attemptFailed)
{
<MudAlert Class="my-3" Dense="true" Severity="Severity.Error" Variant="Variant.Filled">@ErrorMessage</MudAlert>
}
<div class="onboarding">
<form class="onboarding__panel" @onsubmit="SignInUser" @onsubmit:preventDefault="true">
<img src="_content/SteUp.Shared/images/logo_steup.svg" class="onboarding__logo" alt="SteUP"/>
<div class="onboarding__fields">
<div class="onboarding__field">
<MudTextField @bind-Value="UserData.Username" Label="Username"
Variant="Variant.Text" Immediate="true"
UserAttributes="@(new() { ["autocomplete"] = "username", ["enterkeyhint"] = "next", ["autocapitalize"] = "none" })"/>
</div>
<div class="my-4 login-footer">
<span>@GenericSystemService.GetCurrentAppVersion() | Powered by</span>
<img src="_content/SteUp.Shared/images/logoIntegry.svg" class="img-fluid" alt="Integry">
<div class="onboarding__field">
<MudTextField InputType="@_passwordInput" @bind-Value="UserData.Password" Label="Password"
Variant="Variant.Text" Immediate="true"
Adornment="Adornment.End" AdornmentIcon="@_passwordInputIcon"
OnAdornmentClick="ShowPassword"
AdornmentAriaLabel="@(_isShow ? "Nascondi la password" : "Mostra la password")"
UserAttributes="@(new() { ["autocomplete"] = "current-password", ["enterkeyhint"] = "next" })"/>
</div>
<div class="onboarding__field">
<MudTextField @bind-Value="UserData.CodHash" Label="Profilo azienda"
Variant="Variant.Text" Immediate="true"
UserAttributes="@(new() { ["autocomplete"] = "organization", ["enterkeyhint"] = "go", ["autocapitalize"] = "none" })"/>
</div>
</div>
</div>
}
@if (_attemptFailed)
{
<p class="onboarding__error" role="alert" aria-live="assertive">@ErrorMessage</p>
}
<button type="submit" class="onboarding__submit" disabled="@(_busy || !CanSubmit)">
@if (_busy)
{
<span class="onboarding__spinner" aria-hidden="true"></span>
<span>Accesso in corso…</span>
}
else
{
<span>Accedi</span>
}
</button>
<div class="onboarding__footer">
<span>@GenericSystemService.GetCurrentAppVersion() · Powered by</span>
<img src="_content/SteUp.Shared/images/logoIntegry.svg" alt="Integry"/>
</div>
</form>
</div>
@code {
private SignIn UserData { get; } = new();
private bool Spinner { get; set; }
private string ErrorMessage { get; set; } = "";
private bool _attemptFailed;
private bool _busy;
private bool _isShow;
private InputType _passwordInput = InputType.Password;
private string _passwordInputIcon = Icons.Material.Rounded.VisibilityOff;
private bool CanSubmit =>
!string.IsNullOrWhiteSpace(UserData.Username) &&
!string.IsNullOrWhiteSpace(UserData.Password) &&
!string.IsNullOrWhiteSpace(UserData.CodHash);
private void ShowPassword()
{
if (_isShow)
{
_isShow = false;
_passwordInputIcon = Icons.Material.Rounded.VisibilityOff;
_passwordInput = InputType.Password;
}
else
{
_isShow = true;
_passwordInputIcon = Icons.Material.Rounded.Visibility;
_passwordInput = InputType.Text;
}
_isShow = !_isShow;
_passwordInputIcon = _isShow ? Icons.Material.Rounded.Visibility : Icons.Material.Rounded.VisibilityOff;
_passwordInput = _isShow ? InputType.Text : InputType.Password;
}
protected override void OnInitialized()
@@ -82,36 +96,34 @@ else
private async Task SignInUser()
{
if (_busy || !CanSubmit) return;
_attemptFailed = false;
if (!string.IsNullOrEmpty(UserData.Username) && !string.IsNullOrEmpty(UserData.Password) && !string.IsNullOrEmpty(UserData.CodHash))
_busy = true;
StateHasChanged();
try
{
Spinner = true;
SteupDataService.RegisterAppVersion();
await UserAccountService.Login(UserData.Username!, UserData.Password!, UserData.CodHash!);
AuthenticationStateProvider.NotifyAuthenticationState(); //Chiamato per forzare il refresh
await SteupDataService.Init();
BarcodeManager.Init();
LocalStorage.SetString("codHash", UserData.CodHash!);
NavigationManager.NavigateTo("/");
}
catch (Exception e)
{
ErrorMessage = e.Message;
Logger.LogError(e, ErrorMessage);
_attemptFailed = true;
}
finally
{
_busy = false;
StateHasChanged();
try
{
SteupDataService.RegisterAppVersion();
await UserAccountService.Login(UserData.Username, UserData.Password, UserData.CodHash);
AuthenticationStateProvider.NotifyAuthenticationState(); //Chiamato per forzare il refresh
await SteupDataService.Init();
BarcodeManager.Init();
LocalStorage.SetString("codHash", UserData.CodHash);
NavigationManager.NavigateTo("/");
StateHasChanged();
}
catch (Exception e)
{
ErrorMessage = e.Message;
Logger.LogError(e, ErrorMessage);
Console.WriteLine(e.Message);
Spinner = false;
StateHasChanged();
_attemptFailed = true;
Console.WriteLine(e);
}
}
}
@@ -122,4 +134,4 @@ else
public string? CodHash { get; set; }
}
}
}
@@ -1,54 +0,0 @@
.login-page {
height: 100%;
display: flex;
flex-direction: column;
background: var(--mud-palette-surface);
}
.container-top-logo > .logo {
width: 50%;
}
.container-login > span {
font-size: large;
font-weight: 900;
text-align: center;
}
.container-top-logo {
height: 35vh;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.login-form-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.container-login {
height: 100%;
display: flex;
flex-direction: column;
padding: 4px 16px 16px;
justify-content: space-between;
}
.login-footer {
width: 100%;
display: flex;
justify-content: center;
}
.login-footer span {
font-size: 9px;
color: var(--mud-palette-gray-darker);
}
.login-footer img {
height: 15px;
margin-left: 4px;
}
+16 -2
View File
@@ -2,5 +2,19 @@
@using SteUp.Shared.Components.Layout
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
<HeaderLayout Title="Pagina non trovata" Back="true"/>
<div class="app-scroll">
<div class="page-width page-body">
<div class="empty-state">
<i class="empty-state__icon ri-compass-3-line" aria-hidden="true"></i>
<h1 class="empty-state__title">Qui non c'è niente</h1>
<p class="empty-state__text">
La schermata che cercavi non esiste. Torna all'elenco delle ispezioni per riprendere il lavoro.
</p>
<div class="empty-state__action">
<MudButton Variant="Variant.Outlined" Href="/ispezioni">Vai alle ispezioni</MudButton>
</div>
</div>
</div>
</div>
+119 -107
View File
@@ -3,6 +3,7 @@
@using SteUp.Shared.Components.Layout
@using SteUp.Shared.Components.Layout.Overlay
@using SteUp.Shared.Components.SingleElements
@using SteUp.Shared.Components.SingleElements.MessageBox
@using SteUp.Shared.Core.Authorization.Enum
@using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Interface.IntegryApi
@@ -10,109 +11,94 @@
@using SteUp.Shared.Core.Services
@using SteUp.Shared.Core.Utility
@inject AppAuthenticationStateProvider AuthenticationStateProvider
@inject INetworkService NetworkService
@inject IGenericSystemService GenericSystemService
@inject IFileManager FileManager
@inject IIntegryApiService IntegryApiService
<HeaderLayout Title="Profilo"/>
@if (IsLoggedIn)
{
<div class="container content pb-safe-area">
<div class="container-primary-info mud-elevation-1">
<div class="section-primary-info">
<MudAvatar Style="height: 70px; width: 70px; font-size: 2rem; font-weight: bold"
Color="Color.Secondary">
@UtilityString.ExtractInitials(UserSession.User.Fullname)
</MudAvatar>
<div class="app-scroll">
<div class="page-width page-body stack stack-md">
<div class="personal-info">
<span class="info-nome">@UserSession.User.Fullname</span>
@if (UserSession.User.KeyGroup is not null)
{
<span
class="info-section">@(((KeyGroupEnum)UserSession.User.KeyGroup).ConvertToHumanReadable())</span>
}
</div>
</div>
<div class="divider"></div>
<div class="section-info">
<div class="section-personal-info">
<div>
<span class="info-title">Profilo azienda</span>
<span class="info-text">@CodHash</span>
@if (IsLoggedIn)
{
@* §6.16 Intestazione profilo *@
<section class="panel panel--lg">
<div class="profile-head">
<div class="profile-avatar" aria-hidden="true">
@UtilityString.ExtractInitials(UserSession.User.Fullname)
</div>
</div>
<div class="section-personal-info">
<div>
<span class="info-title">Status</span>
@if (NetworkService.ConnectionAvailable)
<div class="section-header__text">
<h2 class="summary__title">@UserSession.User.Fullname</h2>
@if (UserSession.User.KeyGroup is not null)
{
<div class="status online">
<i class="ri-wifi-line"></i>
<span>Online</span>
</div>
}
else
{
<div class="status offline">
<i class="ri-wifi-off-line"></i>
<span>Offline</span>
</div>
<p class="summary__subtitle">
@(((KeyGroupEnum)UserSession.User.KeyGroup).ConvertToHumanReadable())
</p>
}
</div>
</div>
</div>
</div>
<div class="container-button ripple-container mud-elevation-1">
<MudButton Class="button-settings red-icon"
FullWidth="true"
StartIcon="@Icons.Material.Rounded.UploadFile"
Size="Size.Medium"
OnClick="@ExportLog"
Variant="Variant.Outlined">
Esporta log
</MudButton>
</div>
</section>
<div class="container-button ripple-container mud-elevation-1">
<MudButton Class="button-settings green-icon"
FullWidth="true"
StartIcon="@Icons.Material.Outlined.Sync"
Size="Size.Medium"
OnClick="@UpdateDb"
Variant="Variant.Outlined">
Sincronizza ispezioni esportate
</MudButton>
</div>
<section class="panel">
<div class="nav-list__item" style="pointer-events: none;">
<i class="nav-list__icon ri-building-line" aria-hidden="true"></i>
<span class="nav-list__label">Profilo azienda</span>
<span class="t-meta">@CodHash</span>
</div>
<div class="container-button ripple-container mud-elevation-1">
<MudButton Class="button-settings exit"
FullWidth="true"
Color="Color.Error"
Size="Size.Medium"
OnClick="@Logout"
Variant="Variant.Outlined">
Esci
</MudButton>
</div>
<AppVersion/>
<div class="nav-list__item" style="pointer-events: none;">
<i class="nav-list__icon ri-cloud-line" aria-hidden="true"></i>
<span class="nav-list__label">Connessione</span>
<Badge Spec="@ConnessioneBadge"/>
</div>
<div class="nav-list__item" style="pointer-events: none;">
<i class="nav-list__icon ri-information-line" aria-hidden="true"></i>
<span class="nav-list__label">Versione</span>
<span class="t-meta">@GenericSystemService.GetCurrentAppVersion()</span>
</div>
</section>
<section class="panel">
<button type="button" class="nav-list__item" @onclick="UpdateDb">
<i class="nav-list__icon ri-refresh-line" aria-hidden="true"></i>
<span class="nav-list__label">Sincronizza ispezioni esportate</span>
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
</button>
<button type="button" class="nav-list__item" @onclick="ExportLog">
<i class="nav-list__icon ri-file-upload-line" aria-hidden="true"></i>
<span class="nav-list__label">Invia i log allo sviluppo</span>
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
</button>
@* Le voci distruttive portano l'icona nel tono danger e non hanno chevron. *@
<button type="button" class="nav-list__item nav-list__item--danger" @onclick="ConfirmLogout">
<i class="nav-list__icon ri-logout-box-r-line" aria-hidden="true"></i>
<span class="nav-list__label">Esci</span>
</button>
</section>
}
</div>
}
</div>
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
<SpinnerOverlay VisibleOverlay="VisibleOverlay" Message="@_progressMessage"/>
<ConfirmMessageBox @ref="_logoutBox" NoText="Annulla" YesText="Esci" YesColor="Color.Error"
Message="Uscendo servirà di nuovo la connessione per rientrare. Le ispezioni salvate sul dispositivo restano al loro posto."/>
@code {
private bool IsLoggedIn { get; set; }
private bool VisibleOverlay { get; set; }
private string? _progressMessage;
private string? CodHash { get; set; } = "";
private ConfirmMessageBox _logoutBox = null!;
private Core.Helpers.StatusBadgeHelper.BadgeSpec ConnessioneBadge => NetworkService.ConnectionAvailable
? new Core.Helpers.StatusBadgeHelper.BadgeSpec("success", "ri-cloud-line", "Online")
: new Core.Helpers.StatusBadgeHelper.BadgeSpec("danger", "ri-wifi-off-line", "Offline");
protected override async Task OnInitializedAsync()
{
IsLoggedIn = await UserSession.IsLoggedIn();
@@ -122,46 +108,72 @@
private async Task ExportLog()
{
_progressMessage = "Invio dei log…";
VisibleOverlay = true;
StateHasChanged();
var profiloAzienda = LocalStorage.GetString("codHash");
var email = new SendEmailDto
try
{
FromName = "Integry Log",
To = "developer@integry.it",
Subject = $"SteUP - Log del {DateTime.Today:d} di {UserSession.User.Username}",
IsHtml = true,
MsgText = $"Username: <b>{UserSession.User.Username}</b><br/>" +
$"Profilo azienda: <b>{profiloAzienda}</b><br/>" +
$"ProfileDb: <b>{UserSession.ProfileDb}</b><br/>" +
$"Versione app: <b>{GenericSystemService.GetCurrentAppVersion()}</b>",
Attachments = FileManager.GetFileForExport()
};
await IntegryApiService.SendEmail(email);
VisibleOverlay = false;
StateHasChanged();
var profiloAzienda = LocalStorage.GetString("codHash");
var email = new SendEmailDto
{
FromName = "Integry Log",
To = "developer@integry.it",
Subject = $"SteUP - Log del {DateTime.Today:d} di {UserSession.User.Username}",
IsHtml = true,
MsgText = $"Username: <b>{UserSession.User.Username}</b><br/>" +
$"Profilo azienda: <b>{profiloAzienda}</b><br/>" +
$"ProfileDb: <b>{UserSession.ProfileDb}</b><br/>" +
$"Versione app: <b>{GenericSystemService.GetCurrentAppVersion()}</b>",
Attachments = FileManager.GetFileForExport()
};
await IntegryApiService.SendEmail(email);
Snackbar.Add("Log inviati allo sviluppo", Severity.Success);
}
catch
{
Snackbar.Add("Invio dei log non riuscito. Controlla la connessione e riprova.", Severity.Error);
}
finally
{
VisibleOverlay = false;
_progressMessage = null;
StateHasChanged();
}
}
private async Task UpdateDb()
{
_progressMessage = "Sincronizzazione…";
VisibleOverlay = true;
StateHasChanged();
await SteupDataService.CheckAndUpdateStatus();
VisibleOverlay = false;
StateHasChanged();
try
{
await SteupDataService.CheckAndUpdateStatus();
Snackbar.Add("Ispezioni aggiornate", Severity.Success);
}
catch
{
Snackbar.Add("Sincronizzazione non riuscita. Controlla la connessione e riprova.", Severity.Error);
}
finally
{
VisibleOverlay = false;
_progressMessage = null;
StateHasChanged();
}
}
private async Task Logout()
private async Task ConfirmLogout()
{
var result = await _logoutBox.ShowAsync();
if (result is not true) return;
await AuthenticationStateProvider.SignOut();
IsLoggedIn = await UserSession.IsLoggedIn();
StateHasChanged();
}
}
}
@@ -1,83 +0,0 @@
.container-primary-info {
/*background: var(--light-card-background);*/
width: 100%;
margin-bottom: 2rem;
border-radius: 20px;
}
.container-primary-info .divider {
margin: 0 0 0 7rem;
width: unset;
}
.section-primary-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: .8rem 1.2rem .4rem;
}
.personal-info {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: normal;
}
.info-nome {
color: var(--mud-palette-text-primary);
font-weight: 800;
font-size: x-large;
}
.info-section {
color: var(--mud-palette-gray-default);
font-size: medium;
font-weight: 600;
}
.section-info {
display: flex;
justify-content: space-between;
flex-direction: row;
padding: .4rem 1.2rem .8rem;
}
.section-personal-info {
display: flex;
flex-direction: column;
}
.section-personal-info > div {
display: flex;
flex-direction: column;
line-height: normal;
margin: .25rem 0;
}
.info-title {
color: var(--mud-palette-gray-darker);
font-weight: 800;
}
.info-text {
color: var(--mud-palette-text-secondary);
font-weight: 700;
font-size: small;
}
.content ::deep .user-button { border: 1px solid var(--card-border-color) !important; }
.user-button > i { font-size: large; }
.user-button > span {
font-size: medium;
font-weight: 600;
}
.status { font-weight: 700; }
.status.online { color: var(--mud-palette-success); }
.status.offline { color: var(--mud-palette-error); }
@@ -1,6 +0,0 @@
@using SteUp.Shared.Core.Interface.System
@inject IGenericSystemService GenericSystemService
<div class="app-version">
<span>@GenericSystemService.GetCurrentAppVersion()</span>
</div>
@@ -1,11 +0,0 @@
.app-version{
width: 100%;
display: flex;
justify-content: center;
margin: 8px 0;
}
.app-version span{
font-size: smaller;
color: var(--mud-palette-gray-darker);
}
@@ -0,0 +1,20 @@
@using SteUp.Shared.Core.Helpers
@*
Badge di stato (STILE_UI §6.4). Non riceve colori: riceve uno stato, e la
mappatura in StatusBadgeHelper decide tono, icona ed etichetta.
*@
<span class="badge badge--@Spec.Tone" aria-label="@(AriaLabel ?? Spec.Label)">
<i class="badge__icon @Spec.Icon" aria-hidden="true"></i>
<span>@(Text ?? Spec.Label)</span>
</span>
@code {
[Parameter] public required StatusBadgeHelper.BadgeSpec Spec { get; set; }
/// Testo alternativo, per i casi in cui l'etichetta porta anche un conteggio.
[Parameter] public string? Text { get; set; }
[Parameter] public string? AriaLabel { get; set; }
}
@@ -1,105 +1,87 @@
@using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Entities
@using SteUp.Shared.Core.Helpers
<div class="container-primary-info mud-elevation-1 @Class @(OnClick.HasDelegate ? "ripple-container" : "")" @onclick="OnCardClick">
<div class="section-primary-info">
<div class="inspection-info">
@if (CompactView)
{
if (OnLoading)
{
<MudSkeleton Width="40vw"/>
}
else
{
<span class="info-title compactView">
@PuntoVendita.CodMdep - @PuntoVendita.Descrizione
</span>
}
}
else
{
if (OnLoading)
{
<MudSkeleton Width="40vw"/>
<MudSkeleton Width="55vw"/>
}
else
{
<span class="info-title">
@PuntoVendita.CodMdep - @PuntoVendita.Descrizione
</span>
<span class="info-subtitle">
@PuntoVendita.Indirizzo
</span>
}
}
@*
Card di elenco (STILE_UI §6.5). Il titolo e' l'entita' — il punto vendita —
non il tipo di record. I conteggi stanno sempre a vista: il rilevatore deve
sapere quanto ha davanti senza contare.
*@
<div class="list-card @(Tappable ? "panel--tappable" : "")"
@onclick="OnCardClick"
role="@(Tappable ? "button" : null)"
tabindex="@(Tappable ? 0 : -1)"
aria-label="@Ispezione.ToSemanticLabel()">
<div class="list-card__head">
<div class="list-card__ident">
<div class="t-ident">@Ispezione.CodMdep</div>
<h3 class="list-card__title">@Descrizione</h3>
</div>
</div>
<div class="divider"></div>
@if (!string.IsNullOrWhiteSpace(Indirizzo))
{
<p class="list-card__meta">@Indirizzo</p>
}
<div class="section-info">
<div class="section-inspection-info">
<div>
<span class="info-inspection-title">Data ispezione</span>
@if (OnLoading)
{
<MudSkeleton Width="100%"/>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Default">
@Ispezione.Data.ToString("d")
</MudChip>
}
</div>
</div>
<div class="list-card__badges badge-row">
<Badge Spec="@Ispezione.Stato.ToBadge()"/>
<div class="section-inspection-info">
<div>
<span class="info-inspection-title">Stato ispezione</span>
@if (OnLoading)
{
<MudSkeleton Width="100%"/>
}
else
{
<MudChip T="string" Size="Size.Small" Color="@Ispezione.Stato.GetColor()">
@Ispezione.Stato.ConvertToHumanReadable()
</MudChip>
}
</div>
</div>
@if (DaInviare > 0)
{
<Badge Spec="@SyncState.DaInviare.ToBadge()" Text="@($"{DaInviare} da inviare")"/>
}
@if (Bozze > 0)
{
<Badge Spec="@SyncState.Bozza.ToBadge()" Text="@($"{Bozze} da completare")"/>
}
</div>
<div class="list-card__footer">
<i class="icon ri-calendar-line" aria-hidden="true"></i>
<span>@Ispezione.Data.ToString("d MMMM yyyy")</span>
<span aria-hidden="true">·</span>
<span>@SchedeLabel</span>
</div>
</div>
@code
{
[Parameter] public string Class { get; set; } = string.Empty;
[Parameter] public Ispezione Ispezione { get; set; } = new();
[Parameter] public bool CompactView { get; set; }
[Parameter] public EventCallback<Ispezione> OnClick { get; set; }
private PuntoVenditaDto PuntoVendita { get; set; } = new();
private bool Tappable => OnClick.HasDelegate;
private bool OnLoading { get; set; } = true;
private PuntoVenditaDto? _puntoVendita;
protected override async Task OnParametersSetAsync()
// Il punto vendita puo' mancare dalla cache di riferimento (primo avvio
// offline): si degrada al codice invece di far fallire la card.
private string Descrizione => _puntoVendita?.Descrizione.IsNullOrEmpty() == false
? _puntoVendita.Descrizione!
: Ispezione.CodMdep;
private string? Indirizzo => _puntoVendita is null
? null
: string.Join(" · ", new[] { _puntoVendita.Indirizzo, _puntoVendita.Citta }
.Where(x => !x.IsNullOrEmpty()));
private int DaInviare => Ispezione.Schede.CountDaInviare();
private int Bozze => Ispezione.Schede.CountBozze();
private string SchedeLabel => Ispezione.Schede.Count == 1
? "1 scheda"
: $"{Ispezione.Schede.Count} schede";
protected override void OnParametersSet()
{
OnLoading = true;
StateHasChanged();
var puntoVendita = SteupDataService.PuntiVenditaList.Find(x =>
_puntoVendita = SteupDataService.PuntiVenditaList.Find(x =>
x.CodMdep != null && x.CodMdep.EqualsIgnoreCase(Ispezione.CodMdep)
);
await Task.Delay(250);
PuntoVendita = puntoVendita ?? throw new Exception("Punto vendita non trovato");
OnLoading = false;
StateHasChanged();
}
private Task OnCardClick() =>
OnClick.HasDelegate ? OnClick.InvokeAsync(Ispezione) : Task.CompletedTask;
}
Tappable ? OnClick.InvokeAsync(Ispezione) : Task.CompletedTask;
}
@@ -1,86 +0,0 @@
.container-primary-info {
width: 100%;
margin-bottom: 2rem;
border-radius: 20px;
background: var(--mud-palette-surface);
border: 1px solid var(--card-border-color);
box-shadow: var(--card-box-shadow);
}
.container-primary-info.no-margin {
margin-bottom: 0 !important;
min-height: max-content;
}
.container-primary-info .divider {
margin: .25rem 0;
}
.section-primary-info {
display: flex;
flex-direction: row;
align-items: center;
gap: 1.5rem;
padding: .8rem 1.2rem .4rem;
}
.personal-info {
display: flex;
flex-direction: column;
align-items: flex-start;
line-height: normal;
}
.inspection-info{
display: flex;
flex-direction: column;
}
.info-title {
color: var(--mud-palette-text-primary);
font-weight: 800;
font-size: x-large;
line-height: normal;
}
.info-subtitle {
/* gray-darker (non gray-default) per superare il contrasto AA e restare leggibile al sole */
color: var(--mud-palette-gray-darker);
font-size: medium;
font-weight: 600;
line-height: normal;
}
.info-title.compactView{
font-size: medium;
}
.section-info {
display: flex;
justify-content: space-between;
flex-direction: row;
padding: .4rem 1.2rem .8rem;
}
.section-inspection-info {
display: flex;
flex-direction: column;
}
.section-inspection-info > div {
display: flex;
flex-direction: column;
line-height: normal;
margin: .25rem 0;
}
.info-inspection-title {
color: var(--mud-palette-gray-darker);
font-weight: 800;
}
.info-inspection-text {
color: var(--mud-palette-text-secondary);
font-weight: 700;
font-size: small;
}
@@ -1,23 +1,29 @@
<div class="card-form-modal">
<div class="card-form-modal-title @ClassTitle">
<MudText Style="font-weight: 800" Typo="Typo.subtitle1">@Title</MudText>
</div>
<div class="card-form-modal-body @ClassBody">
@if (Loading)
@* Gruppo di campo del form scheda: un pannello con la sua etichetta e,
quando serve, il conteggio di cio' che contiene. *@
<div class="form-group @Class">
<div class="form-group__label">
<span>@Title</span>
@if (!string.IsNullOrWhiteSpace(Count))
{
<MudSkeleton/>
}
else
{
@ChildContent
<span class="form-group__count">@Count</span>
}
</div>
@if (Loading)
{
<div class="skeleton skeleton--line" style="height: 24px;" aria-hidden="true"></div>
}
else
{
@ChildContent
}
</div>
@code {
[Parameter] public string Title { get; set; } = "";
[Parameter] public string? Count { get; set; }
[Parameter] public bool Loading { get; set; }
[Parameter] public string Class { get; set; } = "";
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public string ClassTitle { get; set; } = "";
[Parameter] public string ClassBody { get; set; } = "";
}
}
@@ -1,23 +0,0 @@
.card-form-modal {
background: var(--mud-palette-table-striped);
border-radius: 20px;
padding: .5rem 1rem;
width: -webkit-fill-available;
}
.is-dark .card-form-modal {
background: var(--mud-palette-background); !important;
}
.card-form-modal-body ::deep .file-upload-input {
position: absolute;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 10;
opacity: 0;
}
small ::deep {
color: var(--mud-palette-text-primary) !important;
}
@@ -1,8 +1,8 @@
@using SteUp.Shared.Components.Layout.Overlay
@using SteUp.Shared.Components.SingleElements.MessageBox
@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
@@ -13,74 +13,125 @@
@inject IFileManager FileManager
@inject ILogger<SchedaCard> Logger
<div class="scheda-card">
<div class="scheda-body-section">
<div class="title-section">
<div class="scheda-title-group">
<MudText Class="scheda-title" Typo="Typo.subtitle1">
<b>@Scheda.ActivityTypeId</b>
</MudText>
@{
var syncState = Scheda.GetSyncState();
}
<MudChip T="string" Class="sync-badge" Icon="@syncState.GetIcon()"
Size="Size.Small" Variant="Variant.Filled" Color="@syncState.GetColor()">
@syncState.ToLabel()
</MudChip>
</div>
<div class="sub-info-section">
<MudChip T="string" Icon="@Icons.Material.Rounded.PhotoCamera" Size="Size.Small" Color="Color.Default">
@Scheda.ImageNames.Count()
</MudChip>
<MudChip T="string" Icon="@Icons.Material.Rounded.QrCode2" Size="Size.Small" Color="Color.Default">
@Scheda.Articoli.Count
</MudChip>
</div>
</div>
<div class="scheda-card-action">
@if (Ispezione.Stato != StatusEnum.Completata)
{
<div class="action-secondary">
<MudFab Color="Color.Default" Size="Size.Medium" StartIcon="@Icons.Material.Rounded.Edit"
aria-label="Modifica scheda" OnClick="@UpdateScheda"/>
<MudFab Color="Color.Error" Size="Size.Medium" StartIcon="@Icons.Material.Rounded.Delete"
aria-label="Elimina scheda" OnClick="@DeleteScheda"/>
</div>
@if (NetworkService.IsNetworkAvailable() && syncState == SyncState.DaInviare)
{
<MudFab Color="Color.Primary" Size="Size.Medium" StartIcon="@Icons.Material.Rounded.CloudUpload"
Label="Invia" aria-label="Invia scheda al server" OnClick="@ExportScheda"/>
}
}
else
{
<div class="action-secondary">
<MudFab Color="Color.Default" Size="Size.Medium" StartIcon="@Icons.Material.Rounded.RemoveRedEye"
aria-label="Visualizza scheda" OnClick="@ViewScheda"/>
</div>
<MudFab Color="Color.Primary" Size="Size.Medium" StartIcon="@Icons.Material.Rounded.CloudUpload"
Label="Invia foto" aria-label="Invia foto al server" OnClick="@ExportImg"/>
}
<div class="list-card" aria-label="@SemanticLabel">
<div class="list-card__head">
<div class="list-card__ident">
<div class="t-ident">@Scheda.DescrizioneReparto</div>
<h3 class="list-card__title">@Titolo</h3>
</div>
</div>
<div class="list-card__badges badge-row">
<Badge Spec="@SyncState.ToBadge()"/>
<Badge Spec="@ScadenzaBadge" />
</div>
@if (!Scheda.Note.IsNullOrEmpty())
{
<p class="list-card__meta t-prose">@Scheda.Note</p>
}
<div class="list-card__footer">
<i class="icon ri-qr-code-line" aria-hidden="true"></i>
<span>@ArticoliLabel</span>
<span aria-hidden="true">·</span>
<i class="icon ri-camera-line" aria-hidden="true"></i>
<span>@FotoLabel</span>
</div>
<div class="list-card__actions">
@if (ReadOnly)
{
<MudButton Variant="Variant.Outlined" FullWidth="true"
StartIcon="@Icons.Material.Rounded.Visibility" OnClick="@ViewScheda">
Apri
</MudButton>
@if (Scheda.ImageNames?.Count > 0)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
StartIcon="@Icons.Material.Rounded.CloudUpload" OnClick="@ExportImg">
Invia foto
</MudButton>
}
}
else
{
<MudButton Variant="Variant.Outlined" FullWidth="true"
StartIcon="@Icons.Material.Rounded.Edit" OnClick="@UpdateScheda">
@(IsBozza ? "Completa" : "Modifica")
</MudButton>
@if (NetworkService.IsNetworkAvailable() && SyncState == Core.Helpers.SyncState.DaInviare)
{
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
StartIcon="@Icons.Material.Rounded.CloudUpload" OnClick="@ExportScheda">
Invia
</MudButton>
}
}
</div>
@if (!ReadOnly)
{
<MudButton Class="mt-2" Variant="Variant.Text" FullWidth="true"
StartIcon="@Icons.Material.Rounded.DeleteOutline"
Style="color: var(--danger-ink)" OnClick="@DeleteScheda">
Elimina scheda
</MudButton>
}
</div>
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
<ConfirmMessageBox @ref="_messageBox" YesText="Cancella" YesColor="Color.Error"
Message="Confermi la cancellazione della scheda corrente?"/>
<ConfirmMessageBox @ref="_messageBox" NoText="Annulla" YesText="Elimina" YesColor="Color.Error"
Message="La scheda verrà eliminata dal dispositivo e, se già inviata, anche dal server. L'operazione non è reversibile."/>
@code{
[Parameter] public Ispezione Ispezione { get; set; } = new();
[Parameter] public Ispezione Ispezione { get; set; } = new();
[Parameter] public required Scheda Scheda { get; set; }
[Parameter] public EventCallback<Scheda> OnSchedaModified { get; set; }
[Parameter] public EventCallback<Scheda> OnSchedaDeleted { get; set; }
private bool VisibleOverlay { get; set; }
[Parameter] public EventCallback<bool> OnBusyChanged { get; set; }
private ConfirmMessageBox _messageBox = null!;
private bool _busy;
private bool ReadOnly => Ispezione.Stato == StatusEnum.Completata;
private SyncState SyncState => Scheda.GetSyncState();
private bool IsBozza => SyncState == Core.Helpers.SyncState.Bozza;
// Una bozza puo' non avere ancora il motivo: meglio dirlo che lasciare un vuoto.
private string Titolo => Scheda.ActivityTypeId.IsNullOrEmpty()
? "Motivo da indicare"
: Scheda.ActivityTypeId!;
private StatusBadgeHelper.BadgeSpec ScadenzaBadge => (ScadenzaEnum)Scheda.Scadenza switch
{
ScadenzaEnum.Altissima24Ore => new StatusBadgeHelper.BadgeSpec("danger", "ri-alarm-warning-line", "Entro 24 ore"),
ScadenzaEnum.Alta1Settimana => new StatusBadgeHelper.BadgeSpec("warning", "ri-time-line", "Entro 1 settimana"),
ScadenzaEnum.Media1Mese => new StatusBadgeHelper.BadgeSpec("info", "ri-calendar-line", "Entro 1 mese"),
_ => new StatusBadgeHelper.BadgeSpec("neutral", "ri-calendar-line", "Entro 2 mesi")
};
private string ArticoliLabel => Scheda.Articoli.Count == 1
? "1 articolo"
: $"{Scheda.Articoli.Count} articoli";
private string FotoLabel => Scheda.ImageNames?.Count switch
{
null or 0 => "nessuna foto",
1 => "1 foto",
var n => $"{n} foto"
};
private string SemanticLabel =>
$"Scheda {Titolo}, reparto {Scheda.DescrizioneReparto}, {SyncState.ToBadge().Label}, {ArticoliLabel}, {FotoLabel}";
private async Task SetBusy(bool value)
{
_busy = value;
await OnBusyChanged.InvokeAsync(value);
StateHasChanged();
}
private async Task UpdateScheda()
{
@@ -88,15 +139,18 @@
if (modal is { Canceled: false, Data: Scheda scheda }) await OnSchedaModified.InvokeAsync(scheda);
}
private void ViewScheda() =>
_ = Dialog.OpenFormScheda(Ispezione.CodMdep, Ispezione.Data, false, Scheda, true);
private async Task DeleteScheda()
{
var result = await _messageBox.ShowAsync();
if (result is not true) return;
if (result is true)
await SetBusy(true);
try
{
VisibleOverlay = true;
StateHasChanged();
if (Scheda.ActivityId != null)
await IntegrySteupService.DeleteScheda(Scheda.ActivityId);
@@ -110,22 +164,18 @@
var deleteScheda = await IspezioniService.DeleteSchedaAsync(Scheda.Id);
if (deleteScheda) await OnSchedaDeleted.InvokeAsync(Scheda);
VisibleOverlay = false;
StateHasChanged();
}
finally
{
await SetBusy(false);
}
}
private void ViewScheda() =>
_ = Dialog.OpenFormScheda(Ispezione.CodMdep, Ispezione.Data, false, Scheda, true);
private async Task ExportScheda()
{
// Guardia anti doppio-tap: invio irreversibile al server.
if (VisibleOverlay) return;
VisibleOverlay = true;
StateHasChanged();
if (_busy) return;
await SetBusy(true);
try
{
@@ -172,17 +222,14 @@
}
finally
{
VisibleOverlay = false;
StateHasChanged();
await SetBusy(false);
}
}
private async Task ExportImg()
{
if (VisibleOverlay || Scheda.ImageNames == null) return;
VisibleOverlay = true;
StateHasChanged();
if (_busy || Scheda.ImageNames == null) return;
await SetBusy(true);
try
{
@@ -209,8 +256,7 @@
}
finally
{
VisibleOverlay = false;
StateHasChanged();
await SetBusy(false);
}
}
}
}
@@ -1,48 +0,0 @@
.scheda-card{
padding: .55rem .85rem;
background-color: var(--mud-palette-surface);
border: 1px solid var(--card-border-color);
border-radius: 1em;
box-shadow: var(--card-box-shadow);
}
.scheda-body-section{
display: flex;
flex-direction: column;
gap: .5rem;
}
.title-section{
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
}
.scheda-title-group{
display: flex;
flex-direction: column;
align-items: flex-start;
gap: .15rem;
min-width: 0;
}
.sync-badge{
margin: 0;
}
.scheda-card-action{
display: flex;
gap: .5rem;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
/* Azioni secondarie (modifica/elimina) raggruppate e ancorate a sinistra;
l'azione primaria "Invia" (corallo) resta separata e a portata di pollice a destra. */
.action-secondary{
display: flex;
gap: .5rem;
align-items: center;
}
@@ -1,60 +1,45 @@
@using SteUp.Shared.Components.Layout.Overlay
@using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Interface.LocalDb
@inject IIspezioniService IspezioniService
<div class="shop-card ripple-container" @onclick="StartInspection">
<div class="shop-body-section">
<div class="title-section">
<MudText Class="shop-title" Typo="Typo.subtitle1">
<b>@PuntoVendita.CodMdep</b> - @PuntoVendita.Descrizione
</MudText>
</div>
<div class="list-card panel--tappable" @onclick="StartInspection" role="button" tabindex="0"
aria-label="@($"Apri ispezione su {PuntoVendita.CodMdep} {PuntoVendita.Descrizione}")">
@if (!PuntoVendita.Indirizzo.IsNullOrEmpty())
{
<div class="subtitle-section">
<MudText Class="shop-title" Typo="Typo.subtitle1">
@PuntoVendita.Indirizzo
</MudText>
</div>
<div class="sub-info-section">
<MudChip T="string" Icon="@Icons.Material.Rounded.LocationCity" Size="Size.Small" Color="Color.Default">
@PuntoVendita.Citta
</MudChip>
<MudChip T="string" Size="Size.Small" Color="Color.Default">
@PuntoVendita.Cap
</MudChip>
<MudChip T="string" Size="Size.Small" Color="Color.Default">
@PuntoVendita.Provincia
</MudChip>
</div>
}
</div>
<div class="t-ident">@PuntoVendita.CodMdep</div>
<h3 class="list-card__title">@PuntoVendita.Descrizione</h3>
@if (!Luogo.IsNullOrEmpty())
{
<p class="list-card__meta">@Luogo</p>
}
</div>
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
@code {
[Parameter] public PuntoVenditaDto PuntoVendita { get; set; } = null!;
private bool VisibleOverlay { get; set; }
[Parameter] public EventCallback OnStarting { get; set; }
// Indirizzo, citta', CAP e provincia su una riga sola con separatore " · ":
// sono metadati, non meritano tre chip che fanno rumore.
private string Luogo => string.Join(" · ", new[]
{
PuntoVendita.Indirizzo,
PuntoVendita.Citta,
string.Join(" ", new[] { PuntoVendita.Cap, PuntoVendita.Provincia }
.Where(x => !x.IsNullOrEmpty()))
}
.Where(x => !x.IsNullOrEmpty()));
private async Task StartInspection()
{
VisibleOverlay = true;
StateHasChanged();
await OnStarting.InvokeAsync();
SteupDataService.InspectionPageState.Ispezione = await IspezioniService.GetOrCreateIspezioneAsync(
PuntoVendita.CodMdep!,
DateTime.Now,
UserSession.User.Username
);
VisibleOverlay = false;
StateHasChanged();
NavigationManager.NavigateTo("/ispezione");
}
}
}
@@ -1,5 +0,0 @@
.shop-card{
padding: .5rem 1rem;
background-color: var(--mud-palette-background-gray);
border-radius: 1em;
}
@@ -1,26 +1,35 @@
<div class="Connection @(ShowWarning ? "Show" : "Hide") @(IsNetworkAvailable? ServicesIsDown ? "ServicesIsDown" : "SystemOk" : "NetworkKo")">
@if (IsNetworkAvailable)
{
if(ServicesIsDown)
{
<i class="ri-cloud-off-fill"></i>
<span>Servizi offline</span>
}
else
{
<i class="ri-cloud-fill"></i>
<span>Online</span>
}
}
else
{
<i class="ri-wifi-off-line"></i>
<span>Nessuna connessione</span>
}
@*
La firma visiva di "Offline e' la verita'": una striscia sottile ancorata in
cima che scende quando lo stato di rete cambia. Mai il solo colore: sempre
icona piu' etichetta, leggibile anche in pieno sole.
*@
<div class="connection @(ShowWarning ? "connection--show" : "") @ToneClass"
role="status" aria-live="polite">
<i class="@IconClass" aria-hidden="true"></i>
<span>@Label</span>
</div>
@code{
[Parameter] public bool IsNetworkAvailable { get; set; }
[Parameter] public bool ServicesIsDown { get; set; }
[Parameter] public bool ShowWarning { get; set; }
}
private string ToneClass => !IsNetworkAvailable
? "connection--offline"
: ServicesIsDown
? "connection--down"
: "connection--ok";
private string IconClass => !IsNetworkAvailable
? "ri-wifi-off-line"
: ServicesIsDown
? "ri-cloud-off-line"
: "ri-cloud-line";
private string Label => !IsNetworkAvailable
? "Nessuna connessione · il lavoro resta sul dispositivo"
: ServicesIsDown
? "Servizi non raggiungibili"
: "Di nuovo online";
}
@@ -1,25 +1,33 @@
@using Microsoft.Extensions.Logging
@inject ILogger<ExceptionModal> Logger
<div class="container container-modal">
<div class="c-modal">
<div class="exception-header mb-2">
<i class="ri-emotion-unhappy-line"></i>
<span>Ops</span>
</div>
<div class="text">
@if (Exception != null)
{
<code>@Message</code>
}
</div>
<div class="button-container">
<div @onclick="OnRetryClick" class="card-button">
<span>Riprova</span>
@*
Schermata di ripiego quando un componente va in errore. Non lascia il
rilevatore davanti a uno stack trace: dice cosa è successo in una frase e
offre due vie d'uscita. Il dettaglio tecnico resta sotto, per l'assistenza.
*@
<div class="onboarding">
<div class="onboarding__panel">
<div class="panel panel--lg">
<div class="dialog-icon dialog-icon--danger" aria-hidden="true">
<i class="ri-error-warning-line"></i>
</div>
<div @onclick="OnContinueClick" class="card-button">
<span>Continua</span>
<h1 class="dialog-title">Qualcosa si è inceppato</h1>
<p class="dialog-message">
Il lavoro salvato sul dispositivo non è andato perso. Riprova, e se succede di nuovo
invia i log dalla schermata Profilo.
</p>
@if (!string.IsNullOrWhiteSpace(Message))
{
<p class="dialog-detail selectable">@Message</p>
}
<div class="action-bar" style="border: none; background: none; padding: var(--space-lg) 0 0;">
<MudButton Variant="Variant.Outlined" OnClick="@OnContinueClick">Torna all'elenco</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@OnRetryClick">Riprova</MudButton>
</div>
</div>
</div>
@@ -61,4 +69,4 @@
NavigationManager.NavigateTo("/");
await OnRetry.InvokeAsync();
}
}
}
@@ -1,61 +0,0 @@
.container-modal {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.c-modal {
border-radius: 16px;
box-shadow: var(--exception-box-shadow);
padding: 16px;
}
.button-container {
display: flex;
gap: 1rem;
flex-direction: row;
align-items: center;
width: 100%;
justify-content: space-between;
margin: 1.5rem 0 0 0;
}
.text {
font-size: medium;
font-weight: 500;
display: flex;
text-align: center;
}
.card-button {
text-align: center;
background-color: transparent;
padding: .3rem 1rem;
font-weight: 700;
color: var(--bs-primary-text-emphasis);
}
.exception-header {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.exception-header > i {
font-size: 3rem;
line-height: normal;
color: var(--bs-danger);
}
.exception-header > span {
font-size: x-large;
font-weight: 700;
}
code {
width: 100%;
height: auto;
color: var(--bs-gray-dark);
}
@@ -1,27 +1,24 @@
<MudDialog OnBackdropClick="Cancel">
@*
Dialog informativo (STILE_UI §6.10): cerchio-icona del tono, titolo,
messaggio, un solo bottone a piena larghezza.
*@
<MudDialog>
<DialogContent>
@if (IsWarning)
{
<div class="exception-header mb-2">
<i class="ri-error-warning-line warning-icon"></i>
<span>Attenzione</span>
</div>
}
else
{
<div class="exception-header mb-2">
<i class="ri-emotion-unhappy-line error-icon"></i>
<span>Ops</span>
</div>
}
<div class="text">
<code>@ErrorMessage</code>
<div class="dialog-icon dialog-icon--@(IsWarning ? "warning" : "danger")" aria-hidden="true">
<i class="@(IsWarning ? "ri-alert-line" : "ri-error-warning-line")"></i>
</div>
<h2 class="dialog-title">@(IsWarning ? "Attenzione" : "Qualcosa non ha funzionato")</h2>
<p class="dialog-message">@Message</p>
@if (ShowDetail)
{
<p class="dialog-detail">@ErrorMessage</p>
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" OnClick="@Cancel" Size="Size.Small" Color="Color.Primary">
Chiudi
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@Cancel">Ho capito</MudButton>
</DialogActions>
</MudDialog>
@@ -30,5 +27,14 @@
[Parameter] public string ErrorMessage { get; set; } = string.Empty;
[Parameter] public bool IsWarning { get; set; }
// Gli avvisi sono gia' scritti per il rilevatore. Gli errori tecnici no:
// si mostra una frase comprensibile e il dettaglio resta sotto, per chi
// deve riferirlo all'assistenza.
private bool ShowDetail => !IsWarning;
private string Message => IsWarning
? ErrorMessage
: "Il lavoro resta salvato sul dispositivo. Riprova, e se continua segnala il dettaglio qui sotto.";
private void Cancel() => MudDialog.Cancel();
}
}
@@ -1,37 +0,0 @@
.exception-header {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.exception-header > i {
font-size: 3rem;
line-height: normal;
}
.error-icon {
color: var(--bs-danger);
}
.warning-icon {
color: var(--bs-warning);
}
.exception-header > span {
font-size: x-large;
font-weight: 700;
}
.text {
font-size: medium;
font-weight: 500;
display: flex;
text-align: center;
}
code {
width: 100%;
height: auto;
color: var(--bs-gray-dark);
}
@@ -1,27 +1,35 @@
@using SteUp.Shared.Components.Layout
@using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Dto
@using SteUp.Shared.Core.Interface.System
@inject IAttachedService AttachedService
<MudDialog Class="disable-safe-area">
<MudDialog>
<DialogContent>
<HeaderLayout SmallHeader="true" Cancel="true" OnCancel="@(() => MudDialog.Cancel())" Title="Aggiungi allegati"/>
<h2 class="dialog-title">Aggiungi una foto</h2>
<p class="dialog-message">Scatta sul posto oppure scegli un'immagine già sul dispositivo.</p>
<div style="margin-bottom: 1rem;" class="content attached">
<MudFab Size="Size.Small" Color="Color.Primary"
StartIcon="@Icons.Material.Rounded.CameraAlt"
Label="Camera" OnClick="@OnCamera"/>
<div class="stack stack-xs" style="margin-top: var(--space-md);">
<button type="button" class="action-tile" @onclick="@OnCamera">
<span class="action-tile__icon action-tile__icon--primary">
<i class="ri-camera-line" aria-hidden="true"></i>
</span>
<span class="action-tile__label">Scatta una foto</span>
</button>
<MudFab Size="Size.Small" Color="Color.Primary"
StartIcon="@Icons.Material.Rounded.Image"
Label="Galleria" OnClick="@OnGallery"/>
<button type="button" class="action-tile" @onclick="@OnGallery">
<span class="action-tile__icon action-tile__icon--neutral">
<i class="ri-image-line" aria-hidden="true"></i>
</span>
<span class="action-tile__label">Scegli dalla galleria</span>
</button>
</div>
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Outlined" OnClick="@(() => MudDialog.Cancel())">Annulla</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public bool CanAddPosition { get; set; }
private List<AttachedDto>? Attached { get; set; }
@@ -48,4 +56,4 @@
if (Attached != null) MudDialog.Close(Attached);
}
}
}
@@ -1,9 +0,0 @@
.content.attached {
display: flex;
gap: 2rem;
padding: 1rem;
height: unset;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
@@ -24,174 +24,225 @@
@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">
<HeaderLayout Cancel="true" OnCancel="@Cancel" Title="Scheda"/>
<div class="app-shell">
<div class="content">
<HeaderLayout Title="@(IsNew ? "Nuova scheda" : ReadOnly ? "Scheda" : "Modifica scheda")"
Subtitle="@Scheda.DescrizioneReparto"
Cancel="true" OnCancel="@Cancel" Compact="true"/>
<CardFormModal Title="Reparto" Loading="SteupDataService.Reparti.IsNullOrEmpty()">
<MudSelectExtended T="JtbFasiDto?" Variant="Variant.Text"
@bind-Value="Scheda.Reparto" ToStringFunc="@(x => x?.Descrizione)"
@bind-Value:after="OnAfterChangeReparto" Required="true"
RequiredError="Reparto obbligatorio">
@foreach (var fasi in SteupDataService.Reparti)
<div class="app-scroll">
<div class="page-width stack stack-10" style="padding-bottom: var(--space-lg);">
@if (IsBozza)
{
<MudSelectItemExtended Class="custom-item-select" Value="@fasi">
@fasi.Descrizione
</MudSelectItemExtended>
<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>
}
</MudSelectExtended>
</CardFormModal>
<CardFormModal Title="Motivo" Loading="SteupDataService.TipiAttività.IsNullOrEmpty()">
<MudSelectExtended ReadOnly="@Scheda.CodJfas.IsNullOrEmpty()" T="string?"
Variant="Variant.Text"
@bind-Value="Scheda.ActivityTypeId" @bind-Value:after="OnAfterChangeValue"
Required="true" RequiredError="Motivo obbligatorio">
@foreach (var type in SteupDataService.TipiAttività.Where(x => x.CodJfas.EqualsIgnoreCase(Scheda.CodJfas!)))
{
<MudSelectItemExtended Class="custom-item-select"
Value="@type.ActivityTypeId">@type.ActivityTypeId</MudSelectItemExtended>
}
</MudSelectExtended>
</CardFormModal>
@if (FileLoading)
{
<CardFormModal Title="Immagini">
<div class="container-attached">
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-3"/>
</div>
</CardFormModal>
}
else if (!AttachedList.IsNullOrEmpty())
{
<CardFormModal Title="Immagini">
<div class="container-attached">
<div class="scroll-attached">
@foreach (var item in AttachedList!.Select((p, index) => new { p, index }))
<CardFormModal Title="Reparto" Loading="SteupDataService.Reparti.IsNullOrEmpty()">
<MudSelectExtended T="JtbFasiDto?" Variant="Variant.Text"
@bind-Value="Scheda.Reparto" ToStringFunc="@(x => x?.Descrizione)"
@bind-Value:after="OnAfterChangeReparto" Required="true"
Placeholder="Scegli il reparto"
RequiredError="Indica il reparto">
@foreach (var fasi in SteupDataService.Reparti)
{
<MudCard>
@if (!item.p.ThumbPath.IsNullOrEmpty())
{
<MudCardMedia Image="@item.p.ThumbPath" Height="100"/>
}
<MudCardContent Class="image_card">
<MudText Typo="Typo.subtitle1"><b>@item.p.Name</b></MudText>
@if (item.p.ToUpload)
<MudSelectItemExtended Class="custom-item-select" Value="@fasi">
@fasi.Descrizione
</MudSelectItemExtended>
}
</MudSelectExtended>
</CardFormModal>
<CardFormModal Title="Motivo" Loading="SteupDataService.TipiAttività.IsNullOrEmpty()">
@if (Scheda.CodJfas.IsNullOrEmpty())
{
<p class="t-meta">Scegli prima il reparto.</p>
}
else
{
<MudSelectExtended T="string?" Variant="Variant.Text"
@bind-Value="Scheda.ActivityTypeId"
@bind-Value:after="OnAfterChangeValue"
Required="true" Placeholder="Scegli il motivo"
RequiredError="Indica il motivo">
@foreach (var type in SteupDataService.TipiAttività.Where(x => x.CodJfas.EqualsIgnoreCase(Scheda.CodJfas!)))
{
<MudSelectItemExtended Class="custom-item-select"
Value="@type.ActivityTypeId">@type.ActivityTypeId</MudSelectItemExtended>
}
</MudSelectExtended>
}
</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)
{
<MudIconButton Variant="Variant.Outlined"
Icon="@Icons.Material.Rounded.Close"
Size="Size.Small" Color="Color.Error"
OnClick="@(() => OnRemoveAttached(item.index))"/>
<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>
}
</MudCardContent>
</MudCard>
</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>
}
@if (item.p.ToUpload && !ReadOnly)
{
<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>
</div>
</CardFormModal>
}
</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)
{
<div class="container-button ripple-container">
<MudButton Class="button-settings green-icon"
FullWidth="true"
StartIcon="@Icons.Material.Rounded.AttachFile"
Size="Size.Medium"
OnClick="@OpenAddAttached"
Variant="Variant.Outlined">
Aggiungi foto
</MudButton>
</div>
}
<CardFormModal Title="Articoli">
@if (!ReadOnly)
{
<div class="input-manual-barcode">
<MudTextField FullWidth="true" T="string?" Variant="Variant.Text"
@bind-Value="ManualBarcode" Placeholder="Digita manualmente il codice"/>
<MudIconButton Color="Color.Primary" OnClick="@OnManualBarcodeSet"
Size="Size.Small" Icon="@Icons.Material.Rounded.Send"/>
</div>
}
@if (!Scheda.Articoli.IsNullOrEmpty())
{
<div class="art-list">
@foreach (var articolo in Scheda.Articoli)
{
<MudChip T="string" OnClose="@(() => RemoveArt(articolo.Barcode))"
style="height: auto;">
<MudStack Direction="Column" Spacing="0" class="py-1">
<MudText Typo="Typo.subtitle2" Style="line-height: 1.1; font-weight: 700;">
@articolo.Descrizione
</MudText>
<MudText Typo="Typo.body2" Style="line-height: 1.5;">
Barcode: @articolo.Barcode
</MudText>
</MudStack>
</MudChip>
}
</div>
}
</CardFormModal>
@if (NetworkService.ConnectionAvailable && !ReadOnly)
{
<div class="container-button ripple-container">
<MudButton Class="button-settings red-icon"
FullWidth="true"
StartIcon="@Icons.Material.Rounded.Balance"
Size="Size.Medium"
OnClick="@OpenSelectArt"
Variant="Variant.Outlined">
Consulta articoli
</MudButton>
</div>
}
<CardFormModal Title="Scadenza">
<MudSelectExtended FullWidth="true" T="int" Variant="Variant.Text"
@bind-Value="@Scheda.Scadenza" @bind-Value:after="OnAfterChangeValue">
<MudSelectItemExtended Class="custom-item-select" Text="24H" Value="3"/>
<MudSelectItemExtended Class="custom-item-select" Text="1 Settimana" Value="2"/>
<MudSelectItemExtended Class="custom-item-select" Text="1 Mese" Value="1"/>
<MudSelectItemExtended Class="custom-item-select" Text="2 Mesi" Value="0"/>
</MudSelectExtended>
</CardFormModal>
<CardFormModal Title="Responsabile">
<MudTextField FullWidth="true" T="string?" Variant="Variant.Text"
@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"
@bind-Value="Scheda.Note" @bind-Value:after="OnAfterChangeValue"
DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/>
</CardFormModal>
<div class="container-button">
<MudButton Class="button-settings primary-icon"
FullWidth="true"
StartIcon="@Icons.Material.Rounded.Description"
Size="Size.Medium"
OnClick="@SuggestActivityDescription"
Variant="Variant.Outlined">
Suggerisci note descrittive
</MudButton>
</div>
@if (!LabelSave.IsNullOrEmpty())
{
<div class="container-button">
<MudButton OnClick="@Save" Color="Color.Primary" Variant="Variant.Filled" FullWidth="true">
@LabelSave
@* 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>
}
@@ -203,7 +254,7 @@
</MudDialog>
<SpinnerOverlay VisibleOverlay="VisibleOverlay" SuccessAnimation="SuccessAnimation"/>
<LoaderLayout Visible="OnLoading" Text="@TextLoading"/>
<SpinnerOverlay VisibleOverlay="OnLoading" Message="@TextLoading"/>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
@@ -215,6 +266,45 @@
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; }
@@ -1,49 +0,0 @@
.container-attached {
width: 100%;
margin-bottom: 1rem;
}
.container-attached .divider {
margin: unset;
}
.input-manual-barcode {
display: flex;
align-items: center;
gap: 1.5rem;
margin-top: .5rem;
margin-bottom: .5rem;
}
.scroll-attached {
max-height: 185px;
overflow: auto;
display: flex;
gap: 1rem;
flex-direction: column;
padding: .5rem;
}
.container-attached ::deep .image_card {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px;
}
.container-button {
margin-bottom: unset !important;
}
.input-card {
margin-bottom: 1.5rem;
}
.art-list {
max-height: 150px;
overflow: auto;
}
.art-list ::deep p {
white-space: normal;
}
@@ -1,59 +1,128 @@
@using SteUp.Shared.Components.Layout
@using SteUp.Shared.Core.Dto
<MudDialog OnBackdropClick="Cancel">
<DialogContent>
<HeaderLayout SmallHeader="true" Cancel="true" OnCancel="@(() => MudDialog.Cancel())"
Title="Seleziona articoli"/>
@*
Scelta degli articoli dalla griglia. Al posto del menu a tendina a selezione
multipla, un elenco a schermo intero con righe da 56px: si centra con i
guanti, e il conteggio dei selezionati resta sempre a vista.
*@
@if (!Articoli.IsNullOrEmpty())
{
<MudSelectExtended MultiSelection="true" ItemCollection="Articoli" SearchBox="true"
SearchBoxAutoFocus="true" @bind-SelectedValues="ArticoliSelected"
SearchFunc="SearchFunc" T="ArticoliInGrigliaDto" Virtualize="true"
Label="Articoli" AnchorOrigin="Origin.BottomCenter" Variant="Variant.Outlined"
SearchBoxPlaceholder="Descrizione articolo" SearchBoxClearable="true"
MultiSelectionTextFunc="MultiSelectionTextFunc" ToStringFunc="ToStringFunc"/>
}
else
{
<div class="spinner-container" style="height: unset !important; margin-bottom: 1rem;">
<MudIcon Size="Size.Large" Color="Color.Error" Icon="@Icons.Material.Rounded.Close"/>
<MudText>Nessun articolo trovato</MudText>
<MudDialog>
<DialogContent>
<div class="app-shell">
<HeaderLayout Title="Articoli" Subtitle="@Conteggio" Cancel="true"
OnCancel="@Cancel" Compact="true"/>
@if (!Articoli.IsNullOrEmpty())
{
<div class="page-width" style="padding-bottom: var(--space-sm);">
<div class="search-field">
<i class="search-field__icon ri-search-line" aria-hidden="true"></i>
<input type="search" placeholder="Cerca per descrizione o codice"
autocapitalize="none" autocorrect="off"
aria-label="Cerca un articolo"
value="@FilterText" @oninput="OnFilterInput"/>
@if (!FilterText.IsNullOrEmpty())
{
<MudIconButton Icon="@Icons.Material.Rounded.Close" Size="Size.Small"
aria-label="Cancella la ricerca" OnClick="@ClearFilter"/>
}
</div>
</div>
}
<div class="app-scroll">
<div class="page-width page-body--flush">
@if (Articoli.IsNullOrEmpty())
{
<div class="empty-state">
<i class="empty-state__icon ri-inbox-line" aria-hidden="true"></i>
<p class="empty-state__title">Nessun articolo in griglia</p>
<p class="empty-state__text">
Per questo reparto e motivo non ci sono articoli: usa lo scanner o digita il codice.
</p>
</div>
}
else if (Filtrati.Count == 0)
{
<div class="empty-state">
<i class="empty-state__icon ri-search-line" aria-hidden="true"></i>
<p class="empty-state__title">Nessun risultato</p>
<p class="empty-state__text">Nessun articolo corrisponde alla ricerca.</p>
<div class="empty-state__action">
<MudButton Variant="Variant.Outlined" OnClick="@ClearFilter">
Azzera la ricerca
</MudButton>
</div>
</div>
}
else
{
<div class="stack stack-xs">
<Virtualize Items="Filtrati" Context="art" OverscanCount="8">
<button type="button" class="select-row"
aria-pressed="@(IsSelected(art) ? "true" : "false")"
@onclick="@(() => Toggle(art))">
<span class="select-row__check" aria-hidden="true">
<i class="ri-check-line"></i>
</span>
<span class="select-row__text">
<span class="select-row__title">@art.Descrizione</span>
<span class="select-row__meta">@art.Barcode</span>
</span>
</button>
</Virtualize>
</div>
}
</div>
</div>
}
@if (!Articoli.IsNullOrEmpty())
{
<div class="action-bar">
<MudButton Variant="Variant.Outlined" OnClick="@Cancel">Annulla</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Disabled="@(_selected.Count == 0)" OnClick="@Submit">
@(_selected.Count == 0 ? "Aggiungi" : $"Aggiungi {_selected.Count}")
</MudButton>
</div>
}
</div>
</DialogContent>
<DialogActions>
@if (!Articoli.IsNullOrEmpty())
{
<MudButton Variant="Variant.Filled" OnClick="@Submit" Size="Size.Small" Color="Color.Primary">
Seleziona
</MudButton>
}
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
[Parameter] public List<ArticoliInGrigliaDto>? Articoli { get; set; }
private IEnumerable<ArticoliInGrigliaDto>? ArticoliSelected { get; set; }
private readonly List<ArticoliInGrigliaDto> _selected = [];
private string? FilterText { get; set; }
private List<ArticoliInGrigliaDto> Filtrati =>
FilterText.IsNullOrEmpty()
? Articoli ?? []
: (Articoli ?? []).FindAll(x =>
x.Descrizione.ContainsIgnoreCase(FilterText!) ||
x.Barcode.ContainsIgnoreCase(FilterText!));
private string? Conteggio => Articoli.IsNullOrEmpty()
? null
: _selected.Count > 0
? $"{_selected.Count} selezionat{(_selected.Count == 1 ? "o" : "i")}"
: $"{Articoli!.Count} disponibili";
private bool IsSelected(ArticoliInGrigliaDto art) => _selected.Contains(art);
private void Toggle(ArticoliInGrigliaDto art)
{
if (!_selected.Remove(art)) _selected.Add(art);
}
private void OnFilterInput(ChangeEventArgs e) => FilterText = e.Value?.ToString();
private void ClearFilter() => FilterText = null;
private void Cancel() => MudDialog.Cancel();
private void Submit() =>
MudDialog.Close(DialogResult.Ok(ArticoliSelected?.ToList()));
private static bool SearchFunc(ArticoliInGrigliaDto? obj, string? searchString)
{
if (searchString.IsNullOrEmpty() || obj == null) return true;
return obj.Descrizione.ContainsIgnoreCase(searchString!);
}
private static string? ToStringFunc(ArticoliInGrigliaDto? arg) => arg?.Descrizione;
private static string MultiSelectionTextFunc(List<ArticoliInGrigliaDto?> arg) =>
$"{arg.Count} selezionat{(arg.Count == 1 ? "o" : "i")}";
}
private void Submit() => MudDialog.Close(DialogResult.Ok(_selected.ToList()));
}
@@ -1,87 +1,120 @@
@using SteUp.Shared.Components.SingleElements.Card
@using SteUp.Shared.Components.Layout
@using SteUp.Shared.Components.SingleElements.Card
@using SteUp.Shared.Core.Dto
<MudDialog OnBackdropClick="Cancel">
@*
Scelta del punto vendita: schermata intera, non un riquadro. L'elenco e'
lungo e si scorre in piedi: serve tutto lo spazio e un campo di ricerca
grande quanto il pollice.
*@
<MudDialog>
<DialogContent>
<div class="app-shell">
<HeaderLayout Title="Punto vendita" Subtitle="@Conteggio" Cancel="true"
OnCancel="@Cancel" Compact="true"/>
<div class="select-shop-content">
<div class="shop-header">
<div class="shop-title">
<MudText Typo="Typo.h5"><b>Seleziona il negozio</b></MudText>
<MudIconButton Icon="@Icons.Material.Rounded.Close" Size="Size.Small" OnClick="@Cancel"/>
</div>
<div class="input-card clearButton">
<MudTextField T="string?" Placeholder="Cerca..." Variant="Variant.Text"
@bind-Value="FilterText" DebounceInterval="500"
OnDebounceIntervalElapsed="ApplyFilters"/>
<div class="page-width" style="padding-bottom: var(--space-sm);">
<div class="search-field">
<i class="search-field__icon ri-search-line" aria-hidden="true"></i>
<input type="search" placeholder="Cerca per codice, nome o città"
autocapitalize="none" autocorrect="off"
aria-label="Cerca un punto vendita"
value="@FilterText" @oninput="OnFilterInput"/>
@if (!FilterText.IsNullOrEmpty())
{
<MudIconButton Icon="@Icons.Material.Rounded.Close" Size="Size.Small"
aria-label="Cancella la ricerca" OnClick="@ClearFilter"/>
}
</div>
</div>
<div class="shop-body">
@if (_afterRender)
{
if (FilteredList.IsNullOrEmpty())
<div class="app-scroll">
<div class="page-width page-body--flush">
@if (FilteredList is null)
{
<MudText Typo="Typo.body2">Nessun negozio trovato</MudText>
<div class="card-list" aria-hidden="true">
@for (var i = 0; i < 4; i++)
{
<div class="list-card">
<div class="skeleton skeleton--line-short"></div>
<div class="skeleton skeleton--title" style="margin-top: 10px;"></div>
</div>
}
</div>
}
else if (FilteredList.Count == 0)
{
<div class="empty-state">
<i class="empty-state__icon ri-search-line" aria-hidden="true"></i>
<p class="empty-state__title">Nessun punto vendita</p>
<p class="empty-state__text">Nessun risultato per "@FilterText".</p>
<div class="empty-state__action">
<MudButton Variant="Variant.Outlined" OnClick="@ClearFilter">
Azzera la ricerca
</MudButton>
</div>
</div>
}
else
{
<Virtualize Items="FilteredList" Context="puntoVendita">
<ShopCard PuntoVendita="puntoVendita"/>
</Virtualize>
<div class="card-list">
<Virtualize Items="FilteredList" Context="puntoVendita" OverscanCount="6">
<ShopCard PuntoVendita="puntoVendita" OnStarting="@(() => _busy = true)"/>
</Virtualize>
</div>
}
}
</div>
</div>
</div>
</DialogContent>
</MudDialog>
<SpinnerOverlay VisibleOverlay="_busy" Message="Apertura ispezione…"/>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
private List<PuntoVenditaDto>? FilteredList { get; set; }
private string? FilterText { get; set; }
private bool _busy;
private bool _afterRender;
private string? Conteggio => FilteredList is null
? null
: FilteredList.Count == 1 ? "1 punto vendita" : $"{FilteredList.Count} punti vendita";
private void Cancel() => MudDialog.Cancel();
protected override void OnInitialized()
protected override void OnInitialized() => ApplyFilters();
private void OnFilterInput(ChangeEventArgs e)
{
_afterRender = false;
FilterText = e.Value?.ToString();
ApplyFilters();
}
protected override void OnAfterRender(bool firstRender)
private void ClearFilter()
{
if (_afterRender) return;
_afterRender = true;
FilterText = null;
ApplyFilters();
}
private void ApplyFilters()
{
if (FilterText.IsNullOrEmpty())
{
FilteredList = SteupDataService.PuntiVenditaList;
}
else
{
FilteredList = SteupDataService.PuntiVenditaList.FindAll(x =>
(x.Indirizzo != null && x.Indirizzo.ContainsIgnoreCase(FilterText!)) ||
(x.Descrizione != null && x.Descrizione.ContainsIgnoreCase(FilterText!)) ||
(x.CodMdep != null && x.CodMdep.ContainsIgnoreCase(FilterText!)) ||
(x.Citta != null && x.Citta.ContainsIgnoreCase(FilterText!)) ||
(x.Cap != null && x.Cap.ContainsIgnoreCase(FilterText!)) ||
(x.Provincia != null && x.Provincia.ContainsIgnoreCase(FilterText!))
);
}
var source = SteupDataService.PuntiVenditaList;
FilteredList = (FilterText.IsNullOrEmpty()
? source
: source.FindAll(x =>
(x.Indirizzo != null && x.Indirizzo.ContainsIgnoreCase(FilterText!)) ||
(x.Descrizione != null && x.Descrizione.ContainsIgnoreCase(FilterText!)) ||
(x.CodMdep != null && x.CodMdep.ContainsIgnoreCase(FilterText!)) ||
(x.Citta != null && x.Citta.ContainsIgnoreCase(FilterText!)) ||
(x.Cap != null && x.Cap.ContainsIgnoreCase(FilterText!)) ||
(x.Provincia != null && x.Provincia.ContainsIgnoreCase(FilterText!))))
.OrderBy(x => x.CodMdep)
.ToList();
FilteredList = FilteredList.OrderBy(x => x.CodMdep).ToList();
StateHasChanged();
}
}
}
@@ -1,18 +0,0 @@
.shop-header{
display: flex;
flex-direction: column;
gap: .5rem;
}
.shop-title{
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.shop-body{
display: flex;
flex-direction: column;
gap: 1rem;
}
@@ -1,24 +1,34 @@
@using SteUp.Shared.Core.Dto
<MudDialog OnBackdropClick="Cancel">
<MudDialog>
<DialogContent>
<h2 class="dialog-title">Note descrittive</h2>
<p class="dialog-message">Scegli una descrizione ricorrente per questo motivo.</p>
@if (!ActivityTypers.IsNullOrEmpty())
{
<MudList T="string" SelectedValueChanged="OnClickItem">
<div class="stack stack-xs" style="margin-top: var(--space-md);">
@foreach (var item in ActivityTypers!)
{
<MudListItem Text="@item.ActivityTypeDescription" Value="item.ActivityTypeDescription"/>
<button type="button" class="action-tile"
@onclick="@(() => OnClickItem(item.ActivityTypeDescription))">
<span class="action-tile__label">@item.ActivityTypeDescription</span>
</button>
}
</MudList>
</div>
}
else
{
<div class="spinner-container" style="height: unset !important;">
<MudIcon Size="Size.Large" Color="Color.Error" Icon="@Icons.Material.Rounded.Close"/>
<MudText>Nessuna descrizione consigliata</MudText>
<div class="empty-state">
<i class="empty-state__icon ri-chat-off-line" aria-hidden="true"></i>
<p class="empty-state__title">Nessuna descrizione consigliata</p>
<p class="empty-state__text">Per questo motivo non ci sono note ricorrenti: scrivila a mano.</p>
</div>
}
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Outlined" OnClick="@Cancel">Annulla</MudButton>
</DialogActions>
</MudDialog>
@code {
@@ -29,4 +39,4 @@
private void OnClickItem(string? selectedValue) =>
MudDialog.Close(DialogResult.Ok(selectedValue));
}
}
@@ -1,10 +1,17 @@
<div class="no-data opacity-75 d-flex flex-column align-items-center">
<img src="@ImageSource" alt=""/>
@* Stato vuoto (STILE_UI §6.12): suggerisce la mossa successiva, non si limita
a dire che non c'e' niente. *@
<p class="mt-3">@Text</p>
<div class="empty-state">
<i class="empty-state__icon @Icon" aria-hidden="true"></i>
<p class="empty-state__title">@Text</p>
@if (!string.IsNullOrWhiteSpace(Hint))
{
<p class="empty-state__text">@Hint</p>
}
</div>
@code {
[Parameter] public string ImageSource { get; set; } = string.Empty;
[Parameter] public string Text { get; set; } = string.Empty;
}
[Parameter] public string? Hint { get; set; }
[Parameter] public string Icon { get; set; } = "ri-inbox-line";
}
@@ -1,13 +0,0 @@
.no-data {
position: fixed;
top: 35%;
width: calc(100% - 3rem); /* remove page padding */
}
.no-data img {
width: 60%;
}
.no-data p {
font-size: 1.2rem;
}