Finish v1.2.0
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"detector": {
|
||||
"ignoreRules": [],
|
||||
"ignoreFiles": [],
|
||||
"ignoreValues": [
|
||||
{
|
||||
"rule": "overused-font",
|
||||
"value": "inter",
|
||||
"reason": "Scelta esplicita dell'utente: STILE_UI.md prescrive Inter per uniformare le app Integry; il register product della skill ammette Inter per le UI di prodotto",
|
||||
"createdAt": "2026-09-17T08:51:01.079Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -3,69 +3,150 @@ name: SteUP
|
||||
description: Strumento da campo per ispezioni nei punti vendita — solido, chiaro, offline-first.
|
||||
colors:
|
||||
primary: "#ec4c41"
|
||||
secondary: "#002339"
|
||||
tertiary: "#dff2ff"
|
||||
primary-pressed: "#d23a30"
|
||||
primary-dark: "#002339"
|
||||
on-primary: "#ffffff"
|
||||
workspace: "#e9edf2"
|
||||
surface: "#ffffff"
|
||||
surface-muted: "#f4f6f9"
|
||||
divider: "#d9e0e8"
|
||||
ink: "#000000"
|
||||
paper: "#ffffff"
|
||||
surface-dark: "#000406"
|
||||
success: "#26b050"
|
||||
error: "#e50000"
|
||||
ink-muted: "#465563"
|
||||
danger-ink: "#b3120c"
|
||||
danger-surface: "#fdeceb"
|
||||
danger-solid: "#e50000"
|
||||
warning-ink: "#8a4f00"
|
||||
warning-surface: "#fdf0db"
|
||||
warning-solid: "#b26a00"
|
||||
success-ink: "#10702f"
|
||||
success-surface: "#e4f5e9"
|
||||
success-solid: "#26b050"
|
||||
info-ink: "#002339"
|
||||
info-surface: "#dff2ff"
|
||||
info-solid: "#1877d8"
|
||||
neutral-ink: "#465563"
|
||||
neutral-surface: "#f4f6f9"
|
||||
focus-ring: "#258cfb"
|
||||
typography:
|
||||
page-title:
|
||||
fontFamily: "Nunito, sans-serif"
|
||||
fontSize: "1.5rem"
|
||||
fontWeight: 800
|
||||
headline-medium:
|
||||
fontFamily: "Inter, sans-serif"
|
||||
fontSize: "34px"
|
||||
fontWeight: 700
|
||||
lineHeight: 1.2
|
||||
letterSpacing: "normal"
|
||||
title:
|
||||
fontFamily: "Nunito, sans-serif"
|
||||
fontSize: "1.1rem"
|
||||
letterSpacing: "-0.02em"
|
||||
headline-small:
|
||||
fontFamily: "Inter, sans-serif"
|
||||
fontSize: "28px"
|
||||
fontWeight: 700
|
||||
lineHeight: 1.4
|
||||
lineHeight: 1.25
|
||||
letterSpacing: "-0.02em"
|
||||
title-large:
|
||||
fontFamily: "Inter, sans-serif"
|
||||
fontSize: "22px"
|
||||
fontWeight: 700
|
||||
lineHeight: 1.3
|
||||
letterSpacing: "-0.01em"
|
||||
title-medium:
|
||||
fontFamily: "Inter, sans-serif"
|
||||
fontSize: "18px"
|
||||
fontWeight: 700
|
||||
lineHeight: 1.35
|
||||
letterSpacing: "normal"
|
||||
body:
|
||||
fontFamily: "Nunito, sans-serif"
|
||||
fontSize: "0.875rem"
|
||||
body-large:
|
||||
fontFamily: "Inter, sans-serif"
|
||||
fontSize: "16px"
|
||||
fontWeight: 400
|
||||
lineHeight: 1.8
|
||||
lineHeight: 1.5
|
||||
letterSpacing: "normal"
|
||||
label:
|
||||
fontFamily: "Nunito, sans-serif"
|
||||
fontSize: "0.875rem"
|
||||
body-medium:
|
||||
fontFamily: "Inter, sans-serif"
|
||||
fontSize: "15px"
|
||||
fontWeight: 400
|
||||
lineHeight: 1.45
|
||||
letterSpacing: "normal"
|
||||
label-large:
|
||||
fontFamily: "Inter, sans-serif"
|
||||
fontSize: "14px"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.25
|
||||
letterSpacing: "normal"
|
||||
label-medium:
|
||||
fontFamily: "Inter, sans-serif"
|
||||
fontSize: "13px"
|
||||
fontWeight: 600
|
||||
lineHeight: 1.25
|
||||
letterSpacing: "normal"
|
||||
label-small:
|
||||
fontFamily: "Inter, sans-serif"
|
||||
fontSize: "12px"
|
||||
fontWeight: 700
|
||||
lineHeight: 1.4
|
||||
lineHeight: 1.3
|
||||
letterSpacing: "0.3px"
|
||||
icon-sm:
|
||||
fontFamily: "remixicon"
|
||||
fontSize: "15px"
|
||||
fontWeight: 400
|
||||
lineHeight: 1
|
||||
letterSpacing: "normal"
|
||||
icon-md:
|
||||
fontFamily: "remixicon"
|
||||
fontSize: "20px"
|
||||
fontWeight: 400
|
||||
lineHeight: 1
|
||||
letterSpacing: "normal"
|
||||
icon-lg:
|
||||
fontFamily: "remixicon"
|
||||
fontSize: "26px"
|
||||
fontWeight: 400
|
||||
lineHeight: 1
|
||||
letterSpacing: "normal"
|
||||
icon-xl:
|
||||
fontFamily: "remixicon"
|
||||
fontSize: "44px"
|
||||
fontWeight: 400
|
||||
lineHeight: 1
|
||||
letterSpacing: "normal"
|
||||
rounded:
|
||||
input: "9px"
|
||||
panel: "1em"
|
||||
container: "20px"
|
||||
skeleton: "0.5em"
|
||||
compact: "8px"
|
||||
control: "16px"
|
||||
surface: "20px"
|
||||
sheet: "24px"
|
||||
pill: "999px"
|
||||
spacing:
|
||||
page-x: "1rem"
|
||||
header-h: "4rem"
|
||||
xs: "8px"
|
||||
sm: "12px"
|
||||
md: "16px"
|
||||
lg: "24px"
|
||||
xl: "32px"
|
||||
touch-min: "44px"
|
||||
button-height: "48px"
|
||||
appbar-height: "80px"
|
||||
navbar-height: "68px"
|
||||
content-max: "920px"
|
||||
components:
|
||||
panel:
|
||||
backgroundColor: "{colors.surface}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.surface}"
|
||||
padding: "16px"
|
||||
button-primary:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.paper}"
|
||||
rounded: "{rounded.container}"
|
||||
typography: "{typography.label}"
|
||||
padding: "0.5rem 1.25rem"
|
||||
button-primary-active:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.paper}"
|
||||
rounded: "{rounded.container}"
|
||||
input-card:
|
||||
backgroundColor: "#f5f5f5"
|
||||
textColor: "{colors.on-primary}"
|
||||
rounded: "{rounded.control}"
|
||||
typography: "{typography.label-large}"
|
||||
padding: "0 24px"
|
||||
button-outlined:
|
||||
backgroundColor: "{colors.surface}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.input}"
|
||||
padding: "0.5rem 1rem"
|
||||
panel:
|
||||
backgroundColor: "{colors.paper}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.panel}"
|
||||
padding: "1rem"
|
||||
rounded: "{rounded.control}"
|
||||
typography: "{typography.label-large}"
|
||||
badge:
|
||||
rounded: "{rounded.pill}"
|
||||
typography: "{typography.label-small}"
|
||||
padding: "5px 9px"
|
||||
navbar:
|
||||
backgroundColor: "{colors.surface}"
|
||||
rounded: "{rounded.sheet}"
|
||||
---
|
||||
|
||||
# Design System: SteUP
|
||||
@@ -74,119 +155,116 @@ components:
|
||||
|
||||
**Creative North Star: "Il taccuino da campo"**
|
||||
|
||||
SteUP è lo strumento che il rilevatore tiene in mano mentre gira i punti vendita: deve funzionare come carta e inchiostro — sempre, anche senza rete, leggibile in pieno sole, comprensibile con un'occhiata. La chiarezza viene prima di qualsiasi ornamento. L'interfaccia poggia su MudBlazor con un'unica famiglia tipografica (Nunito) e una palette essenziale: un solo accento caldo per l'azione, un blu profondo come base stabile, superfici bianche e pulite con angoli generosamente arrotondati.
|
||||
SteUP è lo strumento che il rilevatore tiene in mano mentre gira i punti vendita: deve funzionare come carta e inchiostro — sempre, anche senza rete, leggibile in pieno sole, comprensibile con un'occhiata.
|
||||
|
||||
Il sistema è **tattile e sicuro**: bersagli ampi pensati per il pollice, una sola mano e i guanti; feedback fisico immediato a ogni tocco e a ogni scansione (effetto ripple, cambi di stato evidenti). Lo stato — salvato in locale, in attesa di sync, inviato al server, completato — non è mai ambiguo: è la funzione più importante che la UI comunica.
|
||||
Il sistema visivo segue **`STILE_UI.md`**, lo stile condiviso delle app Integry (riferimento: Fixiy). Quel documento definisce forma, ritmo, tipografia e composizione; questo documento fissa i valori concreti di SteUP su quei ruoli.
|
||||
|
||||
Questo sistema rifiuta esplicitamente due cose. Non è un **gestionale datato e denso**: niente tabelle fittissime, testo minuscolo, grigio ovunque, densità che non serve al lavoro da campo. E non è un'**app consumer social/giocosa**: niente colore ludico, niente decorazione fine a sé stessa. È uno strumento professionale.
|
||||
|
||||
**Key Characteristics:**
|
||||
- Offline-first: lo stato di salvataggio e sincronizzazione è sempre esplicito
|
||||
- Mobile-first, uso a una mano/guanti: tocchi ≥44px, azioni a portata di pollice
|
||||
- Alto contrasto e testo generoso per la leggibilità all'aperto
|
||||
- Un solo accento (Arancio-Rosso Allerta), usato per azione e stato, mai per decorazione
|
||||
- Un'unica famiglia tipografica, angoli arrotondati coerenti (9px → 20px → 1em)
|
||||
**Caratteristiche portanti:**
|
||||
- **Superfici piatte.** Nessuna ombra costruisce gerarchia: la separazione viene dal contrasto di superficie (sfondo pagina più freddo delle superfici) e dallo spazio. L'unica ombra del sistema è quella del toast.
|
||||
- **Offline-first.** Lo stato di salvataggio e sincronizzazione è sempre esplicito. Il carattere è ospitato in locale: un font che arriva dalla rete è un font che in campo non arriva.
|
||||
- **Il pollice comanda.** Tocchi ≥44px, azioni primarie in basso: navigazione flottante, barra azioni ancorata in fondo ai form.
|
||||
- **Alto contrasto.** Inchiostro pieno sul corpo del testo, per la luce diretta del sole.
|
||||
- **Un solo accento.** Il corallo indica azione e stato attivo, mai decorazione.
|
||||
|
||||
## 2. Colors
|
||||
|
||||
Una palette essenziale: un accento caldo che chiama l'azione, un blu profondo come ancora, il resto neutro e pulito.
|
||||
I ruoli sono quelli di `STILE_UI.md` §2, riempiti con la palette storica di SteUP. Sono stati aggiunti solo i ruoli che mancavano: `workspace`, `surface-muted`, `divider`, `ink-muted` e le triplette dei toni semantici.
|
||||
|
||||
### Primary
|
||||
- **Arancio-Rosso Allerta** (#ec4c41): il colore dell'azione e dello stato attivo. Pulsanti primari, FAB, selezione corrente, icone d'azione, indicatori di stato che richiedono attenzione. Riservato: la sua presenza segnala "qui si agisce".
|
||||
### Accento e ancora
|
||||
- **Corallo** (`#ec4c41`) — `primary`: il colore dell'azione. Pulsanti primari, selezione, progresso, FAB. La sua rarità è ciò che lo rende un segnale.
|
||||
- **Blu Profondo** (`#002339`) — `primary-dark`: ancora della struttura. Testo su superfici informative, icone metrica, valori in evidenza, bottone di accesso.
|
||||
|
||||
### Secondary
|
||||
- **Blu Profondo** (#002339): base stabile e di struttura. Testo forte, intestazioni, elementi strutturali, superfici scure in dark mode. È l'ancora visiva contro cui l'accento risalta.
|
||||
### Superfici — tre gradini netti
|
||||
- `workspace` (`#e9edf2`): sfondo dell'applicazione, più freddo delle superfici di lavoro.
|
||||
- `surface` (`#ffffff`): card, pannelli, testate, barra di navigazione, dialog.
|
||||
- `surface-muted` (`#f4f6f9`): superficie secondaria dentro una superficie (campo di ricerca, chip a riposo, riquadri interni).
|
||||
- `divider` (`#d9e0e8`): bordi 1px e contorni dei controlli a riposo.
|
||||
|
||||
### Tertiary
|
||||
- **Azzurro Tenue** (#dff2ff): tocco freddo e leggero per evidenziazioni informative e superfici secondarie, dove serve distinguere senza gridare.
|
||||
### Testo
|
||||
- `ink` (`#000000`): corpo del testo. **La Regola del Contrasto da Sole**: il grigio chiaro "per eleganza" è vietato sul testo leggibile.
|
||||
- `ink-muted` (`#465563`): testo secondario, metadati, icone non attive. 8.3:1 su `surface`, 7.4:1 su `workspace`. Il testo secondario si distingue per **colore**, mai per opacità o peso sotto 400.
|
||||
|
||||
### Neutral
|
||||
- **Inchiostro** (#000000): corpo del testo su fondo chiaro; massimo contrasto per la lettura al sole.
|
||||
- **Carta** (#ffffff): superficie di contenuto primaria (pannelli, card, dialog).
|
||||
- **Superficie Scura** (#000406): sfondo della dark mode (attualmente predisposta ma disattivata).
|
||||
- **Grigio Campo** (~#f5f5f5): fondo delle input-card e delle superfici tonali secondarie; distingue per tono, non per bordo.
|
||||
### Toni semantici — la regola delle triplette
|
||||
Ogni tono è una tripletta *(ink, surface, solid)*:
|
||||
- `-ink` colora testo e icone **sopra la superficie tenue** (sempre ≥4.5:1 su quella superficie);
|
||||
- `-surface` riempie badge, pannelli di feedback e cerchi-icona dei dialog;
|
||||
- `-solid` è il riempimento pieno dei toast, con testo bianco sopra.
|
||||
|
||||
### Semantic
|
||||
- **Verde OK** (#26b050): validazione positiva, esito riuscito.
|
||||
- **Rosso Errore** (#e50000): validazione fallita, messaggi d'errore, connessione/servizio KO.
|
||||
- **Blu Focus** (#258cfb): anello di focus per la navigazione da tastiera.
|
||||
I colori "puri" della palette storica (`#e50000`, `#26b050`) vivono come `-solid`: sono giusti per riempimenti e icone, non abbastanza contrastati per il testo. Per questo esistono `danger-ink` e `success-ink`.
|
||||
|
||||
| Tono | Uso |
|
||||
|---|---|
|
||||
| danger | errori, azioni distruttive, schede da inviare |
|
||||
| warning | attenzione, dati incompleti, bozze da completare |
|
||||
| success | conferme, completamenti, sincronizzato |
|
||||
| info | stato informativo, pannelli di riepilogo, selezione |
|
||||
| neutral | stati senza carica semantica |
|
||||
|
||||
### Named Rules
|
||||
**La Regola dell'Unico Accento.** L'Arancio-Rosso Allerta è l'unico accento del sistema e appare solo su azione o stato attivo, mai come decorazione. La sua rarità è ciò che lo rende un segnale.
|
||||
**La Regola dell'Unico Accento.** Il corallo appare solo su azione o stato attivo, mai come decorazione.
|
||||
|
||||
**La Regola del Contrasto da Sole.** Il testo di corpo è inchiostro (#000) su carta (#fff). Il grigio chiaro "per eleganza" è vietato sul testo leggibile: si progetta per lo schermo colpito dal sole, non per lo screenshot.
|
||||
**Mai il solo colore.** Ogni stato porta sempre icona **e** etichetta testuale. Le mappature stanno in `StatusBadgeHelper`: uno stato si presenta identico ovunque, e uno nuovo si aggiunge in un punto solo.
|
||||
|
||||
## 3. Typography
|
||||
|
||||
**Display / Body / Label Font:** Nunito (variable 200–1000, con fallback `sans-serif`). Caricata da Google Fonts; usata su tutta l'interfaccia.
|
||||
**Famiglia unica: Inter** (variabile 100–900), **ospitata in locale** in `wwwroot/fonts`. Scala fissa, pensata per la scansione rapida, non per l'effetto editoriale. La gerarchia si fa con peso e dimensione.
|
||||
|
||||
**Character:** un'unica famiglia sans humanist e morbida per tutto — titoli, etichette, pulsanti, corpo, dati. Gli angoli tondeggianti di Nunito si accordano con i raggi ampi dei componenti e rafforzano il tono solido ma accessibile del "taccuino da campo". Nessun accostamento display/body: la gerarchia si fa con peso e dimensione.
|
||||
|
||||
### Hierarchy
|
||||
- **Page Title** (peso 800, ~1.5rem/x-large, line-height stretta): titolo di schermata; ancora l'utente al compito corrente.
|
||||
- **Title** (peso 700, ~1.1rem): intestazioni di sezione, titoli di dialog e message-box.
|
||||
- **Body** (peso 400, 0.875rem/14px, line-height 1.8): testo corrente e dati. Per la prosa mantenere 65–75ch; dati e UI compatta possono essere più densi.
|
||||
- **Label** (peso 700, 0.875rem): etichette dei pulsanti e dei controlli di form. Il grassetto dà presenza al tocco senza ricorrere al maiuscolo.
|
||||
Le cifre sono tabellari (`font-feature-settings: "tnum"`), così contatori e codici non "ballano" mentre si aggiornano.
|
||||
|
||||
### Named Rules
|
||||
**La Regola del Peso, non del Maiuscolo.** L'enfasi si ottiene con il peso (700/800) e la dimensione, non con il maiuscolo spaziato. Niente eyebrow maiuscoli tracciati sopra le sezioni.
|
||||
**La Regola del Peso, non del Maiuscolo.** L'enfasi si ottiene con il peso (600/700/800) e la dimensione. Niente maiuscoletto spaziato, niente eyebrow sopra le sezioni.
|
||||
|
||||
**Il titolo è l'entità.** Nelle card di elenco il titolo è il punto vendita o il motivo, non il tipo di record. L'identificativo tecnico sta sopra, in 12px `ink-muted`.
|
||||
|
||||
**Le righe di metadato** si compongono con separatore ` · `.
|
||||
|
||||
## 4. Elevation
|
||||
|
||||
Sistema prevalentemente **piatto con ombre funzionali leggere**. La profondità è tonale (Carta su Grigio Campo) più un'ombra morbida e diffusa dove un elemento deve staccarsi davvero (card in rilievo, overlay). MudBlazor fornisce la scala di elevazione; sopra di essa il progetto definisce due ombre custom soffuse. Le superfici a riposo sono piatte; l'ombra è una risposta al ruolo (galleggiamento, overlay), non decorazione.
|
||||
**Zero.** Le superfici sono piatte: nessuna ombra, nessun `surface-tint`. La profondità è tonale (`surface` su `workspace`) più lo spazio.
|
||||
|
||||
### Shadow Vocabulary
|
||||
- **Ombra Custom** (`box-shadow: 1px 2px 5px hsl(from var(--mud-palette-overlay-dark) h s 40%)`): stacco morbido per card ed elementi che devono galleggiare sul contenuto.
|
||||
- **Ombra Eccezione** (`box-shadow: 1px 2px 5px rgba(0,0,0,0.3)`): rilievo leggermente più marcato per box di errore/eccezione.
|
||||
Le uniche eccezioni: il **toast**, che deve leggersi sopra qualunque contenuto, e i livelli modali di sistema (scrim del bottom sheet e degli overlay). Popover e menu si staccano con un bordo 1px `divider`, non con un'ombra.
|
||||
|
||||
### Named Rules
|
||||
**La Regola del Piatto-di-Default.** Le superfici sono piatte a riposo. L'ombra compare solo quando un elemento deve galleggiare (overlay, dialog, card sollevata) o rispondere a uno stato. Se sembra un'app del 2014, l'ombra è troppo scura e troppo stretta.
|
||||
La profondità di impilamento usa una **scala semantica** (`--z-sticky` → `--z-navbar` → `--z-connection` → `--z-overlay` → `--z-modal` → `--z-toast`), mai valori arbitrari.
|
||||
|
||||
## 5. Components
|
||||
|
||||
Componenti **tattili e sicuri**: bersagli generosi e arrotondati, feedback fisico, presenza netta, pensati per il pollice in campo.
|
||||
|
||||
### Buttons
|
||||
- **Shape:** angoli molto arrotondati (raggio 20px sul contenitore `container-button`; i pulsanti MudBlazor ereditano `--mud-default-borderradius: 20px`).
|
||||
- **Primary:** fondo Arancio-Rosso Allerta (#ec4c41), testo Carta (#fff), etichetta in peso 700; padding compatto verticale ma area di tocco ampia.
|
||||
- **Hover / Focus / Active:** effetto **ripple** al tocco (chiaro su fondo scuro, scuro su fondo chiaro); focus da tastiera con doppio anello (bianco + Blu Focus #258cfb). Nei contenitori `ripple-container` il ripple nativo Mud è disattivato in favore di quello custom.
|
||||
- **Settings buttons:** riga con icona in "pill" arrotondata (raggio 6px) tinta per ruolo — grigio (neutro), primary (azione), verde (successo), rosso (distruttivo) — su fondo tenue della stessa tinta.
|
||||
- **FAB (`custom-mudfab`):** azione primaria fissa in basso a destra (bottom 4rem, right 16px), a portata di pollice; rispetta la safe-area inferiore.
|
||||
|
||||
### Cards / Containers
|
||||
- **Corner Style:** pannelli e dialog a 1em; contenitori d'azione a 20px; input-card a 9px.
|
||||
- **Background:** Carta (#fff) per il contenuto; Grigio Campo (~#f5f5f5) per input-card e superfici tonali secondarie.
|
||||
- **Shadow Strategy:** piatte di default; Ombra Custom solo quando devono galleggiare (vedi Elevation).
|
||||
- **Border:** preferire lo stacco tonale al bordo; quando serve, bordo pieno sottile (`--card-border-color`). Mai bordo-laterale colorato come accento.
|
||||
- **Internal Padding:** input-card `.5rem 1rem`; pannelli ~1rem; margine orizzontale di pagina `--m-page-x: 1rem`.
|
||||
|
||||
### Inputs / Fields
|
||||
- **Style:** input dentro `input-card` su fondo Grigio Campo, raggio 9px; underline Mud rimossa (`:before/:after` a `none`) per un aspetto pulito a "scheda".
|
||||
- **Layout:** `form-container` a due colonne (etichetta in peso 700 a sinistra, valore a destra), altezza minima 35px per un tocco comodo.
|
||||
- **Valid / Error:** valido → outline verde 1px (#26b050); invalido → outline rosso 1px (#e50000) con `validation-message` in rosso.
|
||||
|
||||
### Navigation
|
||||
- **NavMenu** in testa alla pagina; barra di stato connessione (`ConnectionState`) che scorre dall'alto: verde (SystemOk) o rosso (NetworkKo / ServicesIsDown), peso 700, testo bianco. Comunica lo stato di rete/servizio senza rubare spazio al contenuto.
|
||||
- **Panel** — il mattone di tutto: `surface`, raggio 20, padding 16, senza bordo. Varianti tonali per riepiloghi (`info`) e sotto-riquadri (`muted`).
|
||||
- **Card di elenco** — identificativo, titolo-entità, riga meta, badge, footer con conteggi, barra azioni interna che passa a colonna sotto i 350px.
|
||||
- **Badge** — pillola con icona 15px + etichetta 12px peso 700, costruita da funzione di mappatura.
|
||||
- **Testata** — alta 80px, titolo a sinistra, sfondo uguale alla pagina. Non una AppBar colorata col titolo centrato.
|
||||
- **Navigazione** — barra flottante in basso (raggio 24, margine 16, bordo 1px), icona outline a riposo e piena da selezionata, pastiglia `info-surface` sulla voce attiva. Da 720px diventa colonna laterale.
|
||||
- **Bottom sheet** — maniglia, testata, un'unica linea di struttura, corpo scorrevole. Sostituisce i menu flottanti.
|
||||
- **Barra azioni ancorata** — bordo superiore 1px, bottoni in riga, colonna sotto i 390px. Nei form il salvataggio sta qui, non in fondo allo scroll.
|
||||
- **Campi** — riempiti, raggio 16, bordo 1px a riposo e 2px in focus. Sottolineatura Material rimossa: i campi sono schede, non righe.
|
||||
- **Selezione multipla** — righe da 56px con casella 26px, non un menu a tendina: con i guanti si centra un bersaglio grande.
|
||||
- **Stato vuoto** — icona, titolo, messaggio che **suggerisce la mossa successiva**, azione facoltativa.
|
||||
- **Caricamento** — scheletri che hanno già la forma del contenuto, non spinner in mezzo alla pagina. L'overlay pieno è riservato alle operazioni bloccanti, e dice sempre cosa sta facendo.
|
||||
|
||||
### Signature: Barra di Stato Connessione
|
||||
Striscia sottile animata (`.Connection`) ancorata in cima che appare/scompare con transizione morbida (translateY) per segnalare rete assente o backend giù. È l'incarnazione visiva del principio "Offline è la verità": lo stato di connettività è sempre onesto e visibile.
|
||||
Striscia sottile ancorata in cima che scende con una transizione morbida quando la rete o il backend cambiano stato. È l'incarnazione visiva di "Offline è la verità".
|
||||
|
||||
## 6. Do's and Don'ts
|
||||
## 6. Motion
|
||||
|
||||
Curve esponenziali in uscita (`cubic-bezier(0.22, 1, 0.36, 1)`), durate 120/200/280ms. Il movimento **serve allo stato**: risposta al tocco (l'elemento cede sotto il dito), entrata di un articolo scansionato, salita di uno sheet, disegno della spunta di conferma. Niente animazioni di scena al caricamento.
|
||||
|
||||
Nessuno stato dipende dall'hover: su un touch screen non esiste, e lascia elementi accesi dopo il tocco. Gli stili hover sono dietro `@media (hover: hover)`.
|
||||
|
||||
`prefers-reduced-motion` ha un'alternativa per ogni animazione.
|
||||
|
||||
## 7. Do's and Don'ts
|
||||
|
||||
### Do:
|
||||
- **Do** usare l'Arancio-Rosso Allerta (#ec4c41) solo per azione e stato attivo; mantenerlo raro (Regola dell'Unico Accento).
|
||||
- **Do** tenere il testo di corpo in inchiostro (#000) su carta (#fff); alto contrasto per la lettura al sole.
|
||||
- **Do** dimensionare i bersagli di tocco ≥44px e collocare le azioni primarie a portata di pollice (FAB in basso a destra), pensando all'uso a una mano/guanti.
|
||||
- **Do** rendere sempre esplicito lo stato offline/sync (barra di connessione, stati di scheda/ispezione).
|
||||
- **Do** dare feedback fisico immediato a ogni tocco e scansione (ripple, cambio di stato).
|
||||
- **Do** usare un'unica famiglia (Nunito) e fare gerarchia con peso e dimensione.
|
||||
- **Do** rispettare le safe-area iOS/Android e `prefers-reduced-motion`.
|
||||
- **Do** usare solo i token: un colore, un raggio o una spaziatura scritti a mano in una schermata sono un difetto da correggere.
|
||||
- **Do** mostrare sempre i conteggi ("12 schede", "3 da inviare"): il rilevatore deve sapere quanto ha davanti senza contare.
|
||||
- **Do** distinguere "non c'è nulla" da "i filtri non trovano nulla", e nel secondo caso offrire l'azzeramento.
|
||||
- **Do** dimensionare i bersagli ≥44px e tenere le azioni primarie a portata di pollice.
|
||||
- **Do** rendere esplicito lo stato offline/sync, sempre con icona più etichetta.
|
||||
- **Do** rispettare le safe-area e `prefers-reduced-motion`.
|
||||
|
||||
### Don't:
|
||||
- **Don't** costruire un **gestionale datato e denso**: niente tabelle fittissime, testo minuscolo, form infiniti, grigio ovunque.
|
||||
- **Don't** virare verso un'estetica **consumer social/giocosa**: niente colore ludico o decorazione fine a sé stessa.
|
||||
- **Don't** usare testo grigio chiaro sul corpo "per eleganza": è la causa numero uno di illeggibilità al sole.
|
||||
- **Don't** usare bordo-laterale colorato (`border-left/right` > 1px) come accento su card, liste o alert.
|
||||
- **Don't** usare testo con gradiente (`background-clip: text`), glassmorphism decorativo, griglie di card tutte identiche, o eyebrow maiuscoli tracciati su ogni sezione.
|
||||
- **Don't** introdurre animazioni di scena orchestrate al caricamento: l'app entra dritta nel compito; il movimento serve allo stato, non allo spettacolo.
|
||||
- **Don't** reinventare affordance standard (scrollbar strane, controlli di form non convenzionali) per "carattere".
|
||||
- **Don't** usare ombre per costruire gerarchia, né `surface-tint` sulle superfici.
|
||||
- **Don't** usare le firme Android di serie: card con ombra, FAB circolare al centro, AppBar colorata col titolo centrato, snackbar di sistema.
|
||||
- **Don't** usare bordo-laterale colorato (`border-left/right` > 1px) come accento.
|
||||
- **Don't** usare testo grigio chiaro sul corpo, testo con gradiente, glassmorphism decorativo, griglie di card tutte identiche, eyebrow maiuscoli.
|
||||
- **Don't** reinventare affordance standard per "carattere".
|
||||
- **Don't** far dipendere un'informazione dal solo colore.
|
||||
|
||||
+304
@@ -0,0 +1,304 @@
|
||||
# Specifiche grafiche — stile UI condiviso
|
||||
|
||||
Documento di riferimento per **uniformare la grafica delle app Integry** allo stile adottato in Fixiy.
|
||||
|
||||
Descrive *forma, ritmo, tipografia e composizione dei componenti*. **Non contiene una palette**: ogni app mantiene i propri colori e li mappa sui **ruoli semantici** elencati al §2. Lo stile è quello che rimane costante al variare della tinta: superfici piatte, raggi ampi, spaziature a scala fissa, gerarchia data dallo spazio prima che dalla decorazione.
|
||||
|
||||
Riferimento d'implementazione: Flutter + Material 3 (`useMaterial3: true`). I nomi dei token qui sotto sono quelli reali del codice Fixiy (`lib/core/design_system.dart`, `lib/core/feedback.dart`); in un'altra app si rinominano col prefisso dell'app, ma **valori e semantica restano invariati**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Principi
|
||||
|
||||
1. **Superfici piatte.** Niente ombre per costruire gerarchia: `elevation: 0` ovunque (card, AppBar, NavigationBar, NavigationRail). L'unica ombra ammessa è quella del toast (elevation 6) e dei livelli modali di sistema. La separazione si ottiene con *contrasto di superficie* (sfondo pagina più freddo delle superfici) e con lo **spazio**.
|
||||
2. **Nessun tint automatico.** `surfaceTintColor: Colors.transparent` su AppBar, Card, Dialog, pannelli: le superfici non devono virare verso il colore primario quando si scrolla.
|
||||
3. **Colore = significato.** L'accento primario serve solo ad azioni, selezione, focus e progresso — mai a decorare. I colori semantici (urgenza, stato, esito) compaiono sempre accompagnati da **icona + etichetta testuale**, mai da soli.
|
||||
4. **Target generosi.** Ogni elemento interattivo ha lato minimo **44 px** (`minimumSize: Size(44, 44)`), 48 px d'altezza per i bottoni principali. L'app si usa in piedi, con i guanti, in negozio.
|
||||
5. **Densità standard.** `visualDensity: VisualDensity.standard`: si rifiuta la densità desktop, anche su schermo largo.
|
||||
6. **Lo spazio prima della linea.** I divisori si usano solo dove l'informazione cambia natura (testata di uno sheet, righe di una lista di navigazione); altrove separa la spaziatura.
|
||||
7. **Nulla di stock.** Si evitano le firme visive Android di default: card con ombra, FAB circolare al centro, AppBar colorata con titolo centrato, snackbar di sistema.
|
||||
8. **Scrivibile a voce alta.** Testi in lingua dell'app, brevi, orientati all'azione; gli enum passano da una mappa di etichette centralizzata (`labels.dart`), mai formattati inline.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ruoli colore (da riempire con la palette dell'app)
|
||||
|
||||
L'app ospite definisce i **propri** valori per questi ruoli e li usa con la stessa semantica. Nessun valore è fissato qui.
|
||||
|
||||
| Ruolo | Uso |
|
||||
|---|---|
|
||||
| `primary` | accento unico di azione: bottone pieno, focus, selezione, indicatori di progresso, icone attive |
|
||||
| `primaryDark` | testo/icona sopra `primaryContainer`, valori numerici in evidenza |
|
||||
| `workspace` | sfondo dell'applicazione (`scaffoldBackgroundColor`), leggermente più freddo/scuro di `surface` |
|
||||
| `surface` | superficie di lavoro: card, pannelli, AppBar, barra di navigazione, dialog |
|
||||
| `surfaceMuted` | superficie secondaria dentro una superficie (campo di ricerca, chip non selezionato, riquadro informativo interno) |
|
||||
| `divider` | bordi 1 px, separatori, contorno dei controlli a riposo |
|
||||
| `ink` | testo principale |
|
||||
| `inkMuted` | testo secondario, hint, etichette di metadato, icone non attive |
|
||||
| `emergency` / `errorSurface` | tono **danger**: errori, azioni distruttive, priorità massima |
|
||||
| `high` / `warningSurface` | tono **warning**: attenzione, dati incompleti, priorità alta |
|
||||
| `success` / `successSurface` | tono **success**: conferme, completamenti |
|
||||
| `info` / `infoSurface` | tono **info**: stato informativo, pannello di riepilogo, indicatore di selezione |
|
||||
| `neutral` / `surfaceMuted` | tono **neutral**: stati senza carica semantica |
|
||||
|
||||
**Regola delle coppie.** Ogni tono semantico è una coppia *(foreground forte, superficie tenue)*. Le superfici tenui riempiono badge, pannelli di feedback e cerchi-icona dei dialog; il foreground forte colora icona e testo sopra di esse. I **toast** invertono la regola: riempimento pieno col colore forte e testo bianco, per essere leggibili sopra qualunque contenuto.
|
||||
|
||||
**Schema colore.** Si genera da un seme (`ColorScheme.fromSeed`) e poi si sovrascrivono esplicitamente `primary`, `onPrimary`, `primaryContainer` (= `infoSurface`), `onPrimaryContainer` (= `primaryDark`), `secondaryContainer` (= `surfaceMuted`), `outline` e `outlineVariant` (= `divider`). Non si lascia al seme la scelta dei contenitori: devono restare tenui e neutri.
|
||||
|
||||
---
|
||||
|
||||
## 3. Scale di token
|
||||
|
||||
### Spaziatura (`Space`)
|
||||
|
||||
```
|
||||
xs = 8 sm = 12 md = 16 lg = 24 xl = 32
|
||||
```
|
||||
|
||||
Tutti i margini e le distanze sono multipli di 2 e, per quanto possibile, uno di questi cinque valori. Valori d'uso frequente derivati: 4 e 6 per le micro-distanze interne (titolo→sottotitolo, icona→testo), 10 tra le card di una lista, 18/20/22 per il padding dei pannelli di testata.
|
||||
|
||||
### Raggi (`Radius`)
|
||||
|
||||
```
|
||||
compact = 8 riquadri informativi, barre di progresso, feedback inline
|
||||
control = 16 campi di testo, bottoni, toast
|
||||
surface = 20 card, pannelli, dialog, sheet
|
||||
pill = 999 badge, chip, segmented button, campo di ricerca, bottoni onboarding
|
||||
```
|
||||
|
||||
Il contrasto tra i raggi è voluto: **superfici più arrotondate dei controlli**, elementi di stato completamente stondati (pill).
|
||||
|
||||
### Breakpoint
|
||||
|
||||
```
|
||||
rail = 720 da qui la navigazione passa da barra inferiore a NavigationRail laterale
|
||||
twoColumn = 900 soglia per layout a due colonne
|
||||
contentMax = 1180 larghezza massima assoluta del contenuto
|
||||
```
|
||||
|
||||
Larghezza tipica di una pagina: `maxWidth: 920`, centrata in alto (`PageWidth` = `Align(topCenter) + ConstrainedBox`). Su schermi larghi il contenuto **non si allarga all'infinito**: resta una colonna leggibile.
|
||||
|
||||
---
|
||||
|
||||
## 4. Tipografia
|
||||
|
||||
Famiglia principale **Inter**, fallback `['SF Pro Display', 'Roboto', 'sans-serif']`. Scala fissa, pensata per la scansione rapida, non per l'effetto editoriale:
|
||||
|
||||
| Stile | Dim. | Interlinea | Peso | Uso |
|
||||
|---|---|---|---|---|
|
||||
| `headlineMedium` | 34 | 1.2 | 700 | titoli di apertura |
|
||||
| `headlineSmall` | 28 | 1.25 | 700 | titolo dell'AppBar, valore percentuale in evidenza |
|
||||
| `titleLarge` | 22 | 1.3 | 700 | titolo di card e di sheet, testata di sezione |
|
||||
| `titleMedium` | 18 | 1.35 | 700 | sotto-titoli, valori metrica, titolo dialog |
|
||||
| `bodyLarge` | 16 | 1.5 | 400 | testo corrente, descrizioni |
|
||||
| `bodyMedium` | 15 | 1.45 | 400 | metadati, righe secondarie |
|
||||
| `labelLarge` | 14 | 1.25 | 600 | etichette dei bottoni |
|
||||
| `labelMedium` | 13 | 1.25 | 600 | etichette di navigazione, caption |
|
||||
|
||||
Regole d'uso:
|
||||
|
||||
- Il **titolo di una card è l'entità**, non il tipo di record (es. il negozio, non "Manutenzione"): `titleLarge` con peso 800 e interlinea compressa 1.15.
|
||||
- Gli **identificativi tecnici** (numero ticket, codice) vanno sopra il titolo, 12 px, peso 700, colore `inkMuted`, `letterSpacing: 0.3`.
|
||||
- Le **righe di metadato** si compongono con separatore ` · ` (spazio–punto medio–spazio) e colore `inkMuted`.
|
||||
- Il testo secondario non usa mai un peso inferiore a 400 né opacità: si distingue per **colore** (`inkMuted`), non per trasparenza.
|
||||
- Nessun maiuscoletto forzato, nessun `letterSpacing` ampio: le etichette restano in caso naturale.
|
||||
|
||||
---
|
||||
|
||||
## 5. Tema — impostazioni globali
|
||||
|
||||
| Elemento | Configurazione |
|
||||
|---|---|
|
||||
| **Scaffold** | sfondo `workspace`; `extendBody: true` quando la barra di navigazione è flottante |
|
||||
| **AppBar** | `toolbarHeight: 80`, titolo a sinistra (`centerTitle: false`), `titleSpacing` 20 (28 su schermo largo), sfondo `workspace` (si fonde con la pagina), elevation 0, `scrolledUnderElevation: 0` |
|
||||
| **Card / pannelli** | elevation 0, `margin: EdgeInsets.zero`, sfondo `surface`, raggio `surface`, senza bordo |
|
||||
| **Campi di testo** | `filled: true` su `surface`, padding 16×16, raggio `control`; bordo a riposo 1 px `divider`, in focus 2 px `primary`, in errore 1 px `emergency`; hint e helper in `inkMuted` |
|
||||
| **Bottone pieno** | `minimumSize: (44, 48)`, sfondo `primary`, testo bianco `labelLarge`, raggio `control` |
|
||||
| **Bottone contornato** | `minimumSize: (44, 48)`, testo `ink`, bordo `divider`, raggio `control` |
|
||||
| **Bottone testuale / icona** | `minimumSize: (44, 44)` |
|
||||
| **Segmented button** | `StadiumBorder`, min `(52, 44)`; selezionato = riempimento `primary` + testo bianco; non selezionato = `surface` + testo `inkMuted`; bordo `divider` |
|
||||
| **NavigationBar** | altezza 68, elevation 0, sfondo `surface`, indicatore `infoSurface`, etichette sempre visibili (`alwaysShow`), stile `labelMedium` |
|
||||
| **NavigationRail** | sfondo `surface`, indicatore `primaryContainer`, etichette su tutte le voci (`labelType: all`), etichetta selezionata in `primary` |
|
||||
| **Snackbar** | comportamento `floating` (ma nell'app si usa il toast custom, §6.9) |
|
||||
| **Progress** | colore `primary` |
|
||||
|
||||
**Localizzazione.** Locale unico dichiarato esplicitamente con i tre delegati globali (Material, Widgets, Cupertino), così date, picker e messaggi di sistema parlano la lingua dell'app.
|
||||
|
||||
---
|
||||
|
||||
## 6. Componenti
|
||||
|
||||
### 6.1 `PageWidth` — contenitore di pagina
|
||||
|
||||
`Align(topCenter)` + `ConstrainedBox(maxWidth: 920)`. Avvolge **ogni** contenuto di pagina. Su telefono è trasparente; su tablet/desktop impedisce righe di testo lunghissime e mantiene la stessa composizione a tutte le larghezze.
|
||||
|
||||
### 6.2 `Panel` — superficie base
|
||||
|
||||
`Material` con `color: surface`, `surfaceTintColor: transparent`, `clipBehavior: antiAlias`, forma arrotondata `surface` (20), padding di default 16.
|
||||
|
||||
È il mattone di tutto: card di lista, riquadri di riepilogo, righe raggruppate, campi compositi. Varianti d'uso:
|
||||
|
||||
- **con colore**: `color: infoSurface` per i pannelli di riepilogo/testata; `color: surfaceMuted` per i sotto-riquadri dentro un form; `color: errorSurface` per una card in stato critico.
|
||||
- **`padding: EdgeInsets.zero` + `InkWell` interno** quando l'intera superficie è toccabile (raggio dell'ink coerente con quello del pannello).
|
||||
|
||||
### 6.3 `SectionHeader` — testata di sezione
|
||||
|
||||
Riga con, a sinistra, `titleLarge` + eventuale descrizione (`inkMuted`, 4 px sotto), e a destra un widget opzionale (bottone, badge, contatore) separato da 12 px. Apre ogni blocco tematico dentro una pagina lunga.
|
||||
|
||||
### 6.4 `Badge` — stato in pillola
|
||||
|
||||
`DecoratedBox` con raggio 999, superficie tenue del tono, padding 9×5, contenuto = icona 15 px + 5 px + testo 12 px peso 700, entrambi nel foreground del tono. Avvolto in `Semantics(label:)`.
|
||||
|
||||
I badge **si costruiscono da funzioni di mappatura** (`priorityBadge(priority)`, `statusBadge(status)`), non a mano nelle pagine: così ogni schermata mostra lo stesso stato con la stessa icona e lo stesso tono. Ogni nuovo stato si aggiunge a quella funzione.
|
||||
|
||||
### 6.5 Card di elenco
|
||||
|
||||
Composizione canonica, dall'alto:
|
||||
|
||||
1. **Barra di priorità** verticale a tutta altezza sul bordo sinistro, larghezza **6 px**, colore dal tono di urgenza (`Stack` + `Positioned(top/bottom/left: 0)`). La card usa padding asimmetrico `(22, 16, 16, 16)` per far respirare la barra.
|
||||
2. **Identificativo** (12 px, `inkMuted`, 700) con, a destra, l'eventuale maniglia di trascinamento.
|
||||
3. **Titolo** = l'entità, `titleLarge` peso 800.
|
||||
4. **Riga meta** con separatori ` · `, `bodyMedium` peso 600 `inkMuted`.
|
||||
5. **Badge** (priorità, stato) in un `Wrap(spacing: 8, runSpacing: 8)`.
|
||||
6. **Descrizione** integrale, `bodyLarge` interlinea 1.35 — niente troncamenti sul contenuto che serve a decidere.
|
||||
7. **Riga oraria/assegnatario** preceduta da icona 16 px `inkMuted`.
|
||||
8. **Barra azioni interna**: secondaria contornata + primaria piena, affiancate con `Expanded`; sotto i **350 px** di larghezza della card diventano una colonna a piena larghezza (`LayoutBuilder`).
|
||||
|
||||
La card intera espone un `Semantics(label:)` che riassume entità, priorità e stato.
|
||||
|
||||
### 6.6 Pannello di riepilogo
|
||||
|
||||
Primo elemento della pagina, `Panel` con superficie tenue (`primaryContainer`/`infoSurface`), padding 18:
|
||||
|
||||
- riga di testata: titolo `titleLarge` + data/sottotitolo `inkMuted`, e a destra un **badge di stato** del documento ("Da salvare" / "Salvato" / "Concluso");
|
||||
- eventuale fila di **chip** di scelta (`Wrap(spacing: 8, runSpacing: 8)`);
|
||||
- fila di **metriche** in `Expanded` uguali, ciascuna: icona 20 px in `primaryDark` → valore `titleMedium` → etichetta 12 px `inkMuted` su una riga sola con ellissi;
|
||||
- eventuale riga di stato in coda (`labelMedium` `inkMuted`), es. l'ora dell'ultimo salvataggio.
|
||||
|
||||
Quando c'è un progresso, si aggiunge una `LinearProgressIndicator` con `minHeight: 8` e `borderRadius: 8`, preceduta dal conteggio in chiaro ("3 di 7 completate") e dalla percentuale in `headlineSmall`.
|
||||
|
||||
### 6.7 Chip e filtri
|
||||
|
||||
- **Filtri**: `ChoiceChip` `StadiumBorder`, `showCheckmark: false`, **l'etichetta include il conteggio** (`Tutte · 12`). A riposo: superficie `surfaceMuted`, bordo `divider`, testo `inkMuted` peso 600. Selezionato: superficie `infoSurface`, bordo `primary`, testo `primaryDark`. Disposti in una riga orizzontale scorrevole alta **44 px**, spaziatura 8.
|
||||
- **Chip-entità** (selezione di un oggetto): `Material` + `InkWell` con `StadiumBorder`, padding 12×8, icona 16 px + etichetta peso 600 + dato secondario 12 px `inkMuted`. Selezionato = superficie `surface` e bordo `primaryDark`; non selezionato = `surfaceMuted` e bordo `divider`.
|
||||
|
||||
### 6.8 Campo di ricerca
|
||||
|
||||
Non usa il tema dei campi: è **completamente stondato** (raggio 999), riempito in `surfaceMuted`, **senza bordo** a riposo e in enabled, bordo 1.5 px `primary` solo in focus. Padding 18×14, icona di ricerca `inkMuted` come prefisso, pulsante "cancella" come suffisso solo quando c'è testo.
|
||||
|
||||
### 6.9 Toast
|
||||
|
||||
Sostituisce ogni snackbar di sistema. `SnackBar` `floating` con sfondo trasparente, elevation 0, padding zero e margine `(md, 0, md, md)`; il contenuto è un `Material` con **riempimento pieno del tono**, raggio `control`, elevation 6, ombra nera al 24 %, padding 14×12, e dentro: icona bianca 22 px → 12 px → testo bianco peso 600 interlinea 1.35 → eventuale azione testuale bianca peso 700.
|
||||
|
||||
Prima di mostrarlo si chiama `hideCurrentSnackBar()`: mai una coda di toast. Durata 4 s. Si espone come scorciatoie per tono (`success`/`error`/`warning`/`info`) più una funzione generica.
|
||||
|
||||
### 6.10 Dialog
|
||||
|
||||
`Dialog` su `surface`, raggio `surface`, `insetPadding` 24, `maxWidth: 420`, padding interno 24. Composizione verticale allineata a sinistra:
|
||||
|
||||
1. **cerchio-icona** 48×48 riempito con la superficie tenue del tono, icona del tono 26 px;
|
||||
2. 16 px → **titolo** `titleMedium`;
|
||||
3. 8 px → **messaggio** `inkMuted` interlinea 1.45;
|
||||
4. 24 px → **azioni**: due bottoni in `Expanded` (annulla contornato a sinistra, conferma pieno a destra, 12 px di distanza); se non c'è annullamento, un solo bottone a piena larghezza.
|
||||
|
||||
Le azioni distruttive colorano di `emergency` il bottone di conferma. Due sole funzioni di ingresso: conferma (ritorna booleano) e informativo (un solo bottone).
|
||||
|
||||
### 6.11 Feedback inline
|
||||
|
||||
Riquadro non modale per messaggi persistenti dentro la pagina: superficie tenue del tono, raggio `compact` (8), padding 12, icona 20 px del tono + 10 px + testo peso 600 del tono, allineati in alto. Marcato `Semantics(liveRegion: true)`. Si usa per errori di caricamento e avvisi che devono restare visibili, a differenza del toast che è effimero.
|
||||
|
||||
### 6.12 Stato vuoto
|
||||
|
||||
Centrato, padding 28, larghezza massima 420: icona 44 px `inkMuted` → 14 px → titolo `titleMedium` centrato → 6 px → messaggio `inkMuted` centrato → 18 px → azione facoltativa (bottone contornato). Il messaggio **suggerisce la mossa successiva**; se lo stato vuoto dipende da filtri, l'azione li azzera.
|
||||
|
||||
### 6.13 Barra azioni fissa
|
||||
|
||||
Ancorata in fondo alla pagina: `SafeArea(top: false)`, superficie `surface`, **bordo superiore 1 px** `divider`, padding `(16, 12, 16, 16)`. I bottoni sono in riga con `Expanded` e 12 px di distanza; sotto i **390 px** diventano una colonna a piena larghezza con 8 px di distanza.
|
||||
|
||||
### 6.14 Bottom sheet
|
||||
|
||||
Sempre `isScrollControlled: true`, `useSafeArea: true`, `showDragHandle: true`. Altezza **fissata a una frazione dello schermo**: ~0.78 per uno sheet di consultazione, ~0.92 per uno sheet di compilazione.
|
||||
|
||||
Struttura:
|
||||
|
||||
1. **testata**: padding `(20, 0, 8, 12)`, titolo `titleLarge` + sottotitolo `inkMuted` a sinistra, `IconButton` di chiusura a destra;
|
||||
2. eventuale **segmented button** a piena larghezza (`expandedInsets: EdgeInsets.zero`) quando lo sheet ha due modalità;
|
||||
3. `Divider(height: 1)` — l'unica linea di struttura;
|
||||
4. **corpo** in `Expanded`, scrollabile, padding 16. Con più modalità si usa `IndexedStack`, che preserva lo stato dei form.
|
||||
|
||||
### 6.15 Navigazione
|
||||
|
||||
- **Telefono**: `NavigationBar` **flottante** — avvolta in `SafeArea(minimum: (16, 0, 16, 12))` e in un `ClipRRect(borderRadius: 24)`, con `extendBody: true` sullo Scaffold, così il contenuto scorre sotto. Le pagine lasciano ~110–120 px di padding inferiore per non finirci sotto.
|
||||
- **Da 720 px**: `NavigationRail` a sinistra con etichette su tutte le voci, seguito da `VerticalDivider(width: 1)`.
|
||||
- Le destinazioni usano **icona outline a riposo e piena da selezionata**, con la stessa etichetta in entrambe le modalità.
|
||||
- Le sezioni si tengono vive in un `IndexedStack`: cambiare tab non ricostruisce né perde lo scroll.
|
||||
- Le **notifiche** stanno nelle `actions` dell'AppBar: `IconButton` con icona outline dentro un `Badge` numerico visibile solo se ci sono elementi non letti, e 12 px di margine destro.
|
||||
|
||||
### 6.16 Liste di navigazione (profilo, impostazioni)
|
||||
|
||||
Un `Panel` che contiene `ListTile` con `contentPadding: EdgeInsets.zero`, icona a sinistra, `Icon(chevron_right)` come trailing per le voci che aprono una pagina, e `Divider()` tra una voce e l'altra. Le voci distruttive (uscita, eliminazione) portano l'icona in `emergency` e non hanno chevron.
|
||||
|
||||
L'intestazione del profilo è un `Panel` con padding 22: `CircleAvatar` raggio 30 su superficie tenue con l'iniziale in 22 px peso 800, 16 px, poi nome `titleLarge` e riga secondaria `inkMuted`.
|
||||
|
||||
### 6.17 Campo composito (tappabile)
|
||||
|
||||
Per valori che si scelgono da un picker invece che digitare: `Panel` `surfaceMuted` con padding zero, `InkWell` interno, padding 14, dentro un'etichetta `labelMedium`, 5 px, e una riga icona 19 px + 7 px + valore con ellissi. Si comporta come un campo ma apre un selettore.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tema di onboarding (login e schermate pre-accesso)
|
||||
|
||||
Le schermate fuori dall'applicazione autenticata usano una **variante derivata** dello stesso tema (`base.copyWith(...)`), non un tema separato: cambia solo il registro, non la struttura.
|
||||
|
||||
- Sfondo pagina uniforme e neutro; primario = colore d'inchiostro (accento cromatico assente: l'attenzione è sul marchio).
|
||||
- Campi con riempimento bianco e bordo neutro; in focus bordo 2 px del colore d'inchiostro.
|
||||
- Bottone principale **a pillola** (`StadiumBorder`), altezza minima 52.
|
||||
- Composizione: `SafeArea` → `Center` → `SingleChildScrollView(padding: 24)` → `ConstrainedBox(maxWidth: 440)` → colonna `stretch`.
|
||||
- Ordine: logo (altezza 128, dentro `Semantics(image: true, label:)`) → 32 px → campi a 16 px di distanza → 24 px → eventuale messaggio d'errore centrato in `colorScheme.error` e `liveRegion` → 12 px → bottone di invio → 20 px → footer con versione.
|
||||
- Il bottone di invio mostra il caricamento **al posto dell'icona** (`SizedBox.square(dimension: 18)` con `CircularProgressIndicator(strokeWidth: 2)`), restando della stessa dimensione; durante il caricamento `onPressed: null`.
|
||||
- I campi dichiarano `autofillHints` dentro un `AutofillGroup` e incatenano `textInputAction: next`, con invio sull'ultimo.
|
||||
|
||||
---
|
||||
|
||||
## 8. Comportamenti trasversali
|
||||
|
||||
**Caricamento.** Al primo caricamento senza dati: `CircularProgressIndicator` centrato in un'area di almeno 48 px di padding verticale. Con dati già presenti non si sostituisce mai il contenuto con uno spinner: si aggiorna in silenzio. Ogni elenco è avvolto in un `RefreshIndicator` (pull-to-refresh).
|
||||
|
||||
**Errore di caricamento.** Non svuota la pagina: compare un **feedback inline** (§6.11) in tono danger sopra il contenuto, con l'istruzione per riprovare ("Trascina verso il basso per riprovare"). Gli errori di rete non bloccano la navigazione.
|
||||
|
||||
**Vuoto vs. filtrato.** Si distinguono: "non c'è nulla" e "i filtri non trovano nulla" hanno testi diversi, e il secondo offre l'azzeramento dei filtri.
|
||||
|
||||
**Riordino.** Nelle liste riordinabili solo gli elementi *ammissibili* mostrano la maniglia (`ReorderableDragStartListener` con `Icon(drag_handle)`); il riordino è disabilitato quando è attivo un filtro o una ricerca, e un suggerimento testuale compare accanto al conteggio solo sopra i 520 px di larghezza.
|
||||
|
||||
**Conteggi sempre visibili.** Titoli di sezione, filtri e badge portano il numero ("12 attività", "Allegati (3)", "Tutte · 12"): il tecnico deve sapere quanto ha davanti senza contare.
|
||||
|
||||
**Adattamento locale.** L'adattamento non è solo a livello di pagina: i **singoli componenti** misurano il proprio spazio con `LayoutBuilder` e passano da riga a colonna sotto le soglie indicate (350 px per le azioni di una card, 390 px per la barra azioni). Così lo stesso componente funziona in colonna singola e affiancato.
|
||||
|
||||
**Accessibilità.**
|
||||
- `Semantics(label:)` su card, badge e maniglie di riordino, con una descrizione che riassume l'entità e i suoi stati.
|
||||
- `Semantics(liveRegion: true)` su toast, feedback inline e messaggi d'errore dei form.
|
||||
- `tooltip` su ogni `IconButton` senza etichetta.
|
||||
- Nessuna informazione veicolata dal solo colore: sempre icona e/o testo.
|
||||
- Target ≥ 44 px anche per le icone in testata.
|
||||
|
||||
**Testi e stati.** Le date estese si scrivono per intero nella lingua dell'app; gli orari in `HH:mm` con zero iniziale, e i valori mancanti come `--:--`; i dati incompleti si dichiarano ("km finali mancanti", "Partenza da compilare") invece di lasciare un vuoto.
|
||||
|
||||
---
|
||||
|
||||
## 9. Come adottare lo stile in un'altra app
|
||||
|
||||
1. **Copiare la struttura dei token**: quattro classi statiche `Colors`, `Space`, `Radius`, `Breakpoints` con i valori dei §3 (spazi, raggi e breakpoint **identici**) e i propri colori sui ruoli del §2.
|
||||
2. **Ricostruire il tema** con le impostazioni del §5: sono la parte che rende immediatamente riconoscibile lo stile (AppBar alta e fusa con la pagina, card piatte a raggio 20, controlli a raggio 16, target 44/48).
|
||||
3. **Portare i componenti condivisi** del §6 — `PageWidth`, `Panel`, `SectionHeader`, `Badge`, `Feedback`, `EmptyState`, `ActionBar`, toast e dialog — rinominandoli col prefisso dell'app. Sono ~600 righe senza dipendenze oltre a Material.
|
||||
4. **Definire le mappature di stato** dell'app (`priorityBadge`, `statusBadge`, o gli equivalenti del dominio) in un unico punto, accanto ai componenti.
|
||||
5. **Vietare i valori ad-hoc**: nelle pagine si usano solo i token. Un colore o un raggio scritto a mano in una pagina è un difetto da correggere, non una scorciatoia.
|
||||
6. **Verificare la lista di controllo**:
|
||||
- [ ] nessuna ombra sulle superfici (elevation 0) e nessun `surfaceTint`;
|
||||
- [ ] sfondo pagina diverso dalle superfici;
|
||||
- [ ] ogni tocco ≥ 44 px;
|
||||
- [ ] ogni stato = icona + etichetta + tono;
|
||||
- [ ] contenuto limitato a ~920 px e centrato;
|
||||
- [ ] barra di navigazione flottante sotto i 720 px, rail sopra;
|
||||
- [ ] toast custom al posto delle snackbar di sistema;
|
||||
- [ ] stato vuoto, stato di caricamento e stato d'errore previsti per ogni elenco;
|
||||
- [ ] componenti che si riorganizzano da soli sotto le soglie di larghezza.
|
||||
@@ -0,0 +1,157 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using SteUp.Data.LocalDb;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SteUp.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260916111422_AddBozzaScheda")]
|
||||
partial class AddBozzaScheda
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b =>
|
||||
{
|
||||
b.Property<string>("CodMdep")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Data")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Rilevatore")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ActivityId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Stato")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CodMdep", "Data", "Rilevatore");
|
||||
|
||||
b.ToTable("Ispezioni");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActivityId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ActivityTypeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Bozza")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CodJfas")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CodMdep")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Data")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DescrizioneReparto")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageNames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Responsabile")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Rilevatore")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Scadenza")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CodMdep", "Data", "Rilevatore");
|
||||
|
||||
b.ToTable("Schede");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.SchedaArticolo", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Barcode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Descrizione")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SchedaId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SchedaId", "Barcode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SchedaArticoli");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b =>
|
||||
{
|
||||
b.HasOne("SteUp.Shared.Core.Entities.Ispezione", "Ispezione")
|
||||
.WithMany("Schede")
|
||||
.HasForeignKey("CodMdep", "Data", "Rilevatore")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Ispezione");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.SchedaArticolo", b =>
|
||||
{
|
||||
b.HasOne("SteUp.Shared.Core.Entities.Scheda", "Scheda")
|
||||
.WithMany("Articoli")
|
||||
.HasForeignKey("SchedaId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Scheda");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b =>
|
||||
{
|
||||
b.Navigation("Schede");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b =>
|
||||
{
|
||||
b.Navigation("Articoli");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SteUp.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBozzaScheda : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Bozza",
|
||||
table: "Schede",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Bozza",
|
||||
table: "Schede");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace SteUp.Data.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b =>
|
||||
{
|
||||
@@ -51,6 +51,9 @@ namespace SteUp.Data.Migrations
|
||||
b.Property<string>("ActivityTypeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Bozza")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CodJfas")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -17,9 +17,8 @@ using SteUp.Shared.Core.Interface.IntegryApi;
|
||||
using SteUp.Shared.Core.Interface.LocalDb;
|
||||
using SteUp.Shared.Core.Interface.System;
|
||||
using SteUp.Shared.Core.Interface.System.Network;
|
||||
using SteUp.Shared.Core.Messages.Ispezione;
|
||||
using SteUp.Shared.Core.Messages.Scanner;
|
||||
using SteUp.Shared.Core.Messages.Scheda;
|
||||
using SteUp.Shared.Core.Messages.System;
|
||||
using SteUp.Shared.Core.Services;
|
||||
|
||||
namespace SteUp.Maui.Core;
|
||||
@@ -62,9 +61,9 @@ public static class CoreModule
|
||||
public void RegisterMessageServices()
|
||||
{
|
||||
builder.Services.AddSingleton<IMessenger, WeakReferenceMessenger>();
|
||||
builder.Services.AddSingleton<NewSchedaService>();
|
||||
builder.Services.AddSingleton<OnScannerService>();
|
||||
builder.Services.AddSingleton<CompleteInspectionService>();
|
||||
builder.Services.AddSingleton<BackButtonService>();
|
||||
builder.Services.AddSingleton<PageActionsService>();
|
||||
}
|
||||
|
||||
public void RegisterDbServices()
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using SkiaSharp;
|
||||
|
||||
namespace SteUp.Maui.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Raddrizza le immagini applicando l'orientamento EXIF ai pixel.
|
||||
/// La fotocamera di sistema non ruota lo scatto: salva i pixel come li ha letti il
|
||||
/// sensore e annota la rotazione in EXIF. SkiaSharp (<see cref="SKBitmap.Decode(SKCodec)"/>)
|
||||
/// ignora quel tag, quindi senza questo passaggio le foto scattate ruotate finiscono
|
||||
/// storte nelle miniature e sul server.
|
||||
/// </summary>
|
||||
public static class ImageOrientation
|
||||
{
|
||||
/// <summary>
|
||||
/// Restituisce i byte dell'immagine con i pixel già nel verso giusto e senza tag
|
||||
/// di orientamento residui. In caso di formato non gestito o errore restituisce
|
||||
/// l'originale: meglio una foto storta che perdere lo scatto del rilevatore.
|
||||
/// </summary>
|
||||
public static byte[] Normalize(byte[] bytes, int quality = 92)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var codec = SKCodec.Create(new SKMemoryStream(bytes));
|
||||
if (codec is null) return bytes;
|
||||
|
||||
var origin = codec.EncodedOrigin;
|
||||
|
||||
// Già dritta: non ricodifichiamo, così non si perde qualità inutilmente.
|
||||
if (origin == SKEncodedOrigin.TopLeft) return bytes;
|
||||
|
||||
using var decoded = SKBitmap.Decode(codec);
|
||||
if (decoded is null) return bytes;
|
||||
|
||||
using var upright = ApplyOrigin(decoded, origin);
|
||||
using var image = SKImage.FromBitmap(upright);
|
||||
|
||||
// Manteniamo il formato di partenza: il nome file conserva la sua estensione.
|
||||
var format = codec.EncodedFormat == SKEncodedImageFormat.Png
|
||||
? SKEncodedImageFormat.Png
|
||||
: SKEncodedImageFormat.Jpeg;
|
||||
|
||||
using var encoded = image.Encode(format, quality);
|
||||
return encoded?.ToArray() ?? bytes;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Versione dritta di una bitmap già decodificata. Restituisce sempre una nuova
|
||||
/// bitmap, anche quando non serve ruotare, così chi chiama ne possiede una sola.
|
||||
/// </summary>
|
||||
public static SKBitmap Upright(SKBitmap src, SKEncodedOrigin origin) =>
|
||||
origin == SKEncodedOrigin.TopLeft ? src.Copy() : ApplyOrigin(src, origin);
|
||||
|
||||
private static SKBitmap ApplyOrigin(SKBitmap src, SKEncodedOrigin origin)
|
||||
{
|
||||
// Gli orientamenti "di lato" scambiano larghezza e altezza.
|
||||
var sideways = origin is SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightTop
|
||||
or SKEncodedOrigin.RightBottom or SKEncodedOrigin.LeftBottom;
|
||||
|
||||
var w = sideways ? src.Height : src.Width;
|
||||
var h = sideways ? src.Width : src.Height;
|
||||
|
||||
var dst = new SKBitmap(w, h, src.ColorType, src.AlphaType);
|
||||
using var canvas = new SKCanvas(dst);
|
||||
|
||||
switch (origin)
|
||||
{
|
||||
case SKEncodedOrigin.TopRight: // specchiata in orizzontale
|
||||
canvas.Translate(w, 0);
|
||||
canvas.Scale(-1, 1);
|
||||
break;
|
||||
case SKEncodedOrigin.BottomRight: // ruotata di 180°
|
||||
canvas.Translate(w, h);
|
||||
canvas.RotateDegrees(180);
|
||||
break;
|
||||
case SKEncodedOrigin.BottomLeft: // specchiata in verticale
|
||||
canvas.Translate(0, h);
|
||||
canvas.Scale(1, -1);
|
||||
break;
|
||||
case SKEncodedOrigin.LeftTop: // trasposta
|
||||
canvas.RotateDegrees(90);
|
||||
canvas.Scale(1, -1);
|
||||
break;
|
||||
case SKEncodedOrigin.RightTop: // ruotata di 90° in senso orario
|
||||
canvas.Translate(w, 0);
|
||||
canvas.RotateDegrees(90);
|
||||
break;
|
||||
case SKEncodedOrigin.RightBottom: // trasversa
|
||||
canvas.Translate(w, h);
|
||||
canvas.RotateDegrees(270);
|
||||
canvas.Scale(1, -1);
|
||||
break;
|
||||
case SKEncodedOrigin.LeftBottom: // ruotata di 90° in senso antiorario
|
||||
canvas.Translate(0, h);
|
||||
canvas.RotateDegrees(-90);
|
||||
break;
|
||||
}
|
||||
|
||||
canvas.DrawBitmap(src, 0, 0);
|
||||
return dst;
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,15 @@ public static class ImageThumb
|
||||
throw new InvalidOperationException("Formato immagine non supportato o file corrotto.");
|
||||
|
||||
// Decodifica
|
||||
var info = codec.Info;
|
||||
using var bitmap = SKBitmap.Decode(codec);
|
||||
if (bitmap is null)
|
||||
using var decoded = SKBitmap.Decode(codec);
|
||||
if (decoded is null)
|
||||
throw new InvalidOperationException("Impossibile decodificare l'immagine.");
|
||||
|
||||
// SKBitmap.Decode non applica l'orientamento EXIF: senza questo passaggio le
|
||||
// miniature delle foto scattate ruotate escono storte (vale anche per gli
|
||||
// scatti già sul dispositivo, che non passano da UtilityFile.ConvertToDto).
|
||||
using var bitmap = ImageOrientation.Upright(decoded, codec.EncodedOrigin);
|
||||
|
||||
// Calcola resize mantenendo aspect ratio
|
||||
var w = bitmap.Width;
|
||||
var h = bitmap.Height;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using SteUp.Maui.Core.Services;
|
||||
using SteUp.Shared.Core.Dto;
|
||||
|
||||
namespace SteUp.Maui.Core.Utility;
|
||||
@@ -10,13 +11,20 @@ public static class UtilityFile
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
|
||||
var bytes = ms.ToArray();
|
||||
|
||||
// Unico punto di passaggio di fotocamera e galleria: raddrizziamo qui, cosi'
|
||||
// miniature, salvataggio locale e upload lavorano tutti su pixel gia' dritti.
|
||||
if (type == AttachedDto.TypeAttached.Image)
|
||||
bytes = ImageOrientation.Normalize(bytes);
|
||||
|
||||
return new AttachedDto
|
||||
{
|
||||
Name = file.FileName,
|
||||
Path = file.FullPath,
|
||||
MimeType = file.ContentType,
|
||||
DimensionBytes = ms.Length,
|
||||
FileBytes = ms.ToArray(),
|
||||
DimensionBytes = bytes.Length,
|
||||
FileBytes = bytes,
|
||||
Type = type
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
xmlns:shared="clr-namespace:SteUp.Shared;assembly=SteUp.Shared"
|
||||
x:Class="SteUp.Maui.MainPage"
|
||||
SafeAreaEdges="All"
|
||||
BackgroundColor="{DynamicResource PageBackgroundColor}">
|
||||
BackgroundColor="#E9EDF2">
|
||||
|
||||
<BlazorWebView x:Name="BlazorWebView" HostPage="wwwroot/index.html">
|
||||
<BlazorWebView.RootComponents>
|
||||
|
||||
@@ -164,7 +164,14 @@ public partial class HoneywellScannerService
|
||||
: Java.Lang.Object, BarcodeReader.IBarcodeListener
|
||||
{
|
||||
public void OnBarcodeEvent(BarcodeReadEvent? e) => service.DispatchEvent(e!);
|
||||
public void OnFailureEvent(BarcodeFailureEvent? e) =>
|
||||
service.OnScanFailed?.Invoke(new Exception($"Scan failure: {e}"));
|
||||
|
||||
// Honeywell emette BarcodeFailureEvent anche solo premendo e rilasciando il
|
||||
// trigger senza inquadrare nulla: e' la normale mancata lettura, non un guasto.
|
||||
// Trattarlo come eccezione faceva comparire un dialog bloccante col toString()
|
||||
// Java a ogni pressione a vuoto. Non c'e' nulla da comunicare: chi ha premuto
|
||||
// il trigger sa gia' di non aver letto niente e sta ripuntando lo scanner.
|
||||
public void OnFailureEvent(BarcodeFailureEvent? e)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,146 @@
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using Android.Views;
|
||||
using AndroidX.Core.View;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SteUp.Shared.Core.Messages.System;
|
||||
|
||||
namespace SteUp.Maui
|
||||
{
|
||||
[Activity(
|
||||
Theme = "@style/Maui.SplashTheme",
|
||||
Theme = "@style/SteUp.SplashTheme",
|
||||
MainLauncher = true,
|
||||
WindowSoftInputMode = SoftInput.AdjustResize | SoftInput.StateHidden,
|
||||
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode |
|
||||
ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
||||
public class MainActivity : MauiAppCompatActivity
|
||||
{
|
||||
private BackButtonService? _backButton;
|
||||
private BlazorBackCallback? _backCallback;
|
||||
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
|
||||
ApplySystemBarsAppearance();
|
||||
HookBackButton();
|
||||
|
||||
// Da Android 15 (API 35) il edge-to-edge e' forzato e adjustResize non ridimensiona
|
||||
// piu' la finestra: la tastiera si limita a coprire la WebView. Riduciamo noi il
|
||||
// content view dell'altezza dell'IME cosi' il layout Blazor (100vh) si ricalcola.
|
||||
if (FindViewById(Android.Resource.Id.Content) is { } content)
|
||||
ViewCompat.SetOnApplyWindowInsetsListener(content, new ImeInsetsListener());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le barre di sistema si fondono con lo sfondo chiaro dell'app: icone
|
||||
/// scure su fondo chiaro. Senza questo la barra di stato resta tinta
|
||||
/// dell'accento, che nel sistema di design indica l'azione, non la
|
||||
/// cornice.
|
||||
/// </summary>
|
||||
private void ApplySystemBarsAppearance()
|
||||
{
|
||||
if (Window is null) return;
|
||||
|
||||
var controller = WindowCompat.GetInsetsController(Window, Window.DecorView);
|
||||
controller.AppearanceLightStatusBars = true;
|
||||
controller.AppearanceLightNavigationBars = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Il tasto indietro va intercettato sul dispatcher di AndroidX, non
|
||||
/// sovrascrivendo OnBackPressed: da AndroidX Activity 1.6 il gesto passa
|
||||
/// tutto di li' e il metodo dell'Activity non viene piu' invocato — la
|
||||
/// UI Blazor non riceveva mai il back e il lavoro non salvato del form
|
||||
/// se ne andava con la navigazione.
|
||||
///
|
||||
/// Il callback resta acceso solo finche' c'e' davvero qualcuno in
|
||||
/// ascolto: quando la UI non ha nulla da dire, il back torna a
|
||||
/// comportarsi come sempre invece di essere inghiottito.
|
||||
/// </summary>
|
||||
private void HookBackButton()
|
||||
{
|
||||
_backButton = IPlatformApplication.Current?.Services.GetService<BackButtonService>();
|
||||
if (_backButton is null) return;
|
||||
|
||||
_backCallback = new BlazorBackCallback(_backButton);
|
||||
OnBackPressedDispatcher.AddCallback(this, _backCallback);
|
||||
|
||||
_backCallback.Enabled = _backButton.HasHandlers;
|
||||
_backButton.HandlersChanged += OnBackHandlersChanged;
|
||||
}
|
||||
|
||||
private void OnBackHandlersChanged(bool hasHandlers)
|
||||
{
|
||||
if (_backCallback is null) return;
|
||||
|
||||
_backCallback.Enabled = hasHandlers;
|
||||
if (!hasHandlers) return;
|
||||
|
||||
// Il dispatcher consulta i callback dall'ultimo registrato al primo,
|
||||
// e BlazorWebView registra il suo (che fa tornare indietro la
|
||||
// cronologia della WebView) quando l'handler viene creato, cioe' dopo
|
||||
// OnCreate. Ri-registrandoci nel momento in cui la UI ha davvero
|
||||
// qualcosa da dire torniamo in cima e il back arriva prima a noi.
|
||||
_backCallback.Remove();
|
||||
OnBackPressedDispatcher.AddCallback(this, _backCallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Con la navigazione a tre tasti il back arriva come evento di tastiera
|
||||
/// e la WebView lo intercetta per conto suo, tornando indietro nella
|
||||
/// propria cronologia: ne' l'Activity ne' il dispatcher lo vedono mai.
|
||||
/// Qui la chiave passa prima della gerarchia di view, quindi e' l'unico
|
||||
/// punto in cui possiamo fermarla mentre un form ha lavoro non salvato.
|
||||
/// Il callback sul dispatcher resta per la navigazione a gesti, dove
|
||||
/// evento di tastiera non ce n'e'.
|
||||
/// </summary>
|
||||
public override bool DispatchKeyEvent(KeyEvent? e)
|
||||
{
|
||||
if (e?.KeyCode == Keycode.Back && _backButton?.HasHandlers == true)
|
||||
{
|
||||
// Si consuma anche il DOWN: lasciandolo passare la WebView
|
||||
// reagirebbe lo stesso.
|
||||
if (e.Action == KeyEventActions.Up) _backButton.TryHandleBackPressed();
|
||||
return true;
|
||||
}
|
||||
|
||||
return base.DispatchKeyEvent(e);
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_backButton is not null) _backButton.HandlersChanged -= OnBackHandlersChanged;
|
||||
|
||||
base.OnDestroy();
|
||||
}
|
||||
|
||||
private sealed class BlazorBackCallback(BackButtonService service)
|
||||
: AndroidX.Activity.OnBackPressedCallback(false)
|
||||
{
|
||||
public override void HandleOnBackPressed() => service.TryHandleBackPressed();
|
||||
}
|
||||
|
||||
private sealed class ImeInsetsListener : Java.Lang.Object, IOnApplyWindowInsetsListener
|
||||
{
|
||||
public WindowInsetsCompat OnApplyWindowInsets(Android.Views.View? v, WindowInsetsCompat? insets)
|
||||
{
|
||||
if (v is null || insets is null)
|
||||
return insets!;
|
||||
|
||||
var ime = insets.GetInsets(WindowInsetsCompat.Type.Ime()).Bottom;
|
||||
var systemBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars()).Bottom;
|
||||
|
||||
// La barra di navigazione e' gia' gestita da SafeAreaEdges in MainPage:
|
||||
// teniamo solo la parte di tastiera che la eccede, per non sommare due volte.
|
||||
var bottom = System.Math.Max(0, ime - systemBars);
|
||||
|
||||
if (v.PaddingBottom != bottom)
|
||||
v.SetPadding(v.PaddingLeft, v.PaddingTop, v.PaddingRight, bottom);
|
||||
|
||||
return insets;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- windowLightNavigationBar esiste solo da API 27 in su. -->
|
||||
<style name="SteUp.SplashTheme" parent="Maui.SplashTheme">
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowLightNavigationBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPrimary">#ec4c41</color>
|
||||
<color name="colorPrimaryDark">#d02315</color>
|
||||
<color name="colorAccent">#d02315</color>
|
||||
<!-- Barra di stato: si fonde con lo sfondo dell'app. Il corallo e' il
|
||||
colore dell'azione, non una decorazione della cornice di sistema. -->
|
||||
<color name="colorPrimaryDark">#e9edf2</color>
|
||||
<color name="colorAccent">#ec4c41</color>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
Tema dello splash. Deriva da quello generato da MAUI e cambia solo
|
||||
l'aspetto delle barre di sistema: senza questo lo splash mostra icone
|
||||
chiare, e all'apparire della UI Blazor - che le vuole scure - si vede
|
||||
un ribaltamento di colore a ogni avvio.
|
||||
-->
|
||||
<style name="SteUp.SplashTheme" parent="Maui.SplashTheme">
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -30,8 +30,8 @@
|
||||
<ApplicationId>it.integry.SteUp</ApplicationId>
|
||||
|
||||
<!-- Versions -->
|
||||
<ApplicationDisplayVersion>1.1.1</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>5</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>1.2.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>6</ApplicationVersion>
|
||||
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||
<!-- <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">-->
|
||||
@@ -98,7 +98,9 @@
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#FDECEA" />
|
||||
|
||||
<!-- Splash Screen -->
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#FDECEA" BaseSize="128,128" />
|
||||
<!-- Lo splash e' la prima superficie dell'app: stesso fondo del workspace,
|
||||
cosi' l'avvio non lampeggia di rosa prima di mostrare la lista. -->
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#E9EDF2" BaseSize="112,112" />
|
||||
|
||||
<!-- Images -->
|
||||
<MauiImage Include="Resources\Images\*" />
|
||||
|
||||
@@ -1,49 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"/>
|
||||
<title>SteUp.Maui</title>
|
||||
<title>SteUP</title>
|
||||
<base href="/"/>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap"
|
||||
rel="stylesheet">
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/fonts.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/tokens.css"/>
|
||||
|
||||
<link href="_content/SteUp.Shared/css/bootstrap/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="_content/SteUp.Shared/css/bootstrap/bootstrap-icons.min.css" rel="stylesheet"/>
|
||||
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet"/>
|
||||
|
||||
<link href="_content/CodeBeam.MudBlazor.Extensions/MudExtensions.min.css" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/remixicon/remixicon.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/app.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/form.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/default-theme.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/custom-mudBlazor.css"/>
|
||||
<link rel="stylesheet" href="SteUp.Maui.styles.css"/>
|
||||
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/base.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/components.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/forms.css"/>
|
||||
<!-- Dopo MudBlazor: riporta i controlli di libreria allo stile condiviso. -->
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/mudblazor-overrides.css"/>
|
||||
<link href="SteUp.Maui.styles.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<style>
|
||||
/* Primo fotogramma, prima che Blazor sia pronto: nessun lampo bianco,
|
||||
nessun salto di layout quando la schermata compare. */
|
||||
.boot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100dvh;
|
||||
background-color: var(--workspace, #e9edf2);
|
||||
}
|
||||
|
||||
.boot__spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(0, 35, 57, 0.15);
|
||||
border-top-color: var(--primary, #ec4c41);
|
||||
animation: boot-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes boot-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.boot__spinner { animation-duration: 2s; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="status-bar-safe-area"></div>
|
||||
|
||||
<div id="app">
|
||||
<div class="spinner-container">
|
||||
<span class="loader"></span>
|
||||
<div class="boot">
|
||||
<div class="boot__spinner" role="status" aria-label="Caricamento"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
Si è verificato un errore imprevisto.
|
||||
<a href="" class="reload">Ricarica</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
|
||||
<script src="_framework/blazor.webview.js" autostart="false"></script>
|
||||
<script src="_content/SteUp.Shared/js/bootstrap/bootstrap.bundle.min.js"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
<script src="_content/CodeBeam.MudBlazor.Extensions/MudExtensions.min.js"></script>
|
||||
<script src="_content/SteUp.Shared/js/main.js"></script>
|
||||
|
||||
@@ -1,67 +1,56 @@
|
||||
@inject IJSRuntime Js
|
||||
|
||||
<div class="@(Back ? "" : "container") header">
|
||||
<div class="header-content @(Back ? "with-back" : "no-back")">
|
||||
@if (!SmallHeader)
|
||||
@*
|
||||
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.
|
||||
*@
|
||||
|
||||
<header class="appbar @(Compact ? "appbar--compact" : "")">
|
||||
@if (Back || Cancel)
|
||||
{
|
||||
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 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>
|
||||
}
|
||||
|
||||
<h3 class="page-title">@Title</h3>
|
||||
|
||||
<div class="right-section">
|
||||
@if (!LabelSave.IsNullOrEmpty())
|
||||
<div class="appbar__titles">
|
||||
<h1 class="appbar__title">@Title</h1>
|
||||
@if (!string.IsNullOrWhiteSpace(Subtitle))
|
||||
{
|
||||
<MudButton OnClick="@OnSave"
|
||||
Color="Color.Info"
|
||||
Style="text-transform: none"
|
||||
Variant="Variant.Text">
|
||||
@LabelSave
|
||||
</MudButton>
|
||||
<p class="appbar__subtitle">@Subtitle</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="title">
|
||||
<MudText Typo="Typo.h6">
|
||||
<b>@Title</b>
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Rounded.Close" OnClick="@GoBack" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code{
|
||||
@if (Actions is not null)
|
||||
{
|
||||
<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; }
|
||||
[Parameter] public string BackTo { get; set; } = "";
|
||||
|
||||
[Parameter] public EventCallback OnFilterToggle { 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; }
|
||||
[Parameter] public string? LabelSave { get; set; }
|
||||
[Parameter] public EventCallback OnSave { get; set; }
|
||||
|
||||
[Parameter] public bool SmallHeader { 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; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
Back = !Back ? !Back && Cancel : Back;
|
||||
BackTo = Cancel ? "Annulla" : BackTo;
|
||||
}
|
||||
[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; }
|
||||
@@ -3,30 +3,28 @@
|
||||
@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/>
|
||||
@* NoHeader NON va messo qui: e' il valore predefinito di tutti i dialog e
|
||||
sopprimerebbe il titolo dei MudMessageBox, che lo rendono nella testata.
|
||||
Le schermate a tutto schermo (form scheda, scelta punto vendita) lo
|
||||
chiedono gia' per conto loro nelle DialogOptions. *@
|
||||
<MudDialogProvider CloseButton="false" BackdropClick="false"/>
|
||||
<MudSnackbarProvider/>
|
||||
|
||||
<ConnectionState IsNetworkAvailable="IsNetworkAvailable" ServicesIsDown="ServicesIsDown" ShowWarning="ShowWarning" />
|
||||
<ConnectionState IsNetworkAvailable="IsNetworkAvailable" ServicesIsDown="ServicesIsDown" ShowWarning="ShowWarning"/>
|
||||
|
||||
<div class="page">
|
||||
<NavMenu/>
|
||||
|
||||
<main>
|
||||
<article>
|
||||
<div class="app-shell">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<NavMenu/>
|
||||
|
||||
@code {
|
||||
private MudThemeProvider _mudThemeProvider = null!;
|
||||
private bool IsDarkMode { get; set; }
|
||||
private string _mainContentClass = "";
|
||||
|
||||
//Connection state
|
||||
private bool _isNetworkAvailable;
|
||||
@@ -71,58 +69,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"
|
||||
Tertiary = "#dde5ee",
|
||||
Success = "#26b050",
|
||||
Error = "#e50000",
|
||||
Warning = "#b26a00",
|
||||
Info = "#002339",
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,167 +1,95 @@
|
||||
@using CommunityToolkit.Mvvm.Messaging
|
||||
@using SteUp.Shared.Components.SingleElements.MessageBox
|
||||
@using SteUp.Shared.Core.Enum
|
||||
@using SteUp.Shared.Core.Interface.LocalDb
|
||||
@using SteUp.Shared.Core.Messages.Ispezione
|
||||
@using SteUp.Shared.Core.Messages.Scheda
|
||||
@inject INetworkService NetworkService
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@inject IMessenger Messenger
|
||||
@inject IIspezioniService IspezioniService
|
||||
@inject IJSRuntime Js
|
||||
@inject PageActionsService PageActions
|
||||
@using SteUp.Shared.Core.Messages.System
|
||||
|
||||
<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>
|
||||
@*
|
||||
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).
|
||||
|
||||
In mezzo alle due destinazioni sta il comando. Non e' un pulsante che
|
||||
galleggia sopra la barra e ne copre il centro: e' la casella centrale,
|
||||
alla stessa altezza delle altre due. Sta sempre nello stesso punto, ma
|
||||
quello che fa lo decide la schermata sotto (vedi PageActionsService):
|
||||
dentro un'ispezione apre le sue azioni, altrove apre una nuova ispezione.
|
||||
*@
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<button type="button" class="navbar__action" @onclick="RunAction"
|
||||
disabled="@(!NetworkService.IsNetworkAvailable())"
|
||||
aria-label="@ActionLabel"
|
||||
title="@(NetworkService.IsNetworkAvailable() ? ActionLabel : "Serve la connessione")">
|
||||
<i class="ri-add-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@if (PlusVisible)
|
||||
{
|
||||
<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"/>
|
||||
}
|
||||
|
||||
<MudFabMenuItem Disabled="!NetworkService.IsNetworkAvailable()" OnClick="@DeleteInspection"
|
||||
Label="Cancella ispezione" Color="Color.Surface"/>
|
||||
|
||||
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>
|
||||
}
|
||||
</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?"/>
|
||||
</nav>
|
||||
|
||||
@code
|
||||
{
|
||||
private bool IsVisible { get; set; } = true;
|
||||
private bool PlusVisible { get; set; } = true;
|
||||
private bool ShowCompleteInspection { get; set; }
|
||||
private bool SchedaVisible { get; set; }
|
||||
private bool _isCompleted;
|
||||
|
||||
private ConfirmMessageBox _messageBox = null!;
|
||||
private static readonly string[] NavigableRoutes = ["ispezioni", "ispezione", "user"];
|
||||
|
||||
protected override Task OnInitializedAsync()
|
||||
private string CurrentRoute =>
|
||||
NavigationManager.Uri.Remove(0, NavigationManager.BaseUri.Length).Split('?')[0];
|
||||
|
||||
private bool IsOn(string route) => CurrentRoute.EqualsIgnoreCase(route);
|
||||
|
||||
private string ActionLabel => PageActions.HasActions ? "Azioni" : "Nuova ispezione";
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
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;
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
PageActions.Changed += OnPageActionsChanged;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnPageActionsChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs args) => Refresh();
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
IsVisible = NavigableRoutes.Contains(CurrentRoute, StringComparer.OrdinalIgnoreCase);
|
||||
StateHasChanged();
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// Il comando vale quello che la schermata gli ha dato; se non gliel'ha
|
||||
/// dato nessuno, vale la cosa che l'app fa piu' spesso.
|
||||
private Task RunAction() => PageActions.HasActions ? PageActions.Invoke() : NewInspection();
|
||||
|
||||
private async Task NewInspection()
|
||||
{
|
||||
if (await SteupDataService.CanOpenNewInspection())
|
||||
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.");
|
||||
return;
|
||||
}
|
||||
|
||||
private async Task CompleteInspection()
|
||||
{
|
||||
var result = await _messageBox.ShowAsync();
|
||||
|
||||
if (result is true)
|
||||
Messenger.Send(new CompleteInspectionMessage());
|
||||
// La scelta del punto vendita naviga da sola sull'ispezione: qui non
|
||||
// c'e' altro da fare, nemmeno quando l'utente annulla.
|
||||
await Dialog.OpenSelectShop();
|
||||
}
|
||||
|
||||
private async Task NewScheda()
|
||||
public void Dispose()
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
private async Task DeleteInspection()
|
||||
{
|
||||
try
|
||||
{
|
||||
var ispezione = SteupDataService.InspectionPageState.Ispezione;
|
||||
await IspezioniService.DeleteIspezioneAsync(ispezione.CodMdep, ispezione.Data, ispezione.Rilevatore);
|
||||
|
||||
await Js.InvokeVoidAsync("goBack");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
await Dialog.ShowError(e.Message);
|
||||
}
|
||||
NavigationManager.LocationChanged -= OnLocationChanged;
|
||||
PageActions.Changed -= OnPageActionsChanged;
|
||||
}
|
||||
}
|
||||
@@ -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 (VisibleOverlay)
|
||||
{
|
||||
<div class="overlay" role="status" aria-live="polite">
|
||||
@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>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Message))
|
||||
{
|
||||
<p class="overlay-message">@Message</p>
|
||||
}
|
||||
<div class="checkmark">
|
||||
<svg viewBox="0 0 32 32" aria-hidden="true">
|
||||
<path d="M6 17 L13 24 L26 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="overlay-content">
|
||||
<SpinnerLayout/>
|
||||
<span class="spinner spinner--lg" aria-hidden="true"></span>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Message))
|
||||
{
|
||||
<p class="overlay-message">@Message</p>
|
||||
<p class="overlay__message">@Message</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudOverlay>
|
||||
}
|
||||
|
||||
@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,81 @@
|
||||
@using SteUp.Shared.Core.Messages.System
|
||||
@inject BackButtonService BackButton
|
||||
@implements IDisposable
|
||||
|
||||
@*
|
||||
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; }
|
||||
|
||||
// Il tasto indietro del dispositivo chiude lo sheet invece di uscire dalla
|
||||
// schermata sotto: e' il gesto che l'utente si aspetta, ed e' anche la
|
||||
// ragione per cui i gestori sono impilati (vedi BackButtonService).
|
||||
private IDisposable? _backRegistration;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Open && _backRegistration is null)
|
||||
{
|
||||
_backRegistration = BackButton.Register(() => InvokeAsync(Close));
|
||||
}
|
||||
else if (!Open)
|
||||
{
|
||||
Unregister();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
Unregister();
|
||||
Open = false;
|
||||
await OpenChanged.InvokeAsync(false);
|
||||
}
|
||||
|
||||
private void Unregister()
|
||||
{
|
||||
_backRegistration?.Dispose();
|
||||
_backRegistration = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Unregister();
|
||||
}
|
||||
@@ -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,5 +1,8 @@
|
||||
<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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,109 +2,229 @@
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using SteUp.Shared.Components.Layout
|
||||
@using SteUp.Shared.Components.Layout.Overlay
|
||||
@using SteUp.Shared.Components.Layout.Sheet
|
||||
@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
|
||||
@using SteUp.Shared.Core.Messages.Ispezione
|
||||
@using SteUp.Shared.Core.Messages.Scheda
|
||||
@inject NewSchedaService NewScheda
|
||||
@inject CompleteInspectionService CompleteInspection
|
||||
@using SteUp.Shared.Core.Messages.System
|
||||
@inject IIspezioniService IspezioniService
|
||||
@inject IDialogService Dialog
|
||||
@inject IIntegrySteupService IntegrySteupService
|
||||
@inject IFileManager FileManager
|
||||
@inject ILogger<IspezionePage> Logger
|
||||
@inject PageActionsService PageActions
|
||||
@implements IDisposable
|
||||
|
||||
<HeaderLayout Title="Ispezione" BackTo="Indietro" Back="true"/>
|
||||
@* Le azioni non stanno nella testata: le apre il comando al centro della
|
||||
barra in basso, che finche' questa schermata e' a video significa "azioni
|
||||
di questa ispezione" (vedi PageActionsService). Su un'ispezione conclusa
|
||||
non si registra nulla e resta solo la consultazione. *@
|
||||
<HeaderLayout Title="@Titolo" Subtitle="@Sottotitolo" Back="true" Compact="true"/>
|
||||
|
||||
<div class="container content pb-safe-area ispezione">
|
||||
<InspectionCard Ispezione="SteupDataService.InspectionPageState.Ispezione"/>
|
||||
<ActionSheet @bind-Open="_actionsOpen" Title="Azioni ispezione" Subtitle="@Ispezione.CodMdep">
|
||||
<div class="stack stack-xs">
|
||||
<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>
|
||||
|
||||
@if (!SchedeGrouped.IsNullOrEmpty())
|
||||
@if (!Ispezione.ActivityId.IsNullOrEmpty())
|
||||
{
|
||||
<MudExpansionPanels MultiExpansion="true">
|
||||
@{
|
||||
var panelIndex = 0;
|
||||
<button type="button" class="action-tile" disabled="@(!NetworkService.IsNetworkAvailable())"
|
||||
@onclick="ConfirmCompleteInspection">
|
||||
<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>
|
||||
}
|
||||
@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();
|
||||
}
|
||||
<div class="header-scheda-group">
|
||||
<div class="title">
|
||||
<MudText Typo="Typo.h6"><b>@group.Key.Descrizione</b></MudText>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">
|
||||
@($"{group.Value.Count} sched{(group.Value.Count == 1 ? "a" : "e")}")
|
||||
</MudChip>
|
||||
@if (daInviare > 0)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
|
||||
Color="Color.Primary" Icon="@Icons.Material.Rounded.PhonelinkOff">
|
||||
@($"{daInviare} da inviare")
|
||||
</MudChip>
|
||||
}
|
||||
</div>
|
||||
@if (SteupDataService.InspectionPageState.Ispezione.Stato != StatusEnum.Completata)
|
||||
{
|
||||
<div class="action-scheda-group">
|
||||
<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))"/>
|
||||
|
||||
@if (NetworkService.IsNetworkAvailable() && daInviare > 0)
|
||||
<button type="button" class="action-tile" disabled="@(!NetworkService.IsNetworkAvailable())"
|
||||
@onclick="ConfirmDeleteInspection">
|
||||
<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>
|
||||
|
||||
@if (!NetworkService.IsNetworkAvailable())
|
||||
{
|
||||
<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 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>
|
||||
</TitleContent>
|
||||
<ChildContent>
|
||||
@foreach (var scheda in group.Value)
|
||||
</ActionSheet>
|
||||
|
||||
<div class="app-scroll">
|
||||
<div class="page-width page-body stack stack-lg">
|
||||
|
||||
@* §6.6 Pannello di riepilogo: primo elemento della pagina, con lo stato
|
||||
del documento e i conteggi sempre a vista. Superficie bianca come
|
||||
ogni altro pannello: era l'unico blocco tinto dell'app, e quel
|
||||
colore non appartiene a nessuno stato. *@
|
||||
<section class="panel panel--lg">
|
||||
@* Il nome del punto vendita e' gia' nella testata: qui basta la data
|
||||
e lo stato del documento, che la testata non porta. *@
|
||||
<div class="summary__head">
|
||||
<div class="section-header__text">
|
||||
<p class="summary__subtitle" style="margin: 0;">@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)
|
||||
{
|
||||
<SchedaCard Scheda="scheda" OnSchedaDeleted="OnSchedaDeleted"
|
||||
OnSchedaModified="OnSchedaModified"
|
||||
Ispezione="@SteupDataService.InspectionPageState.Ispezione"/>
|
||||
<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>
|
||||
}
|
||||
</ChildContent>
|
||||
</MudExpansionPanel>
|
||||
</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>
|
||||
}
|
||||
</MudExpansionPanels>
|
||||
|
||||
@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">
|
||||
Aggiungi la prima scheda di questa ispezione.
|
||||
</p>
|
||||
<div class="empty-state__action">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Rounded.Add"
|
||||
Disabled="@(!NetworkService.IsNetworkAvailable())"
|
||||
OnClick="NewScheda">
|
||||
Nuova scheda
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
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)
|
||||
@* Un reparto per volta: la testata si tocca per richiudere il
|
||||
gruppo, e i conteggi restano scritti li' anche da chiuso — un
|
||||
reparto compattato non deve nascondere quello che gli manca. *@
|
||||
@foreach (var group in SchedeGrouped)
|
||||
{
|
||||
<p class="empty-state-text">
|
||||
Tocca <b>+</b> in basso per creare la prima scheda di questa ispezione.
|
||||
</p>
|
||||
var reparto = group.Key;
|
||||
var chiuso = IsCollapsed(reparto);
|
||||
var daInviare = group.Value.CountDaInviare();
|
||||
|
||||
<section class="reparto @(chiuso ? "reparto--collapsed" : "")">
|
||||
<div class="reparto__head">
|
||||
<button type="button" class="reparto__toggle" @onclick="@(() => Toggle(reparto))"
|
||||
aria-expanded="@((!chiuso).ToString().ToLowerInvariant())">
|
||||
<i class="reparto__chevron ri-arrow-down-s-line" aria-hidden="true"></i>
|
||||
<span class="reparto__text">
|
||||
<span class="reparto__title">@reparto.Descrizione</span>
|
||||
<span class="reparto__meta">@Conteggi(group.Value)</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div class="reparto__actions">
|
||||
@if (!IsCompleted)
|
||||
{
|
||||
@* Da qui la scheda nasce gia' con il suo reparto: e'
|
||||
l'unica differenza con il "+" della barra. *@
|
||||
<button type="button" class="reparto__add"
|
||||
disabled="@(!NetworkService.IsNetworkAvailable())"
|
||||
aria-label="@($"Nuova scheda nel reparto {reparto.Descrizione}")"
|
||||
title="@($"Nuova scheda in {reparto.Descrizione}")"
|
||||
@onclick="@(() => CreateNewScheda(reparto))">
|
||||
<i class="ri-add-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@if (NetworkService.IsNetworkAvailable() && daInviare > 0)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Rounded.CloudUpload"
|
||||
aria-label="@($"Invia le schede del reparto {reparto.Descrizione}")"
|
||||
OnClick="@(() => ExportReparto(reparto))">
|
||||
Invia @daInviare
|
||||
</MudButton>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!chiuso)
|
||||
{
|
||||
<div class="card-list reparto__body">
|
||||
@foreach (var scheda in group.Value)
|
||||
{
|
||||
<SchedaCard Scheda="scheda" Ispezione="Ispezione"
|
||||
OnSchedaDeleted="OnSchedaDeleted"
|
||||
OnSchedaModified="OnSchedaModified"
|
||||
OnBusyChanged="@(b => VisibleOverlay = b)"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
}
|
||||
</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="_completeBox" Title="Concludere l'ispezione?" NoText="Annulla"
|
||||
YesText="Concludi" YesColor="Color.Primary"
|
||||
Message="Una volta conclusa, l'ispezione non sarà più modificabile e non potrai aggiungere altre schede."/>
|
||||
<ConfirmMessageBox @ref="_deleteBox" Title="Eliminare l'ispezione?" NoText="Annulla"
|
||||
YesText="Elimina" YesColor="Color.Error"
|
||||
Message="L'ispezione e tutte le sue schede verranno eliminate dal dispositivo. L'operazione non è reversibile."/>
|
||||
<ConfirmMessageBox @ref="_exportRepartoBox" Title="Inviare le schede del reparto?" 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; } = [];
|
||||
@@ -116,18 +236,61 @@
|
||||
private string? _progressMessage;
|
||||
|
||||
private ConfirmMessageBox _exportRepartoBox = null!;
|
||||
private ConfirmMessageBox _completeBox = null!;
|
||||
private ConfirmMessageBox _deleteBox = null!;
|
||||
|
||||
private bool _actionsOpen;
|
||||
|
||||
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 PuntoVenditaDto? PuntoVendita => SteupDataService.PuntiVenditaList
|
||||
.Find(x => x.CodMdep != null && x.CodMdep.EqualsIgnoreCase(Ispezione.CodMdep));
|
||||
|
||||
// Il codice del punto vendita sta davanti al nome, come su ogni documento
|
||||
// cartaceo dell'ispezione: e' quello che si confronta, il nome serve a
|
||||
// riconoscerlo.
|
||||
private string Titolo => PuntoVendita?.Descrizione is { Length: > 0 } descrizione
|
||||
? $"{Ispezione.CodMdep} - {descrizione}"
|
||||
: Ispezione.CodMdep;
|
||||
|
||||
private string? Sottotitolo => PuntoVendita?.Indirizzo;
|
||||
|
||||
private IDisposable? _actionsRegistration;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NewScheda.OnNewScheda += LoadSchede;
|
||||
CompleteInspection.OnComplete += HandleCompleteInspection;
|
||||
|
||||
LoadSchede();
|
||||
|
||||
// Su un'ispezione conclusa non c'e' niente da fare: il comando della
|
||||
// barra si tiene il suo significato di default invece di aprire uno
|
||||
// sheet di azioni tutte spente.
|
||||
if (!IsCompleted)
|
||||
_actionsRegistration = PageActions.Register(() => InvokeAsync(OpenActions));
|
||||
}
|
||||
|
||||
private void OpenActions()
|
||||
{
|
||||
_actionsOpen = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose() => _actionsRegistration?.Dispose();
|
||||
|
||||
private void LoadSchede()
|
||||
{
|
||||
var ispezione = SteupDataService.InspectionPageState.Ispezione;
|
||||
var ispezione = Ispezione;
|
||||
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
@@ -140,6 +303,81 @@
|
||||
});
|
||||
}
|
||||
|
||||
private async Task NewScheda()
|
||||
{
|
||||
_actionsOpen = false;
|
||||
|
||||
var modal = await Dialog.OpenFormScheda(Ispezione.CodMdep, Ispezione.Data, true);
|
||||
if (modal is { Canceled: false }) LoadSchede();
|
||||
}
|
||||
|
||||
/// Stessa scheda nuova, ma con il reparto gia' scelto: partendo da un
|
||||
/// reparto non ha senso richiederlo.
|
||||
private async Task CreateNewScheda(JtbFasiDto reparto)
|
||||
{
|
||||
var modal = await Dialog.OpenFormScheda(
|
||||
Ispezione.CodMdep, Ispezione.Data, true, new Scheda { Reparto = reparto }
|
||||
);
|
||||
|
||||
if (modal is { Canceled: false }) LoadSchede();
|
||||
}
|
||||
|
||||
// I reparti richiusi stanno qui e non nell'entita': e' uno stato della
|
||||
// schermata, non del documento.
|
||||
private readonly HashSet<string> _collapsed = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private bool IsCollapsed(JtbFasiDto reparto) =>
|
||||
reparto.CodJfas is not null && _collapsed.Contains(reparto.CodJfas);
|
||||
|
||||
private void Toggle(JtbFasiDto reparto)
|
||||
{
|
||||
if (reparto.CodJfas is null) return;
|
||||
if (!_collapsed.Remove(reparto.CodJfas)) _collapsed.Add(reparto.CodJfas);
|
||||
}
|
||||
|
||||
private static string Conteggi(List<Scheda> schede)
|
||||
{
|
||||
var parti = new List<string> { schede.Count == 1 ? "1 scheda" : $"{schede.Count} schede" };
|
||||
|
||||
var daInviare = schede.CountDaInviare();
|
||||
if (daInviare > 0) parti.Add($"{daInviare} da inviare");
|
||||
|
||||
var bozze = schede.CountBozze();
|
||||
if (bozze > 0) parti.Add($"{bozze} da completare");
|
||||
|
||||
return string.Join(" · ", parti);
|
||||
}
|
||||
|
||||
private async Task ConfirmCompleteInspection()
|
||||
{
|
||||
_actionsOpen = false;
|
||||
|
||||
var confirmed = await _completeBox.ShowAsync();
|
||||
if (confirmed is true) HandleCompleteInspection();
|
||||
}
|
||||
|
||||
private async Task ConfirmDeleteInspection()
|
||||
{
|
||||
_actionsOpen = false;
|
||||
|
||||
// Prima non c'era conferma: un tocco cancellava l'ispezione e tutte le
|
||||
// sue schede senza appello.
|
||||
var confirmed = await _deleteBox.ShowAsync();
|
||||
if (confirmed is not true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var ispezione = Ispezione;
|
||||
await IspezioniService.DeleteIspezioneAsync(ispezione.CodMdep, ispezione.Data, ispezione.Rilevatore);
|
||||
|
||||
NavigationManager.NavigateTo("/ispezioni");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
OnError(e, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async void HandleCompleteInspection()
|
||||
{
|
||||
// Guardia anti doppio-invio: il completamento è irreversibile.
|
||||
@@ -154,9 +392,27 @@
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
var ispezione = SteupDataService.InspectionPageState.Ispezione;
|
||||
var ispezione = Ispezione;
|
||||
|
||||
var daInviare = ispezione.Schede.Where(x => x.ActivityId == null).ToList();
|
||||
// Le bozze non sono inviabili: l'ispezione non si chiude finché ci sono.
|
||||
var bozze = SchedeList.CountBozze();
|
||||
if (bozze > 0)
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
await Dialog.ShowWarning(
|
||||
bozze == 1
|
||||
? "C'è 1 scheda da completare. Completala o eliminala prima di concludere l'ispezione."
|
||||
: $"Ci sono {bozze} schede da completare. Completale o eliminale prima di concludere l'ispezione."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var daInviare = ispezione.Schede.Where(x => x.ActivityId == null && !x.Bozza).ToList();
|
||||
var totale = daInviare.Count;
|
||||
var indice = 0;
|
||||
var fallite = 0;
|
||||
@@ -251,7 +507,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(() =>
|
||||
@@ -313,18 +569,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.
|
||||
@@ -339,9 +583,18 @@
|
||||
|
||||
try
|
||||
{
|
||||
var ispezione = SteupDataService.InspectionPageState.Ispezione;
|
||||
var ispezione = Ispezione;
|
||||
|
||||
var saveRequest = SchedeGrouped[jtbFasi].ConvertAll(x =>
|
||||
// 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 inviare in questo reparto", Severity.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
var saveRequest = daEsportare.ConvertAll(x =>
|
||||
{
|
||||
return new SaveRequestDto
|
||||
{
|
||||
@@ -363,11 +616,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
|
||||
@@ -375,7 +628,7 @@
|
||||
|
||||
if (!apiResponse.ActivityIdSchedaList.IsNullOrEmpty())
|
||||
{
|
||||
foreach (var scheda in SchedeGrouped[jtbFasi])
|
||||
foreach (var scheda in daEsportare)
|
||||
{
|
||||
var activityId = apiResponse.ActivityIdSchedaList!.Find(x =>
|
||||
x.LocalId != null && x.LocalId == scheda.Id
|
||||
@@ -388,7 +641,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
Snackbar.Add("Reparto esportato", Severity.Success);
|
||||
Snackbar.Add(daEsportare.Count == 1 ? "Scheda inviata" : $"{daEsportare.Count} schede inviate",
|
||||
Severity.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -412,10 +666,4 @@
|
||||
);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
NewScheda.OnNewScheda -= LoadSchede;
|
||||
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,95 @@
|
||||
@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
|
||||
@inject IDialogService Dialog
|
||||
|
||||
<HeaderLayout Title="Ispezioni"/>
|
||||
@* L'azione "nuova ispezione" non sta qui: vive nella barra in basso, dove
|
||||
il pollice arriva senza attraversare lo schermo. *@
|
||||
<HeaderLayout Title="Ispezioni" Subtitle="@Subtitle"/>
|
||||
|
||||
<div class="container ispezioni pb-safe-area">
|
||||
@if (Ispezioni.IsNullOrEmpty())
|
||||
<div class="app-scroll">
|
||||
<div class="page-width page-body">
|
||||
@if (_loading)
|
||||
{
|
||||
<NoDataAvailable Text="Nessuna ispezione effettuata"
|
||||
ImageSource="_content/SteUp.Shared/images/undraw_file-search_cbur.svg"/>
|
||||
@* 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">
|
||||
Apri la prima ispezione scegliendo il punto vendita da controllare.
|
||||
</p>
|
||||
<div class="empty-state__action">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Rounded.Add"
|
||||
Disabled="@(!NetworkService.IsNetworkAvailable())"
|
||||
OnClick="NewInspection">
|
||||
Nuova ispezione
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Virtualize Items="Ispezioni" Context="ispezione">
|
||||
<InspectionCard Class="no-margin" Ispezione="ispezione" CompactView="true" OnClick="@OnClickIspezione"/>
|
||||
<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();
|
||||
Ispezioni = await IspezioniService.GetAllIspezioniWithSchedeAsync(UserSession.User.Username);
|
||||
|
||||
VisibleOverlay = false;
|
||||
_loading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
private async Task NewInspection()
|
||||
{
|
||||
Ispezioni = await IspezioniService.GetAllIspezioniWithSchedeAsync(UserSession.User.Username);
|
||||
if (await SteupDataService.CanOpenNewInspection())
|
||||
{
|
||||
var modal = await Dialog.OpenSelectShop();
|
||||
// La scelta del punto vendita naviga da sola sull'ispezione; se
|
||||
// l'utente torna indietro, la lista dev'essere quella aggiornata.
|
||||
if (modal is { Canceled: true }) return;
|
||||
}
|
||||
else
|
||||
{
|
||||
await Dialog.ShowWarning(
|
||||
"Per aprire una nuova ispezione devi prima concludere tutte le ispezioni aperte da piu' di 20 giorni.");
|
||||
}
|
||||
}
|
||||
|
||||
private void OnClickIspezione(Ispezione ispezione)
|
||||
@@ -49,5 +97,4 @@
|
||||
SteupDataService.InspectionPageState.Ispezione = ispezione;
|
||||
NavigationManager.NavigateTo("/ispezione");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.ispezioni{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -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,69 +9,92 @@
|
||||
@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"/>
|
||||
@*
|
||||
Accesso (STILE_UI §7): stessa struttura dell'app, registro diverso.
|
||||
L'accento resta dove sta sempre, sull'unica azione della schermata.
|
||||
*@
|
||||
|
||||
<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"/>
|
||||
|
||||
@* Un solo pannello con tre righe divise da una linea, come le liste
|
||||
del resto dell'app. Prima ogni campo era un riquadro dentro il
|
||||
riquadro, con l'etichetta che saltava sopra il testo al primo tocco:
|
||||
un gesto da modulo web, non da applicazione. Qui l'etichetta sta
|
||||
ferma sopra il campo e non si muove mai. *@
|
||||
<div class="onboarding__fields">
|
||||
<label class="onboarding__field">
|
||||
<span class="onboarding__label">Username</span>
|
||||
<input class="onboarding__input" type="text" autocomplete="username"
|
||||
autocapitalize="none" autocorrect="off" enterkeyhint="next"
|
||||
value="@UserData.Username" @oninput="@(e => UserData.Username = e.Value?.ToString())"/>
|
||||
</label>
|
||||
|
||||
<label class="onboarding__field">
|
||||
<span class="onboarding__label">Password</span>
|
||||
<span class="onboarding__input-row">
|
||||
<input class="onboarding__input" type="@(_isShow ? "text" : "password")"
|
||||
autocomplete="current-password" enterkeyhint="next"
|
||||
value="@UserData.Password" @oninput="@(e => UserData.Password = e.Value?.ToString())"/>
|
||||
<button type="button" class="onboarding__reveal" @onclick="ShowPassword"
|
||||
aria-label="@(_isShow ? "Nascondi la password" : "Mostra la password")">
|
||||
<i class="@(_isShow ? "ri-eye-off-line" : "ri-eye-line")" aria-hidden="true"></i>
|
||||
</button>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="onboarding__field">
|
||||
<span class="onboarding__label">Profilo azienda</span>
|
||||
<input class="onboarding__input" type="text" autocomplete="organization"
|
||||
autocapitalize="none" autocorrect="off" enterkeyhint="go"
|
||||
value="@UserData.CodHash" @oninput="@(e => UserData.CodHash = e.Value?.ToString())"/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
@* Stesso riquadro di avviso usato dentro l'app: l'errore di accesso
|
||||
non e' un caso speciale che merita un suo stile. *@
|
||||
<div class="feedback feedback--danger onboarding__error" role="alert" aria-live="assertive">
|
||||
<i class="feedback__icon ri-error-warning-line" aria-hidden="true"></i>
|
||||
<span>@ErrorMessage</span>
|
||||
</div>
|
||||
}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
@code {
|
||||
private SignIn UserData { get; } = new();
|
||||
private bool Spinner { get; set; }
|
||||
private string ErrorMessage { get; set; } = "";
|
||||
private bool _attemptFailed;
|
||||
|
||||
private bool _isShow;
|
||||
private InputType _passwordInput = InputType.Password;
|
||||
private string _passwordInputIcon = Icons.Material.Rounded.VisibilityOff;
|
||||
|
||||
private void ShowPassword()
|
||||
<button type="submit" class="onboarding__submit" disabled="@(_busy || !CanSubmit)">
|
||||
@if (_busy)
|
||||
{
|
||||
if (_isShow)
|
||||
{
|
||||
_isShow = false;
|
||||
_passwordInputIcon = Icons.Material.Rounded.VisibilityOff;
|
||||
_passwordInput = InputType.Password;
|
||||
<span class="onboarding__spinner" aria-hidden="true"></span>
|
||||
<span>Accesso in corso…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
_isShow = true;
|
||||
_passwordInputIcon = Icons.Material.Rounded.Visibility;
|
||||
_passwordInput = InputType.Text;
|
||||
}
|
||||
<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 string ErrorMessage { get; set; } = "";
|
||||
private bool _attemptFailed;
|
||||
private bool _busy;
|
||||
|
||||
private bool _isShow;
|
||||
|
||||
private bool CanSubmit =>
|
||||
!string.IsNullOrWhiteSpace(UserData.Username) &&
|
||||
!string.IsNullOrWhiteSpace(UserData.Password) &&
|
||||
!string.IsNullOrWhiteSpace(UserData.CodHash);
|
||||
|
||||
private void ShowPassword() => _isShow = !_isShow;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
@@ -82,36 +104,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))
|
||||
{
|
||||
Spinner = true;
|
||||
_busy = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
SteupDataService.RegisterAppVersion();
|
||||
await UserAccountService.Login(UserData.Username, UserData.Password, UserData.CodHash);
|
||||
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);
|
||||
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);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,95 @@
|
||||
@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 (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 class="section-header__text">
|
||||
<h2 class="summary__title">@UserSession.User.Fullname</h2>
|
||||
@if (UserSession.User.KeyGroup is not null)
|
||||
{
|
||||
<span
|
||||
class="info-section">@(((KeyGroupEnum)UserSession.User.KeyGroup).ConvertToHumanReadable())</span>
|
||||
<p class="summary__subtitle">
|
||||
@(((KeyGroupEnum)UserSession.User.KeyGroup).ConvertToHumanReadable())
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</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="section-personal-info">
|
||||
<div>
|
||||
<span class="info-title">Status</span>
|
||||
@if (NetworkService.ConnectionAvailable)
|
||||
{
|
||||
<div class="status online">
|
||||
<i class="ri-wifi-line"></i>
|
||||
<span>Online</span>
|
||||
<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>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="status offline">
|
||||
<i class="ri-wifi-off-line"></i>
|
||||
<span>Offline</span>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
}
|
||||
|
||||
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
|
||||
<SpinnerOverlay VisibleOverlay="VisibleOverlay" Message="@_progressMessage"/>
|
||||
<ConfirmMessageBox @ref="_logoutBox" Title="Uscire dall'account?" 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,9 +109,12 @@
|
||||
|
||||
private async Task ExportLog()
|
||||
{
|
||||
_progressMessage = "Invio dei log…";
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var profiloAzienda = LocalStorage.GetString("codHash");
|
||||
|
||||
var email = new SendEmailDto
|
||||
@@ -141,27 +131,50 @@
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
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)
|
||||
<div class="list-card__badges badge-row">
|
||||
<Badge Spec="@Ispezione.Stato.ToBadge()"/>
|
||||
|
||||
@if (DaInviare > 0)
|
||||
{
|
||||
<MudSkeleton Width="100%"/>
|
||||
<Badge Spec="@SyncState.DaInviare.ToBadge()" Text="@($"{DaInviare} da inviare")"/>
|
||||
}
|
||||
else
|
||||
|
||||
@if (Bozze > 0)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">
|
||||
@Ispezione.Data.ToString("d")
|
||||
</MudChip>
|
||||
<Badge Spec="@SyncState.Bozza.ToBadge()" Text="@($"{Bozze} da completare")"/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
@* 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))
|
||||
{
|
||||
<span class="form-group__count">@Count</span>
|
||||
}
|
||||
</div>
|
||||
<div class="card-form-modal-body @ClassBody">
|
||||
|
||||
@if (Loading)
|
||||
{
|
||||
<MudSkeleton/>
|
||||
<div class="skeleton skeleton--line" style="height: 24px;" aria-hidden="true"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@ChildContent
|
||||
}
|
||||
</div>
|
||||
</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,126 @@
|
||||
@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>
|
||||
<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>
|
||||
|
||||
@{
|
||||
var syncState = Scheda.GetSyncState();
|
||||
<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>
|
||||
}
|
||||
<MudChip T="string" Class="sync-badge" Icon="@syncState.GetIcon()"
|
||||
Size="Size.Small" Variant="Variant.Filled" Color="@syncState.GetColor()">
|
||||
@syncState.ToLabel()
|
||||
</MudChip>
|
||||
|
||||
<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="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="list-card__actions">
|
||||
@if (ReadOnly)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" FullWidth="true"
|
||||
StartIcon="@Icons.Material.Rounded.Visibility" OnClick="@ViewScheda">
|
||||
Apri
|
||||
</MudButton>
|
||||
|
||||
<div class="scheda-card-action">
|
||||
@if (Ispezione.Stato != StatusEnum.Completata)
|
||||
@if (Scheda.ImageNames?.Count > 0)
|
||||
{
|
||||
<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"/>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
|
||||
StartIcon="@Icons.Material.Rounded.CloudUpload" OnClick="@ExportImg">
|
||||
Invia foto
|
||||
</MudButton>
|
||||
}
|
||||
}
|
||||
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"/>
|
||||
<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>
|
||||
</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" Title="Eliminare la scheda?" 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 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 +140,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 +165,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 +223,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 +257,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,49 +1,67 @@
|
||||
@using SteUp.Shared.Components.Layout.Overlay
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Helpers
|
||||
@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>
|
||||
@*
|
||||
Riga di scelta di un punto vendita. E' un <button>: si raggiunge da
|
||||
tastiera, il lettore di schermo la annuncia per quello che e', e il tocco
|
||||
si comporta come quello nativo. Il chevron dice che porta altrove.
|
||||
*@
|
||||
|
||||
@if (!PuntoVendita.Indirizzo.IsNullOrEmpty())
|
||||
<button type="button" class="list-card shop-card panel--tappable" @onclick="StartInspection"
|
||||
aria-label="@AriaLabel">
|
||||
<span class="shop-card__text">
|
||||
<span class="t-ident">@PuntoVendita.CodMdep</span>
|
||||
<span class="list-card__title">@PuntoVendita.Descrizione</span>
|
||||
|
||||
@if (!Luogo.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>
|
||||
<span class="list-card__meta">@Luogo</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
|
||||
@if (Aperta)
|
||||
{
|
||||
@* Toccando si riapre l'ispezione di oggi invece di crearne una
|
||||
nuova: meglio dirlo prima del tocco. *@
|
||||
<span class="list-card__badges badge-row">
|
||||
<Badge Spec="@ApertaBadge"/>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<i class="shop-card__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@code {
|
||||
[Parameter] public PuntoVenditaDto PuntoVendita { get; set; } = null!;
|
||||
|
||||
private bool VisibleOverlay { get; set; }
|
||||
[Parameter] public EventCallback OnStarting { get; set; }
|
||||
|
||||
/// Vero se il rilevatore ha gia' un'ispezione aperta oggi su questo punto vendita.
|
||||
[Parameter] public bool Aperta { get; set; }
|
||||
|
||||
private static readonly StatusBadgeHelper.BadgeSpec ApertaBadge =
|
||||
new("info", "ri-play-circle-line", "Ispezione di oggi");
|
||||
|
||||
// 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 string AriaLabel => Aperta
|
||||
? $"Riprendi l'ispezione di oggi su {PuntoVendita.CodMdep} {PuntoVendita.Descrizione}"
|
||||
: $"Apri ispezione su {PuntoVendita.CodMdep} {PuntoVendita.Descrizione}";
|
||||
|
||||
private async Task StartInspection()
|
||||
{
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
await OnStarting.InvokeAsync();
|
||||
|
||||
SteupDataService.InspectionPageState.Ispezione = await IspezioniService.GetOrCreateIspezioneAsync(
|
||||
PuntoVendita.CodMdep!,
|
||||
@@ -51,10 +69,6 @@
|
||||
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,4 +1,9 @@
|
||||
<MudMessageBox @ref="_confirmDelete" Title="Attenzione!" CancelText="@NoText">
|
||||
@* Conferma di un'azione. Il titolo dice cosa sta per succedere, il messaggio
|
||||
che conseguenze ha: davanti a un'azione irreversibile la domanda deve
|
||||
essere leggibile prima del testo, non dedotta da esso.
|
||||
|
||||
YesText -> true, NoText -> false, chiusura senza scelta -> null. *@
|
||||
<MudMessageBox @ref="_confirmDelete" Title="@Title" CancelText="@NoText">
|
||||
<MessageContent>
|
||||
@Message
|
||||
</MessageContent>
|
||||
@@ -11,6 +16,7 @@
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public string Title { get; set; } = "Attenzione";
|
||||
[Parameter] public string Message { get; set; } = string.Empty;
|
||||
[Parameter] public string YesText { get; set; } = string.Empty;
|
||||
[Parameter] public string NoText { get; set; } = "Annulla";
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<MudMessageBox @ref="_confirmSave" Title="Attenzione!" CancelText="Non salvare">
|
||||
@* NoText -> false (salva bozza), YesText -> true (salva), chiusura senza scelta -> null.
|
||||
Niente CancelText: da qui non si esce buttando via il lavoro. *@
|
||||
<MudMessageBox @ref="_confirmSave" Title="Lavoro non salvato" NoText="Salva come bozza">
|
||||
<MessageContent>
|
||||
Sono state apportate delle modifiche. Vuoi salvarle prima di continuare?
|
||||
Hai delle modifiche non salvate. Salva la scheda, oppure tienila come bozza
|
||||
da completare più tardi: in ogni caso non perdi nulla.
|
||||
</MessageContent>
|
||||
<YesButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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="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)
|
||||
{
|
||||
<div class="exception-header mb-2">
|
||||
<i class="ri-error-warning-line warning-icon"></i>
|
||||
<span>Attenzione</span>
|
||||
</div>
|
||||
<p class="dialog-detail">@ErrorMessage</p>
|
||||
}
|
||||
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>
|
||||
</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.";
|
||||
|
||||
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; }
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
.content.attached {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
padding: 1rem;
|
||||
height: unset;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
@using SteUp.Shared.Components.Layout.Spinner
|
||||
@using SteUp.Shared.Components.SingleElements.Card.ModalForm
|
||||
@using SteUp.Shared.Components.SingleElements.MessageBox
|
||||
@using SteUp.Shared.Components.SingleElements.Picker
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Entities
|
||||
@using SteUp.Shared.Core.Enum
|
||||
@@ -11,6 +12,8 @@
|
||||
@using SteUp.Shared.Core.Interface.LocalDb
|
||||
@using SteUp.Shared.Core.Interface.System
|
||||
@using SteUp.Shared.Core.Messages.Scanner
|
||||
@using SteUp.Shared.Core.Messages.System
|
||||
@inject BackButtonService BackButton
|
||||
@inject INetworkService NetworkService
|
||||
@inject IDialogService Dialog
|
||||
@inject IIntegryApiService IntegryApiService
|
||||
@@ -20,175 +23,233 @@
|
||||
@inject OnScannerService OnScannerService
|
||||
@inject IAttachedService AttachedService
|
||||
@inject ILogger<ModalFormScheda> Logger
|
||||
@implements IDisposable
|
||||
|
||||
@*
|
||||
Form scheda: la schermata dove il rilevatore passa la maggior parte del
|
||||
turno. Struttura a shell — testata, corpo scorrevole, barra azioni ancorata
|
||||
in fondo (STILE_UI §6.13) — così il salvataggio resta sempre sotto il
|
||||
pollice invece di stare in fondo a uno scroll lungo.
|
||||
*@
|
||||
|
||||
<MudDialog Class="customDialog-form">
|
||||
<DialogContent>
|
||||
<MudForm ReadOnly="ReadOnly" @ref="_form">
|
||||
<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>
|
||||
}
|
||||
</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 class="feedback feedback--warning" role="status">
|
||||
<i class="feedback__icon ri-edit-line" aria-hidden="true"></i>
|
||||
<span>Questa scheda è una bozza: completala per poterla inviare.</span>
|
||||
</div>
|
||||
</CardFormModal>
|
||||
}
|
||||
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 }))
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<MudIconButton Variant="Variant.Outlined"
|
||||
Icon="@Icons.Material.Rounded.Close"
|
||||
Size="Size.Small" Color="Color.Error"
|
||||
OnClick="@(() => OnRemoveAttached(item.index))"/>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</CardFormModal>
|
||||
}
|
||||
|
||||
<CardFormModal Title="Reparto" Loading="SteupDataService.Reparti.IsNullOrEmpty()"
|
||||
Class="@(_showErrors && RepartoMancante ? "form-group--invalid" : "")">
|
||||
<SelectField TValue="JtbFasiDto" Title="Reparto"
|
||||
Items="SteupDataService.Reparti"
|
||||
ItemLabel="@(x => x.Descrizione)"
|
||||
Value="Scheda.Reparto" ValueChanged="OnRepartoChanged"
|
||||
Placeholder="Scegli il reparto" ReadOnly="ReadOnly"/>
|
||||
|
||||
@if (_showErrors && RepartoMancante)
|
||||
{
|
||||
<p class="form-error" role="alert">
|
||||
<i class="ri-error-warning-line" aria-hidden="true"></i>
|
||||
Indica il reparto
|
||||
</p>
|
||||
}
|
||||
</CardFormModal>
|
||||
|
||||
<CardFormModal Title="Motivo" Loading="SteupDataService.TipiAttività.IsNullOrEmpty()"
|
||||
Class="@(_showErrors && MotivoMancante ? "form-group--invalid" : "")">
|
||||
@if (Scheda.CodJfas.IsNullOrEmpty())
|
||||
{
|
||||
<p class="t-meta">Scegli prima il reparto.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<SelectField TValue="string" Title="Motivo"
|
||||
Subtitle="@Scheda.DescrizioneReparto"
|
||||
Items="MotiviDelReparto"
|
||||
ItemLabel="@(x => x)"
|
||||
Value="Scheda.ActivityTypeId" ValueChanged="OnMotivoChanged"
|
||||
Placeholder="Scegli il motivo" ReadOnly="ReadOnly"/>
|
||||
|
||||
@if (_showErrors && MotivoMancante)
|
||||
{
|
||||
<p class="form-error" role="alert">
|
||||
<i class="ri-error-warning-line" aria-hidden="true"></i>
|
||||
Indica il motivo
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</CardFormModal>
|
||||
|
||||
@* --- Articoli: il compito primario della schermata --- *@
|
||||
<CardFormModal Title="Articoli" Count="@ArticoliCount">
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<div class="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 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>
|
||||
}
|
||||
|
||||
<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">
|
||||
<div class="art-list" style="margin-top: var(--space-sm);">
|
||||
@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 class="art-item" @key="articolo.Barcode">
|
||||
<div class="art-item__text">
|
||||
<div class="art-item__name">@articolo.Descrizione</div>
|
||||
<div class="art-item__code selectable">@articolo.Barcode</div>
|
||||
</div>
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<button type="button" class="art-item__remove"
|
||||
aria-label="@($"Rimuovi {articolo.Descrizione}")"
|
||||
@onclick="@(() => RemoveArt(articolo.Barcode))">
|
||||
<i class="ri-close-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</CardFormModal>
|
||||
</div>
|
||||
}
|
||||
else if (ReadOnly)
|
||||
{
|
||||
<p class="t-meta">Nessun articolo.</p>
|
||||
}
|
||||
|
||||
@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>
|
||||
<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>
|
||||
}
|
||||
|
||||
<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>
|
||||
@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>
|
||||
|
||||
<CardFormModal Title="Responsabile">
|
||||
@* --- Urgenza: pillole invece di un menu a tendina --- *@
|
||||
<CardFormModal Title="Entro quando">
|
||||
<div class="option-row">
|
||||
@foreach (var opzione in ScadenzaOptions)
|
||||
{
|
||||
<button type="button" class="option"
|
||||
disabled="@ReadOnly"
|
||||
aria-pressed="@(Scheda.Scadenza == opzione.Value ? "true" : "false")"
|
||||
@onclick="@(() => SetScadenza(opzione.Value))">
|
||||
@opzione.Label
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</CardFormModal>
|
||||
|
||||
<CardFormModal Title="Persona di riferimento">
|
||||
<MudTextField FullWidth="true" T="string?" Variant="Variant.Text"
|
||||
Placeholder="Nome di chi ha seguito il controllo"
|
||||
@bind-Value="Scheda.Responsabile" @bind-Value:after="OnAfterChangeValue"
|
||||
DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/>
|
||||
</CardFormModal>
|
||||
|
||||
<CardFormModal Title="Note">
|
||||
<MudTextField @key="_noteFieldKey" T="string?" Variant="Variant.Text" Lines="3"
|
||||
Placeholder="Cosa hai rilevato"
|
||||
@bind-Value="Scheda.Note" @bind-Value:after="OnAfterChangeValue"
|
||||
DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/>
|
||||
</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>
|
||||
@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 (!LabelSave.IsNullOrEmpty())
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<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>
|
||||
}
|
||||
@@ -200,7 +261,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!;
|
||||
@@ -212,6 +273,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; }
|
||||
@@ -230,8 +330,41 @@
|
||||
private int _noteFieldKey;
|
||||
private bool IsDirty { get; set; }
|
||||
private Scheda _originalScheda = null!;
|
||||
// Clone() e' un MemberwiseClone, quindi _originalScheda condivide la stessa lista
|
||||
// Articoli: per accorgerci degli articoli aggiunti/rimossi serve uno snapshot a parte.
|
||||
private List<string> _originalBarcodes = [];
|
||||
// Gli allegati vivono in AttachedList finche' non si salva: li tracciamo con un flag.
|
||||
private bool _attachmentsDirty;
|
||||
private List<AttachedDto>? AttachedList { get; set; }
|
||||
|
||||
private IDisposable? _backRegistration;
|
||||
// Il tasto indietro puo' arrivare mentre il prompt di uscita e' gia' a video.
|
||||
private bool _closing;
|
||||
// Dialog aperti sopra al form (articoli, suggerimenti, errori).
|
||||
private int _childDialogs;
|
||||
|
||||
private Task HandleBackPressed()
|
||||
{
|
||||
// Con un dialog figlio a video il back non deve agire sul form sottostante:
|
||||
// meglio ignorarlo che far comparire il prompt di uscita sotto un altro dialog.
|
||||
if (_childDialogs > 0) return Task.CompletedTask;
|
||||
|
||||
return Cancel();
|
||||
}
|
||||
|
||||
private async Task<T> WithChildDialog<T>(Func<Task<T>> open)
|
||||
{
|
||||
_childDialogs++;
|
||||
try
|
||||
{
|
||||
return await open();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_childDialogs--;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter;
|
||||
@@ -240,8 +373,13 @@
|
||||
OnScannerService.OnErrorScan += OnErrorScan;
|
||||
|
||||
_originalScheda = Scheda.Clone();
|
||||
_originalBarcodes = Scheda.Articoli.ConvertAll(x => x.Barcode);
|
||||
Snackbar.Configuration.PositionClass = Defaults.Classes.Position.TopCenter;
|
||||
|
||||
// Il tasto indietro del dispositivo deve passare da qui, non mandare l'app
|
||||
// in background lasciando il lavoro non salvato solo in memoria.
|
||||
_backRegistration = BackButton.Register(() => InvokeAsync(HandleBackPressed));
|
||||
|
||||
LoadAttached();
|
||||
}
|
||||
|
||||
@@ -279,6 +417,9 @@
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
|
||||
// Salvataggio completo: la scheda non è più una bozza.
|
||||
Scheda.Bozza = false;
|
||||
|
||||
SaveSchedaResponseDto? apiResponse = null;
|
||||
try
|
||||
{
|
||||
@@ -363,7 +504,7 @@
|
||||
await IspezioniService.AddSchedaAsync(CodMdep, Data, UserSession.User.Username, Scheda);
|
||||
}
|
||||
|
||||
private async Task Update(SaveSchedaResponseDto? apiResponse)
|
||||
private async Task Update(SaveSchedaResponseDto? apiResponse, bool preserveActivityId = false)
|
||||
{
|
||||
if (!AttachedList.IsNullOrEmpty())
|
||||
{
|
||||
@@ -393,7 +534,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
Scheda.ActivityId = apiResponse?.ActivityIdScheda;
|
||||
// Salvando una bozza non tocchiamo l'ActivityId: se la scheda era già sul server,
|
||||
// al completamento va aggiornata, non ricreata.
|
||||
if (!preserveActivityId) Scheda.ActivityId = apiResponse?.ActivityIdScheda;
|
||||
|
||||
await IspezioniService.UpdateSchedaAsync(Scheda);
|
||||
|
||||
if (apiResponse != null)
|
||||
@@ -433,6 +577,11 @@
|
||||
}
|
||||
|
||||
private async Task Cancel()
|
||||
{
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
|
||||
try
|
||||
{
|
||||
if (await CheckSavePreAction())
|
||||
{
|
||||
@@ -441,29 +590,75 @@
|
||||
MudDialog.Cancel();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_closing = false;
|
||||
}
|
||||
}
|
||||
|
||||
#region Form
|
||||
|
||||
private void OnAfterChangeReparto()
|
||||
/// Gli obbligatori si segnalano solo dopo il primo tentativo di salvataggio:
|
||||
/// un form che si tinge di rosso prima che tu abbia fatto qualcosa accusa
|
||||
/// l'utente di un errore che non ha ancora commesso.
|
||||
private bool _showErrors;
|
||||
|
||||
private bool RepartoMancante => Scheda.Reparto is null;
|
||||
private bool MotivoMancante => Scheda.ActivityTypeId.IsNullOrEmpty();
|
||||
|
||||
/// I motivi disponibili dipendono dal reparto scelto.
|
||||
private List<string> MotiviDelReparto => SteupDataService.TipiAttività
|
||||
.Where(x => x.CodJfas.EqualsIgnoreCase(Scheda.CodJfas!))
|
||||
.Select(x => x.ActivityTypeId)
|
||||
.Where(x => !x.IsNullOrEmpty())
|
||||
.Select(x => x!)
|
||||
.ToList();
|
||||
|
||||
private void OnRepartoChanged(JtbFasiDto? reparto)
|
||||
{
|
||||
if (reparto is null) return;
|
||||
|
||||
Scheda.Reparto = reparto;
|
||||
// Cambiando reparto il motivo precedente non e' piu' fra quelli validi.
|
||||
Scheda.ActivityTypeId = null;
|
||||
OnAfterChangeValue();
|
||||
}
|
||||
|
||||
private void OnMotivoChanged(string? motivo)
|
||||
{
|
||||
Scheda.ActivityTypeId = motivo;
|
||||
OnAfterChangeValue();
|
||||
}
|
||||
|
||||
private void OnAfterChangeValue()
|
||||
{
|
||||
RecalcDirty();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void RecalcDirty(bool forceTrue = false)
|
||||
private void RecalcDirty()
|
||||
{
|
||||
IsDirty = forceTrue || !ValueComparer.AreEqual(Scheda, _originalScheda);
|
||||
IsDirty = _attachmentsDirty
|
||||
|| !ValueComparer.AreEqual(Scheda, _originalScheda)
|
||||
|| !BarcodesUnchanged();
|
||||
|
||||
if (IsDirty) LabelSave = !IsNew ? "Aggiorna" : "Salva";
|
||||
else LabelSave = null;
|
||||
}
|
||||
|
||||
private bool BarcodesUnchanged()
|
||||
{
|
||||
if (Scheda.Articoli.Count != _originalBarcodes.Count) return false;
|
||||
|
||||
for (var i = 0; i < _originalBarcodes.Count; i++)
|
||||
{
|
||||
if (!Scheda.Articoli[i].Barcode.Equals(_originalBarcodes[i], StringComparison.Ordinal))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static class ValueComparer
|
||||
{
|
||||
public static bool AreEqual(Scheda? a, Scheda? b)
|
||||
@@ -480,17 +675,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ritorna true se il form può chiudersi scartando (nessuna modifica in sospeso).
|
||||
/// Con del lavoro in corso non si perde mai nulla: o si salva per intero, o si
|
||||
/// salva come bozza da riprendere.
|
||||
/// </summary>
|
||||
private async Task<bool> CheckSavePreAction()
|
||||
{
|
||||
if (!IsDirty) return true;
|
||||
|
||||
var resul = await _confirmUpdateMessage.ShowAsync();
|
||||
if (resul is not true) return true;
|
||||
var result = await WithChildDialog(() => _confirmUpdateMessage.ShowAsync());
|
||||
|
||||
// Prompt chiuso senza scegliere (tap fuori / indietro): si resta sulla scheda.
|
||||
if (result is null) return false;
|
||||
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
|
||||
await Submit();
|
||||
if (result is true) await Submit();
|
||||
else await SaveDraft();
|
||||
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
@@ -498,10 +701,53 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Salva il lavoro in corso solo sul dispositivo, marcato come da completare.
|
||||
/// Niente chiamate al server e niente validazione: una bozza può essere incompleta.
|
||||
/// </summary>
|
||||
private async Task SaveDraft()
|
||||
{
|
||||
try
|
||||
{
|
||||
Scheda.Bozza = true;
|
||||
|
||||
if (IsNew) await NewSave(null);
|
||||
else await Update(null, preserveActivityId: true);
|
||||
|
||||
await FileManager.CleanTempStorageAsync();
|
||||
|
||||
Snackbar.Clear();
|
||||
Snackbar.Add("Scheda salvata come bozza, la ritrovi nell'ispezione", Severity.Success);
|
||||
|
||||
DisposeMessage();
|
||||
MudDialog.Close(Scheda);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Scheda.Bozza = false;
|
||||
OnError(e, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Submit()
|
||||
{
|
||||
// Reparto e motivo non passano piu' dalla validazione di MudForm: sono
|
||||
// campi tappabili, non input. Si controllano qui, e l'errore si vede sul
|
||||
// gruppo interessato oltre che nel messaggio.
|
||||
_showErrors = true;
|
||||
await _form.Validate();
|
||||
if (_form.IsValid) await Save();
|
||||
|
||||
if (!RepartoMancante && !MotivoMancante && _form.IsValid)
|
||||
{
|
||||
await Save();
|
||||
return;
|
||||
}
|
||||
|
||||
// Il form resta aperto sugli errori: senza un avviso il tocco su "Salva"
|
||||
// sembra non fare nulla se i campi mancanti sono fuori schermo.
|
||||
Snackbar.Clear();
|
||||
Snackbar.Add("Compila i campi obbligatori, oppure esci salvando come bozza", Severity.Warning);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -526,7 +772,8 @@
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
RecalcDirty(true);
|
||||
_attachmentsDirty = true;
|
||||
RecalcDirty();
|
||||
|
||||
// Processa in background e aggiorna UI man mano (o a blocchi)
|
||||
_ = Task.Run(async () =>
|
||||
@@ -599,7 +846,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var modal = await Dialog.OpenSelectArt(articoli);
|
||||
var modal = await WithChildDialog(() => Dialog.OpenSelectArt(articoli));
|
||||
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
@@ -618,7 +865,11 @@
|
||||
})
|
||||
);
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
RecalcDirty();
|
||||
StateHasChanged();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -631,7 +882,8 @@
|
||||
AttachedList[index].ToRemove = true;
|
||||
|
||||
AttachedList.RemoveAt(index);
|
||||
RecalcDirty(true);
|
||||
_attachmentsDirty = true;
|
||||
RecalcDirty();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@@ -652,7 +904,7 @@
|
||||
{
|
||||
var activityDescriptions = await IntegryApiService.SuggestActivityDescription(Scheda.ActivityTypeId);
|
||||
|
||||
var modal = await Dialog.OpenSuggestActivityDescription(activityDescriptions);
|
||||
var modal = await WithChildDialog(() => Dialog.OpenSuggestActivityDescription(activityDescriptions));
|
||||
|
||||
if (modal is { Canceled: false, Data: string descrizione } && !string.IsNullOrWhiteSpace(descrizione))
|
||||
{
|
||||
@@ -701,14 +953,13 @@
|
||||
|
||||
if (art != null)
|
||||
{
|
||||
RecalcDirty(true);
|
||||
|
||||
Scheda.Articoli.Insert(0, new SchedaArticolo
|
||||
{
|
||||
Barcode = art.Barcode,
|
||||
Descrizione = art.Descrizione
|
||||
});
|
||||
|
||||
RecalcDirty();
|
||||
StateHasChanged();
|
||||
}
|
||||
else
|
||||
@@ -748,8 +999,8 @@
|
||||
var index = Scheda.Articoli.FindIndex(x => x.Barcode.Equals(barcode));
|
||||
if (index < 0) return;
|
||||
|
||||
RecalcDirty(true);
|
||||
Scheda.Articoli.RemoveAt(index);
|
||||
RecalcDirty();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@@ -757,6 +1008,16 @@
|
||||
{
|
||||
OnScannerService.OnNewScanSuccessful -= HandleNewScanSuccessful;
|
||||
OnScannerService.OnErrorScan -= OnErrorScan;
|
||||
|
||||
_backRegistration?.Dispose();
|
||||
_backRegistration = null;
|
||||
}
|
||||
|
||||
// Rete di sicurezza: se il dialog viene chiuso per altre vie, il tasto indietro
|
||||
// non deve restare agganciato a un form che non esiste più.
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
DisposeMessage();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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">
|
||||
@*
|
||||
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.
|
||||
*@
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<HeaderLayout SmallHeader="true" Cancel="true" OnCancel="@(() => MudDialog.Cancel())"
|
||||
Title="Seleziona articoli"/>
|
||||
<div class="app-shell">
|
||||
<HeaderLayout Title="Articoli" Subtitle="@Conteggio" Cancel="true"
|
||||
OnCancel="@Cancel" Compact="true"/>
|
||||
|
||||
@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"/>
|
||||
<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="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>
|
||||
<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>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!Articoli.IsNullOrEmpty())
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" OnClick="@Submit" Size="Size.Small" Color="Color.Primary">
|
||||
Seleziona
|
||||
<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>
|
||||
}
|
||||
</DialogActions>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</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,146 @@
|
||||
@using SteUp.Shared.Components.SingleElements.Card
|
||||
@using SteUp.Shared.Components.Layout
|
||||
@using SteUp.Shared.Components.SingleElements.Card
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Enum
|
||||
@using SteUp.Shared.Core.Interface.LocalDb
|
||||
@inject IIspezioniService IspezioniService
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="shop-body">
|
||||
@if (_afterRender)
|
||||
<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())
|
||||
{
|
||||
if (FilteredList.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 (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"/>
|
||||
<div class="card-list">
|
||||
<Virtualize Items="FilteredList" Context="puntoVendita" OverscanCount="6">
|
||||
<ShopCard PuntoVendita="puntoVendita"
|
||||
Aperta="@HaIspezioneOggi(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";
|
||||
|
||||
// I codici su cui il rilevatore ha gia' un'ispezione aperta oggi. Si legge
|
||||
// una volta sola: toccare una di quelle righe riapre l'ispezione esistente
|
||||
// invece di crearne una nuova, e la riga deve poterlo dire.
|
||||
private HashSet<string> _aperteOggi = [];
|
||||
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
|
||||
protected override void OnInitialized()
|
||||
private bool HaIspezioneOggi(PuntoVenditaDto pv) =>
|
||||
pv.CodMdep is not null && _aperteOggi.Contains(pv.CodMdep);
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_afterRender = false;
|
||||
ApplyFilters();
|
||||
|
||||
var oggi = DateTime.Today;
|
||||
var ispezioni = await IspezioniService.GetAllIspezioni(UserSession.User.Username);
|
||||
|
||||
_aperteOggi = ispezioni
|
||||
.Where(x => x.Data.Date == oggi && x.Stato != StatusEnum.Completata && x.CodMdep is not null)
|
||||
.Select(x => x.CodMdep!)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
protected override void OnAfterRender(bool firstRender)
|
||||
private void OnFilterInput(ChangeEventArgs e)
|
||||
{
|
||||
if (_afterRender) return;
|
||||
FilterText = e.Value?.ToString();
|
||||
ApplyFilters();
|
||||
}
|
||||
|
||||
_afterRender = true;
|
||||
private void ClearFilter()
|
||||
{
|
||||
FilterText = null;
|
||||
ApplyFilters();
|
||||
}
|
||||
|
||||
private void ApplyFilters()
|
||||
{
|
||||
if (FilterText.IsNullOrEmpty())
|
||||
{
|
||||
FilteredList = SteupDataService.PuntiVenditaList;
|
||||
}
|
||||
else
|
||||
{
|
||||
FilteredList = SteupDataService.PuntiVenditaList.FindAll(x =>
|
||||
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!))
|
||||
);
|
||||
}
|
||||
(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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
@typeparam TValue
|
||||
@using SteUp.Shared.Components.Layout.Sheet
|
||||
|
||||
@*
|
||||
Scelta a valore singolo. Al posto della tendina: una riga tappabile che
|
||||
apre un bottom sheet con le opzioni a tutta larghezza.
|
||||
|
||||
La tendina di serie e' un residuo del web — galleggia bianca su bianco
|
||||
sopra le card, si porta dietro la barra di scorrimento del desktop e offre
|
||||
bersagli alti quanto una riga di testo. Qui l'elenco arriva dal basso, ha
|
||||
un titolo, righe da 56 px e la scelta corrente segnata: si usa in piedi,
|
||||
con una mano, anche con i guanti.
|
||||
*@
|
||||
|
||||
<button type="button" class="select-field" disabled="@ReadOnly" @onclick="Open"
|
||||
aria-haspopup="dialog" aria-label="@($"{Title}: {(HasValue ? SelectedLabel : Placeholder)}")">
|
||||
<span class="select-field__value @(HasValue ? "" : "select-field__value--empty")">
|
||||
@(HasValue ? SelectedLabel : Placeholder)
|
||||
</span>
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<i class="select-field__icon ri-arrow-down-s-line" aria-hidden="true"></i>
|
||||
}
|
||||
</button>
|
||||
|
||||
<ActionSheet @bind-Open="_open" Title="@Title" Subtitle="@Subtitle">
|
||||
<div class="stack stack-xs">
|
||||
@foreach (var item in Items)
|
||||
{
|
||||
var selected = IsSelected(item);
|
||||
<button type="button" class="select-row select-row--single"
|
||||
aria-pressed="@(selected ? "true" : "false")"
|
||||
@onclick="@(() => Choose(item))">
|
||||
<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">@ItemLabel(item)</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</ActionSheet>
|
||||
|
||||
@code {
|
||||
/// Titolo dello sheet: e' il nome del campo ("Reparto", "Motivo").
|
||||
[Parameter, EditorRequired] public string Title { get; set; } = "";
|
||||
|
||||
[Parameter] public string? Subtitle { get; set; }
|
||||
|
||||
[Parameter, EditorRequired] public IEnumerable<TValue> Items { get; set; } = [];
|
||||
|
||||
[Parameter] public TValue? Value { get; set; }
|
||||
[Parameter] public EventCallback<TValue?> ValueChanged { get; set; }
|
||||
|
||||
/// Come si scrive un'opzione. Deve essere fornita: il ToString() di un DTO
|
||||
/// non e' un'etichetta.
|
||||
[Parameter, EditorRequired] public Func<TValue, string?> ItemLabel { get; set; } = _ => null;
|
||||
|
||||
[Parameter] public string Placeholder { get; set; } = "Scegli";
|
||||
[Parameter] public bool ReadOnly { get; set; }
|
||||
|
||||
private bool _open;
|
||||
|
||||
private bool HasValue => Value is not null;
|
||||
private string? SelectedLabel => Value is null ? null : ItemLabel(Value);
|
||||
|
||||
// Il confronto passa per l'etichetta, non per l'oggetto: il valore corrente
|
||||
// arriva ricostruito dall'entita' salvata (Scheda.Reparto lo crea al volo da
|
||||
// codice e descrizione), quindi non e' mai lo stesso oggetto di quelli in
|
||||
// elenco e il confronto di riferimento non segnerebbe mai nulla.
|
||||
private bool IsSelected(TValue item) =>
|
||||
Value is not null &&
|
||||
string.Equals(ItemLabel(item), SelectedLabel, StringComparison.Ordinal);
|
||||
|
||||
private void Open()
|
||||
{
|
||||
if (!ReadOnly) _open = true;
|
||||
}
|
||||
|
||||
private async Task Choose(TValue item)
|
||||
{
|
||||
_open = false;
|
||||
Value = item;
|
||||
await ValueChanged.InvokeAsync(item);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ public class BarcodeManager(
|
||||
|
||||
if (IsEanPeso(dto))
|
||||
stringValue = DecodeEanPeso(stringValue);
|
||||
else if (IsPlu(dto))
|
||||
stringValue = DecodePlu(stringValue);
|
||||
|
||||
messenger.Send(new NewScannerMessage(stringValue));
|
||||
},
|
||||
@@ -39,14 +41,29 @@ public class BarcodeManager(
|
||||
(IsEtichetta128(barcodeScan) || IsEan13(barcodeScan)) && barcodeScan.StringValue is { Length: 13 } &&
|
||||
barcodeScan.StringValue.StartsWith('2');
|
||||
|
||||
private static bool IsPlu(BarcodeScanDto barcodeScan) =>
|
||||
IsCode128(barcodeScan) && barcodeScan.StringValue is { Length: 7 } && barcodeScan.StringValue.StartsWith('2');
|
||||
|
||||
private static bool IsEtichetta128(BarcodeScanDto barcodeScan) =>
|
||||
barcodeScan.Type is BarcodeType.CODE128 or BarcodeType.EAN128;
|
||||
|
||||
private static bool IsCode128(BarcodeScanDto barcodeScan) =>
|
||||
barcodeScan.Type == BarcodeType.CODE128;
|
||||
|
||||
private static bool IsEan13(BarcodeScanDto barcodeScan) =>
|
||||
barcodeScan.Type == BarcodeType.EAN13;
|
||||
|
||||
private static string DecodeEanPeso(string? barcode)
|
||||
{
|
||||
return barcode is not { Length: 13 } ? throw new Exception("Errore durante il parse del barcode (" + barcode + ")") : barcode.Substring(1, 6);
|
||||
return barcode is not { Length: 13 }
|
||||
? throw new Exception("Errore durante il parse del barcode (" + barcode + ")")
|
||||
: barcode.Substring(1, 6);
|
||||
}
|
||||
|
||||
private static string DecodePlu(string? barcode)
|
||||
{
|
||||
return barcode is not { Length: 7 }
|
||||
? throw new Exception("Errore durante il parse del barcode (" + barcode + ")")
|
||||
: barcode.Substring(1, 6);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ public class Scheda : EntityBase<Scheda>
|
||||
public List<SchedaArticolo> Articoli { get; set; } = [];
|
||||
public List<string>? ImageNames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Scheda salvata in locale ma non ancora completata (es. uscita dal form con lavoro in corso).
|
||||
/// Non viene mai inviata al server finché il rilevatore non la conclude.
|
||||
/// </summary>
|
||||
public bool Bozza { get; set; }
|
||||
|
||||
public string? DescrizioneReparto { get; set; }
|
||||
public string? ActivityTypeId { get; set; }
|
||||
public string? Note { get; set; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -14,19 +14,27 @@ public enum SyncState
|
||||
DaInviare = 0,
|
||||
|
||||
/// Inviata al server (ha un ActivityId).
|
||||
Inviato = 1
|
||||
Inviato = 1,
|
||||
|
||||
/// Salvata in locale ma non completata: da riprendere, non inviabile.
|
||||
Bozza = 2
|
||||
}
|
||||
|
||||
public static class SchedaSyncHelper
|
||||
{
|
||||
public static SyncState GetSyncState(this Scheda scheda) =>
|
||||
string.IsNullOrEmpty(scheda.ActivityId) ? SyncState.DaInviare : SyncState.Inviato;
|
||||
scheda.Bozza
|
||||
? SyncState.Bozza
|
||||
: string.IsNullOrEmpty(scheda.ActivityId)
|
||||
? SyncState.DaInviare
|
||||
: SyncState.Inviato;
|
||||
|
||||
public static string ToLabel(this SyncState state) =>
|
||||
state switch
|
||||
{
|
||||
SyncState.DaInviare => "Da inviare",
|
||||
SyncState.Inviato => "Inviato",
|
||||
SyncState.Bozza => "Da completare",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
@@ -39,6 +47,7 @@ public static class SchedaSyncHelper
|
||||
{
|
||||
SyncState.DaInviare => Color.Primary,
|
||||
SyncState.Inviato => Color.Success,
|
||||
SyncState.Bozza => Color.Warning,
|
||||
_ => Color.Default
|
||||
};
|
||||
|
||||
@@ -47,10 +56,16 @@ public static class SchedaSyncHelper
|
||||
{
|
||||
SyncState.DaInviare => Icons.Material.Rounded.PhonelinkOff,
|
||||
SyncState.Inviato => Icons.Material.Rounded.CloudDone,
|
||||
SyncState.Bozza => Icons.Material.Rounded.EditNote,
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
/// Numero di schede non ancora inviate in una lista.
|
||||
/// Numero di schede non ancora inviate in una lista. Le bozze non contano:
|
||||
/// non sono inviabili finché non vengono completate.
|
||||
public static int CountDaInviare(this IEnumerable<Scheda> schede) =>
|
||||
schede.Count(s => s.GetSyncState() == SyncState.DaInviare);
|
||||
|
||||
/// Numero di schede lasciate a metà, da riprendere.
|
||||
public static int CountBozze(this IEnumerable<Scheda> schede) =>
|
||||
schede.Count(s => s.GetSyncState() == SyncState.Bozza);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||
|
||||
namespace SteUp.Shared.Core.Messages.Ispezione;
|
||||
|
||||
public class CompleteInspectionMessage(object? value = null) : ValueChangedMessage<object?>(value);
|
||||
@@ -1,13 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
namespace SteUp.Shared.Core.Messages.Ispezione;
|
||||
|
||||
public class CompleteInspectionService
|
||||
{
|
||||
public event Action? OnComplete;
|
||||
|
||||
public CompleteInspectionService(IMessenger messenger)
|
||||
{
|
||||
messenger.Register<CompleteInspectionMessage>(this, (_, _) => { OnComplete?.Invoke(); });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.Messaging.Messages;
|
||||
|
||||
namespace SteUp.Shared.Core.Messages.Scheda;
|
||||
|
||||
public class NewSchedaMessage(object? value = null) : ValueChangedMessage<object?>(value);
|
||||
@@ -1,13 +0,0 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
|
||||
namespace SteUp.Shared.Core.Messages.Scheda;
|
||||
|
||||
public class NewSchedaService
|
||||
{
|
||||
public event Action? OnNewScheda;
|
||||
|
||||
public NewSchedaService(IMessenger messenger)
|
||||
{
|
||||
messenger.Register<NewSchedaMessage>(this, (_, _) => { OnNewScheda?.Invoke(); });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace SteUp.Shared.Core.Messages.System;
|
||||
|
||||
/// <summary>
|
||||
/// Ponte fra il tasto indietro del dispositivo (host nativo) e la UI Blazor.
|
||||
/// Il form scheda si registra mentre è aperto così il back non manda l'app in
|
||||
/// background buttando via il lavoro non salvato: passa invece dallo stesso
|
||||
/// percorso del pulsante "Annulla", che propone il salvataggio come bozza.
|
||||
///
|
||||
/// I gestori sono impilati: sopra il form può aprirsi un bottom sheet, e il
|
||||
/// tasto indietro deve chiudere prima quello. Chiudendolo si torna al gestore
|
||||
/// sottostante, che resta valido — senza la pila il sheet, uscendo di scena,
|
||||
/// porterebbe via con sé anche la registrazione del form.
|
||||
/// </summary>
|
||||
public class BackButtonService
|
||||
{
|
||||
private readonly List<Func<Task>> _handlers = [];
|
||||
|
||||
/// <summary>
|
||||
/// Segnala all'host se in questo momento c'è qualcuno da avvisare. L'host
|
||||
/// se ne serve per accendere e spegnere la propria intercettazione, così
|
||||
/// quando la UI non ha nulla da dire il tasto indietro torna a comportarsi
|
||||
/// esattamente come sempre.
|
||||
/// </summary>
|
||||
public event Action<bool>? HandlersChanged;
|
||||
|
||||
public bool HasHandlers => _handlers.Count > 0;
|
||||
|
||||
/// Registra il gestore corrente in cima alla pila. Ritorna la deregistrazione.
|
||||
public IDisposable Register(Func<Task> handler)
|
||||
{
|
||||
_handlers.Add(handler);
|
||||
HandlersChanged?.Invoke(true);
|
||||
|
||||
return new Registration(this, handler);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invocato dall'host nativo. Ritorna true se la UI Blazor ha preso in carico
|
||||
/// il tasto indietro: in quel caso l'host non deve fare altro.
|
||||
/// </summary>
|
||||
public bool TryHandleBackPressed()
|
||||
{
|
||||
if (_handlers.Count == 0) return false;
|
||||
|
||||
// Fire-and-forget: il gestore rientra nel dispatcher Blazor da solo.
|
||||
_ = _handlers[^1]();
|
||||
return true;
|
||||
}
|
||||
|
||||
private sealed class Registration(BackButtonService owner, Func<Task> handler) : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
if (!owner._handlers.Remove(handler)) return;
|
||||
owner.HandlersChanged?.Invoke(owner.HasHandlers);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace SteUp.Shared.Core.Messages.System;
|
||||
|
||||
/// <summary>
|
||||
/// Ponte fra il comando al centro della barra di navigazione e la schermata
|
||||
/// che sta sotto. Il comando sta sempre nello stesso punto — sotto il pollice,
|
||||
/// dove il rilevatore lo cerca — ma quello che fa appartiene alla schermata:
|
||||
/// dentro un'ispezione apre le azioni di quell'ispezione, altrove apre una
|
||||
/// nuova ispezione.
|
||||
///
|
||||
/// La pagina si registra finché è a video e si sfila uscendo, così la barra
|
||||
/// non tiene in vita un riferimento a schermate che non ci sono più. Chi non
|
||||
/// registra niente lascia al comando il suo significato di default: la barra
|
||||
/// non resta mai con un tasto che non fa nulla.
|
||||
/// </summary>
|
||||
public class PageActionsService
|
||||
{
|
||||
private Func<Task>? _handler;
|
||||
|
||||
/// Avvisa la barra che il significato del comando è cambiato, così si ridisegna.
|
||||
public event Action? Changed;
|
||||
|
||||
public bool HasActions => _handler is not null;
|
||||
|
||||
public IDisposable Register(Func<Task> handler)
|
||||
{
|
||||
_handler = handler;
|
||||
Changed?.Invoke();
|
||||
|
||||
return new Registration(this, handler);
|
||||
}
|
||||
|
||||
public Task Invoke() => _handler?.Invoke() ?? Task.CompletedTask;
|
||||
|
||||
private sealed class Registration(PageActionsService owner, Func<Task> handler) : IDisposable
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
// Solo chi ha registrato per ultimo può sfilare: se una schermata
|
||||
// nuova si è già registrata, la vecchia uscendo non deve spegnerla.
|
||||
if (owner._handler != handler) return;
|
||||
|
||||
owner._handler = null;
|
||||
owner.Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
/* =============================================================================
|
||||
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: 500;
|
||||
}
|
||||
|
||||
.t-label-md {
|
||||
font-size: var(--label-md-size);
|
||||
line-height: 1.25;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 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: 400;
|
||||
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;
|
||||
}
|
||||
|
||||
/* L'anello segna un comando raggiunto da tastiera. Va messo sui controlli, non
|
||||
su qualunque elemento che il motore decida di mettere a fuoco: la WebView
|
||||
sposta il fuoco anche su contenitori non interattivi (per esempio la testata,
|
||||
dopo la chiusura di un dialog) e li' l'anello indica un bersaglio che non
|
||||
esiste. */
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
[tabindex]:not([tabindex="-1"]):focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-compact);
|
||||
}
|
||||
|
||||
/* Dove e' il contenitore a segnare il fuoco — campo di accesso, ricerca,
|
||||
barcode, gruppo di form — l'anello sull'input disegnerebbe un secondo bordo
|
||||
dentro il primo, con raggio e colore diversi. Il fuoco resta visibile: lo
|
||||
mostra il bordo del campo. */
|
||||
.onboarding__field input:focus-visible,
|
||||
.search-field input:focus-visible,
|
||||
.barcode-field input:focus-visible,
|
||||
.form-group input:focus-visible,
|
||||
.form-group textarea:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
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
Binary file not shown.
Binary file not shown.
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%);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
/* =============================================================================
|
||||
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);
|
||||
}
|
||||
|
||||
/* Su schermo stretto il form e' lungo: ogni pixel di padding e' una riga di
|
||||
lavoro in meno visibile a colpo d'occhio. */
|
||||
@media (max-width: 380px) {
|
||||
.form-group { padding: var(--space-sm) var(--space-14); }
|
||||
}
|
||||
|
||||
.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: 500;
|
||||
color: var(--ink-muted);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.form-group__count {
|
||||
font-size: var(--label-md-size);
|
||||
font-weight: 500;
|
||||
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: 500;
|
||||
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: 500;
|
||||
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: 500;
|
||||
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: 500;
|
||||
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); }
|
||||
|
||||
/* La scelta fatta si segna col tono informativo, non con laccento: un bordo
|
||||
corallo intorno a un riempimento azzurro si legge come un errore, non come
|
||||
"questa e la risposta". Laccento resta del solo pulsante primario. */
|
||||
.option[aria-pressed="true"] {
|
||||
font-weight: 600;
|
||||
background-color: var(--info-surface);
|
||||
border-color: var(--info-solid);
|
||||
color: var(--info-ink);
|
||||
}
|
||||
|
||||
@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: 500;
|
||||
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: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.art-item__code {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--label-md-size);
|
||||
font-weight: 400;
|
||||
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: 500;
|
||||
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); }
|
||||
/* Le azioni costruttive secondarie non portano l'accento: il corallo resta del
|
||||
pulsante primario. Un fondo corallo tenue sarebbe comunque indistinguibile
|
||||
dal tono danger, perche' il primario di SteUP e' gia' un rosso — ed e'
|
||||
esattamente cosi' che "Nuova scheda" finiva identica a "Elimina ispezione". */
|
||||
.action-tile__icon--primary { background-color: var(--info-surface); color: var(--info-ink); }
|
||||
.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: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.select-row__meta {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--label-md-size);
|
||||
font-weight: 400;
|
||||
color: var(--ink-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Campo a scelta singola — la riga che apre il bottom sheet
|
||||
Ha l'aspetto di un valore, non di un controllo: il chevron basta a dire
|
||||
che si tocca.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.select-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
width: 100%;
|
||||
min-height: var(--touch-min);
|
||||
padding: var(--space-xs) 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: opacity var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.select-field:active { opacity: 0.6; }
|
||||
.select-field:disabled { opacity: 1; }
|
||||
|
||||
.select-field__value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: var(--body-lg-size);
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Il segnaposto e' testo secondario, non un valore: si distingue a colpo
|
||||
d'occhio da un campo gia' compilato. */
|
||||
.select-field__value--empty { font-weight: 400; color: var(--ink-muted); }
|
||||
|
||||
.select-field__icon { flex: 0 0 auto; font-size: 22px; color: var(--ink-muted); }
|
||||
|
||||
/* Scelta singola: il segno e' tondo come un radio, non quadrato come una
|
||||
casella. La forma dice gia' che si sceglie una cosa sola. */
|
||||
.select-row--single .select-row__check { border-radius: var(--radius-pill); }
|
||||
@@ -0,0 +1,444 @@
|
||||
/* =============================================================================
|
||||
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: 500;
|
||||
|
||||
--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: 500;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
§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: 500;
|
||||
}
|
||||
|
||||
/* 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: 600;
|
||||
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);
|
||||
}
|
||||
|
||||
/* La misura da riquadro qui sopra vale per i dialog di conferma. Le schermate
|
||||
aperte a tutto schermo (scelta punto vendita, scelta articoli) sono
|
||||
schermate: devono riempire il display, non restare in una scatola con lo
|
||||
sfondo che si vede ai lati. */
|
||||
.mud-dialog.mud-dialog-fullscreen {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
margin: 0;
|
||||
border-radius: 0 !important;
|
||||
background-color: var(--workspace);
|
||||
}
|
||||
|
||||
.mud-dialog.mud-dialog-fullscreen .mud-dialog-content {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* Le scelte stanno in fondo, allineate a destra, con la conferma per ultima.
|
||||
Su schermo stretto le etichette lunghe ("Salva come bozza") andrebbero a
|
||||
sbattere: in quel caso la riga diventa una colonna a piena larghezza invece
|
||||
di comprimere i bersagli. */
|
||||
.mud-dialog-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.mud-dialog-actions > * {
|
||||
margin: 0 !important;
|
||||
min-height: var(--touch-min);
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.mud-dialog-actions { flex-direction: column-reverse; }
|
||||
.mud-dialog-actions > * { width: 100%; }
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
@@ -0,0 +1,221 @@
|
||||
/* =============================================================================
|
||||
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, selezione.
|
||||
Il tono non e' piu' l'azzurro cielo: era il colore piu' diffuso
|
||||
dell'interfaccia (stato "in corso", voce attiva della barra, righe
|
||||
scelte, avatar) e non compare ne' nel marchio ne' altrove nel sistema.
|
||||
Adesso e' l'ancora navy diluita: stessa famiglia del testo forte, quindi
|
||||
lo stato informativo non introduce un colore proprio. */
|
||||
--info-ink: #002339;
|
||||
--info-surface: #dde5ee;
|
||||
--info-solid: #002339;
|
||||
|
||||
/* 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;
|
||||
|
||||
/* PESI — il grassetto e' un segnale, e un segnale ovunque non segnala
|
||||
niente. Quattro gradini, e non si sale sopra il 700:
|
||||
400 corpo, metadati, valori descrittivi
|
||||
500 etichette di campo, voci di lista, testo dei controlli
|
||||
600 stati brevi (badge, voce attiva), nomi in una riga di elenco
|
||||
700 titoli: uno solo per blocco */
|
||||
--weight-body: 400;
|
||||
--weight-label: 500;
|
||||
--weight-strong: 600;
|
||||
--weight-title: 700;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
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 */
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Gradino compatto — schermi stretti (≤380px)
|
||||
|
||||
Il terminale da campo (Honeywell EDA52) ha 360 punti logici di larghezza,
|
||||
molto meno dei telefoni per cui la scala di STILE_UI è tarata: a misura
|
||||
piena i titoli si troncano, il testo va a capo e in una schermata entra
|
||||
pochissimo lavoro. Qui la scala scende di un gradino — le proporzioni e i
|
||||
rapporti restano quelli del documento, cambia solo la taglia.
|
||||
|
||||
È l'"Adattamento locale" del §8: i componenti si riorganizzano sotto le
|
||||
soglie invece di pretendere lo spazio che non c'è.
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 380px) {
|
||||
:root {
|
||||
--headline-md-size: 28px;
|
||||
--headline-sm-size: 22px;
|
||||
--title-lg-size: 18px;
|
||||
--title-md-size: 16px;
|
||||
--body-lg-size: 15px;
|
||||
--body-md-size: 14px;
|
||||
--label-lg-size: 13px;
|
||||
--label-md-size: 12px;
|
||||
--label-sm-size: 11px;
|
||||
|
||||
--space-lg: 20px;
|
||||
--space-xl: 24px;
|
||||
--space-18: 14px;
|
||||
--space-20: 16px;
|
||||
--space-22: 18px;
|
||||
|
||||
--appbar-height: 64px;
|
||||
--button-height: 44px;
|
||||
--navbar-height: 62px;
|
||||
--nav-clearance: 104px;
|
||||
}
|
||||
}
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user