Compare commits
27
Commits
3b04702665
...
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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.
|
||||
@@ -17,9 +17,7 @@ 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;
|
||||
|
||||
@@ -63,10 +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);
|
||||
|
||||
// 3) Img zip
|
||||
var attachedInfo = new DirectoryInfo(Path.Combine(FileSystem.AppDataDirectory, "attached"));
|
||||
if (!attachedInfo.Exists) return attachments;
|
||||
|
||||
var attachedFiles = attachedInfo
|
||||
// Residui di un invio interrotto (app chiusa a metà): si riparte da zero.
|
||||
if (Directory.Exists(workDir))
|
||||
Directory.Delete(workDir, true);
|
||||
Directory.CreateDirectory(workDir);
|
||||
|
||||
var logFiles = RetrieveLogFiles()
|
||||
.Select(f => new LogExport.ZipItem(f, f.Name))
|
||||
.ToList();
|
||||
|
||||
// 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>
|
||||
|
||||
@@ -9,17 +9,23 @@ 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.
|
||||
@@ -27,14 +33,93 @@ namespace SteUp.Maui
|
||||
ViewCompat.SetOnApplyWindowInsetsListener(content, new ImeInsetsListener());
|
||||
}
|
||||
|
||||
public override void OnBackPressed()
|
||||
/// <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()
|
||||
{
|
||||
// Se la UI Blazor ha un form aperto se ne occupa lei (propone il salvataggio
|
||||
// come bozza); altrimenti comportamento standard.
|
||||
var backButton = IPlatformApplication.Current?.Services.GetService<BackButtonService>();
|
||||
if (backButton?.TryHandleBackPressed() == true) return;
|
||||
if (Window is null) return;
|
||||
|
||||
base.OnBackPressed();
|
||||
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
|
||||
|
||||
@@ -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.1</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>7</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,53 +1,76 @@
|
||||
<!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>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
@@ -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();
|
||||
@@ -170,4 +144,4 @@
|
||||
_cts?.Dispose();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
/// Il comando vale quello che la schermata gli ha dato; se non gliel'ha
|
||||
/// dato nessuno, vale la cosa che l'app fa piu' spesso.
|
||||
private Task RunAction() => PageActions.HasActions ? PageActions.Invoke() : NewInspection();
|
||||
|
||||
private async Task NewInspection()
|
||||
{
|
||||
try
|
||||
if (!await SteupDataService.CanOpenNewInspection())
|
||||
{
|
||||
var ispezione = SteupDataService.InspectionPageState.Ispezione;
|
||||
await IspezioniService.DeleteIspezioneAsync(ispezione.CodMdep, ispezione.Data, ispezione.Rilevatore);
|
||||
|
||||
await Js.InvokeVoidAsync("goBack");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
await Dialog.ShowError(e.Message);
|
||||
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,8 +1,11 @@
|
||||
<div class="spinner-container @(FullScreen ? "" : "not-fullScreen")">
|
||||
<span class="loader"></span>
|
||||
@* Indicatore di attesa. A schermo pieno solo quando non c'e' ancora nulla da
|
||||
mostrare; dentro una sezione resta contenuto e non fa saltare il layout. *@
|
||||
|
||||
<div style="display:flex; align-items:center; justify-content:center; @(FullScreen ? "height:100dvh;" : "padding: var(--space-xl) 0;")">
|
||||
<span class="spinner @(FullScreen ? "spinner--lg" : "")" role="status" aria-label="Caricamento"></span>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public bool FullScreen { get; set; } = true;
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: calc(100vh - 10.1rem);
|
||||
align-items: center;
|
||||
color: var(--mud-palette-primary);
|
||||
}
|
||||
|
||||
.not-fullScreen {
|
||||
height: auto !important;
|
||||
padding: 2rem 0 !important;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 50px;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
border: 8px solid #0000;
|
||||
border-right-color: var(--mud-palette-primary);
|
||||
position: relative;
|
||||
animation: l24 1s infinite linear;
|
||||
}
|
||||
|
||||
.loader:before,
|
||||
.loader:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -8px;
|
||||
border-radius: 50%;
|
||||
border: inherit;
|
||||
animation: inherit;
|
||||
animation-duration: 2s;
|
||||
}
|
||||
|
||||
.loader:after {
|
||||
animation-duration: 4s;
|
||||
}
|
||||
|
||||
@keyframes l24 {
|
||||
100% {
|
||||
transform: rotate(1turn)
|
||||
}
|
||||
}
|
||||
@@ -2,117 +2,229 @@
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using SteUp.Shared.Components.Layout
|
||||
@using SteUp.Shared.Components.Layout.Overlay
|
||||
@using SteUp.Shared.Components.Layout.Sheet
|
||||
@using SteUp.Shared.Components.SingleElements
|
||||
@using SteUp.Shared.Components.SingleElements.Card
|
||||
@using SteUp.Shared.Components.SingleElements.MessageBox
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Entities
|
||||
@using SteUp.Shared.Core.Enum
|
||||
@using SteUp.Shared.Core.Helpers
|
||||
@using SteUp.Shared.Core.Interface.IntegryApi
|
||||
@using SteUp.Shared.Core.Interface.LocalDb
|
||||
@using SteUp.Shared.Core.Interface.System
|
||||
@using SteUp.Shared.Core.Messages.Ispezione
|
||||
@using SteUp.Shared.Core.Messages.Scheda
|
||||
@inject NewSchedaService NewScheda
|
||||
@inject CompleteInspectionService CompleteInspection
|
||||
@using SteUp.Shared.Core.Messages.System
|
||||
@inject IIspezioniService IspezioniService
|
||||
@inject IDialogService Dialog
|
||||
@inject IIntegrySteupService IntegrySteupService
|
||||
@inject IFileManager FileManager
|
||||
@inject ILogger<IspezionePage> Logger
|
||||
@inject PageActionsService PageActions
|
||||
@implements IDisposable
|
||||
|
||||
<HeaderLayout Title="Ispezione" BackTo="Indietro" Back="true"/>
|
||||
@* Le azioni non stanno nella testata: le apre il comando al centro della
|
||||
barra in basso, che finche' questa schermata e' a video significa "azioni
|
||||
di questa ispezione" (vedi PageActionsService). Su un'ispezione conclusa
|
||||
non si registra nulla e resta solo la consultazione. *@
|
||||
<HeaderLayout Title="@Titolo" Subtitle="@Sottotitolo" Back="true" Compact="true"/>
|
||||
|
||||
<div class="container content pb-safe-area ispezione">
|
||||
<InspectionCard Ispezione="SteupDataService.InspectionPageState.Ispezione"/>
|
||||
<ActionSheet @bind-Open="_actionsOpen" Title="Azioni ispezione" Subtitle="@Ispezione.CodMdep">
|
||||
<div class="stack stack-xs">
|
||||
<button type="button" class="action-tile" disabled="@(!NetworkService.IsNetworkAvailable())"
|
||||
@onclick="NewScheda">
|
||||
<span class="action-tile__icon action-tile__icon--primary">
|
||||
<i class="ri-add-line" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="action-tile__label">Nuova scheda</span>
|
||||
</button>
|
||||
|
||||
@if (!SchedeGrouped.IsNullOrEmpty())
|
||||
{
|
||||
<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();
|
||||
var daCompletare = group.Value.CountBozze();
|
||||
}
|
||||
<div class="header-scheda-group">
|
||||
<div class="title">
|
||||
<MudText Typo="Typo.h6"><b>@group.Key.Descrizione</b></MudText>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">
|
||||
@($"{group.Value.Count} sched{(group.Value.Count == 1 ? "a" : "e")}")
|
||||
</MudChip>
|
||||
@if (daInviare > 0)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
|
||||
Color="Color.Primary" Icon="@Icons.Material.Rounded.PhonelinkOff">
|
||||
@($"{daInviare} da inviare")
|
||||
</MudChip>
|
||||
}
|
||||
@if (daCompletare > 0)
|
||||
{
|
||||
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled"
|
||||
Color="Color.Warning" Icon="@Icons.Material.Rounded.EditNote">
|
||||
@($"{daCompletare} da completare")
|
||||
</MudChip>
|
||||
}
|
||||
</div>
|
||||
@if (SteupDataService.InspectionPageState.Ispezione.Stato != StatusEnum.Completata)
|
||||
{
|
||||
<div class="action-scheda-group">
|
||||
<MudFab StartIcon="@Icons.Material.Rounded.Add"
|
||||
Color="Color.Default" Size="Size.Medium"
|
||||
Disabled="!NetworkService.IsNetworkAvailable()"
|
||||
aria-label="@($"Nuova scheda su {group.Key.Descrizione}")"
|
||||
OnClick="@(() => CreateNewScheda(group.Key))"/>
|
||||
var reparto = group.Key;
|
||||
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">
|
||||
@if (!IsCompleted)
|
||||
{
|
||||
@* Da qui la scheda nasce gia' con il suo reparto: e'
|
||||
l'unica differenza con il "+" della barra. *@
|
||||
<button type="button" class="reparto__add"
|
||||
disabled="@(!NetworkService.IsNetworkAvailable())"
|
||||
aria-label="@($"Nuova scheda nel reparto {reparto.Descrizione}")"
|
||||
title="@($"Nuova scheda in {reparto.Descrizione}")"
|
||||
@onclick="@(() => CreateNewScheda(reparto))">
|
||||
<i class="ri-add-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@if (NetworkService.IsNetworkAvailable() && daInviare > 0)
|
||||
{
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Rounded.CloudUpload"
|
||||
aria-label="@($"Invia le schede del reparto {reparto.Descrizione}")"
|
||||
OnClick="@(() => ExportReparto(reparto))">
|
||||
Invia @daInviare
|
||||
</MudButton>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</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; } = [];
|
||||
@@ -124,18 +236,61 @@
|
||||
private string? _progressMessage;
|
||||
|
||||
private ConfirmMessageBox _exportRepartoBox = null!;
|
||||
private ConfirmMessageBox _completeBox = null!;
|
||||
private ConfirmMessageBox _deleteBox = null!;
|
||||
|
||||
private bool _actionsOpen;
|
||||
|
||||
private Ispezione Ispezione => SteupDataService.InspectionPageState.Ispezione;
|
||||
private bool IsCompleted => Ispezione.Stato == StatusEnum.Completata;
|
||||
|
||||
private int DaInviare => SchedeList.CountDaInviare();
|
||||
private int Bozze => SchedeList.CountBozze();
|
||||
private int Inviate => SchedeList.Count(s => s.GetSyncState() == SyncState.Inviato);
|
||||
|
||||
private int PercentualeInviate => SchedeList.Count == 0
|
||||
? 0
|
||||
: (int)Math.Round(Inviate * 100.0 / SchedeList.Count);
|
||||
|
||||
private string Progresso => (PercentualeInviate / 100.0).ToString("0.###",
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
private PuntoVenditaDto? PuntoVendita => SteupDataService.PuntiVenditaList
|
||||
.Find(x => x.CodMdep != null && x.CodMdep.EqualsIgnoreCase(Ispezione.CodMdep));
|
||||
|
||||
// Il codice del punto vendita sta davanti al nome, come su ogni documento
|
||||
// cartaceo dell'ispezione: e' quello che si confronta, il nome serve a
|
||||
// riconoscerlo.
|
||||
private string Titolo => PuntoVendita?.Descrizione is { Length: > 0 } descrizione
|
||||
? $"{Ispezione.CodMdep} - {descrizione}"
|
||||
: Ispezione.CodMdep;
|
||||
|
||||
private string? Sottotitolo => PuntoVendita?.Indirizzo;
|
||||
|
||||
private IDisposable? _actionsRegistration;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
NewScheda.OnNewScheda += LoadSchede;
|
||||
CompleteInspection.OnComplete += HandleCompleteInspection;
|
||||
|
||||
LoadSchede();
|
||||
|
||||
// Su un'ispezione conclusa non c'e' niente da fare: il comando della
|
||||
// barra si tiene il suo significato di default invece di aprire uno
|
||||
// sheet di azioni tutte spente.
|
||||
if (!IsCompleted)
|
||||
_actionsRegistration = PageActions.Register(() => InvokeAsync(OpenActions));
|
||||
}
|
||||
|
||||
private void OpenActions()
|
||||
{
|
||||
_actionsOpen = true;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
public void Dispose() => _actionsRegistration?.Dispose();
|
||||
|
||||
private void LoadSchede()
|
||||
{
|
||||
var ispezione = SteupDataService.InspectionPageState.Ispezione;
|
||||
var ispezione = Ispezione;
|
||||
|
||||
InvokeAsync(async () =>
|
||||
{
|
||||
@@ -148,6 +303,81 @@
|
||||
});
|
||||
}
|
||||
|
||||
private async Task NewScheda()
|
||||
{
|
||||
_actionsOpen = false;
|
||||
|
||||
var modal = await Dialog.OpenFormScheda(Ispezione.CodMdep, Ispezione.Data, true);
|
||||
if (modal is { Canceled: false }) LoadSchede();
|
||||
}
|
||||
|
||||
/// Stessa scheda nuova, ma con il reparto gia' scelto: partendo da un
|
||||
/// reparto non ha senso richiederlo.
|
||||
private async Task CreateNewScheda(JtbFasiDto reparto)
|
||||
{
|
||||
var modal = await Dialog.OpenFormScheda(
|
||||
Ispezione.CodMdep, Ispezione.Data, true, new Scheda { Reparto = reparto }
|
||||
);
|
||||
|
||||
if (modal is { Canceled: false }) LoadSchede();
|
||||
}
|
||||
|
||||
// I reparti richiusi stanno qui e non nell'entita': e' uno stato della
|
||||
// schermata, non del documento.
|
||||
private readonly HashSet<string> _collapsed = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private bool IsCollapsed(JtbFasiDto reparto) =>
|
||||
reparto.CodJfas is not null && _collapsed.Contains(reparto.CodJfas);
|
||||
|
||||
private void Toggle(JtbFasiDto reparto)
|
||||
{
|
||||
if (reparto.CodJfas is null) return;
|
||||
if (!_collapsed.Remove(reparto.CodJfas)) _collapsed.Add(reparto.CodJfas);
|
||||
}
|
||||
|
||||
private static string Conteggi(List<Scheda> schede)
|
||||
{
|
||||
var parti = new List<string> { schede.Count == 1 ? "1 scheda" : $"{schede.Count} schede" };
|
||||
|
||||
var daInviare = schede.CountDaInviare();
|
||||
if (daInviare > 0) parti.Add($"{daInviare} da inviare");
|
||||
|
||||
var bozze = schede.CountBozze();
|
||||
if (bozze > 0) parti.Add($"{bozze} da completare");
|
||||
|
||||
return string.Join(" · ", parti);
|
||||
}
|
||||
|
||||
private async Task ConfirmCompleteInspection()
|
||||
{
|
||||
_actionsOpen = false;
|
||||
|
||||
var confirmed = await _completeBox.ShowAsync();
|
||||
if (confirmed is true) HandleCompleteInspection();
|
||||
}
|
||||
|
||||
private async Task ConfirmDeleteInspection()
|
||||
{
|
||||
_actionsOpen = false;
|
||||
|
||||
// Prima non c'era conferma: un tocco cancellava l'ispezione e tutte le
|
||||
// sue schede senza appello.
|
||||
var confirmed = await _deleteBox.ShowAsync();
|
||||
if (confirmed is not true) return;
|
||||
|
||||
try
|
||||
{
|
||||
var ispezione = Ispezione;
|
||||
await IspezioniService.DeleteIspezioneAsync(ispezione.CodMdep, ispezione.Data, ispezione.Rilevatore);
|
||||
|
||||
NavigationManager.NavigateTo("/ispezioni");
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
OnError(e, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async void HandleCompleteInspection()
|
||||
{
|
||||
// Guardia anti doppio-invio: il completamento è irreversibile.
|
||||
@@ -162,7 +392,7 @@
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
var ispezione = SteupDataService.InspectionPageState.Ispezione;
|
||||
var ispezione = Ispezione;
|
||||
|
||||
// Le bozze non sono inviabili: l'ispezione non si chiude finché ci sono.
|
||||
var bozze = SchedeList.CountBozze();
|
||||
@@ -277,7 +507,7 @@
|
||||
StatusEnum.Completata
|
||||
);
|
||||
|
||||
SteupDataService.InspectionPageState.Ispezione.Stato = StatusEnum.Completata;
|
||||
ispezione.Stato = StatusEnum.Completata;
|
||||
|
||||
// Riscontro al momento più importante: la spunta di successo prima di chiudere l'overlay.
|
||||
await InvokeAsync(() =>
|
||||
@@ -339,18 +569,6 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task CreateNewScheda(JtbFasiDto jtbFasi)
|
||||
{
|
||||
var modal = await Dialog.OpenFormScheda(
|
||||
SteupDataService.InspectionPageState.Ispezione.CodMdep,
|
||||
SteupDataService.InspectionPageState.Ispezione.Data,
|
||||
true,
|
||||
new Scheda { Reparto = jtbFasi }
|
||||
);
|
||||
|
||||
if (modal is { Canceled: false }) LoadSchede();
|
||||
}
|
||||
|
||||
private async Task ExportReparto(JtbFasiDto jtbFasi)
|
||||
{
|
||||
// Guardia anti doppio-tap: un secondo tocco durante l'invio non ri-esporta il batch.
|
||||
@@ -365,14 +583,14 @@
|
||||
|
||||
try
|
||||
{
|
||||
var ispezione = SteupDataService.InspectionPageState.Ispezione;
|
||||
var ispezione = Ispezione;
|
||||
|
||||
// Le bozze restano sul dispositivo: si esporta solo ciò che è completo.
|
||||
var daEsportare = SchedeGrouped[jtbFasi].FindAll(x => !x.Bozza);
|
||||
|
||||
if (daEsportare.Count == 0)
|
||||
{
|
||||
Snackbar.Add("Nessuna scheda completa da esportare in questo reparto", Severity.Info);
|
||||
Snackbar.Add("Nessuna scheda completa da inviare in questo reparto", Severity.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -398,11 +616,11 @@
|
||||
|
||||
if (apiResponse == null)
|
||||
{
|
||||
Snackbar.Add("Esportazione non riuscita. Le schede restano sul dispositivo, riprova.", Severity.Error);
|
||||
Snackbar.Add("Invio non riuscito. Le schede restano sul dispositivo, riprova.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione;
|
||||
ispezione.ActivityId = apiResponse.ActivityIdIspezione;
|
||||
|
||||
await IspezioniService.UpdateActivityIdIspezioneAsync(ispezione.CodMdep, ispezione.Data,
|
||||
UserSession.User.Username, apiResponse.ActivityIdIspezione
|
||||
@@ -423,7 +641,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
Snackbar.Add("Reparto esportato", Severity.Success);
|
||||
Snackbar.Add(daEsportare.Count == 1 ? "Scheda inviata" : $"{daEsportare.Count} schede inviate",
|
||||
Severity.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
@@ -447,10 +666,4 @@
|
||||
);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
NewScheda.OnNewScheda -= LoadSchede;
|
||||
CompleteInspection.OnComplete -= HandleCompleteInspection;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/* Tinta di fondo tenue: le card e i pannelli bianchi (Carta) risaltano sul Grigio Campo */
|
||||
.ispezione {
|
||||
background-color: var(--light-card-background);
|
||||
}
|
||||
|
||||
.ispezione ::deep .expansion-panel .mud-expand-panel-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.action-scheda-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: .75rem;
|
||||
margin-top: 3rem;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.empty-state .empty-state-icon {
|
||||
color: var(--mud-palette-primary);
|
||||
opacity: .85;
|
||||
}
|
||||
|
||||
.empty-state .empty-state-title {
|
||||
margin: 0;
|
||||
font-weight: 800;
|
||||
font-size: 1.25rem;
|
||||
color: var(--mud-palette-text-primary);
|
||||
}
|
||||
|
||||
.empty-state .empty-state-text {
|
||||
margin: 0;
|
||||
max-width: 34ch;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: var(--mud-palette-gray-darker);
|
||||
}
|
||||
@@ -1,47 +1,95 @@
|
||||
@page "/ispezioni"
|
||||
@attribute [Authorize]
|
||||
@using SteUp.Shared.Components.Layout
|
||||
@using SteUp.Shared.Components.Layout.Overlay
|
||||
@using SteUp.Shared.Components.SingleElements
|
||||
@using SteUp.Shared.Components.SingleElements.Card
|
||||
@using SteUp.Shared.Core.Entities
|
||||
@using SteUp.Shared.Core.Interface.LocalDb
|
||||
@inject IIspezioniService IspezioniService
|
||||
@inject IDialogService Dialog
|
||||
|
||||
<HeaderLayout Title="Ispezioni"/>
|
||||
@* L'azione "nuova ispezione" non sta qui: vive nella barra in basso, dove
|
||||
il pollice arriva senza attraversare lo schermo. *@
|
||||
<HeaderLayout Title="Ispezioni" Subtitle="@Subtitle"/>
|
||||
|
||||
<div class="container ispezioni pb-safe-area">
|
||||
@if (Ispezioni.IsNullOrEmpty())
|
||||
{
|
||||
<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,4 +142,4 @@ else
|
||||
public string? CodHash { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
.login-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--mud-palette-surface);
|
||||
}
|
||||
|
||||
.container-top-logo > .logo {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.container-login > span {
|
||||
font-size: large;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container-top-logo {
|
||||
height: 35vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.login-form-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.container-login {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 16px 16px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-footer span {
|
||||
font-size: 9px;
|
||||
color: var(--mud-palette-gray-darker);
|
||||
}
|
||||
|
||||
.login-footer img {
|
||||
height: 15px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<div class="container-button ripple-container mud-elevation-1">
|
||||
<MudButton Class="button-settings red-icon"
|
||||
FullWidth="true"
|
||||
StartIcon="@Icons.Material.Rounded.UploadFile"
|
||||
Size="Size.Medium"
|
||||
OnClick="@ExportLog"
|
||||
Variant="Variant.Outlined">
|
||||
Esporta log
|
||||
</MudButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container-button ripple-container mud-elevation-1">
|
||||
<MudButton Class="button-settings green-icon"
|
||||
FullWidth="true"
|
||||
StartIcon="@Icons.Material.Outlined.Sync"
|
||||
Size="Size.Medium"
|
||||
OnClick="@UpdateDb"
|
||||
Variant="Variant.Outlined">
|
||||
Sincronizza ispezioni esportate
|
||||
</MudButton>
|
||||
</div>
|
||||
<section class="panel">
|
||||
<div class="nav-list__item" style="pointer-events: none;">
|
||||
<i class="nav-list__icon ri-building-line" aria-hidden="true"></i>
|
||||
<span class="nav-list__label">Profilo azienda</span>
|
||||
<span class="t-meta">@CodHash</span>
|
||||
</div>
|
||||
|
||||
<div class="container-button ripple-container mud-elevation-1">
|
||||
<MudButton Class="button-settings exit"
|
||||
FullWidth="true"
|
||||
Color="Color.Error"
|
||||
Size="Size.Medium"
|
||||
OnClick="@Logout"
|
||||
Variant="Variant.Outlined">
|
||||
Esci
|
||||
</MudButton>
|
||||
</div>
|
||||
|
||||
<AppVersion/>
|
||||
<div class="nav-list__item" style="pointer-events: none;">
|
||||
<i class="nav-list__icon ri-cloud-line" aria-hidden="true"></i>
|
||||
<span class="nav-list__label">Connessione</span>
|
||||
<Badge Spec="@ConnessioneBadge"/>
|
||||
</div>
|
||||
|
||||
<div class="nav-list__item" style="pointer-events: none;">
|
||||
<i class="nav-list__icon ri-information-line" aria-hidden="true"></i>
|
||||
<span class="nav-list__label">Versione</span>
|
||||
<span class="t-meta">@GenericSystemService.GetCurrentAppVersion()</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<button type="button" class="nav-list__item" @onclick="UpdateDb">
|
||||
<i class="nav-list__icon ri-refresh-line" aria-hidden="true"></i>
|
||||
<span class="nav-list__label">Sincronizza ispezioni esportate</span>
|
||||
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<button type="button" class="nav-list__item" @onclick="ExportLog">
|
||||
<i class="nav-list__icon ri-file-upload-line" aria-hidden="true"></i>
|
||||
<span class="nav-list__label">Invia i log allo sviluppo</span>
|
||||
<i class="nav-list__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@* Le voci distruttive portano l'icona nel tono danger e non hanno chevron. *@
|
||||
<button type="button" class="nav-list__item nav-list__item--danger" @onclick="ConfirmLogout">
|
||||
<i class="nav-list__icon ri-logout-box-r-line" aria-hidden="true"></i>
|
||||
<span class="nav-list__label">Esci</span>
|
||||
</button>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
|
||||
<SpinnerOverlay VisibleOverlay="VisibleOverlay" Message="@_progressMessage"/>
|
||||
<ConfirmMessageBox @ref="_logoutBox" 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 email = new SendEmailDto
|
||||
|
||||
var partCount = 0;
|
||||
var sentParts = 0;
|
||||
|
||||
try
|
||||
{
|
||||
FromName = "Integry Log",
|
||||
To = "developer@integry.it",
|
||||
Subject = $"SteUP - Log del {DateTime.Today:d} di {UserSession.User.Username}",
|
||||
IsHtml = true,
|
||||
MsgText = $"Username: <b>{UserSession.User.Username}</b><br/>" +
|
||||
$"Profilo azienda: <b>{profiloAzienda}</b><br/>" +
|
||||
$"ProfileDb: <b>{UserSession.ProfileDb}</b><br/>" +
|
||||
$"Versione app: <b>{GenericSystemService.GetCurrentAppVersion()}</b>",
|
||||
Attachments = FileManager.GetFileForExport()
|
||||
};
|
||||
|
||||
await IntegryApiService.SendEmail(email);
|
||||
|
||||
VisibleOverlay = false;
|
||||
// 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;
|
||||
|
||||
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>";
|
||||
|
||||
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</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 Ispezione Ispezione { get; set; } = new();
|
||||
[Parameter] public required Scheda Scheda { get; set; }
|
||||
[Parameter] public EventCallback<Scheda> OnSchedaModified { get; set; }
|
||||
[Parameter] public EventCallback<Scheda> OnSchedaDeleted { get; set; }
|
||||
|
||||
private bool VisibleOverlay { get; set; }
|
||||
[Parameter] public EventCallback<bool> OnBusyChanged { get; set; }
|
||||
|
||||
private ConfirmMessageBox _messageBox = null!;
|
||||
private bool _busy;
|
||||
|
||||
private bool ReadOnly => Ispezione.Stato == StatusEnum.Completata;
|
||||
private SyncState SyncState => Scheda.GetSyncState();
|
||||
private bool IsBozza => SyncState == Core.Helpers.SyncState.Bozza;
|
||||
|
||||
// Una bozza puo' non avere ancora il motivo: meglio dirlo che lasciare un vuoto.
|
||||
private string Titolo => Scheda.ActivityTypeId.IsNullOrEmpty()
|
||||
? "Motivo da indicare"
|
||||
: Scheda.ActivityTypeId!;
|
||||
|
||||
private StatusBadgeHelper.BadgeSpec ScadenzaBadge => (ScadenzaEnum)Scheda.Scadenza switch
|
||||
{
|
||||
ScadenzaEnum.Altissima24Ore => new StatusBadgeHelper.BadgeSpec("danger", "ri-alarm-warning-line", "Entro 24 ore"),
|
||||
ScadenzaEnum.Alta1Settimana => new StatusBadgeHelper.BadgeSpec("warning", "ri-time-line", "Entro 1 settimana"),
|
||||
ScadenzaEnum.Media1Mese => new StatusBadgeHelper.BadgeSpec("info", "ri-calendar-line", "Entro 1 mese"),
|
||||
_ => new StatusBadgeHelper.BadgeSpec("neutral", "ri-calendar-line", "Entro 2 mesi")
|
||||
};
|
||||
|
||||
private string ArticoliLabel => Scheda.Articoli.Count == 1
|
||||
? "1 articolo"
|
||||
: $"{Scheda.Articoli.Count} articoli";
|
||||
|
||||
private string FotoLabel => Scheda.ImageNames?.Count switch
|
||||
{
|
||||
null or 0 => "nessuna foto",
|
||||
1 => "1 foto",
|
||||
var n => $"{n} foto"
|
||||
};
|
||||
|
||||
private string SemanticLabel =>
|
||||
$"Scheda {Titolo}, reparto {Scheda.DescrizioneReparto}, {SyncState.ToBadge().Label}, {ArticoliLabel}, {FotoLabel}";
|
||||
|
||||
private async Task SetBusy(bool value)
|
||||
{
|
||||
_busy = value;
|
||||
await OnBusyChanged.InvokeAsync(value);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task UpdateScheda()
|
||||
{
|
||||
@@ -88,15 +140,18 @@
|
||||
if (modal is { Canceled: false, Data: Scheda scheda }) await OnSchedaModified.InvokeAsync(scheda);
|
||||
}
|
||||
|
||||
private void ViewScheda() =>
|
||||
_ = Dialog.OpenFormScheda(Ispezione.CodMdep, Ispezione.Data, false, Scheda, true);
|
||||
|
||||
private async Task DeleteScheda()
|
||||
{
|
||||
var result = await _messageBox.ShowAsync();
|
||||
if (result is not true) return;
|
||||
|
||||
if (result is true)
|
||||
await SetBusy(true);
|
||||
|
||||
try
|
||||
{
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
|
||||
if (Scheda.ActivityId != null)
|
||||
await IntegrySteupService.DeleteScheda(Scheda.ActivityId);
|
||||
|
||||
@@ -110,22 +165,18 @@
|
||||
|
||||
var deleteScheda = await IspezioniService.DeleteSchedaAsync(Scheda.Id);
|
||||
if (deleteScheda) await OnSchedaDeleted.InvokeAsync(Scheda);
|
||||
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void ViewScheda() =>
|
||||
_ = Dialog.OpenFormScheda(Ispezione.CodMdep, Ispezione.Data, false, Scheda, true);
|
||||
|
||||
private async Task ExportScheda()
|
||||
{
|
||||
// Guardia anti doppio-tap: invio irreversibile al server.
|
||||
if (VisibleOverlay) return;
|
||||
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
if (_busy) return;
|
||||
await SetBusy(true);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -172,17 +223,14 @@
|
||||
}
|
||||
finally
|
||||
{
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
await SetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportImg()
|
||||
{
|
||||
if (VisibleOverlay || Scheda.ImageNames == null) return;
|
||||
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
if (_busy || Scheda.ImageNames == null) return;
|
||||
await SetBusy(true);
|
||||
|
||||
try
|
||||
{
|
||||
@@ -209,8 +257,7 @@
|
||||
}
|
||||
finally
|
||||
{
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
await SetBusy(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
.scheda-card{
|
||||
padding: .55rem .85rem;
|
||||
background-color: var(--mud-palette-surface);
|
||||
border: 1px solid var(--card-border-color);
|
||||
border-radius: 1em;
|
||||
box-shadow: var(--card-box-shadow);
|
||||
}
|
||||
|
||||
.scheda-body-section{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.title-section{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.scheda-title-group{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: .15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sync-badge{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.scheda-card-action{
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Azioni secondarie (modifica/elimina) raggruppate e ancorate a sinistra;
|
||||
l'azione primaria "Invia" (corallo) resta separata e a portata di pollice a destra. */
|
||||
.action-secondary{
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,60 +1,74 @@
|
||||
@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!,
|
||||
DateTime.Now,
|
||||
UserSession.User.Username
|
||||
);
|
||||
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
|
||||
NavigationManager.NavigateTo("/ispezione");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.shop-card{
|
||||
padding: .5rem 1rem;
|
||||
background-color: var(--mud-palette-background-gray);
|
||||
border-radius: 1em;
|
||||
}
|
||||
@@ -1,26 +1,35 @@
|
||||
<div class="Connection @(ShowWarning ? "Show" : "Hide") @(IsNetworkAvailable? ServicesIsDown ? "ServicesIsDown" : "SystemOk" : "NetworkKo")">
|
||||
@if (IsNetworkAvailable)
|
||||
{
|
||||
if(ServicesIsDown)
|
||||
{
|
||||
<i class="ri-cloud-off-fill"></i>
|
||||
<span>Servizi offline</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="ri-cloud-fill"></i>
|
||||
<span>Online</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="ri-wifi-off-line"></i>
|
||||
<span>Nessuna connessione</span>
|
||||
}
|
||||
@*
|
||||
La firma visiva di "Offline e' la verita'": una striscia sottile ancorata in
|
||||
cima che scende quando lo stato di rete cambia. Mai il solo colore: sempre
|
||||
icona piu' etichetta, leggibile anche in pieno sole.
|
||||
*@
|
||||
|
||||
<div class="connection @(ShowWarning ? "connection--show" : "") @ToneClass"
|
||||
role="status" aria-live="polite">
|
||||
<i class="@IconClass" aria-hidden="true"></i>
|
||||
<span>@Label</span>
|
||||
</div>
|
||||
|
||||
@code{
|
||||
[Parameter] public bool IsNetworkAvailable { get; set; }
|
||||
[Parameter] public bool ServicesIsDown { get; set; }
|
||||
[Parameter] public bool ShowWarning { get; set; }
|
||||
}
|
||||
|
||||
private string ToneClass => !IsNetworkAvailable
|
||||
? "connection--offline"
|
||||
: ServicesIsDown
|
||||
? "connection--down"
|
||||
: "connection--ok";
|
||||
|
||||
private string IconClass => !IsNetworkAvailable
|
||||
? "ri-wifi-off-line"
|
||||
: ServicesIsDown
|
||||
? "ri-cloud-off-line"
|
||||
: "ri-cloud-line";
|
||||
|
||||
private string Label => !IsNetworkAvailable
|
||||
? "Nessuna connessione · il lavoro resta sul dispositivo"
|
||||
: ServicesIsDown
|
||||
? "Servizi non raggiungibili"
|
||||
: "Di nuovo online";
|
||||
}
|
||||
@@ -1,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;
|
||||
@@ -23,4 +31,4 @@
|
||||
if (_confirmDelete == null) return null;
|
||||
return await _confirmDelete.ShowAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,33 @@
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject ILogger<ExceptionModal> Logger
|
||||
|
||||
<div class="container container-modal">
|
||||
<div class="c-modal">
|
||||
<div class="exception-header mb-2">
|
||||
<i class="ri-emotion-unhappy-line"></i>
|
||||
<span>Ops</span>
|
||||
</div>
|
||||
<div class="text">
|
||||
@if (Exception != null)
|
||||
{
|
||||
<code>@Message</code>
|
||||
}
|
||||
</div>
|
||||
<div class="button-container">
|
||||
<div @onclick="OnRetryClick" class="card-button">
|
||||
<span>Riprova</span>
|
||||
@*
|
||||
Schermata di ripiego quando un componente va in errore. Non lascia il
|
||||
rilevatore davanti a uno stack trace: dice cosa è successo in una frase e
|
||||
offre due vie d'uscita. Il dettaglio tecnico resta sotto, per l'assistenza.
|
||||
*@
|
||||
|
||||
<div class="onboarding">
|
||||
<div class="onboarding__panel">
|
||||
<div class="panel panel--lg">
|
||||
<div class="dialog-icon dialog-icon--danger" aria-hidden="true">
|
||||
<i class="ri-error-warning-line"></i>
|
||||
</div>
|
||||
|
||||
<div @onclick="OnContinueClick" class="card-button">
|
||||
<span>Continua</span>
|
||||
|
||||
<h1 class="dialog-title">Qualcosa si è inceppato</h1>
|
||||
<p class="dialog-message">
|
||||
Il lavoro salvato sul dispositivo non è andato perso. Riprova, e se succede di nuovo
|
||||
invia i log dalla schermata Profilo.
|
||||
</p>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Message))
|
||||
{
|
||||
<p class="dialog-detail selectable">@Message</p>
|
||||
}
|
||||
|
||||
<div class="action-bar" style="border: none; background: none; padding: var(--space-lg) 0 0;">
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@OnContinueClick">Torna all'elenco</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@OnRetryClick">Riprova</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -61,4 +69,4 @@
|
||||
NavigationManager.NavigateTo("/");
|
||||
await OnRetry.InvokeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
.container-modal {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.c-modal {
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--exception-box-shadow);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
margin: 1.5rem 0 0 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: medium;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-button {
|
||||
text-align: center;
|
||||
background-color: transparent;
|
||||
padding: .3rem 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--bs-primary-text-emphasis);
|
||||
}
|
||||
|
||||
.exception-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.exception-header > i {
|
||||
font-size: 3rem;
|
||||
line-height: normal;
|
||||
color: var(--bs-danger);
|
||||
}
|
||||
|
||||
.exception-header > span {
|
||||
font-size: x-large;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
code {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: var(--bs-gray-dark);
|
||||
}
|
||||
@@ -1,27 +1,24 @@
|
||||
<MudDialog OnBackdropClick="Cancel">
|
||||
@*
|
||||
Dialog informativo (STILE_UI §6.10): cerchio-icona del tono, titolo,
|
||||
messaggio, un solo bottone a piena larghezza.
|
||||
*@
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@if (IsWarning)
|
||||
{
|
||||
<div class="exception-header mb-2">
|
||||
<i class="ri-error-warning-line warning-icon"></i>
|
||||
<span>Attenzione</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="exception-header mb-2">
|
||||
<i class="ri-emotion-unhappy-line error-icon"></i>
|
||||
<span>Ops</span>
|
||||
</div>
|
||||
}
|
||||
<div class="text">
|
||||
<code>@ErrorMessage</code>
|
||||
<div class="dialog-icon dialog-icon--@(IsWarning ? "warning" : "danger")" aria-hidden="true">
|
||||
<i class="@(IsWarning ? "ri-alert-line" : "ri-error-warning-line")"></i>
|
||||
</div>
|
||||
|
||||
<h2 class="dialog-title">@(IsWarning ? "Attenzione" : "Qualcosa non ha funzionato")</h2>
|
||||
<p class="dialog-message">@Message</p>
|
||||
|
||||
@if (ShowDetail)
|
||||
{
|
||||
<p class="dialog-detail">@ErrorMessage</p>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Text" OnClick="@Cancel" Size="Size.Small" Color="Color.Primary">
|
||||
Chiudi
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@Cancel">Ho capito</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@@ -30,5 +27,14 @@
|
||||
[Parameter] public string ErrorMessage { get; set; } = string.Empty;
|
||||
[Parameter] public bool IsWarning { get; set; }
|
||||
|
||||
// Gli avvisi sono gia' scritti per il rilevatore. Gli errori tecnici no:
|
||||
// si mostra una frase comprensibile e il dettaglio resta sotto, per chi
|
||||
// deve riferirlo all'assistenza.
|
||||
private bool ShowDetail => !IsWarning;
|
||||
|
||||
private string Message => IsWarning
|
||||
? ErrorMessage
|
||||
: "Il lavoro resta salvato sul dispositivo. Riprova.";
|
||||
|
||||
private void Cancel() => MudDialog.Cancel();
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
.exception-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.exception-header > i {
|
||||
font-size: 3rem;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
color: var(--bs-danger);
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
color: var(--bs-warning);
|
||||
}
|
||||
|
||||
.exception-header > span {
|
||||
font-size: x-large;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: medium;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
code {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: var(--bs-gray-dark);
|
||||
}
|
||||
@@ -1,27 +1,35 @@
|
||||
@using SteUp.Shared.Components.Layout
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Interface.System
|
||||
@inject IAttachedService AttachedService
|
||||
|
||||
<MudDialog Class="disable-safe-area">
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<HeaderLayout SmallHeader="true" Cancel="true" OnCancel="@(() => MudDialog.Cancel())" Title="Aggiungi allegati"/>
|
||||
<h2 class="dialog-title">Aggiungi una foto</h2>
|
||||
<p class="dialog-message">Scatta sul posto oppure scegli un'immagine già sul dispositivo.</p>
|
||||
|
||||
<div style="margin-bottom: 1rem;" class="content attached">
|
||||
<MudFab Size="Size.Small" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Rounded.CameraAlt"
|
||||
Label="Camera" OnClick="@OnCamera"/>
|
||||
<div class="stack stack-xs" style="margin-top: var(--space-md);">
|
||||
<button type="button" class="action-tile" @onclick="@OnCamera">
|
||||
<span class="action-tile__icon action-tile__icon--primary">
|
||||
<i class="ri-camera-line" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="action-tile__label">Scatta una foto</span>
|
||||
</button>
|
||||
|
||||
<MudFab Size="Size.Small" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Rounded.Image"
|
||||
Label="Galleria" OnClick="@OnGallery"/>
|
||||
<button type="button" class="action-tile" @onclick="@OnGallery">
|
||||
<span class="action-tile__icon action-tile__icon--neutral">
|
||||
<i class="ri-image-line" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="action-tile__label">Scegli dalla galleria</span>
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@(() => MudDialog.Cancel())">Annulla</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
[Parameter] public bool CanAddPosition { get; set; }
|
||||
|
||||
private List<AttachedDto>? Attached { get; set; }
|
||||
|
||||
@@ -48,4 +56,4 @@
|
||||
|
||||
if (Attached != null) MudDialog.Close(Attached);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
.content.attached {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
padding: 1rem;
|
||||
height: unset;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -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
|
||||
@@ -24,174 +25,231 @@
|
||||
@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>
|
||||
}
|
||||
@@ -203,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!;
|
||||
@@ -215,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; }
|
||||
@@ -501,12 +598,38 @@
|
||||
|
||||
#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();
|
||||
@@ -608,9 +731,13 @@
|
||||
|
||||
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)
|
||||
if (!RepartoMancante && !MotivoMancante && _form.IsValid)
|
||||
{
|
||||
await Save();
|
||||
return;
|
||||
@@ -620,6 +747,7 @@
|
||||
// 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
|
||||
|
||||
@@ -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 {
|
||||
@@ -29,4 +39,4 @@
|
||||
|
||||
private void OnClickItem(string? selectedValue) =>
|
||||
MudDialog.Close(DialogResult.Ok(selectedValue));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
<div class="no-data opacity-75 d-flex flex-column align-items-center">
|
||||
<img src="@ImageSource" alt=""/>
|
||||
@* Stato vuoto (STILE_UI §6.12): suggerisce la mossa successiva, non si limita
|
||||
a dire che non c'e' niente. *@
|
||||
|
||||
<p class="mt-3">@Text</p>
|
||||
<div class="empty-state">
|
||||
<i class="empty-state__icon @Icon" aria-hidden="true"></i>
|
||||
<p class="empty-state__title">@Text</p>
|
||||
@if (!string.IsNullOrWhiteSpace(Hint))
|
||||
{
|
||||
<p class="empty-state__text">@Hint</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ImageSource { get; set; } = string.Empty;
|
||||
[Parameter] public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
[Parameter] public string? Hint { get; set; }
|
||||
[Parameter] public string Icon { get; set; } = "ri-inbox-line";
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
.no-data {
|
||||
position: fixed;
|
||||
top: 35%;
|
||||
width: calc(100% - 3rem); /* remove page padding */
|
||||
}
|
||||
|
||||
.no-data img {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.no-data p {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -6,28 +6,31 @@ public class SendEmailDto
|
||||
{
|
||||
[JsonPropertyName("from")]
|
||||
public string? From { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("fromName")]
|
||||
public string? FromName { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("to")]
|
||||
public string? To { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("subject")]
|
||||
public string? Subject { get; set; }
|
||||
|
||||
|
||||
[JsonPropertyName("msgText")]
|
||||
public string? MsgText { get; set; }
|
||||
|
||||
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -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(); });
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,32 @@ namespace SteUp.Shared.Core.Messages.System;
|
||||
/// 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 Func<Task>? _handler;
|
||||
private readonly List<Func<Task>> _handlers = [];
|
||||
|
||||
/// Registra il gestore corrente. Ritorna l'azione di deregistrazione.
|
||||
/// <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)
|
||||
{
|
||||
_handler = handler;
|
||||
_handlers.Add(handler);
|
||||
HandlersChanged?.Invoke(true);
|
||||
|
||||
return new Registration(this, handler);
|
||||
}
|
||||
|
||||
@@ -23,11 +40,10 @@ public class BackButtonService
|
||||
/// </summary>
|
||||
public bool TryHandleBackPressed()
|
||||
{
|
||||
var handler = _handler;
|
||||
if (handler is null) return false;
|
||||
if (_handlers.Count == 0) return false;
|
||||
|
||||
// Fire-and-forget: il gestore rientra nel dispatcher Blazor da solo.
|
||||
_ = handler();
|
||||
_ = _handlers[^1]();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -35,8 +51,8 @@ public class BackButtonService
|
||||
{
|
||||
public void Dispose()
|
||||
{
|
||||
// Non azzerare se nel frattempo si è registrato qualcun altro.
|
||||
if (ReferenceEquals(owner._handler, handler)) owner._handler = null;
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
/* =============================================================================
|
||||
SteUP — Form
|
||||
Il form scheda e' il cuore dell'app: si compila in piedi, con una mano.
|
||||
Ogni campo e' un blocco largo e tappabile, non una riga di tabella.
|
||||
========================================================================== */
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Gruppo di campo — un Panel con la sua etichetta
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.form-group {
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-surface);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
/* Su schermo stretto il form e' lungo: ogni pixel di padding e' una riga di
|
||||
lavoro in meno visibile a colpo d'occhio. */
|
||||
@media (max-width: 380px) {
|
||||
.form-group { padding: var(--space-sm) var(--space-14); }
|
||||
}
|
||||
|
||||
.form-group__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--label-md-size);
|
||||
line-height: 1.25;
|
||||
font-weight: 500;
|
||||
color: var(--ink-muted);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.form-group__count {
|
||||
font-size: var(--label-md-size);
|
||||
font-weight: 500;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* Il valore del campo: testo grande, tocco comodo. */
|
||||
.form-group .mud-input-control { margin: 0; }
|
||||
|
||||
.form-group .mud-input-slot,
|
||||
.form-group input,
|
||||
.form-group .mud-select-input {
|
||||
font-size: var(--body-lg-size);
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
min-height: var(--touch-min);
|
||||
}
|
||||
|
||||
.form-group--required .form-group__label::after {
|
||||
content: "obbligatorio";
|
||||
font-size: var(--label-sm-size);
|
||||
font-weight: 500;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.form-group--invalid { box-shadow: inset 0 0 0 1px var(--danger-solid); }
|
||||
|
||||
.form-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-6);
|
||||
margin-top: var(--space-xs);
|
||||
font-size: var(--body-md-size);
|
||||
font-weight: 500;
|
||||
color: var(--danger-ink);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Riga di scelta rapida (scadenza, priorita'): pillole invece di una select
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.option {
|
||||
flex: 1 1 auto;
|
||||
min-width: 96px;
|
||||
min-height: var(--touch-min);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--divider);
|
||||
background-color: var(--surface-muted);
|
||||
color: var(--ink-muted);
|
||||
font-family: inherit;
|
||||
font-size: var(--label-lg-size);
|
||||
font-weight: 500;
|
||||
transition: background-color var(--duration-fast) var(--ease-out),
|
||||
border-color var(--duration-fast) var(--ease-out),
|
||||
color var(--duration-fast) var(--ease-out),
|
||||
transform var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.option:active { transform: scale(0.97); }
|
||||
|
||||
/* La scelta fatta si segna col tono informativo, non con laccento: un bordo
|
||||
corallo intorno a un riempimento azzurro si legge come un errore, non come
|
||||
"questa e la risposta". Laccento resta del solo pulsante primario. */
|
||||
.option[aria-pressed="true"] {
|
||||
font-weight: 600;
|
||||
background-color: var(--info-surface);
|
||||
border-color: var(--info-solid);
|
||||
color: var(--info-ink);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.option:active { transform: none; }
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Campo barcode manuale — affiancato al pulsante di invio
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.barcode-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-xs) var(--space-xs) var(--space-md);
|
||||
border-radius: var(--radius-pill);
|
||||
background-color: var(--surface-muted);
|
||||
border: 1.5px solid transparent;
|
||||
transition: border-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.barcode-field:focus-within { border-color: var(--primary); }
|
||||
|
||||
.barcode-field input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: var(--touch-min);
|
||||
border: none;
|
||||
background: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: var(--body-lg-size);
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
/* Il codice si legge cifra per cifra: cifre a larghezza fissa. */
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.barcode-field input::placeholder { color: var(--ink-muted); font-weight: 400; }
|
||||
|
||||
.barcode-field__send {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--touch-min);
|
||||
height: var(--touch-min);
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
background-color: var(--primary);
|
||||
color: var(--on-primary);
|
||||
font-size: 20px;
|
||||
transition: transform var(--duration-fast) var(--ease-out),
|
||||
background-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.barcode-field__send:active { transform: scale(0.92); background-color: var(--primary-pressed); }
|
||||
.barcode-field__send:disabled { background-color: var(--divider); color: var(--ink-muted); }
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Articoli scansionati
|
||||
L'ultimo letto entra dall'alto: e' la conferma visiva della scansione.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.art-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.art-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-10) var(--space-sm);
|
||||
border-radius: var(--radius-control);
|
||||
background-color: var(--surface-muted);
|
||||
animation: art-enter var(--duration-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
@keyframes art-enter {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.art-item { animation: none; }
|
||||
}
|
||||
|
||||
.art-item__text { flex: 1 1 auto; min-width: 0; }
|
||||
|
||||
.art-item__name {
|
||||
font-size: var(--body-lg-size);
|
||||
line-height: 1.25;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.art-item__code {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--label-md-size);
|
||||
font-weight: 400;
|
||||
color: var(--ink-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.art-item__remove {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--touch-min);
|
||||
height: var(--touch-min);
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
background: none;
|
||||
color: var(--ink-muted);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.art-item__remove:active { background-color: var(--danger-surface); color: var(--danger-ink); }
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Foto allegate — striscia orizzontale scorrevole
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.attach-strip {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-inline: var(--space-md);
|
||||
margin-inline: calc(var(--space-md) * -1);
|
||||
}
|
||||
|
||||
.attach-strip::-webkit-scrollbar { display: none; }
|
||||
|
||||
.attach-item {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
width: 104px;
|
||||
border-radius: var(--radius-control);
|
||||
overflow: hidden;
|
||||
background-color: var(--surface-muted);
|
||||
}
|
||||
|
||||
.attach-item__img {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.attach-item__placeholder {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--ink-muted);
|
||||
font-size: 26px;
|
||||
}
|
||||
|
||||
.attach-item__remove {
|
||||
position: absolute;
|
||||
top: var(--space-4);
|
||||
right: var(--space-4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: none;
|
||||
border-radius: var(--radius-pill);
|
||||
background-color: rgba(0, 0, 0, 0.62);
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.attach-item__remove:active { background-color: var(--danger-solid); }
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Bottone di azione secondaria con icona in pastiglia
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.action-tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-control);
|
||||
background-color: var(--surface);
|
||||
font-family: inherit;
|
||||
font-size: var(--body-lg-size);
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
transition: transform var(--duration-fast) var(--ease-out),
|
||||
background-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.action-tile:active { transform: scale(0.99); background-color: var(--surface-muted); }
|
||||
.action-tile:disabled { color: var(--ink-muted); }
|
||||
|
||||
.action-tile__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-compact);
|
||||
font-size: 20px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.action-tile__icon--neutral { background-color: var(--neutral-surface); color: var(--neutral-ink); }
|
||||
/* Le azioni costruttive secondarie non portano l'accento: il corallo resta del
|
||||
pulsante primario. Un fondo corallo tenue sarebbe comunque indistinguibile
|
||||
dal tono danger, perche' il primario di SteUP e' gia' un rosso — ed e'
|
||||
esattamente cosi' che "Nuova scheda" finiva identica a "Elimina ispezione". */
|
||||
.action-tile__icon--primary { background-color: var(--info-surface); color: var(--info-ink); }
|
||||
.action-tile__icon--success { background-color: var(--success-surface); color: var(--success-ink); }
|
||||
.action-tile__icon--danger { background-color: var(--danger-surface); color: var(--danger-ink); }
|
||||
.action-tile__icon--info { background-color: var(--info-surface); color: var(--info-ink); }
|
||||
|
||||
.action-tile__label { flex: 1 1 auto; min-width: 0; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.action-tile:active { transform: none; }
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Riga di selezione multipla
|
||||
Una riga larga e tappabile al posto di un menu a tendina: con i guanti si
|
||||
centra un bersaglio da 56px, non una casella da 18.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.select-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
min-height: 56px;
|
||||
padding: var(--space-10) var(--space-sm);
|
||||
border: none;
|
||||
border-radius: var(--radius-control);
|
||||
background-color: var(--surface);
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: background-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.select-row[aria-pressed="true"] { background-color: var(--info-surface); }
|
||||
|
||||
.select-row__check {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius-compact);
|
||||
border: 2px solid var(--divider);
|
||||
background-color: var(--surface);
|
||||
color: transparent;
|
||||
font-size: 18px;
|
||||
transition: background-color var(--duration-fast) var(--ease-out),
|
||||
border-color var(--duration-fast) var(--ease-out),
|
||||
color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.select-row[aria-pressed="true"] .select-row__check {
|
||||
background-color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.select-row__text { flex: 1 1 auto; min-width: 0; }
|
||||
|
||||
.select-row__title {
|
||||
font-size: var(--body-lg-size);
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.select-row__meta {
|
||||
margin-top: var(--space-2);
|
||||
font-size: var(--label-md-size);
|
||||
font-weight: 400;
|
||||
color: var(--ink-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Campo a scelta singola — la riga che apre il bottom sheet
|
||||
Ha l'aspetto di un valore, non di un controllo: il chevron basta a dire
|
||||
che si tocca.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.select-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
width: 100%;
|
||||
min-height: var(--touch-min);
|
||||
padding: var(--space-xs) 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
transition: opacity var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.select-field:active { opacity: 0.6; }
|
||||
.select-field:disabled { opacity: 1; }
|
||||
|
||||
.select-field__value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-size: var(--body-lg-size);
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Il segnaposto e' testo secondario, non un valore: si distingue a colpo
|
||||
d'occhio da un campo gia' compilato. */
|
||||
.select-field__value--empty { font-weight: 400; color: var(--ink-muted); }
|
||||
|
||||
.select-field__icon { flex: 0 0 auto; font-size: 22px; color: var(--ink-muted); }
|
||||
|
||||
/* Scelta singola: il segno e' tondo come un radio, non quadrato come una
|
||||
casella. La forma dice gia' che si sceglie una cosa sola. */
|
||||
.select-row--single .select-row__check { border-radius: var(--radius-pill); }
|
||||
@@ -0,0 +1,444 @@
|
||||
/* =============================================================================
|
||||
SteUP — MudBlazor riportato allo stile condiviso
|
||||
MudBlazor e' Material 2 di default: ombre per costruire gerarchia, tinte
|
||||
automatiche sulle superfici, target stretti. Qui lo si appiattisce secondo
|
||||
STILE_UI §1 e §5, cosi' i controlli di libreria e i componenti propri
|
||||
parlano la stessa lingua.
|
||||
========================================================================== */
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Aggancio delle variabili di MudBlazor ai token del progetto.
|
||||
I token restano l'unica fonte di verita': cambiare un ruolo in tokens.css
|
||||
aggiorna sia i componenti propri sia quelli di libreria.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
:root {
|
||||
--mud-palette-primary: var(--primary);
|
||||
--mud-palette-primary-text: var(--on-primary);
|
||||
--mud-palette-secondary: var(--primary-dark);
|
||||
--mud-palette-tertiary: var(--info-surface);
|
||||
--mud-palette-surface: var(--surface);
|
||||
--mud-palette-background: var(--workspace);
|
||||
--mud-palette-background-gray: var(--workspace);
|
||||
--mud-palette-text-primary: var(--ink);
|
||||
--mud-palette-text-secondary: var(--ink-muted);
|
||||
--mud-palette-text-disabled: var(--ink-muted);
|
||||
--mud-palette-action-default: var(--ink-muted);
|
||||
--mud-palette-lines-default: var(--divider);
|
||||
--mud-palette-lines-inputs: var(--divider);
|
||||
--mud-palette-divider: var(--divider);
|
||||
--mud-palette-divider-light: var(--divider);
|
||||
--mud-palette-error: var(--danger-solid);
|
||||
--mud-palette-error-text: #ffffff;
|
||||
--mud-palette-success: var(--success-solid);
|
||||
--mud-palette-warning: var(--warning-solid);
|
||||
--mud-palette-info: var(--info-solid);
|
||||
|
||||
--mud-default-borderradius: var(--radius-control);
|
||||
|
||||
/* Tipografia: stessa scala dei token (STILE_UI §4). */
|
||||
--mud-typography-default-family: "Inter", "SF Pro Display", "Roboto", system-ui, sans-serif;
|
||||
--mud-typography-default-size: var(--body-lg-size);
|
||||
--mud-typography-default-weight: 400;
|
||||
--mud-typography-default-lineheight: var(--body-lg-height);
|
||||
--mud-typography-default-letterspacing: normal;
|
||||
|
||||
--mud-typography-h5-family: var(--mud-typography-default-family);
|
||||
--mud-typography-h5-size: var(--headline-sm-size);
|
||||
--mud-typography-h5-weight: 700;
|
||||
--mud-typography-h5-lineheight: var(--headline-sm-height);
|
||||
|
||||
--mud-typography-h6-family: var(--mud-typography-default-family);
|
||||
--mud-typography-h6-size: var(--title-lg-size);
|
||||
--mud-typography-h6-weight: 700;
|
||||
--mud-typography-h6-lineheight: var(--title-lg-height);
|
||||
|
||||
--mud-typography-subtitle1-family: var(--mud-typography-default-family);
|
||||
--mud-typography-subtitle1-size: var(--title-md-size);
|
||||
--mud-typography-subtitle1-weight: 700;
|
||||
--mud-typography-subtitle1-lineheight: var(--title-md-height);
|
||||
|
||||
--mud-typography-subtitle2-family: var(--mud-typography-default-family);
|
||||
--mud-typography-subtitle2-size: var(--body-md-size);
|
||||
--mud-typography-subtitle2-weight: 500;
|
||||
|
||||
--mud-typography-body1-family: var(--mud-typography-default-family);
|
||||
--mud-typography-body1-size: var(--body-lg-size);
|
||||
--mud-typography-body1-lineheight: var(--body-lg-height);
|
||||
|
||||
--mud-typography-body2-family: var(--mud-typography-default-family);
|
||||
--mud-typography-body2-size: var(--body-md-size);
|
||||
--mud-typography-body2-lineheight: var(--body-md-height);
|
||||
|
||||
--mud-typography-button-family: var(--mud-typography-default-family);
|
||||
--mud-typography-button-size: var(--label-lg-size);
|
||||
--mud-typography-button-weight: 600;
|
||||
--mud-typography-button-transform: none;
|
||||
--mud-typography-button-letterspacing: normal;
|
||||
|
||||
--mud-typography-caption-family: var(--mud-typography-default-family);
|
||||
--mud-typography-caption-size: var(--label-md-size);
|
||||
--mud-typography-caption-weight: 500;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
§1 Superfici piatte, nessun tint automatico
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.mud-paper,
|
||||
.mud-card,
|
||||
.mud-expansion-panels .mud-expand-panel,
|
||||
.mud-toolbar,
|
||||
.mud-appbar {
|
||||
box-shadow: none !important;
|
||||
background-image: none !important;
|
||||
background-color: var(--surface);
|
||||
border-radius: var(--radius-surface);
|
||||
}
|
||||
|
||||
.mud-elevation-0, .mud-elevation-1, .mud-elevation-2, .mud-elevation-3,
|
||||
.mud-elevation-4, .mud-elevation-5, .mud-elevation-6, .mud-elevation-8,
|
||||
.mud-elevation-12, .mud-elevation-16, .mud-elevation-24 {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* I livelli che galleggiano davvero conservano un contorno, non un'ombra. */
|
||||
.mud-popover.mud-popover-open,
|
||||
.mud-picker-popover-paper,
|
||||
.mud-menu-popover {
|
||||
border-radius: var(--radius-control) !important;
|
||||
border: 1px solid var(--divider);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
§5 Bottoni — target 44/48, raggio control, etichette in caso naturale
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.mud-button-root {
|
||||
min-height: var(--touch-min);
|
||||
border-radius: var(--radius-control);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--label-lg-size);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
transition: transform var(--duration-fast) var(--ease-out),
|
||||
background-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.mud-button-root:active { transform: scale(0.97); }
|
||||
|
||||
.mud-button-filled {
|
||||
min-height: var(--button-height);
|
||||
box-shadow: none !important;
|
||||
padding-inline: var(--space-lg);
|
||||
}
|
||||
|
||||
.mud-button-filled.mud-button-filled-primary {
|
||||
background-color: var(--primary);
|
||||
color: var(--on-primary);
|
||||
}
|
||||
|
||||
.mud-button-filled.mud-button-filled-primary:hover,
|
||||
.mud-button-filled.mud-button-filled-primary:active {
|
||||
background-color: var(--primary-pressed);
|
||||
}
|
||||
|
||||
.mud-button-outlined {
|
||||
min-height: var(--button-height);
|
||||
border: 1px solid var(--divider);
|
||||
color: var(--ink);
|
||||
padding-inline: var(--space-lg);
|
||||
}
|
||||
|
||||
.mud-button-text { min-height: var(--touch-min); }
|
||||
|
||||
.mud-icon-button {
|
||||
min-width: var(--touch-min);
|
||||
min-height: var(--touch-min);
|
||||
border-radius: var(--radius-pill);
|
||||
transition: transform var(--duration-fast) var(--ease-out),
|
||||
background-color var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.mud-icon-button:active { transform: scale(0.92); }
|
||||
|
||||
/* Il FAB circolare al centro e' una firma Android di serie (§1.7): qui i FAB
|
||||
sono rettangolari e stondati come il resto dei controlli. */
|
||||
.mud-fab {
|
||||
box-shadow: none !important;
|
||||
border-radius: var(--radius-control);
|
||||
min-height: var(--touch-min);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
.mud-fab.mud-fab-primary { background-color: var(--primary); color: var(--on-primary); }
|
||||
.mud-fab.mud-fab-default { background-color: var(--surface-muted); color: var(--ink); }
|
||||
.mud-fab:active { transform: scale(0.96); }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mud-button-root:active,
|
||||
.mud-icon-button:active,
|
||||
.mud-fab:active { transform: none; }
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
§5 Campi — riempiti, raggio control, bordo 1px a riposo e 2px in focus
|
||||
La sottolineatura Material viene rimossa: i campi sono schede, non righe.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.mud-input.mud-input-text,
|
||||
.mud-input.mud-input-filled,
|
||||
.mud-input-control .mud-input-slot {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--body-lg-size);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mud-input:before,
|
||||
.mud-input:after,
|
||||
.mud-input-filled:before,
|
||||
.mud-input-filled:after,
|
||||
.mud-input-underline:before,
|
||||
.mud-input-underline:after {
|
||||
display: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.field-shell {
|
||||
background-color: var(--surface);
|
||||
border: 1px solid var(--divider);
|
||||
border-radius: var(--radius-control);
|
||||
padding: var(--space-md);
|
||||
transition: border-color var(--duration-fast) var(--ease-out),
|
||||
box-shadow var(--duration-fast) var(--ease-out);
|
||||
}
|
||||
|
||||
.field-shell:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: inset 0 0 0 1px var(--primary);
|
||||
}
|
||||
|
||||
.field-shell--error { border-color: var(--danger-solid); }
|
||||
.field-shell--muted { background-color: var(--surface-muted); }
|
||||
|
||||
.mud-input-label,
|
||||
.mud-input-helper-text {
|
||||
font-family: var(--font-sans);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.mud-input-helper-text.mud-input-error,
|
||||
.mud-input-error .mud-input-helper-text {
|
||||
color: var(--danger-ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Il testo segnaposto deve restare leggibile al sole: stesso contrasto del
|
||||
testo secondario, non il grigio chiarissimo di serie. */
|
||||
.mud-input input::placeholder,
|
||||
.mud-input textarea::placeholder {
|
||||
color: var(--ink-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mud-select .mud-input-adornment .mud-icon-root { color: var(--ink-muted); }
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Select, liste e voci di menu
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.mud-list-item {
|
||||
min-height: var(--touch-min);
|
||||
border-radius: var(--radius-compact);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--body-lg-size);
|
||||
}
|
||||
|
||||
.mud-list-item.mud-selected-item {
|
||||
background-color: var(--info-surface);
|
||||
color: var(--primary-dark);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.custom-item-select { padding-block: var(--space-xs); }
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Chip
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.mud-chip {
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--label-sm-size);
|
||||
font-weight: 600;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.mud-chip.mud-chip-filled.mud-chip-color-default {
|
||||
background-color: var(--neutral-surface);
|
||||
color: var(--neutral-ink);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Pannelli a fisarmonica — nessun bordo di serie, nessuna ombra
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.mud-expansion-panels { width: 100%; }
|
||||
|
||||
.mud-expand-panel {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
margin-block: 0 !important;
|
||||
}
|
||||
|
||||
.mud-expand-panel .mud-expand-panel-header {
|
||||
padding: var(--space-sm) 0;
|
||||
min-height: var(--touch-min);
|
||||
}
|
||||
|
||||
.mud-expand-panel .mud-expand-panel-content { padding: 0; }
|
||||
.mud-expand-panel::before { display: none !important; }
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
§6.10 Dialog
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.mud-dialog {
|
||||
border-radius: var(--radius-surface) !important;
|
||||
background-color: var(--surface);
|
||||
box-shadow: none !important;
|
||||
max-width: 420px;
|
||||
width: calc(100vw - 48px);
|
||||
}
|
||||
|
||||
/* La misura da riquadro qui sopra vale per i dialog di conferma. Le schermate
|
||||
aperte a tutto schermo (scelta punto vendita, scelta articoli) sono
|
||||
schermate: devono riempire il display, non restare in una scatola con lo
|
||||
sfondo che si vede ai lati. */
|
||||
.mud-dialog.mud-dialog-fullscreen {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
margin: 0;
|
||||
border-radius: 0 !important;
|
||||
background-color: var(--workspace);
|
||||
}
|
||||
|
||||
.mud-dialog.mud-dialog-fullscreen .mud-dialog-content {
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mud-dialog-title {
|
||||
padding: var(--space-lg) var(--space-lg) 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--title-md-size);
|
||||
line-height: var(--title-md-height);
|
||||
font-weight: 700;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mud-dialog-content {
|
||||
padding: var(--space-xs) var(--space-lg) 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--body-lg-size);
|
||||
line-height: 1.45;
|
||||
color: var(--ink-muted);
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Le scelte stanno in fondo, allineate a destra, con la conferma per ultima.
|
||||
Su schermo stretto le etichette lunghe ("Salva come bozza") andrebbero a
|
||||
sbattere: in quel caso la riga diventa una colonna a piena larghezza invece
|
||||
di comprimere i bersagli. */
|
||||
.mud-dialog-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.mud-dialog-actions > * {
|
||||
margin: 0 !important;
|
||||
min-height: var(--touch-min);
|
||||
}
|
||||
|
||||
@media (max-width: 380px) {
|
||||
.mud-dialog-actions { flex-direction: column-reverse; }
|
||||
.mud-dialog-actions > * { width: 100%; }
|
||||
}
|
||||
|
||||
/* Il form scheda e' a schermo pieno: e' una schermata, non un riquadro. */
|
||||
.mud-dialog.customDialog-form {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
margin: 0;
|
||||
border-radius: 0 !important;
|
||||
background-color: var(--workspace);
|
||||
}
|
||||
|
||||
.mud-dialog.customDialog-form .mud-dialog-content {
|
||||
padding: 0;
|
||||
max-height: 100%;
|
||||
height: 100%;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.mud-overlay { z-index: var(--z-overlay); }
|
||||
.mud-overlay.on-top { z-index: var(--z-modal) !important; }
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
§6.9 Toast — riempimento pieno del tono, testo bianco, l'unica ombra
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.mud-snackbar {
|
||||
border-radius: var(--radius-control) !important;
|
||||
box-shadow: var(--shadow-toast) !important;
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--body-lg-size);
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: #ffffff !important;
|
||||
padding: var(--space-sm) var(--space-14) !important;
|
||||
z-index: var(--z-toast);
|
||||
}
|
||||
|
||||
.mud-snackbar.mud-alert-filled-success { background-color: var(--success-solid) !important; }
|
||||
.mud-snackbar.mud-alert-filled-error { background-color: var(--danger-solid) !important; }
|
||||
.mud-snackbar.mud-alert-filled-warning { background-color: var(--warning-solid) !important; }
|
||||
.mud-snackbar.mud-alert-filled-info { background-color: var(--info-solid) !important; }
|
||||
.mud-snackbar .mud-snackbar-content-message { color: #ffffff !important; }
|
||||
.mud-snackbar .mud-icon-root { color: #ffffff !important; }
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Progresso
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.mud-progress-linear { border-radius: var(--radius-compact); height: 8px; }
|
||||
.mud-progress-linear .mud-progress-linear-bar { background-color: var(--primary); }
|
||||
.mud-progress-circular.mud-primary-text { color: var(--primary); }
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
Il ripple di Mud viene disattivato dove il contenitore ha il proprio
|
||||
feedback al tocco, per non avere due risposte sovrapposte allo stesso dito.
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
.ripple-container .mud-ripple-effect,
|
||||
.ripple-container .mud-ripple-effect-expanding {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mud-skeleton { border-radius: var(--radius-compact) !important; }
|
||||
@@ -0,0 +1,221 @@
|
||||
/* =============================================================================
|
||||
SteUP — Token di design
|
||||
Implementa STILE_UI.md §2 (ruoli colore) e §3 (scale) sulla palette SteUP.
|
||||
Regola: nelle pagine si usano SOLO questi token. Un colore o un raggio
|
||||
scritto a mano in una schermata e' un difetto, non una scorciatoia.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* -------------------------------------------------------------------------
|
||||
COLORE — ruoli semantici (STILE_UI §2)
|
||||
La palette storica di SteUP resta intatta: corallo azione, navy ancora,
|
||||
azzurro tenue informativo. Sono stati aggiunti solo i ruoli che
|
||||
mancavano: workspace, surfaceMuted, divider, inkMuted e le coppie
|
||||
(foreground forte / superficie tenue) dei toni semantici.
|
||||
---------------------------------------------------------------------- */
|
||||
|
||||
/* Accento unico di azione. La sua rarita' e' cio' che lo rende un segnale. */
|
||||
--primary: #ec4c41;
|
||||
--primary-pressed: #d23a30;
|
||||
--on-primary: #ffffff;
|
||||
|
||||
/* Ancora della struttura: testo forte, icone metrica, foreground su info. */
|
||||
--primary-dark: #002339;
|
||||
|
||||
/* Superfici: lo sfondo pagina e' piu' freddo delle superfici di lavoro.
|
||||
Tre gradini netti, cosi' i pannelli staccano senza bisogno di ombre. */
|
||||
--workspace: #e9edf2;
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f4f6f9;
|
||||
--divider: #d9e0e8;
|
||||
|
||||
/* Testo. Inchiostro pieno per la lettura in pieno sole (Regola del
|
||||
Contrasto da Sole): niente grigio chiaro sul corpo del testo. */
|
||||
--ink: #000000;
|
||||
--ink-muted: #465563; /* 8.3:1 su surface, 7.4:1 su workspace */
|
||||
|
||||
/* --- Toni semantici -----------------------------------------------------
|
||||
Ogni tono e' una tripletta:
|
||||
-ink foreground forte: testo e icone sopra la superficie tenue
|
||||
-surface riempimento tenue: badge, pannelli, cerchi-icona dei dialog
|
||||
-solid riempimento pieno: toast (testo bianco sopra)
|
||||
Gli -ink sono scelti per superare 4.5:1 sulla propria superficie tenue:
|
||||
i colori "puri" della palette restano per icone e riempimenti pieni,
|
||||
dove il contrasto di testo non e' in gioco.
|
||||
-------------------------------------------------------------------- */
|
||||
|
||||
/* Danger — errori, azioni distruttive, priorita' massima */
|
||||
--danger-ink: #b3120c;
|
||||
--danger-surface: #fdeceb;
|
||||
--danger-solid: #e50000;
|
||||
|
||||
/* Warning — attenzione, dati incompleti, bozze da completare */
|
||||
--warning-ink: #8a4f00;
|
||||
--warning-surface: #fdf0db;
|
||||
--warning-solid: #b26a00;
|
||||
|
||||
/* Success — conferme, completamenti, sincronizzato */
|
||||
--success-ink: #10702f;
|
||||
--success-surface: #e4f5e9;
|
||||
--success-solid: #26b050;
|
||||
|
||||
/* Info — stato informativo, selezione.
|
||||
Il tono non e' piu' l'azzurro cielo: era il colore piu' diffuso
|
||||
dell'interfaccia (stato "in corso", voce attiva della barra, righe
|
||||
scelte, avatar) e non compare ne' nel marchio ne' altrove nel sistema.
|
||||
Adesso e' l'ancora navy diluita: stessa famiglia del testo forte, quindi
|
||||
lo stato informativo non introduce un colore proprio. */
|
||||
--info-ink: #002339;
|
||||
--info-surface: #dde5ee;
|
||||
--info-solid: #002339;
|
||||
|
||||
/* Neutral — stati senza carica semantica */
|
||||
--neutral-ink: #465563;
|
||||
--neutral-surface: #f4f6f9;
|
||||
--neutral-solid: #465563;
|
||||
|
||||
/* Focus da tastiera */
|
||||
--focus-ring: #258cfb;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
SPAZIATURA (STILE_UI §3)
|
||||
---------------------------------------------------------------------- */
|
||||
--space-xs: 8px;
|
||||
--space-sm: 12px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 32px;
|
||||
|
||||
/* Micro-distanze derivate, d'uso frequente */
|
||||
--space-2: 2px;
|
||||
--space-4: 4px;
|
||||
--space-6: 6px;
|
||||
--space-10: 10px; /* tra le card di una lista */
|
||||
--space-14: 14px;
|
||||
--space-18: 18px; /* padding dei pannelli di testata */
|
||||
--space-20: 20px;
|
||||
--space-22: 22px;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
RAGGI (STILE_UI §3)
|
||||
Superfici piu' arrotondate dei controlli; stati completamente stondati.
|
||||
---------------------------------------------------------------------- */
|
||||
--radius-compact: 8px;
|
||||
--radius-control: 16px;
|
||||
--radius-surface: 20px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
TIPOGRAFIA (STILE_UI §4)
|
||||
Scala fissa: si legge a colpo d'occhio, non fa effetto editoriale.
|
||||
---------------------------------------------------------------------- */
|
||||
--font-sans: "Inter", "SF Pro Display", "Roboto", system-ui, sans-serif;
|
||||
|
||||
--headline-md-size: 34px;
|
||||
--headline-md-height: 1.2;
|
||||
--headline-sm-size: 28px;
|
||||
--headline-sm-height: 1.25;
|
||||
--title-lg-size: 22px;
|
||||
--title-lg-height: 1.3;
|
||||
--title-md-size: 18px;
|
||||
--title-md-height: 1.35;
|
||||
--body-lg-size: 16px;
|
||||
--body-lg-height: 1.5;
|
||||
--body-md-size: 15px;
|
||||
--body-md-height: 1.45;
|
||||
--label-lg-size: 14px;
|
||||
--label-md-size: 13px;
|
||||
--label-sm-size: 12px;
|
||||
|
||||
/* PESI — il grassetto e' un segnale, e un segnale ovunque non segnala
|
||||
niente. Quattro gradini, e non si sale sopra il 700:
|
||||
400 corpo, metadati, valori descrittivi
|
||||
500 etichette di campo, voci di lista, testo dei controlli
|
||||
600 stati brevi (badge, voce attiva), nomi in una riga di elenco
|
||||
700 titoli: uno solo per blocco */
|
||||
--weight-body: 400;
|
||||
--weight-label: 500;
|
||||
--weight-strong: 600;
|
||||
--weight-title: 700;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
MISURE STRUTTURALI
|
||||
---------------------------------------------------------------------- */
|
||||
--appbar-height: 80px;
|
||||
--navbar-height: 68px;
|
||||
--touch-min: 44px;
|
||||
--button-height: 48px;
|
||||
--content-max: 920px;
|
||||
|
||||
/* Spazio che le pagine lasciano in fondo per non finire sotto la barra
|
||||
flottante di navigazione (STILE_UI §6.15). */
|
||||
--nav-clearance: 116px;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
MOVIMENTO
|
||||
Curve esponenziali in uscita: il movimento serve allo stato, mai alla
|
||||
scena. Durate da prodotto, l'utente e' dentro un compito.
|
||||
---------------------------------------------------------------------- */
|
||||
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
|
||||
--duration-fast: 120ms;
|
||||
--duration-base: 200ms;
|
||||
--duration-slow: 280ms;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
PROFONDITA' — scala semantica, mai valori arbitrari
|
||||
Le superfici sono piatte (STILE_UI §1): questi livelli ordinano solo
|
||||
cio' che galleggia davvero.
|
||||
---------------------------------------------------------------------- */
|
||||
--z-base: 0;
|
||||
--z-sticky: 10;
|
||||
--z-navbar: 20;
|
||||
--z-connection: 30;
|
||||
--z-overlay: 40;
|
||||
--z-modal: 50;
|
||||
--z-toast: 60;
|
||||
|
||||
/* L'unica ombra del sistema: il toast (STILE_UI §6.9). */
|
||||
--shadow-toast: 0 6px 20px rgba(0, 0, 0, 0.24);
|
||||
|
||||
/* Breakpoint (STILE_UI §3) — documentati qui, usati nelle media query:
|
||||
rail 720px · twoColumn 900px · contentMax 1180px */
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Gradino compatto — schermi stretti (≤380px)
|
||||
|
||||
Il terminale da campo (Honeywell EDA52) ha 360 punti logici di larghezza,
|
||||
molto meno dei telefoni per cui la scala di STILE_UI è tarata: a misura
|
||||
piena i titoli si troncano, il testo va a capo e in una schermata entra
|
||||
pochissimo lavoro. Qui la scala scende di un gradino — le proporzioni e i
|
||||
rapporti restano quelli del documento, cambia solo la taglia.
|
||||
|
||||
È l'"Adattamento locale" del §8: i componenti si riorganizzano sotto le
|
||||
soglie invece di pretendere lo spazio che non c'è.
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 380px) {
|
||||
:root {
|
||||
--headline-md-size: 28px;
|
||||
--headline-sm-size: 22px;
|
||||
--title-lg-size: 18px;
|
||||
--title-md-size: 16px;
|
||||
--body-lg-size: 15px;
|
||||
--body-md-size: 14px;
|
||||
--label-lg-size: 13px;
|
||||
--label-md-size: 12px;
|
||||
--label-sm-size: 11px;
|
||||
|
||||
--space-lg: 20px;
|
||||
--space-xl: 24px;
|
||||
--space-18: 14px;
|
||||
--space-20: 16px;
|
||||
--space-22: 18px;
|
||||
|
||||
--appbar-height: 64px;
|
||||
--button-height: 44px;
|
||||
--navbar-height: 62px;
|
||||
--nav-clearance: 104px;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
/*! Sortable 1.15.3 - MIT | git://github.com/SortableJS/Sortable.git */
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),n=t(/iP(ad|od|hone)/i),c=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function f(t,e,n){t.removeEventListener(e,n,!y&&d)}function p(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&p(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function E(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBoundingClientRect();a-=h.top+parseInt(R(i,"border-top-width")),l-=h.left+parseInt(R(i,"border-left-width")),s=a+r.height,c=l+r.width;break}}while(i=i.parentNode);return o&&t!==window&&(o=(e=b(i||t))&&e.a,t=e&&e.d,e&&(s=(a/=t)+(u/=t),c=(l/=o)+(d/=o))),{top:a,left:l,bottom:s,right:c,width:d,height:u}}}function Y(t,e,n){for(var o=M(tLine truncated
|
||||
@@ -1,7 +0,0 @@
|
||||
/*!
|
||||
* Bootstrap v5.3.2 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2023 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=I(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return P(s,{delegateTarget:r}),n.oneOff&&N.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return P(n,{delegateTarget:t}),i.oneOff&&N.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function $(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function I(t){return t=t.replace(y,""),T[t]||t}const N={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))$(t,l,i,e.slLine truncated
|
||||
//# sourceMappingURL=bootstrap.bundle.min.js.map
|
||||
@@ -1,14 +0,0 @@
|
||||
/*!
|
||||
* Chart.js v4.0.1
|
||||
* https://www.chartjs.org
|
||||
* (c) 2022 Chart.js Contributors
|
||||
* Released under the MIT License
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";function t(){}const e=(()=>{let t=0;return()=>t++})();function i(t){return null==t}function s(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function n(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function o(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function a(t,e){return o(t)?t:e}function r(t,e){return void 0===t?e:t}const l=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function c(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function d(t,e,i,o){let a,r,l;if(s(t))if(r=t.length,o)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a<r;a++)e.call(i,t[a],a);else if(n(t))for(l=Object.keys(t),r=l.length,a=0;a<r;a++)e.call(i,t[l[a]],l[a])}function u(t,e){let i,s,n,o;if(!t||!e||t.length!==e.length)return!1;for(i=0,s=t.length;i<s;++i)if(n=t[i],o=e[i],n.datasetIndex!==o.datasetIndex||n.index!==o.index)return!1;return!0}function f(t){if(s(t))return t.map(f);if(n(t)){const e=Object.create(null),i=Object.keys(t),s=i.length;let n=0;for(;n<s;++n)e[i[n]]=f(t[i[n]]);return e}return t}function g(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function p(t,e,i,s){if(!g(t))return;const o=e[t],a=i[t];n(o)&&n(a)?m(o,a,s):e[t]=f(a)}function m(t,e,i){const o=s(e)?e:[e],a=o.length;if(!n(t))return t;const r=(i=i||{}).merger||p;let l;for(let e=0;e<a;++e){if(l=o[e],!n(l))continue;const s=Object.keys(l);for(let e=0,n=s.length;e<n;++e)r(s[e],t,l,i)}return t}function b(t,e){return m(t,e,{merger:x})}function x(t,e,i){if(!g(t))return;const s=e[t],o=i[t];n(s)&&n(o)?b(s,o):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=f(o))}const _={"":t=>t,x:t=>t.x,y:t=>t.y};function y(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function v(t,e){const i=_[e]||(_[e]=function(t){const e=y(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function M(t){return t.charAt(0).toUpperCase()+t.slice(1)}const w=t=>void 0!==t,k=t=>"function"==typeof t,S=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function P(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const D=Math.PI,C=2*D,O=C+D,A=Number.POSITIVE_INFINITY,T=D/180,L=D/2,E=D/4,R=2*D/3,I=Math.log10,z=Math.sign;function F(t,e,i){return Math.abs(t-e)<i}function V(t){const e=Math.round(t);t=F(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(I(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function B(t){const e=[],i=Math.sqrt(t);let s;for(s=1;s<i;s++)t%s==0&&(e.push(s),e.push(t/s));return i===(0|i)&&e.push(i),e.sort(((t,e)=>t-e)).pop(),e}function N(t){return!isNaN(parseFloat(t))&&isFinite(t)}function W(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function H(t,e,i){let s,n,o;for(s=0,n=t.length;s<n;s++)o=t[s][i],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function j(t){return t*(D/180)}function $(t){return t*(180/D)}function Y(t){if(!o(t))return;let e=1,i=0;for(;Math.round(t*e)/e!==t;)e*=10,i++;return i}function U(t,e){const i=e.x-t.x,s=e.y-t.y,n=Math.sqrt(i*i+s*s);let o=Math.atan2(s,i);return o<-.5*D&&(o+=C),{angle:o,distance:n}}function X(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function q(t,e){return(t-e+O)%C-D}function K(t){return(t%C+C)%C}function G(t,e,i,s){const n=K(t),o=K(e),a=K(i),r=K(o-n),l=K(a-n),h=K(n-o),c=K(n-a);return n===o||n===a||s&&o===a||r>l&&h<c}function Z(t,e,i){return Math.max(e,Math.min(i,t))}function J(t){return Z(t,-32768,32767)}function Q(t,e,i,s=1e-6){return t>=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function tt(t,e,i){i=i||(i=>t[i]<e);let s,n=t.length-1,o=0;for(;n-o>1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const et=(t,e,i,s)=>tt(t,i,s?s=>{const n=t[s][e];return n<i||n===i&&t[s+1][e]===i}:s=>t[s][e]<i),it=(t,e,i)=>tt(t,i,(s=>t[s][e]>=i));function st(t,e,i){let s=0,n=t.length;for(;s<n&&t[s]<e;)s++;for(;n>s&&t[n-1]>i;)n--;return s>0||n<t.length?t.slice(s,n):t}const nt=["push","pop","shift","splice","unshift"];function ot(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,"_chartjs",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),nt.forEach((e=>{const i="_onData"+M(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...e){const n=s.apply(this,e);return t._chartjs.listeners.forEach((t=>{"function"==typeof t[i]&&t[i](...e)})),n}})})))}function at(t,e){const i=t._chartjs;if(!i)return;const s=i.listeners,n=s.indexOf(e);-1!==n&&s.splice(n,1),s.length>0||(nt.forEach((e=>{delete t[e]})),delete t._chartjs)}function rt(t){const e=new Set;let i,s;for(i=0,s=t.length;i<sLine truncated
|
||||
/*!
|
||||
* @kurkle/color v0.2.1
|
||||
* https://github.com/kurkle/color#readme
|
||||
* (c) 2022 Jukka Kurkela
|
||||
* Released under the MIT License
|
||||
*/function xt(t){return t+.5|0}const _t=(t,e,i)=>Math.max(Math.min(t,i),e);function yt(t){return _t(xt(2.55*t),0,255)}function vt(t){return _t(xt(255*t),0,255)}function Mt(t){return _t(xt(t/2.55)/100,0,1)}function wt(t){return _t(xt(100*t),0,100)}const kt={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},St=[..."0123456789ABCDEF"],Pt=t=>St[15&t],Dt=t=>St[(240&t)>>4]+St[15&t],Ct=t=>(240&t)>>4==(15&t);function Ot(t){var e=(t=>Ct(t.r)&&Ct(t.g)&&Ct(t.b)&&Ct(t.a))(t)?Pt:Dt;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const At=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Tt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Lt(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Et(t,e,i){const s=Tt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function Rt(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e<i?6:0):e===n?(i-t)/s+2:(t-e)/s+4}(e,i,s,h,n),r=60*r+.5),[0|r,l||0,a]}function It(t,e,i,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,s)).map(vt)}function zt(t,e,i){return It(Tt,t,e,i)}function Ft(t){return(t%360+360)%360}function Vt(t){const e=At.exec(t);let i,s=255;if(!e)return;e[5]!==i&&(s=e[6]?yt(+e[5]):vt(+e[5]));const n=Ft(+e[2]),o=+e[3]/100,a=+e[4]/100;return i="hwb"===e[1]?function(t,e,i){return It(Et,t,e,i)}(n,o,a):"hsv"===e[1]?function(t,e,i){return It(Lt,t,e,i)}(n,o,a):zt(n,o,a),{r:i[0],g:i[1],b:i[2],a:s}}const Bt={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Nt={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};let Wt;function Ht(t){Wt||(Wt=function(){const t={},e=Object.keys(Nt),i=Object.keys(Bt);let s,n,o,a,r;for(s=0;s<e.length;s++){for(a=r=e[s],n=0;n<i.length;n++)o=i[n],r=r.replace(o,Bt[o]);o=parseInt(Nt[a],16),t[r]=[o>>16&255,o>>8&255,255&o]}return t}(),Wt.transparent=[0,0,0,0]);const e=Wt[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const jt=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const $t=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Yt=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Ut(t,e,i){if(t){let s=Rt(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),Line truncated
|
||||
//# sourceMappingURL=chart.umd.js.map
|
||||
@@ -1,7 +0,0 @@
|
||||
/*!
|
||||
* chartjs-plugin-datalabels v2.2.0
|
||||
* https://chartjs-plugin-datalabels.netlify.app
|
||||
* (c) 2017-2022 chartjs-plugin-datalabels contributors
|
||||
* Released under the MIT license
|
||||
*/
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e(require("chart.js/helpers"),require("chart.js")):"function"==typeof define&&define.amd?define(["chart.js/helpers","chart.js"],e):(t="undefined"!=typeof globalThis?globalThis:t||self).ChartDataLabels=e(t.Chart.helpers,t.Chart)}(this,(function(t,e){"use strict";var r=function(){if("undefined"!=typeof window){if(window.devicePixelRatio)return window.devicePixelRatio;var t=window.screen;if(t)return(t.deviceXDPI||1)/(t.logicalXDPI||1)}return 1}(),a=function(e){var r,a=[];for(e=[].concat(e);e.length;)"string"==typeof(r=e.pop())?a.unshift.apply(a,r.split("\n")):Array.isArray(r)?e.push.apply(e,r):t.isNullOrUndef(e)||a.unshift(""+r);return a},o=function(t,e,r){var a,o=[].concat(e),n=o.length,i=t.font,l=0;for(t.font=r.string,a=0;a<n;++a)l=Math.max(t.measureText(o[a]).width,l);return t.font=i,{height:n*r.lineHeight,width:l}},n=function(t,e,r){return Math.max(t,Math.min(e,r))},i=function(t,e){var r,a,o,n,i=t.slice(),l=[];for(r=0,o=e.length;r<o;++r)n=e[r],-1===(a=i.indexOf(n))?l.push([n,1]):i.splice(a,1);for(r=0,o=i.length;r<o;++r)l.push([i[r],-1]);return l};function l(t,e){var r=e.x,a=e.y;if(null===r)return{x:0,y:-1};if(null===a)return{x:1,y:0};var o=t.x-r,n=t.y-a,i=Math.sqrt(o*o+n*n);return{x:i?o/i:0,y:i?n/i:-1}}function s(t,e,r){var a=0;return t<r.left?a|=1:t>r.right&&(a|=2),e<r.top?a|=8:e>r.bottom&&(a|=4),a}function u(t,e){var r,a,o=e.anchor,n=t;return e.clamp&&(n=function(t,e){for(var r,a,o,n=t.x0,i=t.y0,l=t.x1,u=t.y1,d=s(n,i,e),c=s(l,u,e);d|c&&!(d&c);)8&(r=d||c)?(a=n+(l-n)*(e.top-i)/(u-i),o=e.top):4&r?(a=n+(l-n)*(e.bottom-i)/(u-i),o=e.bottom):2&r?(o=i+(u-i)*(e.right-n)/(l-n),a=e.right):1&r&&(o=i+(u-i)*(e.left-n)/(l-n),a=e.left),r===d?d=s(n=a,i=o,e):c=s(l=a,u=o,e);return{x0:n,x1:l,y0:i,y1:u}}(n,e.area)),"start"===o?(r=n.x0,a=n.y0):"end"===o?(r=n.x1,a=n.y1):(r=(n.x0+n.x1)/2,a=(n.y0+n.y1)/2),function(t,e,r,a,o){switch(o){case"center":r=a=0;break;case"bottom":r=0,a=1;break;case"right":r=1,a=0;break;case"left":r=-1,a=0;break;case"top":r=0,a=-1;break;case"start":r=-r,a=-a;break;case"end":break;default:o*=Math.PI/180,r=Math.cos(o),a=Math.sin(o)}return{x:t,y:e,vx:r,vy:a}}(r,a,t.vx,t.vy,e.align)}var d=function(t,e){var r=(t.startAngle+t.endAngle)/2,a=Math.cos(r),o=Math.sin(r),n=t.innerRadius,i=t.outerRadius;return u({x0:t.x+a*n,y0:t.y+o*n,x1:t.x+a*i,y1:t.y+o*i,vx:a,vy:o},e)},c=function(t,e){var r=l(t,e.origin),a=r.x*t.options.radius,o=r.y*t.options.radius;return u({x0:t.x-a,y0:t.y-o,x1:t.x+a,y1:t.y+o,vx:r.x,vy:r.y},e)},h=function(t,e){var r=l(t,e.origin),a=t.x,o=t.y,n=0,i=0;return t.horizontal?(a=Math.min(t.x,t.base),n=Math.abs(t.base-t.x)):(o=Math.min(t.y,t.base),i=Math.abs(t.base-t.y)),u({x0:a,y0:o+i,x1:a+n,y1:o,vx:r.x,vy:r.y},e)},f=function(t,e){var r=l(t,e.origin);return u({x0:t.x,y0:t.y,x1:t.x+(t.width||0),y1:t.y+(t.height||0),vx:r.x,vy:r.y},e)},x=function(t){return Math.round(t*r)/r};function y(t,e){var r=e.chart.getDatasetMeta(e.datasetIndex).vScale;if(!r)return null;if(void 0!==r.xCenter&&void 0!==r.yCenter)return{x:r.xCenter,y:r.yCenter};var a=r.getBasePixel();return t.horizontal?{x:a,y:null}:{x:null,y:a}}function v(t,e,r){var a=r.backgroundColor,o=r.borderColor,n=r.borderWidth;(a||o&&n)&&(t.beginPath(),function(t,e,r,a,o,n){var i=Math.PI/2;if(n){var l=Math.min(n,o/2,a/2),s=e+l,u=r+l,d=e+a-l,c=r+o-l;t.moveTo(e,u),s<d&&u<c?(t.arc(s,u,l,-Math.PI,-i),t.arc(d,u,l,-i,0),t.arc(d,c,l,0,i),t.arc(s,c,l,i,Math.PI)):s<d?(t.moveTo(s,r),t.arc(d,u,l,-i,i),t.arc(s,u,l,i,Math.PI+i)):u<c?(t.arc(s,u,l,-Math.PI,0),t.arc(s,c,l,0,Math.PI)):t.arc(s,u,l,-Math.PI,Math.PI),t.closePath(),t.moveTo(e,r)}else t.rect(e,r,a,o)}(t,x(e.x)+n/2,x(e.y)+n/2,x(e.w)-n,x(e.h)-n,r.borderRadius),t.closePath(),a&&(t.fillStyle=a,t.fill()),o&&n&&(t.strokeStyle=o,t.lineWidth=n,t.lineJoin="miter",t.stroke()))}function b(t,e,r){var a=t.shadowBlur,o=r.stroked,n=x(r.x),i=x(r.y),l=x(r.w);o&&t.strokeText(e,n,i,l),r.filled&&(a&&o&&(t.shadowBlur=0),t.fillText(e,n,i,l),a&&o&&(t.shadowBlur=a))}var _=function(t,e,r,a){var o=this;o._config=t,o._index=a,o._model=null,o._rects=null,o._ctx=e,o._el=r};t.merge(_.prototype,{_modelize:function(r,a,n,i){var l,s=this,u=s._index,x=t.toFont(t.resolve([n.font,{}],i,u)),v=t.resolve([n.color,e.defaults.color],i,u);return{align:t.resolve([n.align,"center"],i,u),anchor:t.resolve([n.anchor,"center"],i,u),area:i.chart.chartArea,backgroundColor:t.resolve([n.backgroundColor,null],i,u),borderColor:t.resolve([n.borderColor,null],i,u),borderRadius:t.resolve([n.borderRadius,0],i,u),borderWidth:t.resolve([n.borderWidth,0],i,u),clamp:t.resolve([n.clamp,!1],i,u),clip:t.resolve([n.clip,!1],i,u),color:v,display:r,font:x,lines:a,offset:t.resolve([n.offset,4],i,u),opacity:t.resolve([n.opacity,1],i,u),origin:y(s._el,i),padding:t.toPadding(t.resolve([n.padding,4],i,u)),positioner:(l=s._el,l instanceof e.ArcElement?d:l instanceof e.PointElement?c:l instanceof e.BarElement?h:f),rotation:t.resolve([n.rotation,0],i,u)*(Math.PI/180),size:o(s._ctx,a,x),textAlign:t.resolve([nLine truncated
|
||||
Loaded 100 of 101 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user