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;
}
+4 -6
View File
@@ -17,10 +17,10 @@ public static class ModalHelper
new DialogParameters(),
new DialogOptions
{
FullScreen = false,
FullScreen = true,
CloseButton = false,
NoHeader = true,
BackdropClick = true
BackdropClick = false
}
);
@@ -100,12 +100,10 @@ public static class ModalHelper
},
new DialogOptions
{
FullScreen = false,
FullScreen = true,
CloseButton = false,
NoHeader = true,
BackdropClick = true,
FullWidth = true,
MaxWidth = MaxWidth.ExtraLarge
BackdropClick = false
}
);
@@ -0,0 +1,59 @@
using SteUp.Shared.Core.Entities;
using SteUp.Shared.Core.Enum;
namespace SteUp.Shared.Core.Helpers;
/// <summary>
/// Mappature di stato per i badge (STILE_UI §6.4): tono, icona ed etichetta di
/// ogni stato vivono qui e solo qui. Le schermate non compongono badge a mano,
/// così lo stesso stato si presenta identico ovunque e un nuovo stato si
/// aggiunge in un punto solo.
/// </summary>
public static class StatusBadgeHelper
{
public readonly record struct BadgeSpec(string Tone, string Icon, string Label);
/// Stato di una scheda rispetto al server.
public static BadgeSpec ToBadge(this SyncState state) =>
state switch
{
SyncState.Bozza => new BadgeSpec("warning", "ri-edit-line", "Da completare"),
SyncState.DaInviare => new BadgeSpec("danger", "ri-smartphone-line", "Da inviare"),
SyncState.Inviato => new BadgeSpec("success", "ri-cloud-line", "Inviato"),
_ => new BadgeSpec("neutral", "ri-question-line", "Sconosciuto")
};
/// Stato di un'ispezione.
public static BadgeSpec ToBadge(this StatusEnum stato) =>
stato switch
{
StatusEnum.InCorso => new BadgeSpec("info", "ri-play-circle-line", "In corso"),
StatusEnum.Completata => new BadgeSpec("success", "ri-check-double-line", "Conclusa"),
StatusEnum.Verifica => new BadgeSpec("warning", "ri-search-eye-line", "In verifica"),
StatusEnum.Annullata => new BadgeSpec("neutral", "ri-close-circle-line", "Annullata"),
_ => new BadgeSpec("neutral", "ri-question-line", "Sconosciuto")
};
/// <summary>
/// Sintesi di un'ispezione per i lettori di schermo: cosa è, a che punto
/// sta e cosa resta da fare, in una frase sola.
/// </summary>
public static string ToSemanticLabel(this Ispezione ispezione)
{
var schede = ispezione.Schede.Count;
var daInviare = ispezione.Schede.CountDaInviare();
var bozze = ispezione.Schede.CountBozze();
var parti = new List<string>
{
$"Ispezione {ispezione.CodMdep}",
ispezione.Stato.ToBadge().Label,
schede == 1 ? "1 scheda" : $"{schede} schede"
};
if (daInviare > 0) parti.Add($"{daInviare} da inviare");
if (bozze > 0) parti.Add($"{bozze} da completare");
return string.Join(", ", parti);
}
}
-335
View File
@@ -1,335 +0,0 @@
html {
overflow: hidden;
}
.page, article, main {
height: 100% !important;
overflow: hidden;
}
#app {
height: 100vh;
}
html, body {
font-family: "Nunito", sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 1.8;
color: black;
}
* {
font-family: "Nunito", sans-serif !important;
}
html ::-webkit-scrollbar {
width: 3px;
height: 3px;
}
html ::-webkit-scrollbar-thumb {
background: #bbb;
border-radius: 2px;
}
.mud-button-label {
font-weight: 700 !important;
}
a, .btn-link {
/*color: #006bb7;*/
text-decoration: none;
color: inherit;
}
article {
display: flex;
flex-direction: column;
}
/*ServicesIsDown" : "SystemOk" : "NetworkKo*/
.Connection {
padding: 0 .75rem;
font-weight: 700;
display: flex;
flex-direction: row;
gap: 1rem;
font-size: larger;
transition: all 0.5s ease;
opacity: 0;
transform: translateY(-35px);
min-height: 35px;
align-items: center;
}
.Connection.ServicesIsDown, .Connection.NetworkKo {
background-color: var(--mud-palette-error);
color: white;
}
.Connection.SystemOk {
background-color: var(--mud-palette-success);
color: white;
}
.Connection.Show {
opacity: 1;
transform: translateY(0);
}
.Connection.Hide ~ .page {
transition: all 0.5s ease;
transform: translateY(-35px);
}
.btn-primary {
color: #fff;
background-color: var(--primary-color);
border-color: var(--darker-color);
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1rem;
display: flex;
align-items: center;
flex-direction: column;
height: 90vh;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.status-bar-safe-area {
display: none;
}
.page-title {
font-size: x-large;
font-weight: 800;
margin: 0;
line-height: normal;
color: var(--mud-palette-text-primary);
}
.custom-mudfab {
position: fixed !important;
bottom: 4rem;
margin-bottom: 16px;
right: 16px;
}
.divider {
display: block;
width: 100%;
border: .05rem solid var(--mud-palette-lines-inputs);
}
.pb-safe-area {
padding-bottom: 75px;
overflow: auto;
}
/*Spinner*/
.spinner-container {
display: flex;
justify-content: center;
height: 100vh;
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)
}
}
/*MudBlazor Personalization*/
.mud-button-group-horizontal:not(.mud-button-group-rtl) > .mud-button-root:not(:last-child), .mud-button-group-horizontal:not(.mud-button-group-rtl) > :not(:last-child) .mud-button-root {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.mud-button-group-horizontal:not(.mud-button-group-rtl) > .mud-button-root:not(:first-child), .mud-button-group-horizontal:not(.mud-button-group-rtl) > :not(:first-child) .mud-button-root {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
.customDialog-form .mud-dialog-content {
padding: 0 0;
margin: 0;
overflow: hidden;
}
.custom-item-select {
padding: 6px 16px;
}
.custom-item-select .mud-typography-body1 {
font-weight: 600;
font-size: .9rem;
}
.container {
padding-right: var(--m-page-x) !important;
padding-left: var(--m-page-x) !important;
}
.lm-container {
padding-right: calc(var(--m-page-x) * 0.5) !important;
padding-left: calc(var(--m-page-x) * 0.5) !important;
}
.mud-message-box > .mud-dialog-title > h6 {
font-weight: 800 !important;
}
.mud-dialog-actions button {
margin-left: .5rem !important;
margin-right: .5rem !important;
}
@supports (-webkit-touch-callout: none) {
.status-bar-safe-area {
display: flex;
position: fixed;
top: 0;
height: env(safe-area-inset-top);
width: 100%;
z-index: 1;
background-color: var(--mud-palette-surface);
}
.modal {
padding-top: env(safe-area-inset-top);
}
.safe-area-bottom {
margin-bottom: env(safe-area-inset-bottom) !important;
}
.pb-safe-area {
padding-bottom: env(safe-area-inset-bottom) !important;
}
#app {
margin-top: env(safe-area-inset-top);
height: calc(100vh - env(safe-area-inset-top));
}
.flex-column, .navbar-brand {
padding-left: env(safe-area-inset-left);
}
.customDialog-form .mud-dialog-content {
margin-top: env(safe-area-inset-top);
}
}
/*Ripple*/
.ripple-container {
position: relative;
overflow: hidden;
cursor: pointer;
transform: translateZ(0);
}
.ripple {
position: absolute;
border-radius: 50%;
transform: scale(0);
animation: ripple-effect 0.6s linear;
background-color: rgba(0, 0, 0, 0.2);
pointer-events: none;
}
@keyframes ripple-effect {
to {
transform: scale(4);
opacity: 0;
}
}
.ripple-white .ripple {
background-color: rgba(255, 255, 255, 0.5);
}
+276
View File
@@ -0,0 +1,276 @@
/* =============================================================================
SteUP — Base
Reset, shell applicativa, tipografia e comportamenti "da app nativa".
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
height: 100%;
overflow: hidden;
/* La WebView non deve comportarsi come un browser: niente rimbalzo elastico
ai bordi, niente pull-to-refresh, niente zoom a doppio tocco. */
overscroll-behavior: none;
touch-action: manipulation;
-webkit-text-size-adjust: 100%;
}
body {
height: 100%;
margin: 0;
overflow: hidden;
background-color: var(--workspace);
color: var(--ink);
font-family: var(--font-sans);
font-size: var(--body-lg-size);
line-height: var(--body-lg-height);
font-weight: 400;
/* Inter: cifre a larghezza fissa, cosi' i contatori non "ballano" mentre
si aggiornano, e forme delle lettere piu' leggibili in piccolo. */
font-feature-settings: "tnum" 1, "cv05" 1;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Niente selezione del testo, niente menu contestuale al tocco lungo,
niente lampo grigio sul tap: sono i tre segnali che tradiscono una
pagina web dentro un'app. */
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
-webkit-tap-highlight-color: transparent;
cursor: default;
}
/* Il contenuto dei dati resta selezionabile: un barcode o un codice servono
anche da copiare. */
.selectable,
input,
textarea {
user-select: text;
-webkit-user-select: text;
-webkit-touch-callout: default;
}
#app {
height: 100dvh;
overflow: hidden;
}
img {
max-width: 100%;
display: block;
}
[hidden] {
display: none !important;
}
/* -----------------------------------------------------------------------------
Tipografia — scala fissa (STILE_UI §4)
La gerarchia si fa con peso e dimensione, mai con il maiuscolo spaziato.
-------------------------------------------------------------------------- */
.t-headline-md {
font-size: var(--headline-md-size);
line-height: var(--headline-md-height);
font-weight: 700;
letter-spacing: -0.02em;
}
.t-headline-sm {
font-size: var(--headline-sm-size);
line-height: var(--headline-sm-height);
font-weight: 700;
letter-spacing: -0.02em;
}
.t-title-lg {
font-size: var(--title-lg-size);
line-height: var(--title-lg-height);
font-weight: 700;
letter-spacing: -0.01em;
}
.t-title-md {
font-size: var(--title-md-size);
line-height: var(--title-md-height);
font-weight: 700;
}
.t-body-lg {
font-size: var(--body-lg-size);
line-height: var(--body-lg-height);
font-weight: 400;
}
.t-body-md {
font-size: var(--body-md-size);
line-height: var(--body-md-height);
font-weight: 400;
}
.t-label-lg {
font-size: var(--label-lg-size);
line-height: 1.25;
font-weight: 600;
}
.t-label-md {
font-size: var(--label-md-size);
line-height: 1.25;
font-weight: 600;
}
/* Identificativo tecnico sopra il titolo di una card (STILE_UI §4) */
.t-ident {
font-size: var(--label-sm-size);
line-height: 1.3;
font-weight: 700;
letter-spacing: 0.3px;
color: var(--ink-muted);
}
.t-muted {
color: var(--ink-muted);
}
/* Il testo secondario si distingue per colore, mai per opacita'. */
.t-meta {
font-size: var(--body-md-size);
line-height: var(--body-md-height);
font-weight: 600;
color: var(--ink-muted);
}
/* Prosa: righe non piu' lunghe di 65-75 caratteri. */
.t-prose {
max-width: 68ch;
text-wrap: pretty;
}
h1, h2, h3 {
text-wrap: balance;
margin: 0;
}
h1, h2, h3, h4, h5, h6, p {
margin: 0;
}
/* -----------------------------------------------------------------------------
Shell applicativa
-------------------------------------------------------------------------- */
.app-shell {
display: flex;
flex-direction: column;
height: 100dvh;
overflow: hidden;
background-color: var(--workspace);
}
/* Area scorrevole unica della schermata. Lo scroll con inerzia e il
contenimento evitano che il gesto "sfondi" sulla pagina sottostante. */
.app-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
scrollbar-width: none;
}
.app-scroll::-webkit-scrollbar {
display: none;
}
/* Contenitore di pagina (STILE_UI §6.1): su telefono e' trasparente, su
schermo largo tiene una colonna leggibile invece di allargarsi all'infinito. */
.page-width {
width: 100%;
max-width: var(--content-max);
margin-inline: auto;
padding-inline: var(--space-md);
}
/* Spazio in coda per non finire sotto la barra di navigazione flottante. */
.page-body {
padding-bottom: calc(var(--nav-clearance) + env(safe-area-inset-bottom, 0px));
}
/* Pagine senza barra di navigazione (login, form a schermo pieno). */
.page-body--flush {
padding-bottom: calc(var(--space-lg) + env(safe-area-inset-bottom, 0px));
}
.stack {
display: flex;
flex-direction: column;
}
.stack-xs { gap: var(--space-xs); }
.stack-10 { gap: var(--space-10); }
.stack-sm { gap: var(--space-sm); }
.stack-md { gap: var(--space-md); }
.stack-lg { gap: var(--space-lg); }
/* -----------------------------------------------------------------------------
Focus
Visibile e coerente ovunque, senza sporcare il tocco: solo da tastiera.
-------------------------------------------------------------------------- */
:focus {
outline: none;
}
:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
border-radius: var(--radius-compact);
}
/* -----------------------------------------------------------------------------
Risposta al tocco
Feedback fisico immediato (principio "Feedback immediato a ogni scan"):
l'elemento cede sotto il dito e torna. Niente stati basati sull'hover, che
su un touch screen non esiste e lascia elementi "accesi" dopo il tocco.
-------------------------------------------------------------------------- */
.pressable {
transition: transform var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out);
}
.pressable:active {
transform: scale(0.98);
}
@media (hover: hover) and (pointer: fine) {
.pressable:hover {
background-color: var(--surface-muted);
}
}
/* -----------------------------------------------------------------------------
Movimento ridotto — non e' opzionale
-------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.pressable:active {
transform: none;
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -1,54 +0,0 @@
/*---Blazor custom---*/
.mud-snackbar {
border-radius: 1em !important;
}
.mud-paper {
border-radius: 1em !important;
}
.mud-dialog {
border-radius: 1em !important;
}
.mud-overlay.on-top {
z-index: 10001 !important;
}
.mud-popover.mud-popover-open {
border-radius: 1em !important;
}
.mud-picker-popover-paper {
border-radius: 1em !important;
}
.mud-skeleton {
border-radius: .5em !important;
}
/*.mud-expand-panel{*/
/* background: var(--light-card-background) !important;*/
/* box-shadow: unset !important;*/
/*}*/
.mud-expansion-panels {
width: 100%;
}
.ripple-container .mud-ripple-effect {
display: none !important;
}
.ripple-container .mud-ripple-effect-expanding {
display: none !important;
}
.ripple-container .mud-button:focus-visible, .ripple-container .mud-button:active{
background-color: unset !important;
}
.ripple-container .mud-button-outlined:focus-visible, .ripple-container .mud-button-outlined:active{
background-color: unset !important;
}
@@ -1,15 +0,0 @@
:root {
/*Color*/
--card-border-color: hsl(from var(--mud-palette-gray-light) h s 86%);
--gray-for-shadow: hsl(from var(--mud-palette-overlay-dark)h s 40%);
/*Utility*/
--exception-box-shadow: 1px 2px 5px rgba(0, 0, 0, 0.3);
--custom-box-shadow: 1px 2px 5px var(--gray-for-shadow);
/* Ombra morbida a due livelli, tinta col navy del brand: stacca le card dallo sfondo senza pesare */
--card-box-shadow: 0 1px 2px rgba(0, 35, 57, 0.08), 0 6px 16px rgba(0, 35, 57, 0.10);
--mud-default-borderradius: 20px !important;
--m-page-x: 1rem;
--mh-header: 4rem;
--light-card-background: hsl(from var(--mud-palette-background-gray) h s 97%);
}
+28
View File
@@ -0,0 +1,28 @@
/* =============================================================================
SteUP Carattere
Inter variabile, ospitato in locale. Non si carica da Google Fonts: l'app
lavora in negozio con la rete incerta o assente, e un carattere che arriva
dalla rete e' un carattere che in campo non arriva.
========================================================================== */
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("../fonts/inter-latin.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Inter";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("../fonts/inter-latin-ext.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
-218
View File
@@ -1,218 +0,0 @@
.title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.customDialog-form.disable-safe-area .mud-dialog-content {
margin-top: unset !important;
}
.customDialog-form.disable-safe-area .content {
height: 100% !important;
}
.customDialog-form.mud-dialog {
border-radius: unset !important;
}
.customDialog-form .content {
height: calc(100vh - (.6rem + 40px));
overflow: auto;
gap: 1.5rem;
padding-top: unset !important;
padding-bottom: 2rem !important;
padding-left: .75rem !important;
padding-right: .75rem !important;
}
@supports (-webkit-touch-callout: none) {
.customDialog-form .content {
height: calc(100vh - (.6rem + 40px) - env(safe-area-inset-top)) !important;
}
.customDialog-form.disable-safe-area .content {
height: 100% !important;
}
.customDialog-form.disable-safe-area .mud-dialog-content {
margin-top: unset !important;
}
}
.customDialog-form .header {
padding: 0 .75rem !important;
}
.input-card {
width: 100%;
background: var(--mud-palette-background-gray);
border-radius: 9px;
padding: .5rem 1rem;
}
.input-card.clearButton {
display: flex;
align-items: center;
justify-content: space-between;
padding: .4rem 1rem !important;
}
.input-card.clearButton.custom-border-bottom {
border-bottom: .1rem solid var(--card-border-color);
}
.input-card > .divider {
margin: 0 !important;
}
.form-container {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 35px;
}
.form-container > span {
font-weight: 700;
width: 50%;
margin-right: .3rem;
}
.form-container > .warning-text {
font-weight: 700;
color: var(--mud-palette-gray-darker);
width: unset;
margin: 0;
}
.form-container > .disable-full-width {
width: unset !important;
white-space: nowrap;
}
.dateTime-picker {
display: flex;
gap: .3rem;
flex-direction: row;
align-items: center;
}
/*Custom mudBlazor*/
.form-container .mud-input.mud-input-underline:before {
border-bottom: none !important;
}
.form-container .mud-input.mud-input-underline:after {
border-bottom: none !important;
}
.form-container.text-align-end .mud-input-slot {
text-align: end;
}
.input-card .mud-input.mud-input-underline:before {
border-bottom: none !important;
}
.input-card .mud-input.mud-input-underline:after {
border-bottom: none !important;
}
.form-container .customIcon-select .mud-icon-root.mud-svg-icon {
rotate: 90deg !important;
font-size: 1.1rem;
}
.form-container .customIcon-select .mud-input-slot {
text-align: end;
}
.input-card .mud-input {
width: 100%;
font-weight: 500;
}
.container-button {
width: 100%;
background: var(--mud-palette-table-striped);
padding: .5rem 0;
border-radius: 20px;
margin-bottom: 2rem;
}
.container-button.remove-backgroud {
background: unset !important;
}
.container-button.mud-elevation-1 {
background: unset !important;
}
.container-button .divider {
margin: .5rem 0 .5rem 3rem;
width: unset;
}
.container-button .button-settings {
border: none !important;
}
.container-button .button-settings .mud-icon-root {
border-radius: 6px;
padding: 2px;
min-width: 25px;
min-height: 25px;
}
.container-button > .mud-button-root {
padding-top: .15rem;
padding-bottom: .15rem;
}
.container-button .button-settings.gray-icon .mud-icon-root {
border: 1px solid hsl(from var(--mud-palette-gray-darker) h s 88%);
background: hsl(from var(--mud-palette-gray-darker) h s 88%);
color: var(--mud-palette-dark);
}
.container-button .button-settings.primary-icon .mud-icon-root {
border: 1px solid hsl(from var(--mud-palette-primary) h s 95%);
background: hsl(from var(--mud-palette-primary-lighten) h s 95%);
color: var(--mud-palette-primary-darken);
}
.container-button .button-settings.green-icon .mud-icon-root {
border: 1px solid hsl(from var(--mud-palette-success-lighten) h s 95%);
background: hsl(from var(--mud-palette-success-lighten) h s 95%);
color: var(--mud-palette-success-darken);
}
.container-button .button-settings.red-icon .mud-icon-root {
border: 1px solid hsl(from var(--mud-palette-error-lighten) h s 95%);
background: hsl(from var(--mud-palette-error-lighten) h s 95%);
color: var(--mud-palette-error-darken);
}
.container-button .button-settings .mud-button-label {
justify-content: flex-start;
text-transform: capitalize;
font-size: 1rem;
}
.container-button.ripple-container{
overflow: clip;
}
.container-button .button-settings.exit {
padding: 0;
}
.container-button .button-settings.exit .mud-button-label {
justify-content: center;
font-size: 1.1rem;
line-height: normal;
}
+392
View File
@@ -0,0 +1,392 @@
/* =============================================================================
SteUP Form
Il form scheda e' il cuore dell'app: si compila in piedi, con una mano.
Ogni campo e' un blocco largo e tappabile, non una riga di tabella.
========================================================================== */
/* -----------------------------------------------------------------------------
Gruppo di campo un Panel con la sua etichetta
-------------------------------------------------------------------------- */
.form-group {
background-color: var(--surface);
border-radius: var(--radius-surface);
padding: var(--space-md);
}
.form-group__label {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
font-size: var(--label-md-size);
line-height: 1.25;
font-weight: 600;
color: var(--ink-muted);
margin-bottom: var(--space-6);
}
.form-group__count {
font-size: var(--label-md-size);
font-weight: 700;
color: var(--ink-muted);
}
/* Il valore del campo: testo grande, tocco comodo. */
.form-group .mud-input-control { margin: 0; }
.form-group .mud-input-slot,
.form-group input,
.form-group .mud-select-input {
font-size: var(--body-lg-size);
font-weight: 600;
color: var(--ink);
min-height: var(--touch-min);
}
.form-group--required .form-group__label::after {
content: "obbligatorio";
font-size: var(--label-sm-size);
font-weight: 700;
color: var(--ink-muted);
}
.form-group--invalid { box-shadow: inset 0 0 0 1px var(--danger-solid); }
.form-error {
display: flex;
align-items: center;
gap: var(--space-6);
margin-top: var(--space-xs);
font-size: var(--body-md-size);
font-weight: 600;
color: var(--danger-ink);
}
/* -----------------------------------------------------------------------------
Riga di scelta rapida (scadenza, priorita'): pillole invece di una select
-------------------------------------------------------------------------- */
.option-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.option {
flex: 1 1 auto;
min-width: 96px;
min-height: var(--touch-min);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-pill);
border: 1px solid var(--divider);
background-color: var(--surface-muted);
color: var(--ink-muted);
font-family: inherit;
font-size: var(--label-lg-size);
font-weight: 600;
transition: background-color var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
}
.option:active { transform: scale(0.97); }
.option[aria-pressed="true"] {
background-color: var(--info-surface);
border-color: var(--primary);
color: var(--primary-dark);
}
@media (prefers-reduced-motion: reduce) {
.option:active { transform: none; }
}
/* -----------------------------------------------------------------------------
Campo barcode manuale affiancato al pulsante di invio
-------------------------------------------------------------------------- */
.barcode-field {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-xs) var(--space-xs) var(--space-md);
border-radius: var(--radius-pill);
background-color: var(--surface-muted);
border: 1.5px solid transparent;
transition: border-color var(--duration-fast) var(--ease-out);
}
.barcode-field:focus-within { border-color: var(--primary); }
.barcode-field input {
flex: 1 1 auto;
min-width: 0;
min-height: var(--touch-min);
border: none;
background: none;
outline: none;
font-family: inherit;
font-size: var(--body-lg-size);
font-weight: 600;
color: var(--ink);
/* Il codice si legge cifra per cifra: cifre a larghezza fissa. */
font-variant-numeric: tabular-nums;
}
.barcode-field input::placeholder { color: var(--ink-muted); font-weight: 400; }
.barcode-field__send {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: var(--touch-min);
height: var(--touch-min);
border: none;
border-radius: var(--radius-pill);
background-color: var(--primary);
color: var(--on-primary);
font-size: 20px;
transition: transform var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out);
}
.barcode-field__send:active { transform: scale(0.92); background-color: var(--primary-pressed); }
.barcode-field__send:disabled { background-color: var(--divider); color: var(--ink-muted); }
/* -----------------------------------------------------------------------------
Articoli scansionati
L'ultimo letto entra dall'alto: e' la conferma visiva della scansione.
-------------------------------------------------------------------------- */
.art-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.art-item {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-10) var(--space-sm);
border-radius: var(--radius-control);
background-color: var(--surface-muted);
animation: art-enter var(--duration-slow) var(--ease-out);
}
@keyframes art-enter {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
.art-item { animation: none; }
}
.art-item__text { flex: 1 1 auto; min-width: 0; }
.art-item__name {
font-size: var(--body-lg-size);
line-height: 1.25;
font-weight: 700;
color: var(--ink);
}
.art-item__code {
margin-top: var(--space-2);
font-size: var(--label-md-size);
font-weight: 600;
color: var(--ink-muted);
font-variant-numeric: tabular-nums;
}
.art-item__remove {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: var(--touch-min);
height: var(--touch-min);
border: none;
border-radius: var(--radius-pill);
background: none;
color: var(--ink-muted);
font-size: 20px;
}
.art-item__remove:active { background-color: var(--danger-surface); color: var(--danger-ink); }
/* -----------------------------------------------------------------------------
Foto allegate striscia orizzontale scorrevole
-------------------------------------------------------------------------- */
.attach-strip {
display: flex;
gap: var(--space-xs);
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
padding-inline: var(--space-md);
margin-inline: calc(var(--space-md) * -1);
}
.attach-strip::-webkit-scrollbar { display: none; }
.attach-item {
position: relative;
flex: 0 0 auto;
width: 104px;
border-radius: var(--radius-control);
overflow: hidden;
background-color: var(--surface-muted);
}
.attach-item__img {
width: 104px;
height: 104px;
object-fit: cover;
display: block;
}
.attach-item__placeholder {
width: 104px;
height: 104px;
display: flex;
align-items: center;
justify-content: center;
color: var(--ink-muted);
font-size: 26px;
}
.attach-item__remove {
position: absolute;
top: var(--space-4);
right: var(--space-4);
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: var(--radius-pill);
background-color: rgba(0, 0, 0, 0.62);
color: #ffffff;
font-size: 20px;
}
.attach-item__remove:active { background-color: var(--danger-solid); }
/* -----------------------------------------------------------------------------
Bottone di azione secondaria con icona in pastiglia
-------------------------------------------------------------------------- */
.action-tile {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
min-height: 56px;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--divider);
border-radius: var(--radius-control);
background-color: var(--surface);
font-family: inherit;
font-size: var(--body-lg-size);
font-weight: 600;
color: var(--ink);
text-align: left;
transition: transform var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out);
}
.action-tile:active { transform: scale(0.99); background-color: var(--surface-muted); }
.action-tile:disabled { color: var(--ink-muted); }
.action-tile__icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-compact);
font-size: 20px;
flex: 0 0 auto;
}
.action-tile__icon--neutral { background-color: var(--neutral-surface); color: var(--neutral-ink); }
.action-tile__icon--primary { background-color: var(--danger-surface); color: var(--primary); }
.action-tile__icon--success { background-color: var(--success-surface); color: var(--success-ink); }
.action-tile__icon--danger { background-color: var(--danger-surface); color: var(--danger-ink); }
.action-tile__icon--info { background-color: var(--info-surface); color: var(--info-ink); }
.action-tile__label { flex: 1 1 auto; min-width: 0; }
@media (prefers-reduced-motion: reduce) {
.action-tile:active { transform: none; }
}
/* -----------------------------------------------------------------------------
Riga di selezione multipla
Una riga larga e tappabile al posto di un menu a tendina: con i guanti si
centra un bersaglio da 56px, non una casella da 18.
-------------------------------------------------------------------------- */
.select-row {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
min-height: 56px;
padding: var(--space-10) var(--space-sm);
border: none;
border-radius: var(--radius-control);
background-color: var(--surface);
font-family: inherit;
text-align: left;
transition: background-color var(--duration-fast) var(--ease-out);
}
.select-row[aria-pressed="true"] { background-color: var(--info-surface); }
.select-row__check {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: var(--radius-compact);
border: 2px solid var(--divider);
background-color: var(--surface);
color: transparent;
font-size: 18px;
transition: background-color var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
.select-row[aria-pressed="true"] .select-row__check {
background-color: var(--primary);
border-color: var(--primary);
color: var(--on-primary);
}
.select-row__text { flex: 1 1 auto; min-width: 0; }
.select-row__title {
font-size: var(--body-lg-size);
line-height: 1.3;
font-weight: 600;
color: var(--ink);
}
.select-row__meta {
margin-top: var(--space-2);
font-size: var(--label-md-size);
font-weight: 600;
color: var(--ink-muted);
font-variant-numeric: tabular-nums;
}
@@ -0,0 +1,409 @@
/* =============================================================================
SteUP MudBlazor riportato allo stile condiviso
MudBlazor e' Material 2 di default: ombre per costruire gerarchia, tinte
automatiche sulle superfici, target stretti. Qui lo si appiattisce secondo
STILE_UI §1 e §5, cosi' i controlli di libreria e i componenti propri
parlano la stessa lingua.
========================================================================== */
/* -----------------------------------------------------------------------------
Aggancio delle variabili di MudBlazor ai token del progetto.
I token restano l'unica fonte di verita': cambiare un ruolo in tokens.css
aggiorna sia i componenti propri sia quelli di libreria.
-------------------------------------------------------------------------- */
:root {
--mud-palette-primary: var(--primary);
--mud-palette-primary-text: var(--on-primary);
--mud-palette-secondary: var(--primary-dark);
--mud-palette-tertiary: var(--info-surface);
--mud-palette-surface: var(--surface);
--mud-palette-background: var(--workspace);
--mud-palette-background-gray: var(--workspace);
--mud-palette-text-primary: var(--ink);
--mud-palette-text-secondary: var(--ink-muted);
--mud-palette-text-disabled: var(--ink-muted);
--mud-palette-action-default: var(--ink-muted);
--mud-palette-lines-default: var(--divider);
--mud-palette-lines-inputs: var(--divider);
--mud-palette-divider: var(--divider);
--mud-palette-divider-light: var(--divider);
--mud-palette-error: var(--danger-solid);
--mud-palette-error-text: #ffffff;
--mud-palette-success: var(--success-solid);
--mud-palette-warning: var(--warning-solid);
--mud-palette-info: var(--info-solid);
--mud-default-borderradius: var(--radius-control);
/* Tipografia: stessa scala dei token (STILE_UI §4). */
--mud-typography-default-family: "Inter", "SF Pro Display", "Roboto", system-ui, sans-serif;
--mud-typography-default-size: var(--body-lg-size);
--mud-typography-default-weight: 400;
--mud-typography-default-lineheight: var(--body-lg-height);
--mud-typography-default-letterspacing: normal;
--mud-typography-h5-family: var(--mud-typography-default-family);
--mud-typography-h5-size: var(--headline-sm-size);
--mud-typography-h5-weight: 700;
--mud-typography-h5-lineheight: var(--headline-sm-height);
--mud-typography-h6-family: var(--mud-typography-default-family);
--mud-typography-h6-size: var(--title-lg-size);
--mud-typography-h6-weight: 700;
--mud-typography-h6-lineheight: var(--title-lg-height);
--mud-typography-subtitle1-family: var(--mud-typography-default-family);
--mud-typography-subtitle1-size: var(--title-md-size);
--mud-typography-subtitle1-weight: 700;
--mud-typography-subtitle1-lineheight: var(--title-md-height);
--mud-typography-subtitle2-family: var(--mud-typography-default-family);
--mud-typography-subtitle2-size: var(--body-md-size);
--mud-typography-subtitle2-weight: 600;
--mud-typography-body1-family: var(--mud-typography-default-family);
--mud-typography-body1-size: var(--body-lg-size);
--mud-typography-body1-lineheight: var(--body-lg-height);
--mud-typography-body2-family: var(--mud-typography-default-family);
--mud-typography-body2-size: var(--body-md-size);
--mud-typography-body2-lineheight: var(--body-md-height);
--mud-typography-button-family: var(--mud-typography-default-family);
--mud-typography-button-size: var(--label-lg-size);
--mud-typography-button-weight: 600;
--mud-typography-button-transform: none;
--mud-typography-button-letterspacing: normal;
--mud-typography-caption-family: var(--mud-typography-default-family);
--mud-typography-caption-size: var(--label-md-size);
--mud-typography-caption-weight: 600;
}
/* -----------------------------------------------------------------------------
§1 Superfici piatte, nessun tint automatico
-------------------------------------------------------------------------- */
.mud-paper,
.mud-card,
.mud-expansion-panels .mud-expand-panel,
.mud-toolbar,
.mud-appbar {
box-shadow: none !important;
background-image: none !important;
background-color: var(--surface);
border-radius: var(--radius-surface);
}
.mud-elevation-0, .mud-elevation-1, .mud-elevation-2, .mud-elevation-3,
.mud-elevation-4, .mud-elevation-5, .mud-elevation-6, .mud-elevation-8,
.mud-elevation-12, .mud-elevation-16, .mud-elevation-24 {
box-shadow: none !important;
}
/* I livelli che galleggiano davvero conservano un contorno, non un'ombra. */
.mud-popover.mud-popover-open,
.mud-picker-popover-paper,
.mud-menu-popover {
border-radius: var(--radius-control) !important;
border: 1px solid var(--divider);
box-shadow: none !important;
}
/* -----------------------------------------------------------------------------
§5 Bottoni target 44/48, raggio control, etichette in caso naturale
-------------------------------------------------------------------------- */
.mud-button-root {
min-height: var(--touch-min);
border-radius: var(--radius-control);
font-family: var(--font-sans);
font-size: var(--label-lg-size);
font-weight: 600;
line-height: 1.25;
letter-spacing: normal;
text-transform: none;
transition: transform var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out);
}
.mud-button-root:active { transform: scale(0.97); }
.mud-button-filled {
min-height: var(--button-height);
box-shadow: none !important;
padding-inline: var(--space-lg);
}
.mud-button-filled.mud-button-filled-primary {
background-color: var(--primary);
color: var(--on-primary);
}
.mud-button-filled.mud-button-filled-primary:hover,
.mud-button-filled.mud-button-filled-primary:active {
background-color: var(--primary-pressed);
}
.mud-button-outlined {
min-height: var(--button-height);
border: 1px solid var(--divider);
color: var(--ink);
padding-inline: var(--space-lg);
}
.mud-button-text { min-height: var(--touch-min); }
.mud-icon-button {
min-width: var(--touch-min);
min-height: var(--touch-min);
border-radius: var(--radius-pill);
transition: transform var(--duration-fast) var(--ease-out),
background-color var(--duration-fast) var(--ease-out);
}
.mud-icon-button:active { transform: scale(0.92); }
/* Il FAB circolare al centro e' una firma Android di serie (§1.7): qui i FAB
sono rettangolari e stondati come il resto dei controlli. */
.mud-fab {
box-shadow: none !important;
border-radius: var(--radius-control);
min-height: var(--touch-min);
font-family: var(--font-sans);
font-weight: 600;
text-transform: none;
letter-spacing: normal;
}
.mud-fab.mud-fab-primary { background-color: var(--primary); color: var(--on-primary); }
.mud-fab.mud-fab-default { background-color: var(--surface-muted); color: var(--ink); }
.mud-fab:active { transform: scale(0.96); }
@media (prefers-reduced-motion: reduce) {
.mud-button-root:active,
.mud-icon-button:active,
.mud-fab:active { transform: none; }
}
/* -----------------------------------------------------------------------------
§5 Campi riempiti, raggio control, bordo 1px a riposo e 2px in focus
La sottolineatura Material viene rimossa: i campi sono schede, non righe.
-------------------------------------------------------------------------- */
.mud-input.mud-input-text,
.mud-input.mud-input-filled,
.mud-input-control .mud-input-slot {
font-family: var(--font-sans);
font-size: var(--body-lg-size);
color: var(--ink);
}
.mud-input:before,
.mud-input:after,
.mud-input-filled:before,
.mud-input-filled:after,
.mud-input-underline:before,
.mud-input-underline:after {
display: none !important;
border: none !important;
}
.field-shell {
background-color: var(--surface);
border: 1px solid var(--divider);
border-radius: var(--radius-control);
padding: var(--space-md);
transition: border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.field-shell:focus-within {
border-color: var(--primary);
box-shadow: inset 0 0 0 1px var(--primary);
}
.field-shell--error { border-color: var(--danger-solid); }
.field-shell--muted { background-color: var(--surface-muted); }
.mud-input-label,
.mud-input-helper-text {
font-family: var(--font-sans);
color: var(--ink-muted);
}
.mud-input-helper-text.mud-input-error,
.mud-input-error .mud-input-helper-text {
color: var(--danger-ink);
font-weight: 600;
}
/* Il testo segnaposto deve restare leggibile al sole: stesso contrasto del
testo secondario, non il grigio chiarissimo di serie. */
.mud-input input::placeholder,
.mud-input textarea::placeholder {
color: var(--ink-muted);
opacity: 1;
}
.mud-select .mud-input-adornment .mud-icon-root { color: var(--ink-muted); }
/* -----------------------------------------------------------------------------
Select, liste e voci di menu
-------------------------------------------------------------------------- */
.mud-list-item {
min-height: var(--touch-min);
border-radius: var(--radius-compact);
font-family: var(--font-sans);
font-size: var(--body-lg-size);
}
.mud-list-item.mud-selected-item {
background-color: var(--info-surface);
color: var(--primary-dark);
font-weight: 600;
}
.custom-item-select { padding-block: var(--space-xs); }
/* -----------------------------------------------------------------------------
Chip
-------------------------------------------------------------------------- */
.mud-chip {
border-radius: var(--radius-pill);
font-family: var(--font-sans);
font-size: var(--label-sm-size);
font-weight: 700;
letter-spacing: normal;
text-transform: none;
}
.mud-chip.mud-chip-filled.mud-chip-color-default {
background-color: var(--neutral-surface);
color: var(--neutral-ink);
}
/* -----------------------------------------------------------------------------
Pannelli a fisarmonica nessun bordo di serie, nessuna ombra
-------------------------------------------------------------------------- */
.mud-expansion-panels { width: 100%; }
.mud-expand-panel {
background-color: transparent !important;
border: none !important;
margin-block: 0 !important;
}
.mud-expand-panel .mud-expand-panel-header {
padding: var(--space-sm) 0;
min-height: var(--touch-min);
}
.mud-expand-panel .mud-expand-panel-content { padding: 0; }
.mud-expand-panel::before { display: none !important; }
/* -----------------------------------------------------------------------------
§6.10 Dialog
-------------------------------------------------------------------------- */
.mud-dialog {
border-radius: var(--radius-surface) !important;
background-color: var(--surface);
box-shadow: none !important;
max-width: 420px;
width: calc(100vw - 48px);
}
.mud-dialog-title {
padding: var(--space-lg) var(--space-lg) 0;
font-family: var(--font-sans);
font-size: var(--title-md-size);
line-height: var(--title-md-height);
font-weight: 700;
color: var(--ink);
}
.mud-dialog-content {
padding: var(--space-xs) var(--space-lg) 0;
font-family: var(--font-sans);
font-size: var(--body-lg-size);
line-height: 1.45;
color: var(--ink-muted);
overflow-x: hidden;
}
.mud-dialog-actions {
display: flex;
gap: var(--space-sm);
padding: var(--space-lg);
}
.mud-dialog-actions > * { flex: 1 1 0; margin: 0 !important; }
/* Il form scheda e' a schermo pieno: e' una schermata, non un riquadro. */
.mud-dialog.customDialog-form {
max-width: 100%;
width: 100%;
height: 100%;
max-height: 100%;
margin: 0;
border-radius: 0 !important;
background-color: var(--workspace);
}
.mud-dialog.customDialog-form .mud-dialog-content {
padding: 0;
max-height: 100%;
height: 100%;
color: var(--ink);
}
.mud-overlay { z-index: var(--z-overlay); }
.mud-overlay.on-top { z-index: var(--z-modal) !important; }
/* -----------------------------------------------------------------------------
§6.9 Toast riempimento pieno del tono, testo bianco, l'unica ombra
-------------------------------------------------------------------------- */
.mud-snackbar {
border-radius: var(--radius-control) !important;
box-shadow: var(--shadow-toast) !important;
font-family: var(--font-sans);
font-size: var(--body-lg-size);
font-weight: 600;
line-height: 1.35;
color: #ffffff !important;
padding: var(--space-sm) var(--space-14) !important;
z-index: var(--z-toast);
}
.mud-snackbar.mud-alert-filled-success { background-color: var(--success-solid) !important; }
.mud-snackbar.mud-alert-filled-error { background-color: var(--danger-solid) !important; }
.mud-snackbar.mud-alert-filled-warning { background-color: var(--warning-solid) !important; }
.mud-snackbar.mud-alert-filled-info { background-color: var(--info-solid) !important; }
.mud-snackbar .mud-snackbar-content-message { color: #ffffff !important; }
.mud-snackbar .mud-icon-root { color: #ffffff !important; }
/* -----------------------------------------------------------------------------
Progresso
-------------------------------------------------------------------------- */
.mud-progress-linear { border-radius: var(--radius-compact); height: 8px; }
.mud-progress-linear .mud-progress-linear-bar { background-color: var(--primary); }
.mud-progress-circular.mud-primary-text { color: var(--primary); }
/* -----------------------------------------------------------------------------
Il ripple di Mud viene disattivato dove il contenitore ha il proprio
feedback al tocco, per non avere due risposte sovrapposte allo stesso dito.
-------------------------------------------------------------------------- */
.ripple-container .mud-ripple-effect,
.ripple-container .mud-ripple-effect-expanding {
display: none !important;
}
.mud-skeleton { border-radius: var(--radius-compact) !important; }
+167
View File
@@ -0,0 +1,167 @@
/* =============================================================================
SteUP Token di design
Implementa STILE_UI.md §2 (ruoli colore) e §3 (scale) sulla palette SteUP.
Regola: nelle pagine si usano SOLO questi token. Un colore o un raggio
scritto a mano in una schermata e' un difetto, non una scorciatoia.
========================================================================== */
:root {
/* -------------------------------------------------------------------------
COLORE ruoli semantici (STILE_UI §2)
La palette storica di SteUP resta intatta: corallo azione, navy ancora,
azzurro tenue informativo. Sono stati aggiunti solo i ruoli che
mancavano: workspace, surfaceMuted, divider, inkMuted e le coppie
(foreground forte / superficie tenue) dei toni semantici.
---------------------------------------------------------------------- */
/* Accento unico di azione. La sua rarita' e' cio' che lo rende un segnale. */
--primary: #ec4c41;
--primary-pressed: #d23a30;
--on-primary: #ffffff;
/* Ancora della struttura: testo forte, icone metrica, foreground su info. */
--primary-dark: #002339;
/* Superfici: lo sfondo pagina e' piu' freddo delle superfici di lavoro.
Tre gradini netti, cosi' i pannelli staccano senza bisogno di ombre. */
--workspace: #e9edf2;
--surface: #ffffff;
--surface-muted: #f4f6f9;
--divider: #d9e0e8;
/* Testo. Inchiostro pieno per la lettura in pieno sole (Regola del
Contrasto da Sole): niente grigio chiaro sul corpo del testo. */
--ink: #000000;
--ink-muted: #465563; /* 8.3:1 su surface, 7.4:1 su workspace */
/* --- Toni semantici -----------------------------------------------------
Ogni tono e' una tripletta:
-ink foreground forte: testo e icone sopra la superficie tenue
-surface riempimento tenue: badge, pannelli, cerchi-icona dei dialog
-solid riempimento pieno: toast (testo bianco sopra)
Gli -ink sono scelti per superare 4.5:1 sulla propria superficie tenue:
i colori "puri" della palette restano per icone e riempimenti pieni,
dove il contrasto di testo non e' in gioco.
-------------------------------------------------------------------- */
/* Danger — errori, azioni distruttive, priorita' massima */
--danger-ink: #b3120c;
--danger-surface: #fdeceb;
--danger-solid: #e50000;
/* Warning — attenzione, dati incompleti, bozze da completare */
--warning-ink: #8a4f00;
--warning-surface: #fdf0db;
--warning-solid: #b26a00;
/* Success — conferme, completamenti, sincronizzato */
--success-ink: #10702f;
--success-surface: #e4f5e9;
--success-solid: #26b050;
/* Info — stato informativo, pannelli di riepilogo, selezione */
--info-ink: #002339;
--info-surface: #dff2ff;
--info-solid: #1877d8;
/* Neutral — stati senza carica semantica */
--neutral-ink: #465563;
--neutral-surface: #f4f6f9;
--neutral-solid: #465563;
/* Focus da tastiera */
--focus-ring: #258cfb;
/* -------------------------------------------------------------------------
SPAZIATURA (STILE_UI §3)
---------------------------------------------------------------------- */
--space-xs: 8px;
--space-sm: 12px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* Micro-distanze derivate, d'uso frequente */
--space-2: 2px;
--space-4: 4px;
--space-6: 6px;
--space-10: 10px; /* tra le card di una lista */
--space-14: 14px;
--space-18: 18px; /* padding dei pannelli di testata */
--space-20: 20px;
--space-22: 22px;
/* -------------------------------------------------------------------------
RAGGI (STILE_UI §3)
Superfici piu' arrotondate dei controlli; stati completamente stondati.
---------------------------------------------------------------------- */
--radius-compact: 8px;
--radius-control: 16px;
--radius-surface: 20px;
--radius-pill: 999px;
/* -------------------------------------------------------------------------
TIPOGRAFIA (STILE_UI §4)
Scala fissa: si legge a colpo d'occhio, non fa effetto editoriale.
---------------------------------------------------------------------- */
--font-sans: "Inter", "SF Pro Display", "Roboto", system-ui, sans-serif;
--headline-md-size: 34px;
--headline-md-height: 1.2;
--headline-sm-size: 28px;
--headline-sm-height: 1.25;
--title-lg-size: 22px;
--title-lg-height: 1.3;
--title-md-size: 18px;
--title-md-height: 1.35;
--body-lg-size: 16px;
--body-lg-height: 1.5;
--body-md-size: 15px;
--body-md-height: 1.45;
--label-lg-size: 14px;
--label-md-size: 13px;
--label-sm-size: 12px;
/* -------------------------------------------------------------------------
MISURE STRUTTURALI
---------------------------------------------------------------------- */
--appbar-height: 80px;
--navbar-height: 68px;
--touch-min: 44px;
--button-height: 48px;
--content-max: 920px;
/* Spazio che le pagine lasciano in fondo per non finire sotto la barra
flottante di navigazione (STILE_UI §6.15). */
--nav-clearance: 116px;
/* -------------------------------------------------------------------------
MOVIMENTO
Curve esponenziali in uscita: il movimento serve allo stato, mai alla
scena. Durate da prodotto, l'utente e' dentro un compito.
---------------------------------------------------------------------- */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--duration-fast: 120ms;
--duration-base: 200ms;
--duration-slow: 280ms;
/* -------------------------------------------------------------------------
PROFONDITA' scala semantica, mai valori arbitrari
Le superfici sono piatte (STILE_UI §1): questi livelli ordinano solo
cio' che galleggia davvero.
---------------------------------------------------------------------- */
--z-base: 0;
--z-sticky: 10;
--z-navbar: 20;
--z-connection: 30;
--z-overlay: 40;
--z-modal: 50;
--z-toast: 60;
/* L'unica ombra del sistema: il toast (STILE_UI §6.9). */
--shadow-toast: 0 6px 20px rgba(0, 0, 0, 0.24);
/* Breakpoint (STILE_UI §3) documentati qui, usati nelle media query:
rail 720px · twoColumn 900px · contentMax 1180px */
}
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+19 -97
View File
@@ -1,105 +1,14 @@
// Funzione goBack
// Interoperabilità minima con l'host. Il feedback al tocco e gli stati sono
// gestiti in CSS: qui resta solo ciò che il CSS non può fare.
window.goBack = function () {
console.log("goBack");
history.back();
};
// Funzione per monitorare bottom-sheet-container e gestire la navbar
function monitorBottomSheetClass(mutations) {
const bottomSheet = document.querySelector(".bottom-sheet-container");
const navbar = document.querySelector(".animated-navbar");
if (!bottomSheet || !navbar) return;
mutations.forEach(function (mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'class' && mutation.target === bottomSheet) {
if (bottomSheet.classList.contains("show")) {
navbar.classList.remove("show-nav");
navbar.classList.add("hide-nav");
console.log("Navbar nascosta (hide-nav)");
} else {
navbar.classList.remove("hide-nav");
navbar.classList.add("show-nav");
console.log("Navbar mostrata (show-nav)");
}
}
});
}
// Observer combinato per tutte le funzionalità
const observer = new MutationObserver((mutations) => {
// Monitora bottom-sheet-container
monitorBottomSheetClass(mutations);
});
// Osserva sia i cambiamenti nel DOM che gli attributi
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class']
});
// Sync iniziale per la navbar (nel caso la pagina parte già con .show)
document.addEventListener("DOMContentLoaded", () => {
const bottomSheet = document.querySelector(".bottom-sheet-container");
const navbar = document.querySelector(".animated-navbar");
if (bottomSheet && navbar) {
if (bottomSheet.classList.contains("show")) {
navbar.classList.remove("show-nav");
navbar.classList.add("hide-nav");
} else {
navbar.classList.remove("hide-nav");
navbar.classList.add("show-nav");
}
}
});
(function () {
// Ascoltiamo l'evento 'mousedown' su tutto il documento
document.addEventListener('mousedown', function (event) {
// Cerca se l'elemento cliccato (o un suo genitore) ha la classe .ripple-container
const target = event.target.closest('.ripple-container');
if (target) {
createRipple(event, target);
}
});
function createRipple(event, element) {
const circle = document.createElement("span");
const diameter = Math.max(element.clientWidth, element.clientHeight);
const radius = diameter / 2;
const rect = element.getBoundingClientRect();
circle.style.width = circle.style.height = `${diameter}px`;
circle.style.left = `${event.clientX - rect.left - radius}px`;
circle.style.top = `${event.clientY - rect.top - radius}px`;
circle.classList.add("ripple");
// Rimuovi l'elemento dal DOM alla fine dell'animazione per non intasare la memoria
const ripple = element.getElementsByClassName("ripple")[0];
if (ripple) {
ripple.remove();
}
element.appendChild(circle);
// Pulizia automatica dopo 600ms (durata animazione CSS)
setTimeout(() => {
circle.remove();
}, 600);
}
})();
// Tastiera: porta il campo attivo dentro l'area visibile.
// Il layout e' a 100vh con overflow:hidden, quindi quando l'host riduce la
// WebView per la tastiera il browser non scrolla da solo sul campo col focus.
// Il layout e' a piena altezza con lo scroll in un contenitore, quindi quando
// l'host riduce la WebView per la tastiera il browser non scrolla da solo sul
// campo col focus.
(function () {
const SELECTOR = 'input, textarea, [contenteditable="true"]';
let pending = null;
@@ -130,3 +39,16 @@ document.addEventListener("DOMContentLoaded", () => {
window.addEventListener('resize', schedule);
}
})();
// Un doppio tocco ravvicinato su una WebView fa zoomare la pagina: e' il
// gesto del browser, non quello di un'app. Il viewport lo vieta gia', questo
// copre i casi in cui Android lo reintroduce.
(function () {
let lastTouch = 0;
document.addEventListener('touchend', function (event) {
const now = Date.now();
if (now - lastTouch <= 320) event.preventDefault();
lastTouch = now;
}, {passive: false});
})();