Compare commits
38
Commits
48e55226f3
...
v1.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deda374b5b | ||
|
|
92271e20fb | ||
|
|
e524c12a96 | ||
|
|
de889ef8c7 | ||
|
|
0e00fb273a | ||
|
|
f1a17a64db | ||
|
|
c3772733ea | ||
|
|
9799f6b0c7 | ||
|
|
a4e5709be6 | ||
|
|
b368b8c10e | ||
|
|
b73a3bc82e | ||
|
|
70a0d0f5fa | ||
|
|
b9de075f45 | ||
|
|
c9b3a5034f | ||
|
|
1392b0e6d1 | ||
|
|
13d5400104 | ||
|
|
9703fca306 | ||
|
|
e3c9880d86 | ||
|
|
8cfce316fa | ||
|
|
4c1ef3d846 | ||
|
|
66ed92299b | ||
|
|
4e61dc011f | ||
|
|
2e3a318132 | ||
|
|
101b68123c | ||
|
|
ac501dc261 | ||
|
|
2d7e3c591e | ||
|
|
7099f02b60 | ||
|
|
ca0aaf69b5 | ||
|
|
52b2994d3b | ||
|
|
811d8a86d8 | ||
|
|
2a0054aa30 | ||
|
|
3a9174f6fc | ||
|
|
3b04702665 | ||
|
|
0174316bb7 | ||
|
|
d076a4b77a | ||
|
|
e3942d00cc | ||
|
|
92b60deac0 | ||
|
|
963eba1e84 |
No files matched your search
@@ -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.
|
||||
@@ -155,14 +155,29 @@ public class IspezioniService(AppDbContext db) : IIspezioniService
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public Task<List<Scheda>> GetAllSchedeOfIspezioneAsync(string codMdep, DateTime data, string rilevatore) =>
|
||||
db.Schede
|
||||
public async Task<List<Scheda>> GetAllSchedeOfIspezioneAsync(string codMdep, DateTime data, string rilevatore)
|
||||
{
|
||||
// Le versioni precedenti dell'app hanno lasciato schede non bozza senza
|
||||
// reparto o motivo: risultano inviabili ma il server le rifiuta, e
|
||||
// bloccano la conclusione dell'ispezione. Tornano bozze, cosi' il
|
||||
// rilevatore le completa. Quelle gia' inviate non si toccano.
|
||||
await db.Schede
|
||||
.Where(x => x.CodMdep == codMdep &&
|
||||
x.Data == data &&
|
||||
x.Rilevatore == rilevatore &&
|
||||
!x.Bozza &&
|
||||
x.ActivityId == null &&
|
||||
(string.IsNullOrEmpty(x.CodJfas) || string.IsNullOrEmpty(x.ActivityTypeId)))
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Bozza, true));
|
||||
|
||||
return await db.Schede
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Articoli)
|
||||
.Where(x => x.CodMdep == codMdep &&
|
||||
x.Data == data &&
|
||||
x.Rilevatore == rilevatore)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<Scheda?> GetSchedaAsync(int schedaId) =>
|
||||
db.Schede
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SteUp.Data.LocalDb;
|
||||
using SteUp.Shared.Core.Dto;
|
||||
using SteUp.Shared.Core.Entities;
|
||||
@@ -343,146 +342,57 @@ public class FileManager(
|
||||
private static string GetInspectionKey(Ispezione ispezione) =>
|
||||
$"{ispezione.CodMdep}_{ispezione.Data:ddMMyyyy}_{ispezione.Rilevatore.ToLower()}";
|
||||
|
||||
public List<SendEmailDto.AttachmentsDto> GetFileForExport()
|
||||
public Task<long> GetLogExportImagesSizeAsync(CancellationToken ct = default) =>
|
||||
Task.Run(() => RetrieveAttachedImages().Sum(x => x.File.Length), ct);
|
||||
|
||||
public Task<ILogExport> PrepareLogExportAsync(bool includeImages, CancellationToken ct = default)
|
||||
{
|
||||
var attachments = new List<SendEmailDto.AttachmentsDto>();
|
||||
|
||||
// 1) log file singolo (se ti serve ancora)
|
||||
var logFile = RetrieveLogFile();
|
||||
if (!logFile.IsNullOrEmpty())
|
||||
return Task.Run<ILogExport>(() =>
|
||||
{
|
||||
attachments.Add(new SendEmailDto.AttachmentsDto
|
||||
{
|
||||
FileName = $"logs_{DateTime.Today:yyyyMMdd}.zip",
|
||||
FileContent = CreateZipBytes(logFile!)
|
||||
});
|
||||
}
|
||||
var workDir = Path.Combine(FileSystem.CacheDirectory, "log_export");
|
||||
|
||||
// 2) database zip
|
||||
var dbZip = CreateDatabaseZipAttachment();
|
||||
if (dbZip != null) attachments.Add(dbZip);
|
||||
// Residui di un invio interrotto (app chiusa a metà): si riparte da zero.
|
||||
if (Directory.Exists(workDir))
|
||||
Directory.Delete(workDir, true);
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
// 3) Img zip
|
||||
var attachedInfo = new DirectoryInfo(Path.Combine(FileSystem.AppDataDirectory, "attached"));
|
||||
if (!attachedInfo.Exists) return attachments;
|
||||
var logFiles = RetrieveLogFiles()
|
||||
.Select(f => new LogExport.ZipItem(f, f.Name))
|
||||
.ToList();
|
||||
|
||||
var attachedFiles = attachedInfo
|
||||
// Col journal WAL le modifiche recenti stanno ancora nel file -wal: senza, il database
|
||||
// inviato può non avere le ultime ispezioni.
|
||||
var dbPath = dbPathProvider.GetDbPath();
|
||||
var dbFiles = new[] { dbPath, $"{dbPath}-wal" }
|
||||
.Select(p => new FileInfo(p))
|
||||
.Where(f => f.Exists)
|
||||
.Select(f => new LogExport.ZipItem(f, f.Name))
|
||||
.ToList();
|
||||
|
||||
var images = includeImages ? RetrieveAttachedImages() : [];
|
||||
|
||||
return new LogExport(workDir, logFiles, dbFiles, images, logger);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private static List<FileInfo> RetrieveLogFiles()
|
||||
{
|
||||
var directory = new DirectoryInfo(Path.Combine(FileSystem.AppDataDirectory, "logs"));
|
||||
return directory.Exists ? directory.GetFiles().ToList() : [];
|
||||
}
|
||||
|
||||
private static List<LogExport.ZipItem> RetrieveAttachedImages()
|
||||
{
|
||||
var root = new DirectoryInfo(Path.Combine(FileSystem.AppDataDirectory, "attached"));
|
||||
if (!root.Exists) return [];
|
||||
|
||||
// Ordine per percorso: le foto della stessa ispezione finiscono nella stessa parte.
|
||||
// Il percorso relativo mantiene le cartelle dentro lo zip (separatore "/" per compatibilità).
|
||||
return root
|
||||
.EnumerateFiles("*", SearchOption.AllDirectories)
|
||||
.OrderBy(f => f.FullName, StringComparer.Ordinal)
|
||||
.Select(f => new LogExport.ZipItem(f,
|
||||
Path.GetRelativePath(root.FullName, f.FullName).Replace('\\', '/')))
|
||||
.ToList();
|
||||
|
||||
if (attachedFiles.Count > 0)
|
||||
{
|
||||
attachments.Add(new SendEmailDto.AttachmentsDto
|
||||
{
|
||||
FileName = $"immagini_allegate_{DateTime.Today:yyyyMMdd}.zip",
|
||||
FileContent = CreateZipBytes(attachedInfo.FullName, attachedFiles)
|
||||
});
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}
|
||||
|
||||
private static List<FileInfo>? RetrieveLogFile()
|
||||
{
|
||||
var appDataPath = FileSystem.AppDataDirectory;
|
||||
var targetDirectory = Path.Combine(appDataPath, "logs");
|
||||
|
||||
var directory = new DirectoryInfo(targetDirectory);
|
||||
|
||||
List<FileInfo>? files = null;
|
||||
|
||||
if (directory.Exists)
|
||||
files = directory.GetFiles().ToList();
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private SendEmailDto.AttachmentsDto? CreateDatabaseZipAttachment()
|
||||
{
|
||||
var files = new[]
|
||||
{
|
||||
new FileInfo(dbPathProvider.GetDbPath())
|
||||
};
|
||||
|
||||
// Filtra solo quelli esistenti
|
||||
var existingFiles = files.Where(f => f.Exists).ToList();
|
||||
|
||||
if (existingFiles.Count == 0)
|
||||
return null;
|
||||
|
||||
using var memoryStream = new MemoryStream();
|
||||
|
||||
using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true))
|
||||
{
|
||||
foreach (var file in existingFiles)
|
||||
{
|
||||
var entry = archive.CreateEntry(file.Name, CompressionLevel.Optimal);
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
using var fileStream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
fileStream.CopyTo(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
return new SendEmailDto.AttachmentsDto
|
||||
{
|
||||
FileName = $"database_{DateTime.Now:yyyyMMdd_HHmm}.zip",
|
||||
FileContent = memoryStream.ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] CreateZipBytes(IEnumerable<FileInfo> files)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (!file.Exists)
|
||||
continue;
|
||||
|
||||
// Nome dentro lo zip (evita path e collisioni minime)
|
||||
var entryName = file.Name;
|
||||
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
using var fileStream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
fileStream.CopyTo(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
private static byte[] CreateZipBytes(string rootDir, IEnumerable<FileInfo> files)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
|
||||
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (!file.Exists)
|
||||
continue;
|
||||
|
||||
// Path relativo rispetto a rootDir -> mantiene le directory nello zip
|
||||
var relativePath = Path.GetRelativePath(rootDir, file.FullName);
|
||||
|
||||
// Zip usa "/" come separatore: normalizziamo per compatibilità
|
||||
var entryName = relativePath.Replace('\\', '/');
|
||||
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
using var fileStream = file.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
fileStream.CopyTo(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
return ms.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SteUp.Shared.Core.Dto;
|
||||
using SteUp.Shared.Core.Interface.System;
|
||||
|
||||
namespace SteUp.Maui.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Divide l'invio dei log in parti sotto <see cref="MaxPartBytes"/>: la prima porta log, database
|
||||
/// e le prime immagini, le successive solo immagini. Gli zip vengono scritti su disco (mai in RAM)
|
||||
/// una parte alla volta, così lo spazio occupato resta quello di una sola parte.
|
||||
/// </summary>
|
||||
internal sealed class LogExport : ILogExport
|
||||
{
|
||||
// Ben sotto il max-file-size dei servizi (100 MB) e i limiti tipici dei server di posta.
|
||||
// Una parte piccola regge anche l'upload da rete mobile entro il timeout HTTP del client.
|
||||
internal const long MaxPartBytes = 40L * 1024 * 1024;
|
||||
|
||||
private const int BufferSize = 81920;
|
||||
|
||||
internal readonly record struct ZipItem(FileInfo File, string EntryName);
|
||||
|
||||
private readonly string _workDir;
|
||||
private readonly List<ZipItem> _logFiles;
|
||||
private readonly List<ZipItem> _dbFiles;
|
||||
private readonly List<List<ZipItem>> _imageParts;
|
||||
private readonly ILogger _logger;
|
||||
private readonly List<string> _currentPartFiles = [];
|
||||
|
||||
public int PartCount => _imageParts.Count;
|
||||
|
||||
internal LogExport(string workDir, List<ZipItem> logFiles, List<ZipItem> dbFiles, List<ZipItem> images,
|
||||
ILogger logger)
|
||||
{
|
||||
_workDir = workDir;
|
||||
_logFiles = logFiles;
|
||||
_dbFiles = dbFiles;
|
||||
_logger = logger;
|
||||
_imageParts = SplitImages(images, logFiles.Concat(dbFiles).Sum(x => x.File.Length));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Riempie le parti in ordine (le foto di un'ispezione restano vicine). La prima parte parte
|
||||
/// già occupata da log e database: uso le dimensioni non compresse, una stima per eccesso.
|
||||
/// Un file più grande di una parte finisce da solo in una parte sua.
|
||||
/// </summary>
|
||||
private static List<List<ZipItem>> SplitImages(List<ZipItem> images, long firstPartReserved)
|
||||
{
|
||||
var parts = new List<List<ZipItem>> { new() };
|
||||
var currentSize = firstPartReserved;
|
||||
|
||||
foreach (var image in images)
|
||||
{
|
||||
var length = image.File.Length;
|
||||
if (currentSize > 0 && currentSize + length > MaxPartBytes)
|
||||
{
|
||||
parts.Add([]);
|
||||
currentSize = 0;
|
||||
}
|
||||
|
||||
parts[^1].Add(image);
|
||||
currentSize += length;
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
public Task<List<SendEmailDto.AttachmentsDto>> BuildPartAsync(int index, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(index);
|
||||
ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, PartCount);
|
||||
|
||||
// Task.Run: la compressione non deve bloccare il thread della UI.
|
||||
return Task.Run(() =>
|
||||
{
|
||||
DeleteCurrentPartFiles();
|
||||
|
||||
var attachments = new List<SendEmailDto.AttachmentsDto>();
|
||||
var date = DateTime.Today.ToString("yyyyMMdd");
|
||||
|
||||
if (index == 0)
|
||||
{
|
||||
if (_logFiles.Count > 0)
|
||||
attachments.Add(WriteZip($"logs_{date}.zip", _logFiles, CompressionLevel.Optimal, ct));
|
||||
|
||||
if (_dbFiles.Count > 0)
|
||||
attachments.Add(WriteZip($"database_{DateTime.Now:yyyyMMdd_HHmm}.zip", _dbFiles,
|
||||
CompressionLevel.Optimal, ct));
|
||||
}
|
||||
|
||||
var images = _imageParts[index];
|
||||
if (images.Count > 0)
|
||||
{
|
||||
var suffix = PartCount > 1 ? $"_parte{index + 1}di{PartCount}" : string.Empty;
|
||||
|
||||
// JPEG già compressi: ricomprimerli costa CPU e non riduce quasi niente.
|
||||
attachments.Add(WriteZip($"immagini_allegate_{date}{suffix}.zip", images,
|
||||
CompressionLevel.NoCompression, ct));
|
||||
}
|
||||
|
||||
return attachments;
|
||||
}, ct);
|
||||
}
|
||||
|
||||
private SendEmailDto.AttachmentsDto WriteZip(string fileName, List<ZipItem> items, CompressionLevel level,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var path = Path.Combine(_workDir, fileName);
|
||||
_currentPartFiles.Add(path);
|
||||
|
||||
using (var zipStream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize))
|
||||
using (var archive = new ZipArchive(zipStream, ZipArchiveMode.Create))
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
// FileShare.ReadWrite: log e database possono essere ancora aperti in scrittura.
|
||||
using var source = item.File.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
var entry = archive.CreateEntry(item.EntryName, level);
|
||||
entry.LastWriteTime = item.File.LastWriteTime;
|
||||
|
||||
using var entryStream = entry.Open();
|
||||
source.CopyTo(entryStream, BufferSize);
|
||||
}
|
||||
catch (Exception e) when (e is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Un file sparito o bloccato non deve far saltare tutto l'invio.
|
||||
_logger.LogWarning(e, "Export log: file {File} saltato", item.File.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new SendEmailDto.AttachmentsDto { FileName = fileName, FilePath = path };
|
||||
}
|
||||
|
||||
private void DeleteCurrentPartFiles()
|
||||
{
|
||||
foreach (var path in _currentPartFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning(e, "Export log: impossibile eliminare {File}", path);
|
||||
}
|
||||
}
|
||||
|
||||
_currentPartFiles.Clear();
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_workDir))
|
||||
Directory.Delete(_workDir, true);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogWarning(e, "Export log: impossibile eliminare {Dir}", _workDir);
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -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.2</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>8</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)
|
||||
{
|
||||
if (Back)
|
||||
{
|
||||
<div class="left-section">
|
||||
<MudButton StartIcon="@(!Cancel ? Icons.Material.Outlined.ArrowBackIosNew : "")"
|
||||
OnClick="@GoBack"
|
||||
Color="Color.Info"
|
||||
Style="text-transform: none"
|
||||
Variant="Variant.Text">
|
||||
@BackTo
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
@*
|
||||
Testata di schermata (STILE_UI §5): alta, titolo a sinistra, sfondo uguale
|
||||
alla pagina. Non e' una AppBar colorata col titolo centrato: quella e' una
|
||||
firma Android di serie, e il documento la esclude esplicitamente.
|
||||
*@
|
||||
|
||||
<h3 class="page-title">@Title</h3>
|
||||
<header class="appbar @(Compact ? "appbar--compact" : "")">
|
||||
@if (Back || Cancel)
|
||||
{
|
||||
<div class="appbar__lead">
|
||||
<MudIconButton Icon="@(Cancel ? Icons.Material.Rounded.Close : Icons.Material.Rounded.ArrowBack)"
|
||||
Color="Color.Inherit"
|
||||
aria-label="@(Cancel ? "Chiudi" : "Indietro")"
|
||||
title="@(Cancel ? "Chiudi" : "Indietro")"
|
||||
OnClick="@GoBack"/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="right-section">
|
||||
@if (!LabelSave.IsNullOrEmpty())
|
||||
{
|
||||
<MudButton OnClick="@OnSave"
|
||||
Color="Color.Info"
|
||||
Style="text-transform: none"
|
||||
Variant="Variant.Text">
|
||||
@LabelSave
|
||||
</MudButton>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
<div class="appbar__titles">
|
||||
<h1 class="appbar__title">@Title</h1>
|
||||
@if (!string.IsNullOrWhiteSpace(Subtitle))
|
||||
{
|
||||
<div class="title">
|
||||
<MudText Typo="Typo.h6">
|
||||
<b>@Title</b>
|
||||
</MudText>
|
||||
<MudIconButton Icon="@Icons.Material.Rounded.Close" OnClick="@GoBack" />
|
||||
</div>
|
||||
<p class="appbar__subtitle">@Subtitle</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code{
|
||||
[Parameter] public string? Title { get; set; }
|
||||
[Parameter] public bool Back { get; set; }
|
||||
[Parameter] public string BackTo { get; set; } = "";
|
||||
|
||||
[Parameter] public EventCallback OnFilterToggle { get; set; }
|
||||
|
||||
[Parameter] public bool Cancel { get; set; }
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
[Parameter] public string? LabelSave { get; set; }
|
||||
[Parameter] public EventCallback OnSave { get; set; }
|
||||
|
||||
[Parameter] public bool SmallHeader { get; set; }
|
||||
|
||||
protected override void OnParametersSet()
|
||||
@if (Actions is not null)
|
||||
{
|
||||
Back = !Back ? !Back && Cancel : Back;
|
||||
BackTo = Cancel ? "Annulla" : BackTo;
|
||||
<div class="appbar__actions">
|
||||
@Actions
|
||||
</div>
|
||||
}
|
||||
</header>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Title { get; set; }
|
||||
[Parameter] public string? Subtitle { get; set; }
|
||||
|
||||
/// Mostra la freccia indietro (navigazione tra schermate).
|
||||
[Parameter] public bool Back { get; set; }
|
||||
|
||||
/// Mostra la X di chiusura e delega a OnCancel (schermate modali).
|
||||
[Parameter] public bool Cancel { get; set; }
|
||||
|
||||
[Parameter] public EventCallback OnCancel { get; set; }
|
||||
|
||||
/// Testata ridotta per le schermate a compito singolo, dove il titolo
|
||||
/// non deve rubare spazio al contenuto.
|
||||
[Parameter] public bool Compact { get; set; }
|
||||
|
||||
[Parameter] public RenderFragment? Actions { get; set; }
|
||||
|
||||
private async Task GoBack()
|
||||
{
|
||||
@@ -73,5 +62,4 @@
|
||||
|
||||
await Js.InvokeVoidAsync("goBack");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
.header {
|
||||
min-height: var(--mh-header);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
width: 100%;
|
||||
line-height: normal;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-content > .title { width: 100%; }
|
||||
|
||||
.header-content.with-back .page-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.left-section ::deep button, .right-section ::deep button { font-size: 1.1rem; }
|
||||
|
||||
.left-section ::deep .mud-button-icon-start { margin-right: 3px !important; }
|
||||
|
||||
.header-content.no-back .page-title { margin: 0; }
|
||||
@@ -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>
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
<div class="app-shell">
|
||||
@Body
|
||||
</div>
|
||||
|
||||
<NavMenu/>
|
||||
|
||||
@code {
|
||||
private MudThemeProvider _mudThemeProvider = null!;
|
||||
private bool IsDarkMode { get; set; }
|
||||
private string _mainContentClass = "";
|
||||
|
||||
//Connection state
|
||||
private bool _isNetworkAvailable;
|
||||
@@ -71,58 +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>
|
||||
</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="user" Match="NavLinkMatch.All">
|
||||
<div class="d-flex flex-column">
|
||||
<i class="ri-user-line"></i>
|
||||
<span>Profilo</span>
|
||||
</div>
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@*
|
||||
Navigazione flottante in basso (STILE_UI §6.15): il contenuto le scorre
|
||||
sotto, le destinazioni restano a portata di pollice. Icona outline a
|
||||
riposo, piena da selezionata, etichetta sempre visibile in entrambe.
|
||||
Da 720px diventa una colonna laterale (vedi components.css).
|
||||
|
||||
@if (PlusVisible)
|
||||
{
|
||||
<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"/>
|
||||
}
|
||||
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.
|
||||
*@
|
||||
|
||||
<MudFabMenuItem Disabled="!NetworkService.IsNetworkAvailable()" OnClick="@DeleteInspection"
|
||||
Label="Cancella ispezione" Color="Color.Surface"/>
|
||||
<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>
|
||||
|
||||
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>
|
||||
<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>
|
||||
|
||||
<ConfirmMessageBox @ref="_messageBox" NoText="No" YesText="Si" YesColor="Color.Primary"
|
||||
Message="Completando l'ispezione non sarà più possibile editarla o aggiungere ulteriori segnalazioni. Procedere?"/>
|
||||
<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>
|
||||
</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;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
PageActions.Changed += OnPageActionsChanged;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private async Task NewInspection()
|
||||
private void OnPageActionsChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs args) => Refresh();
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
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!");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CompleteInspection()
|
||||
{
|
||||
var result = await _messageBox.ShowAsync();
|
||||
|
||||
if (result is true)
|
||||
Messenger.Send(new CompleteInspectionMessage());
|
||||
}
|
||||
|
||||
private async Task NewScheda()
|
||||
{
|
||||
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;
|
||||
IsVisible = NavigableRoutes.Contains(CurrentRoute, StringComparer.OrdinalIgnoreCase);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task DeleteInspection()
|
||||
{
|
||||
try
|
||||
{
|
||||
var ispezione = SteupDataService.InspectionPageState.Ispezione;
|
||||
await IspezioniService.DeleteIspezioneAsync(ispezione.CodMdep, ispezione.Data, ispezione.Rilevatore);
|
||||
/// 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();
|
||||
|
||||
await Js.InvokeVoidAsync("goBack");
|
||||
}
|
||||
catch (Exception e)
|
||||
private async Task NewInspection()
|
||||
{
|
||||
if (!await SteupDataService.CanOpenNewInspection())
|
||||
{
|
||||
await Dialog.ShowError(e.Message);
|
||||
await Dialog.ShowWarning(
|
||||
"Per aprire una nuova ispezione devi prima concludere tutte le ispezioni aperte da piu' di 20 giorni.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
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 (SuccessAnimation)
|
||||
{
|
||||
<div class="overlay-content">
|
||||
<div class="success-checkmark">
|
||||
<div class="check-icon">
|
||||
<span class="icon-line line-tip"></span>
|
||||
<span class="icon-line line-long"></span>
|
||||
<div class="icon-circle"></div>
|
||||
<div class="icon-fix"></div>
|
||||
</div>
|
||||
@if (VisibleOverlay)
|
||||
{
|
||||
<div class="overlay" role="status" aria-live="polite">
|
||||
@if (SuccessAnimation)
|
||||
{
|
||||
<div class="checkmark">
|
||||
<svg viewBox="0 0 32 32" aria-hidden="true">
|
||||
<path d="M6 17 L13 24 L26 9"/>
|
||||
</svg>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Message))
|
||||
{
|
||||
<p class="overlay-message">@Message</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="overlay-content">
|
||||
<SpinnerLayout/>
|
||||
@if (!string.IsNullOrWhiteSpace(Message))
|
||||
{
|
||||
<p class="overlay-message">@Message</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</MudOverlay>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="spinner spinner--lg" aria-hidden="true"></span>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Message))
|
||||
{
|
||||
<p class="overlay__message">@Message</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required bool SuccessAnimation { get; set; }
|
||||
[Parameter] public bool SuccessAnimation { get; set; }
|
||||
[Parameter] public required bool VisibleOverlay { get; set; }
|
||||
|
||||
/// Testo opzionale mostrato sotto lo spinner/spunta (es. "Invio scheda 3 di 7…").
|
||||
/// Testo mostrato sotto lo spinner (es. "Invio scheda 3 di 7…").
|
||||
[Parameter] public string? Message { get; set; }
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
.overlay-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: 0 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.overlay-message {
|
||||
margin: 0;
|
||||
max-width: 30ch;
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.4;
|
||||
/* Inchiostro su fondo chiaro dell'overlay: leggibile anche al sole */
|
||||
color: var(--mud-palette-text-primary);
|
||||
}
|
||||
|
||||
.success-checkmark {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Reduced motion: mostra la spunta di successo statica, senza coreografia */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.success-checkmark .check-icon::after,
|
||||
.icon-line.line-tip,
|
||||
.icon-line.line-long {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
box-sizing: content-box;
|
||||
border: 4px solid var(--mud-palette-success);
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon::before {
|
||||
top: 3px;
|
||||
left: -2px;
|
||||
width: 30px;
|
||||
transform-origin: 100% 50%;
|
||||
border-radius: 100px 0 0 100px;
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon::after {
|
||||
top: 0;
|
||||
left: 30px;
|
||||
width: 60px;
|
||||
transform-origin: 0 50%;
|
||||
border-radius: 0 100px 100px 0;
|
||||
animation: rotate-circle 4.25s ease-in;
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon::before,
|
||||
.success-checkmark .check-icon::after {
|
||||
content: '';
|
||||
height: 100px;
|
||||
position: absolute;
|
||||
transform: rotate(-45deg);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.icon-line {
|
||||
height: 5px;
|
||||
background-color: var(--mud-palette-success);
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.icon-line.line-tip {
|
||||
top: 46px;
|
||||
left: 14px;
|
||||
width: 25px;
|
||||
transform: rotate(45deg);
|
||||
animation: icon-line-tip 0.75s;
|
||||
}
|
||||
|
||||
.icon-line.line-long {
|
||||
top: 38px;
|
||||
right: 8px;
|
||||
width: 47px;
|
||||
transform: rotate(-45deg);
|
||||
animation: icon-line-long 0.75s;
|
||||
}
|
||||
|
||||
|
||||
.icon-circle {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
z-index: 10;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
box-sizing: content-box;
|
||||
border: 4px solid var(--mud-palette-success);
|
||||
}
|
||||
|
||||
.icon-fix {
|
||||
top: 8px;
|
||||
width: 5px;
|
||||
left: 26px;
|
||||
z-index: 1;
|
||||
height: 85px;
|
||||
position: absolute;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
@keyframes rotate-circle {
|
||||
0% { transform: rotate(-45deg); }
|
||||
|
||||
5% { transform: rotate(-45deg); }
|
||||
|
||||
12% { transform: rotate(-405deg); }
|
||||
|
||||
100% { transform: rotate(-405deg); }
|
||||
}
|
||||
|
||||
@keyframes icon-line-tip {
|
||||
0% {
|
||||
width: 0;
|
||||
left: 1px;
|
||||
top: 19px;
|
||||
}
|
||||
|
||||
54% {
|
||||
width: 0;
|
||||
left: 1px;
|
||||
top: 19px;
|
||||
}
|
||||
|
||||
70% {
|
||||
width: 50px;
|
||||
left: -8px;
|
||||
top: 37px;
|
||||
}
|
||||
|
||||
84% {
|
||||
width: 17px;
|
||||
left: 21px;
|
||||
top: 48px;
|
||||
}
|
||||
|
||||
100% {
|
||||
width: 25px;
|
||||
left: 14px;
|
||||
top: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-line-long {
|
||||
0% {
|
||||
width: 0;
|
||||
right: 46px;
|
||||
top: 54px;
|
||||
}
|
||||
|
||||
65% {
|
||||
width: 0;
|
||||
right: 46px;
|
||||
top: 54px;
|
||||
}
|
||||
|
||||
84% {
|
||||
width: 55px;
|
||||
right: 0px;
|
||||
top: 35px;
|
||||
}
|
||||
|
||||
100% {
|
||||
width: 47px;
|
||||
right: 8px;
|
||||
top: 38px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,232 @@
|
||||
@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())
|
||||
{
|
||||
<MudExpansionPanels MultiExpansion="true">
|
||||
@{
|
||||
var panelIndex = 0;
|
||||
@if (!Ispezione.ActivityId.IsNullOrEmpty())
|
||||
{
|
||||
<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>
|
||||
}
|
||||
|
||||
<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())
|
||||
{
|
||||
<div class="feedback feedback--warning" role="status">
|
||||
<i class="feedback__icon ri-wifi-off-line" aria-hidden="true"></i>
|
||||
<span>Senza connessione queste azioni non sono disponibili. Il lavoro gia' fatto resta sul dispositivo.</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ActionSheet>
|
||||
|
||||
<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)
|
||||
{
|
||||
<div class="summary__progress-head">
|
||||
<span class="summary__progress-count">@Inviate di @SchedeList.Count inviate</span>
|
||||
<span class="summary__progress-pct">@PercentualeInviate%</span>
|
||||
</div>
|
||||
<div class="progress-track" role="progressbar" aria-valuenow="@PercentualeInviate"
|
||||
aria-valuemin="0" aria-valuemax="100" aria-label="Schede inviate al server">
|
||||
<div class="progress-fill" style="--progress: @Progresso"></div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
@if (Bozze > 0)
|
||||
{
|
||||
@* Feedback inline: resta a video finche' il problema esiste, a
|
||||
differenza del toast che sparisce. *@
|
||||
<div class="feedback feedback--warning" role="status" aria-live="polite">
|
||||
<i class="feedback__icon ri-edit-line" aria-hidden="true"></i>
|
||||
<span>
|
||||
@(Bozze == 1 ? "1 scheda è rimasta a metà" : $"{Bozze} schede sono rimaste a metà").
|
||||
Completale o eliminale prima di concludere l'ispezione.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (SchedeGrouped.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<i class="empty-state__icon ri-clipboard-line" aria-hidden="true"></i>
|
||||
<p class="empty-state__title">Nessuna scheda</p>
|
||||
@if (!IsCompleted)
|
||||
{
|
||||
<p class="empty-state__text">
|
||||
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
|
||||
{
|
||||
@* 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)
|
||||
{
|
||||
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))"/>
|
||||
var reparto = group.Key;
|
||||
var senzaReparto = ReferenceEquals(reparto, SenzaReparto);
|
||||
var chiuso = IsCollapsed(reparto);
|
||||
var daInviare = group.Value.CountDaInviare();
|
||||
|
||||
@if (NetworkService.IsNetworkAvailable() && daInviare > 0)
|
||||
{
|
||||
<MudFab StartIcon="@Icons.Material.Rounded.CloudUpload" Label="Esporta reparto"
|
||||
Color="Color.Primary" Size="Size.Medium"
|
||||
aria-label="@($"Esporta il reparto {group.Key.Descrizione} al server")"
|
||||
OnClick="@(() => ExportReparto(group.Key))"/>
|
||||
}
|
||||
</div>
|
||||
<section 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">
|
||||
@* Il gruppo "Senza reparto" raccoglie solo bozze da
|
||||
sistemare: non ha un "+" ne' un invio suo. *@
|
||||
@if (!IsCompleted && !senzaReparto)
|
||||
{
|
||||
@* 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>
|
||||
</TitleContent>
|
||||
<ChildContent>
|
||||
@foreach (var scheda in group.Value)
|
||||
{
|
||||
<SchedaCard Scheda="scheda" OnSchedaDeleted="OnSchedaDeleted"
|
||||
OnSchedaModified="OnSchedaModified"
|
||||
Ispezione="@SteupDataService.InspectionPageState.Ispezione"/>
|
||||
}
|
||||
</ChildContent>
|
||||
</MudExpansionPanel>
|
||||
</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>
|
||||
}
|
||||
</MudExpansionPanels>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="empty-state">
|
||||
<MudIcon Icon="@Icons.Material.Rounded.AssignmentLate" Class="empty-state-icon" Size="Size.Large"/>
|
||||
<p class="empty-state-title">Nessuna scheda ancora</p>
|
||||
@if (SteupDataService.InspectionPageState.Ispezione.Stato != StatusEnum.Completata)
|
||||
{
|
||||
<p class="empty-state-text">
|
||||
Tocca <b>+</b> in basso per creare la prima scheda di questa ispezione.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SpinnerOverlay VisibleOverlay="VisibleOverlay" SuccessAnimation="_success" Message="@_progressMessage"/>
|
||||
<ConfirmMessageBox @ref="_exportRepartoBox" NoText="Annulla" YesText="Esporta" YesColor="Color.Primary"
|
||||
Message="Le schede di questo reparto verranno inviate al server. Procedere?"/>
|
||||
<ConfirmMessageBox @ref="_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 +239,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 +306,83 @@
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Il gruppo "Senza reparto" non ha codice: la chiave vuota e' la sua.
|
||||
private static string CollapseKey(JtbFasiDto reparto) => reparto.CodJfas ?? string.Empty;
|
||||
|
||||
private bool IsCollapsed(JtbFasiDto reparto) => _collapsed.Contains(CollapseKey(reparto));
|
||||
|
||||
private void Toggle(JtbFasiDto reparto)
|
||||
{
|
||||
var key = CollapseKey(reparto);
|
||||
if (!_collapsed.Remove(key)) _collapsed.Add(key);
|
||||
}
|
||||
|
||||
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 +397,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 +512,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(() =>
|
||||
@@ -286,15 +547,22 @@
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
// Una bozza si puo' salvare anche senza reparto: senza questo gruppo
|
||||
// sparirebbe dall'elenco pur restando sul dispositivo e contata fra le bozze.
|
||||
private static readonly JtbFasiDto SenzaReparto = new() { Descrizione = "Senza reparto" };
|
||||
|
||||
private void GroupSchede()
|
||||
{
|
||||
SchedeGrouped = SchedeList
|
||||
.Where(s => s.Reparto != null)
|
||||
.GroupBy(s => s.CodJfas)
|
||||
.ToDictionary(
|
||||
g => g.First().Reparto!,
|
||||
g => g.ToList()
|
||||
);
|
||||
var grouped = new Dictionary<JtbFasiDto, List<Scheda>>();
|
||||
|
||||
// In testa: sono schede a meta' che chiedono di essere completate.
|
||||
var senzaReparto = SchedeList.FindAll(s => s.Reparto == null);
|
||||
if (senzaReparto.Count > 0) grouped[SenzaReparto] = senzaReparto;
|
||||
|
||||
foreach (var g in SchedeList.Where(s => s.Reparto != null).GroupBy(s => s.CodJfas))
|
||||
grouped[g.First().Reparto!] = g.ToList();
|
||||
|
||||
SchedeGrouped = grouped;
|
||||
}
|
||||
|
||||
private void OnSchedaModified(Scheda obj)
|
||||
@@ -313,18 +581,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 +595,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 +628,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 +640,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 +653,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 +678,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())
|
||||
{
|
||||
<NoDataAvailable Text="Nessuna ispezione effettuata"
|
||||
ImageSource="_content/SteUp.Shared/images/undraw_file-search_cbur.svg"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Virtualize Items="Ispezioni" Context="ispezione">
|
||||
<InspectionCard Class="no-margin" Ispezione="ispezione" CompactView="true" OnClick="@OnClickIspezione"/>
|
||||
</Virtualize>
|
||||
}
|
||||
<div class="app-scroll">
|
||||
<div class="page-width page-body">
|
||||
@if (_loading)
|
||||
{
|
||||
@* Scheletri, non uno spinner in mezzo al contenuto: la pagina ha
|
||||
gia' la forma che avra', e non salta quando i dati arrivano. *@
|
||||
<div class="card-list" aria-hidden="true">
|
||||
@for (var i = 0; i < 3; i++)
|
||||
{
|
||||
<div class="list-card">
|
||||
<div class="skeleton skeleton--line-short"></div>
|
||||
<div class="skeleton skeleton--title" style="margin-top: 10px;"></div>
|
||||
<div class="skeleton skeleton--line" style="margin-top: 12px;"></div>
|
||||
<div class="skeleton skeleton--badge" style="margin-top: 14px;"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (Ispezioni.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<i class="empty-state__icon ri-clipboard-line" aria-hidden="true"></i>
|
||||
<p class="empty-state__title">Nessuna ispezione</p>
|
||||
<p class="empty-state__text">
|
||||
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
|
||||
{
|
||||
<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">
|
||||
@*
|
||||
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>
|
||||
<div class="container container-login">
|
||||
<div class="login-form-container">
|
||||
<div class="input-group">
|
||||
<MudTextField @bind-Value="UserData.Username" Label="Username" Variant="Variant.Outlined"/>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<MudTextField InputType="@_passwordInput" @bind-Value="UserData.Password" Label="Password" Variant="Variant.Outlined" Adornment="Adornment.End" AdornmentIcon="@_passwordInputIcon" OnAdornmentClick="ShowPassword" AdornmentAriaLabel="Show Password"/>
|
||||
</div>
|
||||
<div class="input-group mb-2">
|
||||
<MudTextField @bind-Value="UserData.CodHash" Label="Profilo azienda" Variant="Variant.Outlined"/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
}
|
||||
@if (_attemptFailed)
|
||||
{
|
||||
@* 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 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>
|
||||
<button type="submit" class="onboarding__submit" disabled="@(_busy || !CanSubmit)">
|
||||
@if (_busy)
|
||||
{
|
||||
<span class="onboarding__spinner" aria-hidden="true"></span>
|
||||
<span>Accesso in corso…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Accedi</span>
|
||||
}
|
||||
</button>
|
||||
|
||||
<div class="onboarding__footer">
|
||||
<span>@GenericSystemService.GetCurrentAppVersion() · Powered by</span>
|
||||
<img src="_content/SteUp.Shared/images/logoIntegry.svg" alt="Integry"/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private SignIn UserData { get; } = new();
|
||||
private bool Spinner { get; set; }
|
||||
private string ErrorMessage { get; set; } = "";
|
||||
private bool _attemptFailed;
|
||||
private bool _busy;
|
||||
|
||||
private bool _isShow;
|
||||
private InputType _passwordInput = InputType.Password;
|
||||
private string _passwordInputIcon = Icons.Material.Rounded.VisibilityOff;
|
||||
|
||||
private void ShowPassword()
|
||||
{
|
||||
if (_isShow)
|
||||
{
|
||||
_isShow = false;
|
||||
_passwordInputIcon = Icons.Material.Rounded.VisibilityOff;
|
||||
_passwordInput = InputType.Password;
|
||||
}
|
||||
else
|
||||
{
|
||||
_isShow = true;
|
||||
_passwordInputIcon = Icons.Material.Rounded.Visibility;
|
||||
_passwordInput = InputType.Text;
|
||||
}
|
||||
}
|
||||
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))
|
||||
_busy = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
Spinner = true;
|
||||
SteupDataService.RegisterAppVersion();
|
||||
await UserAccountService.Login(UserData.Username!, UserData.Password!, UserData.CodHash!);
|
||||
AuthenticationStateProvider.NotifyAuthenticationState(); //Chiamato per forzare il refresh
|
||||
await SteupDataService.Init();
|
||||
BarcodeManager.Init();
|
||||
|
||||
LocalStorage.SetString("codHash", UserData.CodHash!);
|
||||
NavigationManager.NavigateTo("/");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ErrorMessage = e.Message;
|
||||
Logger.LogError(e, ErrorMessage);
|
||||
|
||||
_attemptFailed = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_busy = false;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
SteupDataService.RegisterAppVersion();
|
||||
await UserAccountService.Login(UserData.Username, UserData.Password, UserData.CodHash);
|
||||
AuthenticationStateProvider.NotifyAuthenticationState(); //Chiamato per forzare il refresh
|
||||
await SteupDataService.Init();
|
||||
BarcodeManager.Init();
|
||||
|
||||
LocalStorage.SetString("codHash", UserData.CodHash);
|
||||
NavigationManager.NavigateTo("/");
|
||||
StateHasChanged();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ErrorMessage = e.Message;
|
||||
Logger.LogError(e, ErrorMessage);
|
||||
Console.WriteLine(e.Message);
|
||||
|
||||
Spinner = false;
|
||||
StateHasChanged();
|
||||
|
||||
_attemptFailed = true;
|
||||
Console.WriteLine(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -1,8 +1,10 @@
|
||||
@page "/user"
|
||||
@attribute [Authorize]
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using SteUp.Shared.Components.Layout
|
||||
@using SteUp.Shared.Components.Layout.Overlay
|
||||
@using SteUp.Shared.Components.SingleElements
|
||||
@using SteUp.Shared.Components.SingleElements.MessageBox
|
||||
@using SteUp.Shared.Core.Authorization.Enum
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Interface.IntegryApi
|
||||
@@ -10,109 +12,101 @@
|
||||
@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
|
||||
@inject ILogger<UserPage> Logger
|
||||
|
||||
<HeaderLayout Title="Profilo"/>
|
||||
|
||||
@if (IsLoggedIn)
|
||||
{
|
||||
<div class="container content pb-safe-area">
|
||||
<div class="container-primary-info mud-elevation-1">
|
||||
<div class="section-primary-info">
|
||||
<MudAvatar Style="height: 70px; width: 70px; font-size: 2rem; font-weight: bold"
|
||||
Color="Color.Secondary">
|
||||
@UtilityString.ExtractInitials(UserSession.User.Fullname)
|
||||
</MudAvatar>
|
||||
<div class="app-scroll">
|
||||
<div class="page-width page-body stack stack-md">
|
||||
|
||||
<div class="personal-info">
|
||||
<span class="info-nome">@UserSession.User.Fullname</span>
|
||||
@if (UserSession.User.KeyGroup is not null)
|
||||
{
|
||||
<span
|
||||
class="info-section">@(((KeyGroupEnum)UserSession.User.KeyGroup).ConvertToHumanReadable())</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="section-info">
|
||||
<div class="section-personal-info">
|
||||
<div>
|
||||
<span class="info-title">Profilo azienda</span>
|
||||
<span class="info-text">@CodHash</span>
|
||||
@if (IsLoggedIn)
|
||||
{
|
||||
@* §6.16 Intestazione profilo *@
|
||||
<section class="panel panel--lg">
|
||||
<div class="profile-head">
|
||||
<div class="profile-avatar" aria-hidden="true">
|
||||
@UtilityString.ExtractInitials(UserSession.User.Fullname)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-personal-info">
|
||||
<div>
|
||||
<span class="info-title">Status</span>
|
||||
@if (NetworkService.ConnectionAvailable)
|
||||
<div class="section-header__text">
|
||||
<h2 class="summary__title">@UserSession.User.Fullname</h2>
|
||||
@if (UserSession.User.KeyGroup is not null)
|
||||
{
|
||||
<div class="status online">
|
||||
<i class="ri-wifi-line"></i>
|
||||
<span>Online</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="status offline">
|
||||
<i class="ri-wifi-off-line"></i>
|
||||
<span>Offline</span>
|
||||
</div>
|
||||
<p class="summary__subtitle">
|
||||
@(((KeyGroupEnum)UserSession.User.KeyGroup).ConvertToHumanReadable())
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container-button ripple-container mud-elevation-1">
|
||||
<MudButton Class="button-settings red-icon"
|
||||
FullWidth="true"
|
||||
StartIcon="@Icons.Material.Rounded.UploadFile"
|
||||
Size="Size.Medium"
|
||||
OnClick="@ExportLog"
|
||||
Variant="Variant.Outlined">
|
||||
Esporta log
|
||||
</MudButton>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<div class="nav-list__item" style="pointer-events: none;">
|
||||
<i class="nav-list__icon ri-building-line" aria-hidden="true"></i>
|
||||
<span class="nav-list__label">Profilo azienda</span>
|
||||
<span class="t-meta">@CodHash</span>
|
||||
</div>
|
||||
|
||||
<div class="container-button ripple-container mud-elevation-1">
|
||||
<MudButton Class="button-settings green-icon"
|
||||
FullWidth="true"
|
||||
StartIcon="@Icons.Material.Outlined.Sync"
|
||||
Size="Size.Medium"
|
||||
OnClick="@UpdateDb"
|
||||
Variant="Variant.Outlined">
|
||||
Sincronizza ispezioni esportate
|
||||
</MudButton>
|
||||
</div>
|
||||
<div class="nav-list__item" style="pointer-events: none;">
|
||||
<i class="nav-list__icon ri-cloud-line" aria-hidden="true"></i>
|
||||
<span class="nav-list__label">Connessione</span>
|
||||
<Badge Spec="@ConnessioneBadge"/>
|
||||
</div>
|
||||
|
||||
<div class="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>
|
||||
<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>
|
||||
|
||||
<AppVersion/>
|
||||
<section class="panel">
|
||||
<button type="button" class="nav-list__item" @onclick="UpdateDb">
|
||||
<i class="nav-list__icon ri-refresh-line" aria-hidden="true"></i>
|
||||
<span class="nav-list__label">Sincronizza ispezioni esportate</span>
|
||||
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<button type="button" class="nav-list__item" @onclick="ExportLog">
|
||||
<i class="nav-list__icon ri-file-upload-line" aria-hidden="true"></i>
|
||||
<span class="nav-list__label">Invia i log allo sviluppo</span>
|
||||
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@* Le voci distruttive portano l'icona nel tono danger e non hanno chevron. *@
|
||||
<button type="button" class="nav-list__item nav-list__item--danger" @onclick="ConfirmLogout">
|
||||
<i class="nav-list__icon ri-logout-box-r-line" aria-hidden="true"></i>
|
||||
<span class="nav-list__label">Esci</span>
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
|
||||
<SpinnerOverlay VisibleOverlay="VisibleOverlay" Message="@_progressMessage"/>
|
||||
<ConfirmMessageBox @ref="_logoutBox" 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."/>
|
||||
<ConfirmMessageBox @ref="_imagesBox" Title="Inviare anche le foto?" NoText="Annulla"
|
||||
YesText="Solo i log" AltText="Includi le foto"
|
||||
Message="@_imagesBoxMessage"/>
|
||||
|
||||
@code {
|
||||
private bool IsLoggedIn { get; set; }
|
||||
private bool VisibleOverlay { get; set; }
|
||||
private string? _progressMessage;
|
||||
private string? CodHash { get; set; } = "";
|
||||
|
||||
private ConfirmMessageBox _logoutBox = null!;
|
||||
private ConfirmMessageBox _imagesBox = null!;
|
||||
private string _imagesBoxMessage = string.Empty;
|
||||
|
||||
private Core.Helpers.StatusBadgeHelper.BadgeSpec ConnessioneBadge => NetworkService.ConnectionAvailable
|
||||
? new Core.Helpers.StatusBadgeHelper.BadgeSpec("success", "ri-cloud-line", "Online")
|
||||
: new Core.Helpers.StatusBadgeHelper.BadgeSpec("danger", "ri-wifi-off-line", "Offline");
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
IsLoggedIn = await UserSession.IsLoggedIn();
|
||||
@@ -122,46 +116,151 @@
|
||||
|
||||
private async Task ExportLog()
|
||||
{
|
||||
if (VisibleOverlay) return;
|
||||
|
||||
if (!NetworkService.ConnectionAvailable)
|
||||
{
|
||||
Snackbar.Add("Sei offline: per inviare i log serve la connessione.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
var includeImages = await AskIncludeImages();
|
||||
if (includeImages is null) return;
|
||||
|
||||
_progressMessage = "Preparazione dei log…";
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
|
||||
var profiloAzienda = LocalStorage.GetString("codHash");
|
||||
var partCount = 0;
|
||||
var sentParts = 0;
|
||||
|
||||
var email = new SendEmailDto
|
||||
try
|
||||
{
|
||||
FromName = "Integry Log",
|
||||
To = "developer@integry.it",
|
||||
Subject = $"SteUP - Log del {DateTime.Today:d} di {UserSession.User.Username}",
|
||||
IsHtml = true,
|
||||
MsgText = $"Username: <b>{UserSession.User.Username}</b><br/>" +
|
||||
$"Profilo azienda: <b>{profiloAzienda}</b><br/>" +
|
||||
$"ProfileDb: <b>{UserSession.ProfileDb}</b><br/>" +
|
||||
$"Versione app: <b>{GenericSystemService.GetCurrentAppVersion()}</b>",
|
||||
Attachments = FileManager.GetFileForExport()
|
||||
};
|
||||
// Un'email per parte: ogni parte resta sotto i limiti di upload dei servizi.
|
||||
await using var export = await FileManager.PrepareLogExportAsync(includeImages.Value);
|
||||
partCount = export.PartCount;
|
||||
|
||||
await IntegryApiService.SendEmail(email);
|
||||
var profiloAzienda = LocalStorage.GetString("codHash");
|
||||
var subject = $"SteUP - Log del {DateTime.Today:d} di {UserSession.User.Username}";
|
||||
var body = $"Username: <b>{UserSession.User.Username}</b><br/>" +
|
||||
$"Profilo azienda: <b>{profiloAzienda}</b><br/>" +
|
||||
$"ProfileDb: <b>{UserSession.ProfileDb}</b><br/>" +
|
||||
$"Versione app: <b>{GenericSystemService.GetCurrentAppVersion()}</b>";
|
||||
|
||||
VisibleOverlay = false;
|
||||
for (var i = 0; i < partCount; i++)
|
||||
{
|
||||
var multiPart = partCount > 1;
|
||||
|
||||
_progressMessage = multiPart ? $"Invio dei log… parte {i + 1} di {partCount}" : "Invio dei log…";
|
||||
StateHasChanged();
|
||||
|
||||
var attachments = await export.BuildPartAsync(i);
|
||||
|
||||
await IntegryApiService.SendEmail(new SendEmailDto
|
||||
{
|
||||
FromName = "Integry Log",
|
||||
To = "developer@integry.it",
|
||||
Subject = multiPart ? $"{subject} (parte {i + 1}/{partCount})" : subject,
|
||||
IsHtml = true,
|
||||
MsgText = multiPart ? $"{body}<br/>Parte: <b>{i + 1} di {partCount}</b>" : body,
|
||||
Attachments = attachments
|
||||
});
|
||||
|
||||
sentParts++;
|
||||
}
|
||||
|
||||
Snackbar.Add(partCount > 1 ? $"Log inviati allo sviluppo in {partCount} parti" : "Log inviati allo sviluppo",
|
||||
Severity.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogError(e, "Invio dei log interrotto dopo {Sent} parti su {Total}", sentParts, partCount);
|
||||
Snackbar.Add(sentParts > 0
|
||||
? $"Invio interrotto: inviate {sentParts} parti su {partCount}. Controlla la connessione e riprova."
|
||||
: "Invio dei log non riuscito. Controlla la connessione e riprova.",
|
||||
Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
VisibleOverlay = false;
|
||||
_progressMessage = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Chiede se allegare le foto, indicando quanto pesano. Null = invio annullato.
|
||||
/// Se sul dispositivo non ci sono foto non chiede niente.
|
||||
/// </summary>
|
||||
private async Task<bool?> AskIncludeImages()
|
||||
{
|
||||
long imagesSize;
|
||||
|
||||
_progressMessage = "Controllo delle foto…";
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
imagesSize = await FileManager.GetLogExportImagesSizeAsync();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
// Senza sapere quanto pesano, meglio un invio leggero che uno che rischia di fallire.
|
||||
Logger.LogWarning(e, "Impossibile calcolare la dimensione delle foto da inviare");
|
||||
imagesSize = 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
VisibleOverlay = false;
|
||||
_progressMessage = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
if (imagesSize == 0) return false;
|
||||
|
||||
var sizeText = imagesSize < 1024 * 1024
|
||||
? "meno di 1 MB"
|
||||
: $"circa {imagesSize / (1024d * 1024):N0} MB";
|
||||
_imagesBoxMessage = $"Le foto delle ispezioni occupano {sizeText}. Servono solo se il problema " +
|
||||
"riguarda le foto: senza, l'invio è molto più veloce.";
|
||||
StateHasChanged();
|
||||
|
||||
// "Solo i log" è la scelta consigliata (pulsante pieno, true); "Includi le foto" -> false.
|
||||
var result = await _imagesBox.ShowAsync();
|
||||
return result is null ? null : result == false;
|
||||
}
|
||||
|
||||
private async Task UpdateDb()
|
||||
{
|
||||
_progressMessage = "Sincronizzazione…";
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
|
||||
await SteupDataService.CheckAndUpdateStatus();
|
||||
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
await SteupDataService.CheckAndUpdateStatus();
|
||||
Snackbar.Add("Ispezioni aggiornate", Severity.Success);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Snackbar.Add("Sincronizzazione non riuscita. Controlla la connessione e riprova.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
VisibleOverlay = false;
|
||||
_progressMessage = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Logout()
|
||||
private async Task ConfirmLogout()
|
||||
{
|
||||
var result = await _logoutBox.ShowAsync();
|
||||
if (result is not true) return;
|
||||
|
||||
await AuthenticationStateProvider.SignOut();
|
||||
IsLoggedIn = await UserSession.IsLoggedIn();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
.container-primary-info {
|
||||
/*background: var(--light-card-background);*/
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.container-primary-info .divider {
|
||||
margin: 0 0 0 7rem;
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.section-primary-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: .8rem 1.2rem .4rem;
|
||||
}
|
||||
|
||||
.personal-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.info-nome {
|
||||
color: var(--mud-palette-text-primary);
|
||||
font-weight: 800;
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
color: var(--mud-palette-gray-default);
|
||||
font-size: medium;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
padding: .4rem 1.2rem .8rem;
|
||||
}
|
||||
|
||||
.section-personal-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-personal-info > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: normal;
|
||||
margin: .25rem 0;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
color: var(--mud-palette-gray-darker);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
color: var(--mud-palette-text-secondary);
|
||||
font-weight: 700;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.content ::deep .user-button { border: 1px solid var(--card-border-color) !important; }
|
||||
|
||||
.user-button > i { font-size: large; }
|
||||
|
||||
.user-button > span {
|
||||
font-size: medium;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status { font-weight: 700; }
|
||||
|
||||
.status.online { color: var(--mud-palette-success); }
|
||||
|
||||
.status.offline { color: var(--mud-palette-error); }
|
||||
@@ -1,6 +0,0 @@
|
||||
@using SteUp.Shared.Core.Interface.System
|
||||
@inject IGenericSystemService GenericSystemService
|
||||
|
||||
<div class="app-version">
|
||||
<span>@GenericSystemService.GetCurrentAppVersion()</span>
|
||||
</div>
|
||||
@@ -1,11 +0,0 @@
|
||||
.app-version{
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.app-version span{
|
||||
font-size: smaller;
|
||||
color: var(--mud-palette-gray-darker);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
@using SteUp.Shared.Core.Helpers
|
||||
|
||||
@*
|
||||
Badge di stato (STILE_UI §6.4). Non riceve colori: riceve uno stato, e la
|
||||
mappatura in StatusBadgeHelper decide tono, icona ed etichetta.
|
||||
*@
|
||||
|
||||
<span class="badge badge--@Spec.Tone" aria-label="@(AriaLabel ?? Spec.Label)">
|
||||
<i class="badge__icon @Spec.Icon" aria-hidden="true"></i>
|
||||
<span>@(Text ?? Spec.Label)</span>
|
||||
</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public required StatusBadgeHelper.BadgeSpec Spec { get; set; }
|
||||
|
||||
/// Testo alternativo, per i casi in cui l'etichetta porta anche un conteggio.
|
||||
[Parameter] public string? Text { get; set; }
|
||||
|
||||
[Parameter] public string? AriaLabel { get; set; }
|
||||
}
|
||||
@@ -1,105 +1,87 @@
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Entities
|
||||
@using SteUp.Shared.Core.Helpers
|
||||
|
||||
<div class="container-primary-info mud-elevation-1 @Class @(OnClick.HasDelegate ? "ripple-container" : "")" @onclick="OnCardClick">
|
||||
<div class="section-primary-info">
|
||||
<div class="inspection-info">
|
||||
@if (CompactView)
|
||||
{
|
||||
if (OnLoading)
|
||||
{
|
||||
<MudSkeleton Width="40vw"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="info-title compactView">
|
||||
@PuntoVendita.CodMdep - @PuntoVendita.Descrizione
|
||||
</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (OnLoading)
|
||||
{
|
||||
<MudSkeleton Width="40vw"/>
|
||||
<MudSkeleton Width="55vw"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="info-title">
|
||||
@PuntoVendita.CodMdep - @PuntoVendita.Descrizione
|
||||
</span>
|
||||
<span class="info-subtitle">
|
||||
@PuntoVendita.Indirizzo
|
||||
</span>
|
||||
}
|
||||
}
|
||||
@*
|
||||
Card di elenco (STILE_UI §6.5). Il titolo e' l'entita' — il punto vendita —
|
||||
non il tipo di record. I conteggi stanno sempre a vista: il rilevatore deve
|
||||
sapere quanto ha davanti senza contare.
|
||||
*@
|
||||
|
||||
<div class="list-card @(Tappable ? "panel--tappable" : "")"
|
||||
@onclick="OnCardClick"
|
||||
role="@(Tappable ? "button" : null)"
|
||||
tabindex="@(Tappable ? 0 : -1)"
|
||||
aria-label="@Ispezione.ToSemanticLabel()">
|
||||
|
||||
<div class="list-card__head">
|
||||
<div class="list-card__ident">
|
||||
<div class="t-ident">@Ispezione.CodMdep</div>
|
||||
<h3 class="list-card__title">@Descrizione</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
@if (!string.IsNullOrWhiteSpace(Indirizzo))
|
||||
{
|
||||
<p class="list-card__meta">@Indirizzo</p>
|
||||
}
|
||||
|
||||
<div class="section-info">
|
||||
<div class="section-inspection-info">
|
||||
<div>
|
||||
<span class="info-inspection-title">Data ispezione</span>
|
||||
@if (OnLoading)
|
||||
{
|
||||
<MudSkeleton Width="100%"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">
|
||||
@Ispezione.Data.ToString("d")
|
||||
</MudChip>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-card__badges badge-row">
|
||||
<Badge Spec="@Ispezione.Stato.ToBadge()"/>
|
||||
|
||||
<div class="section-inspection-info">
|
||||
<div>
|
||||
<span class="info-inspection-title">Stato ispezione</span>
|
||||
@if (OnLoading)
|
||||
{
|
||||
<MudSkeleton Width="100%"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Color="@Ispezione.Stato.GetColor()">
|
||||
@Ispezione.Stato.ConvertToHumanReadable()
|
||||
</MudChip>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (DaInviare > 0)
|
||||
{
|
||||
<Badge Spec="@SyncState.DaInviare.ToBadge()" Text="@($"{DaInviare} da inviare")"/>
|
||||
}
|
||||
|
||||
@if (Bozze > 0)
|
||||
{
|
||||
<Badge Spec="@SyncState.Bozza.ToBadge()" Text="@($"{Bozze} da completare")"/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="list-card__footer">
|
||||
<i class="icon ri-calendar-line" aria-hidden="true"></i>
|
||||
<span>@Ispezione.Data.ToString("d MMMM yyyy")</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>@SchedeLabel</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public string Class { get; set; } = string.Empty;
|
||||
[Parameter] public Ispezione Ispezione { get; set; } = new();
|
||||
[Parameter] public bool CompactView { get; set; }
|
||||
[Parameter] public EventCallback<Ispezione> OnClick { get; set; }
|
||||
|
||||
private PuntoVenditaDto PuntoVendita { get; set; } = new();
|
||||
private bool Tappable => OnClick.HasDelegate;
|
||||
|
||||
private bool OnLoading { get; set; } = true;
|
||||
private PuntoVenditaDto? _puntoVendita;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
// Il punto vendita puo' mancare dalla cache di riferimento (primo avvio
|
||||
// offline): si degrada al codice invece di far fallire la card.
|
||||
private string Descrizione => _puntoVendita?.Descrizione.IsNullOrEmpty() == false
|
||||
? _puntoVendita.Descrizione!
|
||||
: Ispezione.CodMdep;
|
||||
|
||||
private string? Indirizzo => _puntoVendita is null
|
||||
? null
|
||||
: string.Join(" · ", new[] { _puntoVendita.Indirizzo, _puntoVendita.Citta }
|
||||
.Where(x => !x.IsNullOrEmpty()));
|
||||
|
||||
private int DaInviare => Ispezione.Schede.CountDaInviare();
|
||||
private int Bozze => Ispezione.Schede.CountBozze();
|
||||
|
||||
private string SchedeLabel => Ispezione.Schede.Count == 1
|
||||
? "1 scheda"
|
||||
: $"{Ispezione.Schede.Count} schede";
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
OnLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var puntoVendita = SteupDataService.PuntiVenditaList.Find(x =>
|
||||
_puntoVendita = SteupDataService.PuntiVenditaList.Find(x =>
|
||||
x.CodMdep != null && x.CodMdep.EqualsIgnoreCase(Ispezione.CodMdep)
|
||||
);
|
||||
await Task.Delay(250);
|
||||
|
||||
PuntoVendita = puntoVendita ?? throw new Exception("Punto vendita non trovato");
|
||||
OnLoading = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private Task OnCardClick() =>
|
||||
OnClick.HasDelegate ? OnClick.InvokeAsync(Ispezione) : Task.CompletedTask;
|
||||
Tappable ? OnClick.InvokeAsync(Ispezione) : Task.CompletedTask;
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
.container-primary-info {
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 20px;
|
||||
background: var(--mud-palette-surface);
|
||||
border: 1px solid var(--card-border-color);
|
||||
box-shadow: var(--card-box-shadow);
|
||||
}
|
||||
|
||||
.container-primary-info.no-margin {
|
||||
margin-bottom: 0 !important;
|
||||
min-height: max-content;
|
||||
}
|
||||
|
||||
.container-primary-info .divider {
|
||||
margin: .25rem 0;
|
||||
}
|
||||
|
||||
.section-primary-info {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
padding: .8rem 1.2rem .4rem;
|
||||
}
|
||||
|
||||
.personal-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.inspection-info{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.info-title {
|
||||
color: var(--mud-palette-text-primary);
|
||||
font-weight: 800;
|
||||
font-size: x-large;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.info-subtitle {
|
||||
/* gray-darker (non gray-default) per superare il contrasto AA e restare leggibile al sole */
|
||||
color: var(--mud-palette-gray-darker);
|
||||
font-size: medium;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.info-title.compactView{
|
||||
font-size: medium;
|
||||
}
|
||||
|
||||
.section-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-direction: row;
|
||||
padding: .4rem 1.2rem .8rem;
|
||||
}
|
||||
|
||||
.section-inspection-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-inspection-info > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: normal;
|
||||
margin: .25rem 0;
|
||||
}
|
||||
|
||||
.info-inspection-title {
|
||||
color: var(--mud-palette-gray-darker);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.info-inspection-text {
|
||||
color: var(--mud-palette-text-secondary);
|
||||
font-weight: 700;
|
||||
font-size: small;
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
<div class="card-form-modal">
|
||||
<div class="card-form-modal-title @ClassTitle">
|
||||
<MudText Style="font-weight: 800" Typo="Typo.subtitle1">@Title</MudText>
|
||||
</div>
|
||||
<div class="card-form-modal-body @ClassBody">
|
||||
@if (Loading)
|
||||
@* Gruppo di campo del form scheda: un pannello con la sua etichetta e,
|
||||
quando serve, il conteggio di cio' che contiene. *@
|
||||
|
||||
<div class="form-group @Class">
|
||||
<div class="form-group__label">
|
||||
<span>@Title</span>
|
||||
@if (!string.IsNullOrWhiteSpace(Count))
|
||||
{
|
||||
<MudSkeleton/>
|
||||
}
|
||||
else
|
||||
{
|
||||
@ChildContent
|
||||
<span class="form-group__count">@Count</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (Loading)
|
||||
{
|
||||
<div class="skeleton skeleton--line" style="height: 24px;" aria-hidden="true"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@ChildContent
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Title { get; set; } = "";
|
||||
[Parameter] public string? Count { get; set; }
|
||||
[Parameter] public bool Loading { get; set; }
|
||||
[Parameter] public string Class { get; set; } = "";
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
[Parameter] public string ClassTitle { get; set; } = "";
|
||||
[Parameter] public string ClassBody { get; set; } = "";
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
.card-form-modal {
|
||||
background: var(--mud-palette-table-striped);
|
||||
border-radius: 20px;
|
||||
padding: .5rem 1rem;
|
||||
width: -webkit-fill-available;
|
||||
}
|
||||
|
||||
.is-dark .card-form-modal {
|
||||
background: var(--mud-palette-background); !important;
|
||||
}
|
||||
|
||||
.card-form-modal-body ::deep .file-upload-input {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
small ::deep {
|
||||
color: var(--mud-palette-text-primary) !important;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
@using SteUp.Shared.Components.Layout.Overlay
|
||||
@using SteUp.Shared.Components.SingleElements.MessageBox
|
||||
@using SteUp.Shared.Components.SingleElements.MessageBox
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Entities
|
||||
@using SteUp.Shared.Core.Enum
|
||||
@using SteUp.Shared.Core.Helpers
|
||||
@using SteUp.Shared.Core.Interface.IntegryApi
|
||||
@using SteUp.Shared.Core.Interface.LocalDb
|
||||
@using SteUp.Shared.Core.Interface.System
|
||||
@@ -13,74 +13,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>
|
||||
|
||||
@{
|
||||
var syncState = Scheda.GetSyncState();
|
||||
}
|
||||
<MudChip T="string" Class="sync-badge" Icon="@syncState.GetIcon()"
|
||||
Size="Size.Small" Variant="Variant.Filled" Color="@syncState.GetColor()">
|
||||
@syncState.ToLabel()
|
||||
</MudChip>
|
||||
</div>
|
||||
|
||||
<div class="sub-info-section">
|
||||
<MudChip T="string" Icon="@Icons.Material.Rounded.PhotoCamera" Size="Size.Small" Color="Color.Default">
|
||||
@Scheda.ImageNames.Count()
|
||||
</MudChip>
|
||||
<MudChip T="string" Icon="@Icons.Material.Rounded.QrCode2" Size="Size.Small" Color="Color.Default">
|
||||
@Scheda.Articoli.Count
|
||||
</MudChip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scheda-card-action">
|
||||
@if (Ispezione.Stato != StatusEnum.Completata)
|
||||
{
|
||||
<div class="action-secondary">
|
||||
<MudFab Color="Color.Default" Size="Size.Medium" StartIcon="@Icons.Material.Rounded.Edit"
|
||||
aria-label="Modifica scheda" OnClick="@UpdateScheda"/>
|
||||
<MudFab Color="Color.Error" Size="Size.Medium" StartIcon="@Icons.Material.Rounded.Delete"
|
||||
aria-label="Elimina scheda" OnClick="@DeleteScheda"/>
|
||||
</div>
|
||||
@if (NetworkService.IsNetworkAvailable() && syncState == SyncState.DaInviare)
|
||||
{
|
||||
<MudFab Color="Color.Primary" Size="Size.Medium" StartIcon="@Icons.Material.Rounded.CloudUpload"
|
||||
Label="Invia" aria-label="Invia scheda al server" OnClick="@ExportScheda"/>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="action-secondary">
|
||||
<MudFab Color="Color.Default" Size="Size.Medium" StartIcon="@Icons.Material.Rounded.RemoveRedEye"
|
||||
aria-label="Visualizza scheda" OnClick="@ViewScheda"/>
|
||||
</div>
|
||||
<MudFab Color="Color.Primary" Size="Size.Medium" StartIcon="@Icons.Material.Rounded.CloudUpload"
|
||||
Label="Invia foto" aria-label="Invia foto al server" OnClick="@ExportImg"/>
|
||||
}
|
||||
<div class="list-card" aria-label="@SemanticLabel">
|
||||
<div class="list-card__head">
|
||||
<div class="list-card__ident">
|
||||
<div class="t-ident">@(Scheda.DescrizioneReparto ?? "Senza reparto")</div>
|
||||
<h3 class="list-card__title">@Titolo</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-card__badges badge-row">
|
||||
<Badge Spec="@SyncState.ToBadge()"/>
|
||||
<Badge Spec="@ScadenzaBadge" />
|
||||
</div>
|
||||
|
||||
@if (!Scheda.Note.IsNullOrEmpty())
|
||||
{
|
||||
<p class="list-card__meta t-prose">@Scheda.Note</p>
|
||||
}
|
||||
|
||||
<div class="list-card__footer">
|
||||
<i class="icon ri-qr-code-line" aria-hidden="true"></i>
|
||||
<span>@ArticoliLabel</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<i class="icon ri-camera-line" aria-hidden="true"></i>
|
||||
<span>@FotoLabel</span>
|
||||
</div>
|
||||
|
||||
<div class="list-card__actions">
|
||||
@if (ReadOnly)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" FullWidth="true"
|
||||
StartIcon="@Icons.Material.Rounded.Visibility" OnClick="@ViewScheda">
|
||||
Apri
|
||||
</MudButton>
|
||||
|
||||
@if (Scheda.ImageNames?.Count > 0)
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
|
||||
StartIcon="@Icons.Material.Rounded.CloudUpload" OnClick="@ExportImg">
|
||||
Invia foto
|
||||
</MudButton>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" FullWidth="true"
|
||||
StartIcon="@Icons.Material.Rounded.Edit" OnClick="@UpdateScheda">
|
||||
@(IsBozza ? "Completa" : "Modifica")
|
||||
</MudButton>
|
||||
|
||||
@if (NetworkService.IsNetworkAvailable() && SyncState == Core.Helpers.SyncState.DaInviare)
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
|
||||
StartIcon="@Icons.Material.Rounded.CloudUpload" OnClick="@ExportScheda">
|
||||
Invia
|
||||
</MudButton>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<MudButton Class="mt-2" Variant="Variant.Text" FullWidth="true"
|
||||
StartIcon="@Icons.Material.Rounded.DeleteOutline"
|
||||
Style="color: var(--danger-ink)" OnClick="@DeleteScheda">
|
||||
Elimina scheda
|
||||
</MudButton>
|
||||
}
|
||||
</div>
|
||||
|
||||
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
|
||||
<ConfirmMessageBox @ref="_messageBox" YesText="Cancella" YesColor="Color.Error"
|
||||
Message="Confermi la cancellazione della scheda corrente?"/>
|
||||
<ConfirmMessageBox @ref="_messageBox" 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 ?? "non indicato"},{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,10 @@
|
||||
<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 o chiusura senza scelta -> null.
|
||||
AltText (opzionale) aggiunge una seconda scelta valida -> false. *@
|
||||
<MudMessageBox @ref="_confirmDelete" Title="@Title" CancelText="@NoText" NoText="@AltText">
|
||||
<MessageContent>
|
||||
@Message
|
||||
</MessageContent>
|
||||
@@ -11,9 +17,11 @@
|
||||
|
||||
@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";
|
||||
[Parameter] public string? AltText { get; set; }
|
||||
[Parameter] public Color YesColor { get; set; } = Color.Primary;
|
||||
|
||||
private MudMessageBox? _confirmDelete;
|
||||
|
||||
@@ -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="exception-header mb-2">
|
||||
<i class="ri-error-warning-line warning-icon"></i>
|
||||
<span>Attenzione</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="exception-header mb-2">
|
||||
<i class="ri-emotion-unhappy-line error-icon"></i>
|
||||
<span>Ops</span>
|
||||
</div>
|
||||
}
|
||||
<div class="text">
|
||||
<code>@ErrorMessage</code>
|
||||
<div class="dialog-icon dialog-icon--@(IsWarning ? "warning" : "danger")" aria-hidden="true">
|
||||
<i class="@(IsWarning ? "ri-alert-line" : "ri-error-warning-line")"></i>
|
||||
</div>
|
||||
|
||||
<h2 class="dialog-title">@(IsWarning ? "Attenzione" : "Qualcosa non ha funzionato")</h2>
|
||||
<p class="dialog-message">@Message</p>
|
||||
|
||||
@if (ShowDetail)
|
||||
{
|
||||
<p class="dialog-detail">@ErrorMessage</p>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Text" OnClick="@Cancel" Size="Size.Small" Color="Color.Primary">
|
||||
Chiudi
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@Cancel">Ho capito</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -30,5 +27,14 @@
|
||||
[Parameter] public string ErrorMessage { get; set; } = string.Empty;
|
||||
[Parameter] public bool IsWarning { get; set; }
|
||||
|
||||
// Gli avvisi sono gia' scritti per il rilevatore. Gli errori tecnici no:
|
||||
// si mostra una frase comprensibile e il dettaglio resta sotto, per chi
|
||||
// deve riferirlo all'assistenza.
|
||||
private bool ShowDetail => !IsWarning;
|
||||
|
||||
private string Message => IsWarning
|
||||
? ErrorMessage
|
||||
: "Il lavoro resta salvato sul dispositivo. Riprova.";
|
||||
|
||||
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>
|
||||
<div class="feedback feedback--warning" role="status">
|
||||
<i class="feedback__icon ri-edit-line" aria-hidden="true"></i>
|
||||
<span>Questa scheda è una bozza: completala per poterla inviare.</span>
|
||||
</div>
|
||||
}
|
||||
</MudSelectExtended>
|
||||
</CardFormModal>
|
||||
|
||||
<CardFormModal Title="Motivo" Loading="SteupDataService.TipiAttività.IsNullOrEmpty()">
|
||||
<MudSelectExtended ReadOnly="@Scheda.CodJfas.IsNullOrEmpty()" T="string?"
|
||||
Variant="Variant.Text"
|
||||
@bind-Value="Scheda.ActivityTypeId" @bind-Value:after="OnAfterChangeValue"
|
||||
Required="true" RequiredError="Motivo obbligatorio">
|
||||
@foreach (var type in SteupDataService.TipiAttività.Where(x => x.CodJfas.EqualsIgnoreCase(Scheda.CodJfas!)))
|
||||
{
|
||||
<MudSelectItemExtended Class="custom-item-select"
|
||||
Value="@type.ActivityTypeId">@type.ActivityTypeId</MudSelectItemExtended>
|
||||
}
|
||||
</MudSelectExtended>
|
||||
</CardFormModal>
|
||||
<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 (FileLoading)
|
||||
{
|
||||
<CardFormModal Title="Immagini">
|
||||
<div class="container-attached">
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-3"/>
|
||||
</div>
|
||||
</CardFormModal>
|
||||
}
|
||||
else if (!AttachedList.IsNullOrEmpty())
|
||||
{
|
||||
<CardFormModal Title="Immagini">
|
||||
<div class="container-attached">
|
||||
<div class="scroll-attached">
|
||||
@foreach (var item in AttachedList!.Select((p, index) => new { p, index }))
|
||||
@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)
|
||||
{
|
||||
<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)
|
||||
<p class="form-error" role="alert">
|
||||
<i class="ri-error-warning-line" aria-hidden="true"></i>
|
||||
Indica il motivo
|
||||
</p>
|
||||
}
|
||||
}
|
||||
</CardFormModal>
|
||||
|
||||
@* --- Articoli: il compito primario della schermata --- *@
|
||||
<CardFormModal Title="Articoli" Count="@ArticoliCount">
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<div class="barcode-field">
|
||||
<input type="text" inputmode="numeric" placeholder="Digita o scansiona un codice"
|
||||
aria-label="Codice a barre"
|
||||
value="@ManualBarcode" @oninput="OnManualBarcodeInput"
|
||||
@onkeydown="OnManualBarcodeKey"/>
|
||||
<button type="button" class="barcode-field__send"
|
||||
disabled="@(ManualBarcode.IsNullOrEmpty())"
|
||||
aria-label="Aggiungi il codice" @onclick="OnManualBarcodeSet">
|
||||
<i class="ri-arrow-right-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!Scheda.Articoli.IsNullOrEmpty())
|
||||
{
|
||||
<div class="art-list" style="margin-top: var(--space-sm);">
|
||||
@foreach (var articolo in Scheda.Articoli)
|
||||
{
|
||||
<div class="art-item" @key="articolo.Barcode">
|
||||
<div class="art-item__text">
|
||||
<div class="art-item__name">@articolo.Descrizione</div>
|
||||
<div class="art-item__code selectable">@articolo.Barcode</div>
|
||||
</div>
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<MudIconButton Variant="Variant.Outlined"
|
||||
Icon="@Icons.Material.Rounded.Close"
|
||||
Size="Size.Small" Color="Color.Error"
|
||||
OnClick="@(() => OnRemoveAttached(item.index))"/>
|
||||
<button type="button" class="art-item__remove"
|
||||
aria-label="@($"Rimuovi {articolo.Descrizione}")"
|
||||
@onclick="@(() => RemoveArt(articolo.Barcode))">
|
||||
<i class="ri-close-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (ReadOnly)
|
||||
{
|
||||
<p class="t-meta">Nessun articolo.</p>
|
||||
}
|
||||
|
||||
@if (NetworkService.ConnectionAvailable && !ReadOnly)
|
||||
{
|
||||
<button type="button" class="action-tile" style="margin-top: var(--space-sm);"
|
||||
@onclick="@OpenSelectArt">
|
||||
<span class="action-tile__icon action-tile__icon--neutral">
|
||||
<i class="ri-list-check-2" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="action-tile__label">Scegli dalla griglia</span>
|
||||
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</CardFormModal>
|
||||
|
||||
@* --- Foto --- *@
|
||||
<CardFormModal Title="Foto" Count="@FotoCount" Loading="FileLoading">
|
||||
@if (!AttachedList.IsNullOrEmpty())
|
||||
{
|
||||
<div class="attach-strip">
|
||||
@foreach (var item in AttachedList!.Select((p, index) => new { p, index }))
|
||||
{
|
||||
<div class="attach-item">
|
||||
@if (!item.p.ThumbPath.IsNullOrEmpty())
|
||||
{
|
||||
<img class="attach-item__img" src="@item.p.ThumbPath"
|
||||
alt="@item.p.Name"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="attach-item__placeholder">
|
||||
<span class="spinner" aria-label="Elaborazione"></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (item.p.ToUpload && !ReadOnly)
|
||||
{
|
||||
<button type="button" class="attach-item__remove"
|
||||
aria-label="@($"Rimuovi la foto {item.p.Name}")"
|
||||
@onclick="@(() => OnRemoveAttached(item.index))">
|
||||
<i class="ri-close-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (ReadOnly)
|
||||
{
|
||||
<p class="t-meta">Nessuna foto.</p>
|
||||
}
|
||||
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<button type="button" class="action-tile" style="margin-top: var(--space-sm);"
|
||||
@onclick="@OpenAddAttached">
|
||||
<span class="action-tile__icon action-tile__icon--primary">
|
||||
<i class="ri-camera-line" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="action-tile__label">Aggiungi una foto</span>
|
||||
</button>
|
||||
}
|
||||
</CardFormModal>
|
||||
|
||||
@* --- Urgenza: pillole invece di un menu a tendina --- *@
|
||||
<CardFormModal Title="Entro quando">
|
||||
<div class="option-row">
|
||||
@foreach (var opzione in ScadenzaOptions)
|
||||
{
|
||||
<button type="button" class="option"
|
||||
disabled="@ReadOnly"
|
||||
aria-pressed="@(Scheda.Scadenza == opzione.Value ? "true" : "false")"
|
||||
@onclick="@(() => SetScadenza(opzione.Value))">
|
||||
@opzione.Label
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</CardFormModal>
|
||||
}
|
||||
</CardFormModal>
|
||||
|
||||
<CardFormModal Title="Persona di riferimento">
|
||||
<MudTextField FullWidth="true" T="string?" Variant="Variant.Text"
|
||||
Placeholder="Nome di chi ha seguito il controllo"
|
||||
@bind-Value="Scheda.Responsabile" @bind-Value:after="OnAfterChangeValue"
|
||||
DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/>
|
||||
</CardFormModal>
|
||||
|
||||
<CardFormModal Title="Note">
|
||||
<MudTextField @key="_noteFieldKey" T="string?" Variant="Variant.Text" Lines="3"
|
||||
Placeholder="Cosa hai rilevato"
|
||||
@bind-Value="Scheda.Note" @bind-Value:after="OnAfterChangeValue"
|
||||
DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/>
|
||||
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<button type="button" class="action-tile" style="margin-top: var(--space-sm);"
|
||||
@onclick="@SuggestActivityDescription">
|
||||
<span class="action-tile__icon action-tile__icon--info">
|
||||
<i class="ri-lightbulb-line" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="action-tile__label">Usa una nota ricorrente</span>
|
||||
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
}
|
||||
</CardFormModal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<div class="container-button ripple-container">
|
||||
<MudButton Class="button-settings green-icon"
|
||||
FullWidth="true"
|
||||
StartIcon="@Icons.Material.Rounded.AttachFile"
|
||||
Size="Size.Medium"
|
||||
OnClick="@OpenAddAttached"
|
||||
Variant="Variant.Outlined">
|
||||
Aggiungi foto
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
|
||||
<CardFormModal Title="Articoli">
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<div class="input-manual-barcode">
|
||||
<MudTextField FullWidth="true" T="string?" Variant="Variant.Text"
|
||||
@bind-Value="ManualBarcode" Placeholder="Digita manualmente il codice"/>
|
||||
<MudIconButton Color="Color.Primary" OnClick="@OnManualBarcodeSet"
|
||||
Size="Size.Small" Icon="@Icons.Material.Rounded.Send"/>
|
||||
</div>
|
||||
}
|
||||
@if (!Scheda.Articoli.IsNullOrEmpty())
|
||||
{
|
||||
<div class="art-list">
|
||||
@foreach (var articolo in Scheda.Articoli)
|
||||
{
|
||||
<MudChip T="string" OnClose="@(() => RemoveArt(articolo.Barcode))"
|
||||
style="height: auto;">
|
||||
<MudStack Direction="Column" Spacing="0" class="py-1">
|
||||
<MudText Typo="Typo.subtitle2" Style="line-height: 1.1; font-weight: 700;">
|
||||
@articolo.Descrizione
|
||||
</MudText>
|
||||
<MudText Typo="Typo.body2" Style="line-height: 1.5;">
|
||||
Barcode: @articolo.Barcode
|
||||
</MudText>
|
||||
</MudStack>
|
||||
</MudChip>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</CardFormModal>
|
||||
|
||||
@if (NetworkService.ConnectionAvailable && !ReadOnly)
|
||||
{
|
||||
<div class="container-button ripple-container">
|
||||
<MudButton Class="button-settings red-icon"
|
||||
FullWidth="true"
|
||||
StartIcon="@Icons.Material.Rounded.Balance"
|
||||
Size="Size.Medium"
|
||||
OnClick="@OpenSelectArt"
|
||||
Variant="Variant.Outlined">
|
||||
Consulta articoli
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
|
||||
<CardFormModal Title="Scadenza">
|
||||
<MudSelectExtended FullWidth="true" T="int" Variant="Variant.Text"
|
||||
@bind-Value="@Scheda.Scadenza" @bind-Value:after="OnAfterChangeValue">
|
||||
<MudSelectItemExtended Class="custom-item-select" Text="24H" Value="3"/>
|
||||
<MudSelectItemExtended Class="custom-item-select" Text="1 Settimana" Value="2"/>
|
||||
<MudSelectItemExtended Class="custom-item-select" Text="1 Mese" Value="1"/>
|
||||
<MudSelectItemExtended Class="custom-item-select" Text="2 Mesi" Value="0"/>
|
||||
</MudSelectExtended>
|
||||
</CardFormModal>
|
||||
|
||||
<CardFormModal Title="Responsabile">
|
||||
<MudTextField FullWidth="true" T="string?" Variant="Variant.Text"
|
||||
@bind-Value="Scheda.Responsabile" @bind-Value:after="OnAfterChangeValue"
|
||||
DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/>
|
||||
</CardFormModal>
|
||||
|
||||
<CardFormModal Title="Note">
|
||||
<MudTextField @key="_noteFieldKey" T="string?" Variant="Variant.Text" Lines="3"
|
||||
@bind-Value="Scheda.Note" @bind-Value:after="OnAfterChangeValue"
|
||||
DebounceInterval="500" OnDebounceIntervalElapsed="OnAfterChangeValue"/>
|
||||
</CardFormModal>
|
||||
|
||||
<div class="container-button">
|
||||
<MudButton Class="button-settings primary-icon"
|
||||
FullWidth="true"
|
||||
StartIcon="@Icons.Material.Rounded.Description"
|
||||
Size="Size.Medium"
|
||||
OnClick="@SuggestActivityDescription"
|
||||
Variant="Variant.Outlined">
|
||||
Suggerisci note descrittive
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
@if (!LabelSave.IsNullOrEmpty())
|
||||
{
|
||||
<div class="container-button">
|
||||
<MudButton OnClick="@Save" Color="Color.Primary" Variant="Variant.Filled" FullWidth="true">
|
||||
@LabelSave
|
||||
@* Barra azioni ancorata: il salvataggio non si cerca mai. *@
|
||||
<div class="action-bar">
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
|
||||
Disabled="@(!IsDirty)" OnClick="@Submit">
|
||||
@(LabelSave ?? (IsNew ? "Salva" : "Aggiorna"))
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
@@ -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)
|
||||
@@ -434,36 +578,87 @@
|
||||
|
||||
private async Task Cancel()
|
||||
{
|
||||
if (await CheckSavePreAction())
|
||||
if (_closing) return;
|
||||
_closing = true;
|
||||
|
||||
try
|
||||
{
|
||||
await FileManager.CleanTempStorageAsync();
|
||||
DisposeMessage();
|
||||
MudDialog.Cancel();
|
||||
if (await CheckSavePreAction())
|
||||
{
|
||||
await FileManager.CleanTempStorageAsync();
|
||||
DisposeMessage();
|
||||
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">
|
||||
<DialogContent>
|
||||
<HeaderLayout SmallHeader="true" Cancel="true" OnCancel="@(() => MudDialog.Cancel())"
|
||||
Title="Seleziona articoli"/>
|
||||
@*
|
||||
Scelta degli articoli dalla griglia. Al posto del menu a tendina a selezione
|
||||
multipla, un elenco a schermo intero con righe da 56px: si centra con i
|
||||
guanti, e il conteggio dei selezionati resta sempre a vista.
|
||||
*@
|
||||
|
||||
@if (!Articoli.IsNullOrEmpty())
|
||||
{
|
||||
<MudSelectExtended MultiSelection="true" ItemCollection="Articoli" SearchBox="true"
|
||||
SearchBoxAutoFocus="true" @bind-SelectedValues="ArticoliSelected"
|
||||
SearchFunc="SearchFunc" T="ArticoliInGrigliaDto" Virtualize="true"
|
||||
Label="Articoli" AnchorOrigin="Origin.BottomCenter" Variant="Variant.Outlined"
|
||||
SearchBoxPlaceholder="Descrizione articolo" SearchBoxClearable="true"
|
||||
MultiSelectionTextFunc="MultiSelectionTextFunc" ToStringFunc="ToStringFunc"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="spinner-container" style="height: unset !important; margin-bottom: 1rem;">
|
||||
<MudIcon Size="Size.Large" Color="Color.Error" Icon="@Icons.Material.Rounded.Close"/>
|
||||
<MudText>Nessun articolo trovato</MudText>
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<div class="app-shell">
|
||||
<HeaderLayout Title="Articoli" Subtitle="@Conteggio" Cancel="true"
|
||||
OnCancel="@Cancel" Compact="true"/>
|
||||
|
||||
@if (!Articoli.IsNullOrEmpty())
|
||||
{
|
||||
<div class="page-width" style="padding-bottom: var(--space-sm);">
|
||||
<div class="search-field">
|
||||
<i class="search-field__icon ri-search-line" aria-hidden="true"></i>
|
||||
<input type="search" placeholder="Cerca per descrizione o codice"
|
||||
autocapitalize="none" autocorrect="off"
|
||||
aria-label="Cerca un articolo"
|
||||
value="@FilterText" @oninput="OnFilterInput"/>
|
||||
@if (!FilterText.IsNullOrEmpty())
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Rounded.Close" Size="Size.Small"
|
||||
aria-label="Cancella la ricerca" OnClick="@ClearFilter"/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="app-scroll">
|
||||
<div class="page-width page-body--flush">
|
||||
@if (Articoli.IsNullOrEmpty())
|
||||
{
|
||||
<div class="empty-state">
|
||||
<i class="empty-state__icon ri-inbox-line" aria-hidden="true"></i>
|
||||
<p class="empty-state__title">Nessun articolo in griglia</p>
|
||||
<p class="empty-state__text">
|
||||
Per questo reparto e motivo non ci sono articoli: usa lo scanner o digita il codice.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else if (Filtrati.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<i class="empty-state__icon ri-search-line" aria-hidden="true"></i>
|
||||
<p class="empty-state__title">Nessun risultato</p>
|
||||
<p class="empty-state__text">Nessun articolo corrisponde alla ricerca.</p>
|
||||
<div class="empty-state__action">
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@ClearFilter">
|
||||
Azzera la ricerca
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="stack stack-xs">
|
||||
<Virtualize Items="Filtrati" Context="art" OverscanCount="8">
|
||||
<button type="button" class="select-row"
|
||||
aria-pressed="@(IsSelected(art) ? "true" : "false")"
|
||||
@onclick="@(() => Toggle(art))">
|
||||
<span class="select-row__check" aria-hidden="true">
|
||||
<i class="ri-check-line"></i>
|
||||
</span>
|
||||
<span class="select-row__text">
|
||||
<span class="select-row__title">@art.Descrizione</span>
|
||||
<span class="select-row__meta">@art.Barcode</span>
|
||||
</span>
|
||||
</button>
|
||||
</Virtualize>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!Articoli.IsNullOrEmpty())
|
||||
{
|
||||
<div class="action-bar">
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@Cancel">Annulla</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary"
|
||||
Disabled="@(_selected.Count == 0)" OnClick="@Submit">
|
||||
@(_selected.Count == 0 ? "Aggiungi" : $"Aggiungi {_selected.Count}")
|
||||
</MudButton>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
@if (!Articoli.IsNullOrEmpty())
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" OnClick="@Submit" Size="Size.Small" Color="Color.Primary">
|
||||
Seleziona
|
||||
</MudButton>
|
||||
}
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
[Parameter] public List<ArticoliInGrigliaDto>? Articoli { get; set; }
|
||||
|
||||
private IEnumerable<ArticoliInGrigliaDto>? ArticoliSelected { get; set; }
|
||||
private readonly List<ArticoliInGrigliaDto> _selected = [];
|
||||
private string? FilterText { get; set; }
|
||||
|
||||
private List<ArticoliInGrigliaDto> Filtrati =>
|
||||
FilterText.IsNullOrEmpty()
|
||||
? Articoli ?? []
|
||||
: (Articoli ?? []).FindAll(x =>
|
||||
x.Descrizione.ContainsIgnoreCase(FilterText!) ||
|
||||
x.Barcode.ContainsIgnoreCase(FilterText!));
|
||||
|
||||
private string? Conteggio => Articoli.IsNullOrEmpty()
|
||||
? null
|
||||
: _selected.Count > 0
|
||||
? $"{_selected.Count} selezionat{(_selected.Count == 1 ? "o" : "i")}"
|
||||
: $"{Articoli!.Count} disponibili";
|
||||
|
||||
private bool IsSelected(ArticoliInGrigliaDto art) => _selected.Contains(art);
|
||||
|
||||
private void Toggle(ArticoliInGrigliaDto art)
|
||||
{
|
||||
if (!_selected.Remove(art)) _selected.Add(art);
|
||||
}
|
||||
|
||||
private void OnFilterInput(ChangeEventArgs e) => FilterText = e.Value?.ToString();
|
||||
|
||||
private void ClearFilter() => FilterText = null;
|
||||
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
|
||||
private void Submit() =>
|
||||
MudDialog.Close(DialogResult.Ok(ArticoliSelected?.ToList()));
|
||||
|
||||
private static bool SearchFunc(ArticoliInGrigliaDto? obj, string? searchString)
|
||||
{
|
||||
if (searchString.IsNullOrEmpty() || obj == null) return true;
|
||||
|
||||
return obj.Descrizione.ContainsIgnoreCase(searchString!);
|
||||
}
|
||||
|
||||
private static string? ToStringFunc(ArticoliInGrigliaDto? arg) => arg?.Descrizione;
|
||||
|
||||
private static string MultiSelectionTextFunc(List<ArticoliInGrigliaDto?> arg) =>
|
||||
$"{arg.Count} selezionat{(arg.Count == 1 ? "o" : "i")}";
|
||||
|
||||
private void Submit() => MudDialog.Close(DialogResult.Ok(_selected.ToList()));
|
||||
}
|
||||
@@ -1,87 +1,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 class="page-width" style="padding-bottom: var(--space-sm);">
|
||||
<div class="search-field">
|
||||
<i class="search-field__icon ri-search-line" aria-hidden="true"></i>
|
||||
<input type="search" placeholder="Cerca per codice, nome o città"
|
||||
autocapitalize="none" autocorrect="off"
|
||||
aria-label="Cerca un punto vendita"
|
||||
value="@FilterText" @oninput="OnFilterInput"/>
|
||||
@if (!FilterText.IsNullOrEmpty())
|
||||
{
|
||||
<MudIconButton Icon="@Icons.Material.Rounded.Close" Size="Size.Small"
|
||||
aria-label="Cancella la ricerca" OnClick="@ClearFilter"/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shop-body">
|
||||
@if (_afterRender)
|
||||
{
|
||||
if (FilteredList.IsNullOrEmpty())
|
||||
<div class="app-scroll">
|
||||
<div class="page-width page-body--flush">
|
||||
@if (FilteredList is null)
|
||||
{
|
||||
<MudText Typo="Typo.body2">Nessun negozio trovato</MudText>
|
||||
<div class="card-list" aria-hidden="true">
|
||||
@for (var i = 0; i < 4; i++)
|
||||
{
|
||||
<div class="list-card">
|
||||
<div class="skeleton skeleton--line-short"></div>
|
||||
<div class="skeleton skeleton--title" style="margin-top: 10px;"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (FilteredList.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
<i class="empty-state__icon ri-search-line" aria-hidden="true"></i>
|
||||
<p class="empty-state__title">Nessun punto vendita</p>
|
||||
<p class="empty-state__text">Nessun risultato per "@FilterText".</p>
|
||||
<div class="empty-state__action">
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@ClearFilter">
|
||||
Azzera la ricerca
|
||||
</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<Virtualize Items="FilteredList" Context="puntoVendita">
|
||||
<ShopCard PuntoVendita="puntoVendita"/>
|
||||
</Virtualize>
|
||||
<div class="card-list">
|
||||
<Virtualize Items="FilteredList" Context="puntoVendita" OverscanCount="6">
|
||||
<ShopCard PuntoVendita="puntoVendita"
|
||||
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 =>
|
||||
(x.Indirizzo != null && x.Indirizzo.ContainsIgnoreCase(FilterText!)) ||
|
||||
(x.Descrizione != null && x.Descrizione.ContainsIgnoreCase(FilterText!)) ||
|
||||
(x.CodMdep != null && x.CodMdep.ContainsIgnoreCase(FilterText!)) ||
|
||||
(x.Citta != null && x.Citta.ContainsIgnoreCase(FilterText!)) ||
|
||||
(x.Cap != null && x.Cap.ContainsIgnoreCase(FilterText!)) ||
|
||||
(x.Provincia != null && x.Provincia.ContainsIgnoreCase(FilterText!))
|
||||
);
|
||||
}
|
||||
var source = SteupDataService.PuntiVenditaList;
|
||||
|
||||
FilteredList = (FilterText.IsNullOrEmpty()
|
||||
? source
|
||||
: source.FindAll(x =>
|
||||
(x.Indirizzo != null && x.Indirizzo.ContainsIgnoreCase(FilterText!)) ||
|
||||
(x.Descrizione != null && x.Descrizione.ContainsIgnoreCase(FilterText!)) ||
|
||||
(x.CodMdep != null && x.CodMdep.ContainsIgnoreCase(FilterText!)) ||
|
||||
(x.Citta != null && x.Citta.ContainsIgnoreCase(FilterText!)) ||
|
||||
(x.Cap != null && x.Cap.ContainsIgnoreCase(FilterText!)) ||
|
||||
(x.Provincia != null && x.Provincia.ContainsIgnoreCase(FilterText!))))
|
||||
.OrderBy(x => x.CodMdep)
|
||||
.ToList();
|
||||
|
||||
FilteredList = FilteredList.OrderBy(x => x.CodMdep).ToList();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
.shop-header{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.shop-title{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shop-body{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -1,24 +1,34 @@
|
||||
@using SteUp.Shared.Core.Dto
|
||||
|
||||
<MudDialog OnBackdropClick="Cancel">
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<h2 class="dialog-title">Note descrittive</h2>
|
||||
<p class="dialog-message">Scegli una descrizione ricorrente per questo motivo.</p>
|
||||
|
||||
@if (!ActivityTypers.IsNullOrEmpty())
|
||||
{
|
||||
<MudList T="string" SelectedValueChanged="OnClickItem">
|
||||
<div class="stack stack-xs" style="margin-top: var(--space-md);">
|
||||
@foreach (var item in ActivityTypers!)
|
||||
{
|
||||
<MudListItem Text="@item.ActivityTypeDescription" Value="item.ActivityTypeDescription"/>
|
||||
<button type="button" class="action-tile"
|
||||
@onclick="@(() => OnClickItem(item.ActivityTypeDescription))">
|
||||
<span class="action-tile__label">@item.ActivityTypeDescription</span>
|
||||
</button>
|
||||
}
|
||||
</MudList>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="spinner-container" style="height: unset !important;">
|
||||
<MudIcon Size="Size.Large" Color="Color.Error" Icon="@Icons.Material.Rounded.Close"/>
|
||||
<MudText>Nessuna descrizione consigliata</MudText>
|
||||
<div class="empty-state">
|
||||
<i class="empty-state__icon ri-chat-off-line" aria-hidden="true"></i>
|
||||
<p class="empty-state__title">Nessuna descrizione consigliata</p>
|
||||
<p class="empty-state__text">Per questo motivo non ci sono note ricorrenti: scrivila a mano.</p>
|
||||
</div>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@Cancel">Annulla</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -22,12 +22,15 @@ public class SendEmailDto
|
||||
[JsonPropertyName("html")]
|
||||
public bool IsHtml { get; set; }
|
||||
|
||||
[JsonPropertyName("attachments")]
|
||||
// Gli allegati viaggiano come parti multipart "allegati", non dentro il JSON della richiesta.
|
||||
[JsonIgnore]
|
||||
public List<AttachmentsDto>? Attachments { get; set; }
|
||||
|
||||
public class AttachmentsDto
|
||||
{
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
public byte[] FileContent { get; set; } = [];
|
||||
|
||||
// Il file resta su disco e viene letto in streaming durante l'upload.
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace SteUp.Shared.Core.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Contenuto HTTP letto dal file durante l'invio, senza caricarlo in RAM. Il file viene riaperto
|
||||
/// a ogni serializzazione, così regge anche un eventuale nuovo tentativo del client
|
||||
/// (es. dopo il refresh del token).
|
||||
/// </summary>
|
||||
public sealed class FileHttpContent : HttpContent
|
||||
{
|
||||
private const int BufferSize = 81920;
|
||||
|
||||
private readonly string _path;
|
||||
|
||||
public FileHttpContent(string path, string mediaType)
|
||||
{
|
||||
_path = path;
|
||||
Headers.ContentType = new MediaTypeHeaderValue(mediaType);
|
||||
}
|
||||
|
||||
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) =>
|
||||
SerializeToStreamAsync(stream, context, CancellationToken.None);
|
||||
|
||||
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var file = new FileStream(_path, FileMode.Open, FileAccess.Read, FileShare.Read,
|
||||
BufferSize, useAsync: true);
|
||||
await file.CopyToAsync(stream, BufferSize, cancellationToken);
|
||||
}
|
||||
|
||||
protected override bool TryComputeLength(out long length)
|
||||
{
|
||||
length = new FileInfo(_path).Length;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -26,5 +26,6 @@ public interface IFileManager
|
||||
Task<(string originalUrl, string thumbUrl)> SaveAndCreateThumbAsync(byte[] bytes, string fileName,
|
||||
CancellationToken ct = default);
|
||||
|
||||
List<SendEmailDto.AttachmentsDto> GetFileForExport();
|
||||
Task<long> GetLogExportImagesSizeAsync(CancellationToken ct = default);
|
||||
Task<ILogExport> PrepareLogExportAsync(bool includeImages, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using SteUp.Shared.Core.Dto;
|
||||
|
||||
namespace SteUp.Shared.Core.Interface.System;
|
||||
|
||||
/// <summary>
|
||||
/// Invio dei log diviso in parti: ogni parte diventa un'email separata, sotto i limiti di upload
|
||||
/// dei servizi. Gli zip di una parte vengono creati solo quando la si chiede e cancellati alla
|
||||
/// parte successiva; il dispose elimina tutto quello che resta su disco.
|
||||
/// </summary>
|
||||
public interface ILogExport : IAsyncDisposable
|
||||
{
|
||||
int PartCount { get; }
|
||||
|
||||
Task<List<SendEmailDto.AttachmentsDto>> BuildPartAsync(int index, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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,9 +1,9 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using IntegryApiClient.Core.Domain.RestClient.Contacts;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SteUp.Shared.Core.Dto;
|
||||
using SteUp.Shared.Core.Helpers;
|
||||
using SteUp.Shared.Core.Interface.IntegryApi;
|
||||
|
||||
namespace SteUp.Shared.Core.Services;
|
||||
@@ -37,21 +37,18 @@ public class IntegryApiService(
|
||||
|
||||
public async Task SendEmail(SendEmailDto sendEmail)
|
||||
{
|
||||
var content = new MultipartFormDataContent();
|
||||
using var content = new MultipartFormDataContent();
|
||||
|
||||
try
|
||||
{
|
||||
if (sendEmail.Attachments != null)
|
||||
foreach (var a in sendEmail.Attachments ?? [])
|
||||
{
|
||||
foreach (var a in sendEmail.Attachments)
|
||||
{
|
||||
var fileContent = new ByteArrayContent(a.FileContent);
|
||||
fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse("multipart/form-data");
|
||||
content.Add(fileContent, "allegati", a.FileName);
|
||||
}
|
||||
var mediaType = a.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)
|
||||
? "application/zip"
|
||||
: "application/octet-stream";
|
||||
content.Add(new FileHttpContent(a.FilePath, mediaType), "allegati", a.FileName);
|
||||
}
|
||||
|
||||
sendEmail.Attachments = null;
|
||||
content.Add(
|
||||
new StringContent(
|
||||
JsonSerializer.Serialize(sendEmail),
|
||||
@@ -69,9 +66,5 @@ public class IntegryApiService(
|
||||
logger.LogError(e, e.Message);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
content.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/*!
|
||||
* Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/)
|
||||
* Copyright 2019-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE)
|
||||
*/@font-face{font-display:block;font-family:bootstrap-icons;src:url("fonts/bootstrap-icons.woff2?dd67030699838ea613ee6dbda90effa6") format("woff2"),url("fonts/bootstrap-icons.woff?dd67030699838ea613ee6dbda90effa6") format("woff")}.bi::before,[class*=" bi-"]::before,[class^=bi-]::before{display:inline-block;font-family:bootstrap-icons!important;font-style:normal;font-weight:400!important;font-variant:normal;text-transform:none;line-height:1;vertical-align:-.125em;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.bi-123::before{content:"\f67f"}.bi-alarm-fill::before{content:"\f101"}.bi-alarm::before{content:"\f102"}.bi-align-bottom::before{content:"\f103"}.bi-align-center::before{content:"\f104"}.bi-align-end::before{content:"\f105"}.bi-align-middle::before{content:"\f106"}.bi-align-start::before{content:"\f107"}.bi-align-top::before{content:"\f108"}.bi-alt::before{content:"\f109"}.bi-app-indicator::before{content:"\f10a"}.bi-app::before{content:"\f10b"}.bi-archive-fill::before{content:"\f10c"}.bi-archive::before{content:"\f10d"}.bi-arrow-90deg-down::before{content:"\f10e"}.bi-arrow-90deg-left::before{content:"\f10f"}.bi-arrow-90deg-right::before{content:"\f110"}.bi-arrow-90deg-up::before{content:"\f111"}.bi-arrow-bar-down::before{content:"\f112"}.bi-arrow-bar-left::before{content:"\f113"}.bi-arrow-bar-right::before{content:"\f114"}.bi-arrow-bar-up::before{content:"\f115"}.bi-arrow-clockwise::before{content:"\f116"}.bi-arrow-counterclockwise::before{content:"\f117"}.bi-arrow-down-circle-fill::before{content:"\f118"}.bi-arrow-down-circle::before{content:"\f119"}.bi-arrow-down-left-circle-fill::before{content:"\f11a"}.bi-arrow-down-left-circle::before{content:"\f11b"}.bi-arrow-down-left-square-fill::before{content:"\f11c"}.bi-arrow-down-left-square::before{content:"\f11d"}.bi-arrow-down-left::before{content:"\f11e"}.bi-arrow-down-right-circle-fill::before{content:"\f11f"}.bi-arrow-down-right-circle::before{content:"\f120"}.bi-arrow-down-right-square-fill::before{content:"\f121"}.bi-arrow-down-right-square::before{content:"\f122"}.bi-arrow-down-right::before{content:"\f123"}.bi-arrow-down-short::before{content:"\f124"}.bi-arrow-down-square-fill::before{content:"\f125"}.bi-arrow-down-square::before{content:"\f126"}.bi-arrow-down-up::before{content:"\f127"}.bi-arrow-down::before{content:"\f128"}.bi-arrow-left-circle-fill::before{content:"\f129"}.bi-arrow-left-circle::before{content:"\f12a"}.bi-arrow-left-right::before{content:"\f12b"}.bi-arrow-left-short::before{content:"\f12c"}.bi-arrow-left-square-fill::before{content:"\f12d"}.bi-arrow-left-square::before{content:"\f12e"}.bi-arrow-left::before{content:"\f12f"}.bi-arrow-repeat::before{content:"\f130"}.bi-arrow-return-left::before{content:"\f131"}.bi-arrow-return-right::before{content:"\f132"}.bi-arrow-right-circle-fill::before{content:"\f133"}.bi-arrow-right-circle::before{content:"\f134"}.bi-arrow-right-short::before{content:"\f135"}.bi-arrow-right-square-fill::before{content:"\f136"}.bi-arrow-right-square::before{content:"\f137"}.bi-arrow-right::before{content:"\f138"}.bi-arrow-up-circle-fill::before{content:"\f139"}.bi-arrow-up-circle::before{content:"\f13a"}.bi-arrow-up-left-circle-fill::before{content:"\f13b"}.bi-arrow-up-left-circle::before{content:"\f13c"}.bi-arrow-up-left-square-fill::before{content:"\f13d"}.bi-arrow-up-left-square::before{content:"\f13e"}.bi-arrow-up-left::before{content:"\f13f"}.bi-arrow-up-right-circle-fill::before{content:"\f140"}.bi-arrow-up-right-circle::before{content:"\f141"}.bi-arrow-up-right-square-fill::before{content:"\f142"}.bi-arrow-up-right-square::before{content:"\f143"}.bi-arrow-up-right::before{content:"\f144"}.bi-arrow-up-short::before{content:"\f145"}.bi-arrow-up-square-fill::before{content:"\f146"}.bi-arrow-up-square::before{content:"\f147"}.bi-arrow-up::before{content:"\f148"}.bi-arrows-angle-contract::before{content:"\f149"}.bi-arrows-angle-expand::before{content:"\f14a"}.bi-arrows-collapse::before{content:"\f14b"}.bi-arrows-expand::before{content:"\f14c"}.bi-arrows-fullscreen::before{content:"\f14d"}.bi-arrows-move::before{content:"\f14e"}.bi-aspect-ratio-fill::before{content:"\f14f"}.bi-aspect-ratio::before{content:"\f150"}.bi-asterisk::before{content:"\f151"}.bi-at::before{content:"\f152"}.bi-award-fill::before{content:"\f153"}.bi-award::before{content:"\f154"}.bi-back::before{content:"\f155"}.bi-backspace-fill::before{content:"\f156"}.bi-backspace-reverse-fill::before{content:"\f157"}.bi-backspace-reverse::before{content:"\f158"}.bi-backspace::before{content:"\f159"}.bi-badge-3d-fill::before{content:"\f15a"}.bi-badge-3d::before{content:"\f15b"}.bi-badge-4k-fill::before{content:"\f15c"}.bi-badge-4k::before{content:"\f15d"}.bi-badge-8k-fill::before{content:"\f15e"}.bi-badge-8k::before{content:"\f15f"}.bi-badge-ad-fill::before{content:"\f160"}.bi-badge-ad::before{content:"\f161"}.bi-badge-ar-fill::before{content:"\f162"}.bi-badge-ar::before{content:"\f163"}.bi-badge-cc-fill::before{content:"\fLine truncated
|
||||
@@ -1,6 +0,0 @@
|
||||
@charset "UTF-8";/*!
|
||||
* Bootstrap v5.3.2 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2023 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
|
||||
/*# sourceMappingURL=bootstrap.min.css.map */
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"sources":["../../scss/mixins/_banner.scss","../../scss/_root.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","../../scss/mixins/_color-mode.scss","../../scss/_reboot.scss","../../scss/mixins/_border-radius.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/_containers.scss","../../scss/mixins/_container.scss","../../scss/mixins/_breakpoints.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/_tables.scss","../../scss/mixins/_table-variants.scss","../../scss/forms/_labels.scss","../../scss/forms/_form-text.scss","../../scss/forms/_form-control.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_gradients.scss","../../scss/forms/_form-select.scss","../../scss/forms/_form-check.scss","../../scss/forms/_form-range.scss","../../scss/forms/_floating-labels.scss","../../scss/forms/_input-group.scss","../../scss/mixins/_forms.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/_button-group.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_accordion.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/_alert.scss","../../scss/_progress.scss","../../scss/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/mixins/_backdrop.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/_offcanvas.scss","../../scss/_placeholders.scss","../../scss/helpers/_color-bg.scss","../../scss/helpers/_colored-links.scss","../../scss/helpers/_focus-ring.scss","../../scss/helpers/_icon-link.scss","../../scss/helpers/_ratio.scss","../../scss/helpers/_position.scss","../../scss/helpers/_stacks.scss","../../scss/helpers/_visually-hidden.scss","../../scss/mixins/_visually-hidden.scss","../../scss/helpers/_stretched-link.scss","../../scss/helpers/_text-truncation.scss","../../scss/mixins/_text-truncate.scss","../../scss/helpers/_vr.scss","../../scss/mixins/_utilities.scss","../../scss/utilities/_api.scss"],"names":[],"mappings":"iBACE;;;;ACDF,MCOA,sBDEI,UAAA,QAAA,YAAA,QAAA,YAAA,QAAA,UAAA,QAAA,SAAA,QAAA,YAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAAA,UAAA,QAAA,WAAA,KAAA,WAAA,KAAA,UAAA,QAAA,eAAA,QAIA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAAA,cAAA,QAIA,aAAA,QAAA,eAAA,QAAA,aAAA,QAAA,UAAA,QAAA,aAAA,QAAA,YAAA,QAAA,WAAA,QAAA,UAAA,QAIA,iBAAA,EAAA,CAAA,GAAA,CAAA,IAAA,mBAAA,GAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,EAAA,CAAA,GAAA,CAAA,GAAA,cAAA,EAAA,CAAA,GAAA,CAAA,IAAA,iBAAA,GAAA,CAAA,GAAA,CAAA,EAAA,gBAAA,GAAA,CAAA,EAAA,CAAA,GAAA,eAAA,GAAA,CAAA,GAAA,CAAA,IAAA,cAAA,EAAA,CAAA,EAAA,CAAA,GAIA,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAIA,uBAAA,QAAA,yBAAA,QAAA,uBAAA,QAAA,oBAAA,QAAA,uBAAA,QAAA,sBAAA,QAAA,qBAAA,QAAA,oBAAA,QAIA,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAGF,eAAA,GAAA,CAAA,GAAA,CAAA,IACA,eAAA,CAAA,CAAA,CAAA,CAAA,EAMA,qBAAA,SAAA,CAAA,aAAA,CAAA,UAAA,CAAA,MAAA,CAAA,gBAAA,CAAA,WAAA,CAAA,iBAAA,CAAA,KAAA,CAAA,UAAA,CAAA,mBAAA,CAAA,gBAAA,CAAA,iBAAA,CAAA,mBACA,oBAAA,cAAA,CAAA,KAAA,CAAA,MAAA,CAAA,QAAA,CAAA,iBAAA,CAAA,aAAA,CAAA,UACA,cAAA,2EAOA,sBAAA,0BE2OI,oBAAA,KFzOJ,sBAAA,IACA,sBAAA,IAKA,gBAAA,QACA,oBAAA,EAAA,CAAA,EAAA,CAAA,GACA,aAAA,KACA,iBAAA,GAAA,CAAA,GAAA,CAAA,IAEA,oBAAA,KACA,wBAAA,CAAA,CAAA,CAAA,CAAA,EAEA,qBAAA,uBACA,yBAAA,EAAA,CAAA,EAAA,CAAA,GACA,kBAAA,QACA,sBAAA,GAAA,CAAA,GAAA,CAAA,IAEA,oBAAA,sBACA,wBAAA,EAAA,CAAA,EAAA,CAAA,GACA,iBAAA,QACA,qBAAA,GAAA,CAAA,GAAA,CAAA,IAGA,mBAAA,QAEA,gBAAA,QACA,oBAAA,EAAA,CAAA,GAAA,CAAA,IACA,qBAAA,UAEA,sBAAA,QACA,0BAAA,EAAA,CAAA,EAAA,CAAA,IAMA,gBAAA,QACA,qBAAA,QACA,kBAAA,QAGA,kBAAA,IACA,kBAAA,MACA,kBAAA,QACA,8BAAA,qBAEA,mBAAA,SACA,sBAAA,QACA,sBAAA,OACA,sBAAA,KACA,uBAAA,KACA,uBAAA,4BACA,wBAAA,MAGA,gBAAA,EAAA,OAAA,KAAA,oBACA,mBAAA,EAAA,SAAA,QAAA,qBACA,mBAAA,EAAA,KAAA,KAAA,qBACA,sBAAA,MAAA,EAAA,IAAA,IAAA,qBAIA,sBAAA,QACA,wBAAA,KACA,sBAAA,yBAIA,sBAAA,QACA,6BAAA,QACA,wBAAA,QACA,+BAAA,QGhHE,qBHsHA,aAAA,KAGA,gBAAA,QACA,oBAAA,GAAA,CAAA,GAAA,CAAA,IACA,aAAA,QACA,iBAAA,EAAA,CAAA,EAAA,CAAA,GAEA,oBAAA,KACA,wBAAA,GAAA,CAAA,GAAA,CAAA,IAEA,qBAAA,0BACA,yBAAA,GAAA,CAAA,GAAA,CAAA,IACA,kBAAA,QACA,sBAAA,EAAA,CAAA,EAAA,CAAA,GAEA,oBAAA,yBACA,wBAAA,GAAA,CAAA,GAAA,CAAA,IACA,iBAAA,QACA,qBAAA,EAAA,CAAA,EAAA,CAAA,GAGE,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAIA,uBAAA,QAAA,yBAAA,QAAA,uBAAA,QAAA,oBAAA,QAAA,uBAAA,QAAA,sBAAA,QAAA,qBAAA,QAAA,oBAAA,QAIA,2BAAA,QAAA,6BAAA,QAAA,2BAAA,QAAA,wBAAA,QAAA,2BAAA,QAAA,0BAAA,QAAA,yBAAA,QAAA,wBAAA,QAGF,mBAAA,QAEA,gBAAA,QACA,sBAAA,QACA,oBAAA,GAAA,CAAA,GAAA,CAAA,IACA,0BAAA,GAAA,CAAA,GAAA,CAAA,IAEALine truncated
|
||||
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;
|
||||
}
|
||||
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user