Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4e5709be6 | |||
| b368b8c10e | |||
| b73a3bc82e | |||
| 70a0d0f5fa | |||
| b9de075f45 | |||
| c9b3a5034f | |||
| 1392b0e6d1 | |||
| 13d5400104 | |||
| 9703fca306 | |||
| e3c9880d86 | |||
| 8cfce316fa | |||
| 4c1ef3d846 | |||
| 66ed92299b | |||
| 4e61dc011f | |||
| 2e3a318132 | |||
| 101b68123c | |||
| ac501dc261 | |||
| 2d7e3c591e | |||
| 7099f02b60 | |||
| ca0aaf69b5 | |||
| 52b2994d3b | |||
| 811d8a86d8 | |||
| 2a0054aa30 | |||
| 3a9174f6fc | |||
| 3b04702665 | |||
| 0174316bb7 | |||
| d076a4b77a | |||
| e3942d00cc | |||
| 92b60deac0 | |||
| 48e55226f3 | |||
| 963eba1e84 | |||
| 55c0e539b1 | |||
| 5ec7468b1c | |||
| 74b671586f | |||
| b2c8174d6f | |||
| ce704fa91b | |||
| 2fad8557ba | |||
| a81689bd56 | |||
| 4e13db24f6 | |||
| c454474f49 | |||
| 9f4088eff3 | |||
| a04ccde2ec | |||
| a3d27c9ef8 | |||
| c2ff6bbbe5 | |||
| 294c2d1dd0 | |||
| 2a42036c29 | |||
| 339828004d | |||
| 76ac8f051e | |||
| a1b4ff664d | |||
| bc8582d3f5 | |||
| 38ef7ebd33 | |||
| 98d2dda780 | |||
| f09ea026bc | |||
| c2da0e150d | |||
| f9e4957ae3 | |||
| 43f17c1410 | |||
| 0abe88939b | |||
| e57738a37f | |||
| 21ee5137b0 | |||
| 2d938fb210 | |||
| 3760e38c8d | |||
| e1e0c91db5 | |||
| 99a5395a1f | |||
| 8fbeaf8637 | |||
| 63368748ab |
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"impeccable@impeccable": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
---
|
||||
target: IspezionePage
|
||||
total_score: 18
|
||||
p0_count: 1
|
||||
p1_count: 3
|
||||
timestamp: 2026-07-08T13-15-08Z
|
||||
slug: steup-shared-components-pages-ispezionepage-razor
|
||||
---
|
||||
# Critique: IspezionePage
|
||||
|
||||
Method: dual-agent (A: design-review · B: detector+evidence). Browser visualization SKIPPED (MAUI Blazor Hybrid / WASM requires app build + auth + runtime state; no static HTML or dev server to inject into).
|
||||
|
||||
## Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key Issue |
|
||||
|---|-----------|-------|-----------|
|
||||
| 1 | Visibility of System Status | 1 | SchedaCard never shows local-only vs already-sent (`ActivityId`); "offline è la verità" is invisible on its central screen |
|
||||
| 2 | Match System / Real World | 3 | Clean Italian, correct `sched{a/e}` pluralization; but online "+" is icon-only, unlabelled |
|
||||
| 3 | User Control and Freedom | 1 | ExportReparto and Completa push to server irreversibly, no undo; export has no confirm |
|
||||
| 4 | Consistency and Standards | 1 | MudBlazor default color rainbow (Warning/Success/Info/Error) violates single-accent; MudFab is both tiny icon and wide labelled button |
|
||||
| 5 | Error Prevention | 2 | Completa & Delete confirmed; Export has no confirm and no double-tap guard |
|
||||
| 6 | Recognition Rather Than Recall | 2 | No per-scheda sync badge → user must remember which reparti were exported |
|
||||
| 7 | Flexibility and Efficiency | 2 | Three overlapping sync paths (per-scheda / per-reparto / Completa), unclear which supersedes |
|
||||
| 8 | Aesthetic and Minimalist Design | 2 | Multi-color FAB clusters + info chips add chroma noise |
|
||||
| 9 | Error Recovery | 2 | OnError surfaces raw `e.Message` (+ Console.WriteLine) — technical, not field-friendly |
|
||||
| 10 | Help and Documentation | 2 | No empty state — empty SchedeGrouped renders a blank body |
|
||||
| **Total** | | **18/40** | **Poor — major UX work before it delivers its promise** |
|
||||
|
||||
## Anti-Patterns Verdict
|
||||
|
||||
**LLM assessment:** No gradients/glass/eyebrows/side-stripes. The tell is subtler: the screen wears MudBlazor's default demo palette. Actions form a semantic rainbow — Warning(orange)=Add, Success(green)=Export, Info(blue)=Edit, Error(red)=Delete — none overridden in MudTheme (only Primary/Secondary/Tertiary/TextPrimary are set). DESIGN.md's loudest rule (La Regola dell'Unico Accento — coral #ec4c41 as the sole action color) appears nowhere on the action surface. Reads as "component-library defaults, lightly arranged" — the enterprise-Blazor flavor of AI slop.
|
||||
|
||||
**Deterministic scan:** detect.mjs clean (exit 0, 0 findings) on IspezionePage.razor and on InspectionCard + SchedaCard. No false positives. The scanner targets HTML/CSS slop patterns; the real defects here are semantic (color roles, state visibility) which it structurally cannot see — so the LLM review carries this critique.
|
||||
|
||||
**Visual overlays:** none — browser visualization not applicable to this target.
|
||||
|
||||
## Overall Impression
|
||||
|
||||
The bones are good — reparto grouping with live counts, restrained rounded surfaces, honest completion-confirm copy — but the screen fails at the one thing the product exists for: making sync/offline state unambiguous. A rilevatore cannot glance at this screen and know what is still trapped on the phone. On top of that, the signature coral accent is absent from every action, replaced by library-default colors. Biggest single opportunity: make per-scheda sync state visible and route all actions through the single coral accent.
|
||||
|
||||
## What's Working
|
||||
|
||||
1. **Completion-confirm copy** is plain, honest, consequence-first — the "conferme che prevengono l'errore" principle done right.
|
||||
2. **Reparto grouping with live count chips** + correct Italian pluralization — thoughtful, task-shaped IA.
|
||||
3. **Restrained surface design** (1em panels, 20px cards, tonal #f5f5f5, flat-by-default) respects the "taccuino da campo" north star.
|
||||
|
||||
## Priority Issues
|
||||
|
||||
**[P0] Sync state is invisible on SchedaCard / the whole screen** — violates "Offline è la verità" (#1 principle)
|
||||
- Why: nothing distinguishes a locally-saved scheda from one on the server (`ActivityId`); no reparto "X da inviare" signal. The core product promise breaks exactly where it matters.
|
||||
- Fix: persistent per-scheda status badge (Locale / Da inviare / Inviato) — coral for needs-action, success green for inviato; reparto-level unsent counter.
|
||||
- Command: /impeccable clarify
|
||||
|
||||
**[P1] Kill the MudBlazor color rainbow; restore the single accent** — violates Regola dell'Unico Accento
|
||||
- Why: coral never appears on actions; "Add" literally looks like a warning; reads as default slop and destroys the accent's meaning.
|
||||
- Fix: coral (Primary) for the single primary action per context; demote secondary actions to neutral/text; reserve red strictly for Delete.
|
||||
- Command: /impeccable colorize
|
||||
|
||||
**[P1] Export reparto is irreversible, unconfirmed, and double-tap-able**
|
||||
- Why: fires on FAB tap with no confirmation; overlay mounts a frame late, so a fast gloved double-tap can re-send the batch → duplicate server activities.
|
||||
- Fix: confirm dialog matching Completa; disable/guard the FAB the instant it's tapped, not on next render.
|
||||
- Command: /impeccable harden
|
||||
|
||||
**[P1] Primary actions sit in panel headers, not at thumb reach** — violates "Il pollice comanda"
|
||||
- Why: Add/Export FABs live in TitleContent at the top of a scrolling list; Size.Small FABs ≈40px (under 44px); Delete sits ~1rem from Export.
|
||||
- Fix: move primary create to bottom-right custom-mudfab; enforce ≥44px; separate Delete from Export (spacing or overflow menu).
|
||||
- Command: /impeccable layout
|
||||
|
||||
**[P2] Opaque blocking wait + no success payoff at completion**
|
||||
- Why: Completa loop shows a featureless full-screen spinner through save→upload-photos→complete with zero progress; SpinnerOverlay's built-in success-checkmark is never wired here — the emotional peak has no reassurance.
|
||||
- Fix: step progress ("Invio scheda 3/7 · foto 2/4"); trigger the existing success animation on completion.
|
||||
- Command: /impeccable animate
|
||||
|
||||
**[P3] All panels Expanded="true" + no empty state**
|
||||
- Why: every reparto expands by default (scroll purgatory for many reparti); an empty inspection renders blank.
|
||||
- Fix: expand only the first (or none) with counts visible; add empty state ("Nessuna scheda — tocca + per iniziare").
|
||||
- Command: /impeccable distill
|
||||
|
||||
## Persona Red Flags
|
||||
|
||||
- **Casey (distracted mobile):** unlabelled orange "+" online; three different sync verbs; nothing shows what's already sent → taps Export twice or completes without exporting.
|
||||
- **Sam (accessibility):** Size.Small FABs under 44px; meaning by color alone across a Warning/Info/Success/Error rainbow (color-blind users can't tell Add from Delete by hue); `info-subtitle` (gray-default) and `info-inspection-text` (text-secondary) muted gray violate the sun-contrast rule and likely fail AA.
|
||||
- **Riley (stress tester):** double-tap Export → duplicate records; errors surface as raw exception strings; 40 schede across 8 reparti all expanded → scroll purgatory.
|
||||
- **Marco the rilevatore (gloved, offline, sunlight):** offline, the Export FAB silently disappears with no "why"; the Add FAB stays enabled here yet the same action is Disabled in NavMenu (contradictory); in bright sun he cannot tell which schede are still trapped on the device — the exact fear the app exists to remove.
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- InspectionCard runs an artificial `await Task.Delay(250)` before showing store data — a deliberate stall on a screen whose principle is "feedback immediato".
|
||||
- scheda action cluster is center-justified — harder to acquire one-handed than edge-anchored.
|
||||
- OnError keeps a `Console.WriteLine(e.Message)` beside the logger — dev residue.
|
||||
- Photo/QR chips in SchedaCard add visual weight but aren't tappable.
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
- If a rilevatore can't glance at this screen and instantly know what's still only on the phone, does the app deliver its single promise?
|
||||
- Why do three separate "send" affordances exist (per-scheda, per-reparto, complete)? Could one honest sync model with clear per-item state replace all three?
|
||||
- The success-checkmark animation was built but never wired here — cut or forgotten? Which is worse?
|
||||
- If coral is "the color of action" but every action button is orange/green/blue, what is coral the color of on this screen?
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
---
|
||||
target: IspezionePage
|
||||
total_score: 34
|
||||
p0_count: 0
|
||||
p1_count: 1
|
||||
timestamp: 2026-07-08T13-44-46Z
|
||||
slug: steup-shared-components-pages-ispezionepage-razor
|
||||
---
|
||||
# Critique: IspezionePage (re-run post-fix)
|
||||
|
||||
Method: dual-agent (A: design-review · B: detector+evidence). Browser visualization SKIPPED (MAUI Blazor Hybrid / WASM requires app build + auth + runtime state).
|
||||
|
||||
## Design Health Score
|
||||
|
||||
| # | Heuristic | Score | Key Issue |
|
||||
|---|-----------|-------|-----------|
|
||||
| 1 | Visibility of System Status | 4 | Per-scheda sync badge + reparto "da inviare" counter + granular completion progress + success checkmark |
|
||||
| 2 | Match System / Real World | 3 | Italian & readable, but status shown ALL-CAPS and scheda title is a raw ActivityTypeId code |
|
||||
| 3 | User Control and Freedom | 3 | Confirmations everywhere; no abort during long upload (sends irreversible by nature) |
|
||||
| 4 | Consistency and Standards | 3 | coral=send now consistent; undermined by off-palette amber status chip |
|
||||
| 5 | Error Prevention | 4 | Export confirms + double-tap guards; completion confirmed + _isBusy guard |
|
||||
| 6 | Recognition Rather Than Recall | 4 | Grouped by reparto, counters, icons, labels |
|
||||
| 7 | Flexibility and Efficiency | 3 | Three send paths (scheda/reparto/concludi) powerful but diffuse |
|
||||
| 8 | Aesthetic and Minimalist | 3 | Clean; one expanded panel still stacks several action types |
|
||||
| 9 | Error Recovery | 4 | OnError copy reassuring, tech detail to logs |
|
||||
| 10 | Help and Documentation | 3 | Good empty-state; no hint why send buttons vanish offline |
|
||||
| **Total** | | **34/40** | **Strong / ship-worthy with targeted fixes** |
|
||||
|
||||
## Anti-Patterns Verdict
|
||||
|
||||
**LLM assessment:** Largely free of AI slop; reads as a genuine field tool. Single-Accent Rule now respected on the action surface — coral is consistently "the send action" (per-scheda Invia, panel Esporta reparto, "da inviare" counter), neutral gray for non-send actions, red reserved for delete. SchedaSyncHelper encodes coral=azione-richiesta / green=fatto. No gradients/glass/side-stripes/identical-grid. One genuine rainbow leak remains: StatusEnumHelper.GetColor() paints the InspectionCard status chip Color.Warning (amber) for InCorso/Verifica — amber is not in the SteUP palette and competes with coral. Status labels are ALL-CAPS (violates Regola del Peso, non del Maiuscolo).
|
||||
|
||||
**Deterministic scan:** detect.mjs clean (exit 0) on IspezionePage.razor, SchedaCard.razor, SpinnerOverlay.razor. CSS scan: 3 advisory design-system-radius findings, all in SpinnerOverlay.razor.css (border-radius 100px/2px) — all confirmed false positives (icon geometry of the animated success-checkmark: arc masks + rounded line caps), not surface radii.
|
||||
|
||||
**Visual overlays:** none — browser visualization not applicable to this target.
|
||||
|
||||
## Overall Impression
|
||||
|
||||
Big jump from the first pass (18→34). The screen now delivers its core promise: sync state is unambiguous per-scheda and per-reparto, the completion moment is reassuring (real progress + success checkmark), destructive/irreversible sends are confirmed and double-tap-guarded, and the single coral accent finally means "send." The remaining issues are a real data-integrity bug in the completion loop and a few palette/copy nits — no longer structural.
|
||||
|
||||
## What's Working
|
||||
|
||||
1. SchedaSyncHelper sync-state system — per-scheda coral "Da inviare" / green "Inviato" badges (icon+text, not color-only) + reparto CountDaInviare() counter. The Offline-è-la-verità principle made concrete.
|
||||
2. Completion progress + confirmation choreography — SetProgress messages, success checkmark with 1.8s dwell, irreversibility confirm guarded by _isBusy.
|
||||
3. Guarded, reassuring exports — ExportReparto confirms, guards double-tap, keeps work local on failure with human copy, logs the stack trace.
|
||||
|
||||
## Priority Issues (remaining)
|
||||
|
||||
**[P1] Silent scheda skip on completion still shows success**
|
||||
- What: In HandleCompleteInspection, `if (apiResponse == null) continue;` skips a scheda that failed to save, yet the loop still calls CompleteInspection, sets Completata, and shows "Ispezione inviata".
|
||||
- Why: The inspection becomes non-editable while one or more schede never reached the server — exactly the data loss PRODUCT.md exists to prevent.
|
||||
- Fix: track failed schede; if any failed, do NOT complete — surface "N schede non inviate, riprova" and keep them "Da inviare". Complete only when all succeed.
|
||||
- Command: /impeccable harden
|
||||
|
||||
**[P2] Off-palette amber status chip**
|
||||
- What: StatusEnumHelper.GetColor() returns Color.Warning (amber) for InCorso/Verifica (InspectionCard status chip).
|
||||
- Why: Amber isn't in the DESIGN.md palette; a second attention color dilutes coral (Regola dell'Unico Accento).
|
||||
- Fix: map InCorso/Verifica to Color.Default (neutral) or navy Secondary; keep green Completata, red Annullata.
|
||||
- Command: /impeccable colorize
|
||||
|
||||
**[P3] ALL-CAPS status labels**
|
||||
- What: ConvertToHumanReadable returns "IN CORSO", "COMPLETATA".
|
||||
- Why: Violates Regola del Peso, non del Maiuscolo.
|
||||
- Fix: sentence case ("In corso", "Completata"); rely on the chip's 700 weight.
|
||||
|
||||
**[P3] Scheda title is a raw code**
|
||||
- What: `<b>@Scheda.ActivityTypeId</b>` as the card headline.
|
||||
- Why: forces decoding an internal identifier in the field.
|
||||
- Fix: show a human descrizione, fall back to the code only if none.
|
||||
|
||||
## Minor Observations
|
||||
|
||||
- OnSchedaModified uses `if (index > 0)` — skips index 0, so modifying the FIRST scheda in SchedeList won't replace it in-list (likely a `>= 0` bug). Pre-existing; now also on the post-export refresh path.
|
||||
- Sync model collapsed to two states (DaInviare/Inviato) vs the three-state brief (Locale/Da inviare/Inviato) — acceptable, but "local draft" vs "ready to send" no longer distinguished.
|
||||
- InspectionCard still has an artificial `await Task.Delay(250)` skeleton delay — fights "feedback immediato".
|
||||
- InspectionCard address (info-subtitle, gray-darker, weight 600) is still gray, not ink — mild sun-legibility compromise (verify AA); verify amber-on-white too.
|
||||
- Offline, all send buttons disappear with no "torna online per inviare" hint — badges show "da inviare" but no next step.
|
||||
|
||||
## Persona Red Flags (delta)
|
||||
|
||||
- Casey (mobile): RESOLVED — FABs Size.Medium (≥44px), flex-wrap. Remaining: per-reparto actions sit in panel TitleContent (top of expanded panel), drifting from the thumb zone on long lists.
|
||||
- Sam (accessibility): RESOLVED — aria-labels on FABs; sync state icon+text not color-only. Remaining: ALL-CAPS status; verify amber-on-white and gray-darker address hit AA.
|
||||
- Marco (gloved/offline/sun): RESOLVED — sync visibility + reparto counter answer "what hasn't gone yet." Remaining: offline the send buttons vanish with no explanation; address text still gray not ink.
|
||||
|
||||
## Questions to Consider
|
||||
|
||||
- If completion continues past a failed scheda and still locks the inspection, how does Marco discover which scheda didn't make it once editing is disabled?
|
||||
- Should the total "da inviare" roll up onto the InspectionCard header, visible before expanding any reparto?
|
||||
- Three send paths (per-scheda, per-reparto, global concludi) — does that dilute the single primary task? Could per-reparto export be the one canonical action?
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"generatedAt": "2026-07-08T00:00:00.000Z",
|
||||
"title": "Design System: SteUP",
|
||||
"extensions": {
|
||||
"colorMeta": {
|
||||
"primary": { "role": "primary", "displayName": "Arancio-Rosso Allerta", "canonical": "#ec4c41", "tonalRamp": ["#3a0f0b", "#5e1712", "#8a2019", "#b62f24", "#ec4c41", "#f27a72", "#f7a9a3", "#fcdcd9"] },
|
||||
"secondary": { "role": "secondary", "displayName": "Blu Profondo", "canonical": "#002339", "tonalRamp": ["#000c14", "#001622", "#002339", "#003653", "#0a5480", "#3f7ea8", "#87b3d0", "#d5e6f1"] },
|
||||
"tertiary": { "role": "tertiary", "displayName": "Azzurro Tenue", "canonical": "#dff2ff", "tonalRamp": ["#12313f", "#1c5570", "#2b7ba3", "#4aa4d1", "#86c8e8", "#b3ddf3", "#dff2ff", "#f2faff"] },
|
||||
"ink": { "role": "neutral", "displayName": "Inchiostro", "canonical": "#000000", "tonalRamp": ["#000000", "#1a1a1a", "#333333", "#4d4d4d", "#737373", "#999999", "#cccccc", "#f5f5f5"] },
|
||||
"paper": { "role": "neutral", "displayName": "Carta", "canonical": "#ffffff", "tonalRamp": ["#c2c2c2", "#d1d1d1", "#e0e0e0", "#ededed", "#f5f5f5", "#fafafa", "#ffffff", "#ffffff"] },
|
||||
"success": { "role": "semantic", "displayName": "Verde OK", "canonical": "#26b050", "tonalRamp": ["#0a3318", "#125025", "#1a7d38", "#26b050", "#4fca72", "#84dc9d", "#b7ecc6", "#e6f9ec"] },
|
||||
"error": { "role": "semantic", "displayName": "Rosso Errore", "canonical": "#e50000", "tonalRamp": ["#3a0000", "#5e0000", "#8a0000", "#b60000", "#e50000", "#f24d4d", "#f79999", "#fcdcdc"] },
|
||||
"focus-ring": { "role": "semantic", "displayName": "Blu Focus", "canonical": "#258cfb", "tonalRamp": ["#08213f", "#0c3a70", "#1257b6", "#258cfb", "#5aa9fc", "#8fc4fd", "#c4e0fe", "#eaf4ff"] }
|
||||
},
|
||||
"typographyMeta": {
|
||||
"page-title": { "displayName": "Page Title", "purpose": "Titolo di schermata; ancora l'utente al compito corrente." },
|
||||
"title": { "displayName": "Title", "purpose": "Intestazioni di sezione, titoli di dialog e message-box." },
|
||||
"body": { "displayName": "Body", "purpose": "Testo corrente e dati; prosa a 65-75ch." },
|
||||
"label": { "displayName": "Label", "purpose": "Etichette di pulsanti e controlli di form; peso 700 per presenza al tocco." }
|
||||
},
|
||||
"shadows": [
|
||||
{ "name": "ombra-custom", "value": "1px 2px 5px hsl(from var(--mud-palette-overlay-dark) h s 40%)", "purpose": "Stacco morbido per card ed elementi che galleggiano sul contenuto." },
|
||||
{ "name": "ombra-eccezione", "value": "1px 2px 5px rgba(0,0,0,0.3)", "purpose": "Rilievo leggermente più marcato per box di errore/eccezione." }
|
||||
],
|
||||
"motion": [
|
||||
{ "name": "connection-slide", "value": "all 0.5s ease", "purpose": "Comparsa/scomparsa della barra di stato connessione (translateY)." },
|
||||
{ "name": "ripple", "value": "ripple-effect 0.6s linear", "purpose": "Feedback fisico al tocco su pulsanti e contenitori tattili." },
|
||||
{ "name": "spinner", "value": "l24 1s infinite linear", "purpose": "Loader di caricamento a doppio anello." }
|
||||
],
|
||||
"breakpoints": [
|
||||
{ "name": "mobile", "value": "0px" }
|
||||
]
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"name": "Primary Button",
|
||||
"kind": "button",
|
||||
"refersTo": "button-primary",
|
||||
"description": "Azione primaria: fondo accento, etichetta bianca in grassetto, angoli molto arrotondati.",
|
||||
"html": "<button class=\"ds-btn-primary\">Salva</button>",
|
||||
"css": ".ds-btn-primary { font-family: Nunito, sans-serif; background: #ec4c41; color: #fff; font-weight: 700; font-size: 0.9375rem; border: none; border-radius: 20px; padding: 0.65rem 1.5rem; min-height: 44px; cursor: pointer; transition: filter 0.15s ease, transform 0.1s ease; } .ds-btn-primary:hover { filter: brightness(1.05); } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 0.1rem #fff, 0 0 0 0.25rem #258cfb; } .ds-btn-primary:active { transform: translateY(1px); filter: brightness(0.95); }"
|
||||
},
|
||||
{
|
||||
"name": "FAB (Floating Action)",
|
||||
"kind": "button",
|
||||
"refersTo": "button-primary",
|
||||
"description": "Azione primaria fissa in basso a destra, a portata di pollice; rispetta la safe-area.",
|
||||
"html": "<button class=\"ds-fab\" aria-label=\"Nuova scheda\"><svg viewBox=\"0 0 24 24\" width=\"24\" height=\"24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\"><path d=\"M12 5v14M5 12h14\"/></svg></button>",
|
||||
"css": ".ds-fab { font-family: Nunito, sans-serif; position: fixed; bottom: 4rem; right: 16px; width: 56px; height: 56px; border-radius: 50%; border: none; background: #ec4c41; color: #fff; display: inline-flex; align-items: center; justify-content: center; box-shadow: 1px 2px 5px rgba(0,0,0,0.3); cursor: pointer; transition: filter 0.15s ease, transform 0.1s ease; } .ds-fab:hover { filter: brightness(1.05); } .ds-fab:active { transform: translateY(1px); } .ds-fab:focus-visible { outline: none; box-shadow: 0 0 0 0.1rem #fff, 0 0 0 0.25rem #258cfb; }"
|
||||
},
|
||||
{
|
||||
"name": "Input Card",
|
||||
"kind": "input",
|
||||
"refersTo": "input-card",
|
||||
"description": "Campo a scheda su fondo grigio tonale, senza underline; etichetta a sinistra, valore a destra.",
|
||||
"html": "<label class=\"ds-input-card\"><span class=\"ds-input-label\">Quantità</span><input class=\"ds-input-field\" type=\"text\" inputmode=\"numeric\" value=\"12\" /></label>",
|
||||
"css": ".ds-input-card { font-family: Nunito, sans-serif; display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; width: 100%; background: #f5f5f5; border-radius: 9px; padding: 0.5rem 1rem; min-height: 44px; } .ds-input-label { font-weight: 700; color: #000; } .ds-input-field { font-family: Nunito, sans-serif; font-weight: 500; text-align: right; border: none; background: transparent; color: #000; outline: none; min-width: 0; } .ds-input-field:focus { outline: none; }"
|
||||
},
|
||||
{
|
||||
"name": "Connection Status Bar",
|
||||
"kind": "custom",
|
||||
"refersTo": null,
|
||||
"description": "Striscia di stato rete/servizio che scorre dall'alto: verde se OK, rosso se KO. Incarna 'Offline è la verità'.",
|
||||
"html": "<div class=\"ds-connection ds-connection--ko\">Nessuna connessione — lavoro salvato in locale</div>",
|
||||
"css": ".ds-connection { font-family: Nunito, sans-serif; display: flex; align-items: center; gap: 1rem; min-height: 35px; padding: 0 0.75rem; font-weight: 700; font-size: 1.05rem; color: #fff; transition: all 0.5s ease; } .ds-connection--ko { background: #e50000; } .ds-connection--ok { background: #26b050; }"
|
||||
},
|
||||
{
|
||||
"name": "Panel / Card",
|
||||
"kind": "card",
|
||||
"refersTo": "panel",
|
||||
"description": "Superficie di contenuto: carta bianca, angoli 1em, piatta di default, ombra solo se galleggia.",
|
||||
"html": "<div class=\"ds-panel\"><h3 class=\"ds-panel-title\">Scheda articolo</h3><p class=\"ds-panel-body\">Dettagli della rilevazione.</p></div>",
|
||||
"css": ".ds-panel { font-family: Nunito, sans-serif; background: #fff; color: #000; border-radius: 1em; padding: 1rem; } .ds-panel-title { font-weight: 700; font-size: 1.1rem; margin: 0 0 0.5rem; } .ds-panel-body { font-weight: 400; font-size: 0.875rem; line-height: 1.8; margin: 0; }"
|
||||
}
|
||||
],
|
||||
"narrative": {
|
||||
"northStar": "Il taccuino da campo",
|
||||
"overview": "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. Il sistema è tattile e sicuro: bersagli ampi per il pollice, una sola mano e i guanti; feedback fisico immediato a ogni tocco e scansione. Lo stato — salvato in locale, in attesa di sync, inviato, completato — non è mai ambiguo. Rifiuta il gestionale datato e denso e l'app consumer social/giocosa: è uno strumento professionale.",
|
||||
"keyCharacteristics": [
|
||||
"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)"
|
||||
],
|
||||
"rules": [
|
||||
{ "name": "La Regola dell'Unico Accento", "body": "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.", "section": "colors" },
|
||||
{ "name": "La Regola del Contrasto da Sole", "body": "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.", "section": "colors" },
|
||||
{ "name": "La Regola del Peso, non del Maiuscolo", "body": "L'enfasi si ottiene con il peso (700/800) e la dimensione, non con il maiuscolo spaziato. Niente eyebrow maiuscoli tracciati sopra le sezioni.", "section": "typography" },
|
||||
{ "name": "La Regola del Piatto-di-Default", "body": "Le superfici sono piatte a riposo. L'ombra compare solo quando un elemento deve galleggiare (overlay, dialog, card sollevata) o rispondere a uno stato.", "section": "elevation" }
|
||||
],
|
||||
"dos": [
|
||||
"Do usare l'Arancio-Rosso Allerta (#ec4c41) solo per azione e stato attivo; mantenerlo raro.",
|
||||
"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).",
|
||||
"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."
|
||||
],
|
||||
"donts": [
|
||||
"Don't costruire un gestionale datato e denso: niente tabelle fittissime, testo minuscolo, 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': 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, glassmorphism decorativo, griglie di card tutte identiche, o eyebrow maiuscoli su ogni sezione.",
|
||||
"Don't introdurre animazioni di scena orchestrate al caricamento: il movimento serve allo stato, non allo spettacolo.",
|
||||
"Don't reinventare affordance standard (scrollbar strane, controlli di form non convenzionali) per 'carattere'."
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What this is
|
||||
|
||||
SteUP is an Integry .NET MAUI Blazor Hybrid app for field inspections ("ispezioni") of retail stores — the operator scans product barcodes (including via Honeywell handheld scanners), fills in inspection sheets ("schede"), attaches photos, and syncs with the Integry backend. Work is stored locally (offline-first via SQLite) and pushed to the server. The same Blazor UI also runs as a standalone WebAssembly app.
|
||||
|
||||
> Note: `README.md` is the leftover Microsoft sample template and does NOT describe this app. Ignore it.
|
||||
|
||||
## Design Context
|
||||
|
||||
For any UI/design work, read **`PRODUCT.md`** (strategy) and **`DESIGN.md`** (visual system) at the repo root first; they are the source of truth for the impeccable design skill.
|
||||
|
||||
- **Register:** product (a field tool; the UI serves the task, it is not a marketing surface).
|
||||
- **Users:** field inspectors ("rilevatori") in stores — one-handed / gloved, outdoors in sunlight, often offline.
|
||||
- **North Star:** "Il taccuino da campo" — works like paper and ink: always, offline, readable in full sun.
|
||||
- **Core principles:** offline state is never ambiguous · thumb-first (touch ≥44px) · high contrast for sunlight · immediate feedback per scan · fewer steps, fewer errors.
|
||||
- **Visual system:** MudBlazor · single font **Nunito** · accent `#ec4c41` (Arancio-Rosso Allerta, action/state only), anchor `#002339` (Blu Profondo) · rounded (9px/20px/1em) · locale `it-IT`.
|
||||
- **Anti-references:** no dated/dense back-office look; no playful consumer-social aesthetic.
|
||||
|
||||
## Build & run
|
||||
|
||||
`global.json` pins SDK 8.0.0 with `rollForward: latestMajor`, but all projects target **.NET 10** (MAUI targets `net10.0-android` / `net10.0-ios`). A .NET 10 SDK must be installed.
|
||||
|
||||
NuGet restores from a **private Integry feed only** (`NuGet.Config` clears nuget.org and uses `nuget.studioml.it`). The `IntegryApiClient.*` packages come from there.
|
||||
|
||||
```bash
|
||||
# MAUI app (primary target) — Android
|
||||
dotnet build SteUp.Maui/SteUp.Maui.csproj -t:Run -f net10.0-android
|
||||
|
||||
# MAUI app — iOS (requires Mac build host; codesigning wired in csproj)
|
||||
dotnet build SteUp.Maui/SteUp.Maui.csproj -f net10.0-ios
|
||||
|
||||
# Web (Blazor WASM) app
|
||||
dotnet run --project SteUp.Web/SteUp.Web.csproj
|
||||
```
|
||||
|
||||
There is **no test project** in the solution. There is no lint config beyond `Nullable`/`ImplicitUsings` enabled per csproj.
|
||||
|
||||
## Projects (5)
|
||||
|
||||
- **SteUp.Shared** — Razor Class Library holding almost everything: all Blazor pages/components, DTOs, entities, enums, the auth state provider, API service wrappers, messaging, and business orchestration (`SteupDataService`). Both MAUI and Web reference it. Uses MudBlazor + MudExtensions for UI.
|
||||
- **SteUp.Maui** — the native host. Owns platform services (DI registration in `Core/CoreModule.cs`), the `BlazorWebView` host (`MainPage.xaml.cs`), file/logging/network/scanner implementations, and app config.
|
||||
- **SteUp.Web** — Blazor WebAssembly host of the same shared UI. Its `Program.cs` mirrors a subset of the MAUI DI setup.
|
||||
- **SteUp.Data** — EF Core + SQLite local database (`AppDbContext`, migrations, `IspezioniService`). References Shared for the entity types.
|
||||
- **Steup.HoneywellScanner** — Android-only binding library wrapping Honeywell `DataCollection.aar`. Referenced by MAUI only on the Android target.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Interface / implementation split (important convention)
|
||||
Interfaces live in **SteUp.Shared** (`Core/Interface/...`) so the shared UI depends only on abstractions. Concrete platform implementations live in **SteUp.Maui** (`Core/Services`, `Core/System`) and are registered per-host:
|
||||
- MAUI wires everything in `SteUp.Maui/Core/CoreModule.cs` via `Register*` extension methods called from `MauiProgram.cs`.
|
||||
- Web wires a smaller subset directly in `SteUp.Web/Program.cs` (only `IFormFactor` + auth; it has no local DB, scanner, or file manager).
|
||||
|
||||
When adding a service consumed by the shared UI: define the interface in `SteUp.Shared/Core/Interface`, implement it in the host, and register it in **both** `CoreModule.cs` and (if the Web app needs it) `Program.cs`.
|
||||
|
||||
### Auth
|
||||
`AppAuthenticationStateProvider` (Shared) bridges Integry's `IUserSession`/`IUserAccountService` (from `IntegryApiClient`) to Blazor's `AuthenticationStateProvider`, building `ClaimsPrincipal` from the JWT. Both hosts call `builder.UseIntegry(appToken, useLoginAzienda: true)` — the `AppToken` is the same GUID in `MauiProgram.cs` and `SteUp.Web/Program.cs`.
|
||||
|
||||
### Backend calls
|
||||
Two service wrappers over `IIntegryApiRestClient`:
|
||||
- `IntegrySteupService` — the app-specific `steup/*` endpoints (retrieve punti vendita, reparti, griglia PLU, barcode lookup, save/complete/delete schede, upload attachments).
|
||||
- `IntegryApiService` — generic Integry endpoints (system health, activity descriptions, email).
|
||||
|
||||
### Offline-first data flow
|
||||
`SteupDataService` (Shared) is the orchestrator: on `Init()` it runs EF migrations (`DbInitializer.InitializeAsync` → `db.Database.MigrateAsync()`), loads reference data from the API, reconciles inspection status, and prunes closed inspections older than 60 days. Local reads/writes go through `IspezioniService` (SteUp.Data).
|
||||
|
||||
### Local DB model
|
||||
`AppDbContext`: `Ispezione` → `Scheda` → `SchedaArticolo`. `Ispezione` has a **composite key** `(CodMdep, Data, Rilevatore)` which is also the FK from `Scheda`. `Scheda.ImageNames` is a `List<string>` persisted as JSON via a value converter. DB file: `steup_db.db3` in `FileSystem.AppDataDirectory` (see `DbPathProvider`).
|
||||
|
||||
Add a migration (from repo root; `AppDbContextFactory` provides the design-time context using a throwaway `design-time.db3`):
|
||||
```bash
|
||||
dotnet ef migrations add <Name> --project SteUp.Data
|
||||
```
|
||||
Migrations are applied automatically at app startup, not via `database update`.
|
||||
|
||||
### Messaging (in-app events)
|
||||
Uses `CommunityToolkit.Mvvm` `WeakReferenceMessenger`. Singleton "services" in `Shared/Core/Messages` (`NewSchedaService`, `OnScannerService`, `CompleteInspectionService`) publish/relay messages that decouple the barcode scanner and page components.
|
||||
|
||||
### Barcode scanning
|
||||
`IBarcodeReaderService` is implemented by `HoneywellScannerService` (MAUI, partial class with Android-specific parts + the Honeywell binding lib). `IBarcodeManager`/`BarcodeManager` (Shared) is the host-agnostic front the UI talks to. Scans flow to the UI through the messaging services above.
|
||||
|
||||
### Attached images
|
||||
Photos are served into the `BlazorWebView` through a custom scheme: `MainPage.xaml.cs` intercepts `WebResourceRequested` for `https://localfiles/attached/<file>` and streams files from `CacheDirectory/attached` (with path-traversal guards).
|
||||
|
||||
## Error tracking
|
||||
Sentry is configured in both hosts (`.UseSentry(...)` in MAUI, Sentry package in Shared). MAUI also has a `GlobalExceptionHandler` (registered in `App.xaml.cs`) and a `FileLogger`/`FileLoggerProvider` writing to `AppDataDirectory/logs`.
|
||||
|
||||
## Versioning
|
||||
App version lives in `SteUp.Maui.csproj` (`ApplicationDisplayVersion` + `ApplicationVersion`). Commit messages track releases (e.g. "-> v1.0.2 (3)"). Bump both when releasing. `ApplicationId` is `it.integry.SteUp`.
|
||||
@@ -0,0 +1,270 @@
|
||||
---
|
||||
name: SteUP
|
||||
description: Strumento da campo per ispezioni nei punti vendita — solido, chiaro, offline-first.
|
||||
colors:
|
||||
primary: "#ec4c41"
|
||||
primary-pressed: "#d23a30"
|
||||
primary-dark: "#002339"
|
||||
on-primary: "#ffffff"
|
||||
workspace: "#e9edf2"
|
||||
surface: "#ffffff"
|
||||
surface-muted: "#f4f6f9"
|
||||
divider: "#d9e0e8"
|
||||
ink: "#000000"
|
||||
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:
|
||||
headline-medium:
|
||||
fontFamily: "Inter, sans-serif"
|
||||
fontSize: "34px"
|
||||
fontWeight: 700
|
||||
lineHeight: 1.2
|
||||
letterSpacing: "-0.02em"
|
||||
headline-small:
|
||||
fontFamily: "Inter, sans-serif"
|
||||
fontSize: "28px"
|
||||
fontWeight: 700
|
||||
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-large:
|
||||
fontFamily: "Inter, sans-serif"
|
||||
fontSize: "16px"
|
||||
fontWeight: 400
|
||||
lineHeight: 1.5
|
||||
letterSpacing: "normal"
|
||||
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.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:
|
||||
compact: "8px"
|
||||
control: "16px"
|
||||
surface: "20px"
|
||||
sheet: "24px"
|
||||
pill: "999px"
|
||||
spacing:
|
||||
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.on-primary}"
|
||||
rounded: "{rounded.control}"
|
||||
typography: "{typography.label-large}"
|
||||
padding: "0 24px"
|
||||
button-outlined:
|
||||
backgroundColor: "{colors.surface}"
|
||||
textColor: "{colors.ink}"
|
||||
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
|
||||
|
||||
## 1. Overview
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
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.** Il corallo appare solo su azione o stato attivo, mai come decorazione.
|
||||
|
||||
**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
|
||||
|
||||
**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.
|
||||
|
||||
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 (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
|
||||
|
||||
**Zero.** Le superfici sono piatte: nessuna ombra, nessun `surface-tint`. La profondità è tonale (`surface` su `workspace`) più lo spazio.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
- **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 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. 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 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** 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.
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Product
|
||||
|
||||
## Register
|
||||
|
||||
product
|
||||
|
||||
## Users
|
||||
|
||||
Utente primario: **il rilevatore in campo**. Gira i punti vendita con un telefono o uno scanner Honeywell in mano, spesso in piedi, in movimento, all'aperto con luce forte, a volte con una mano sola o con i guanti, e con connessione di rete incerta o assente. Il suo compito durante il turno: scansionare i barcode degli articoli, compilare le schede di ispezione, allegare foto e chiudere l'ispezione. Strumento a uso interno Integry (non rivolto a clienti esterni), quindi contano più l'efficienza e la fiducia operativa che l'identità di marca.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
SteUP è lo strumento da campo per le ispezioni dei punti vendita. Esiste per far completare le rilevazioni in modo rapido, accurato e senza perdita di dati, anche quando manca la rete. Il lavoro viene salvato localmente (offline-first, SQLite sul dispositivo) e sincronizzato con il backend Integry quando la connessione torna. Successo = il rilevatore finisce il turno certo che ogni scheda sia stata registrata e inviata, senza mai dover ripetere il lavoro per un dato perso o un dubbio sullo stato.
|
||||
|
||||
## Brand Personality
|
||||
|
||||
Solida, chiara, senza fronzoli. È uno strumento da lavoro, non una vetrina: nitido, diretto, prevedibile. La fiducia nasce dalla robustezza percepita e dalla leggibilità dello stato, non dalla decorazione. Tono asciutto e concreto nei testi (etichette, conferme, errori). Coerente con l'identità esistente: corallo (`#ec4c41`) come colore d'azione, navy (`#002339`) come ancora, superfici pulite e arrotondate.
|
||||
|
||||
## Anti-references
|
||||
|
||||
- **Gestionale datato/denso.** Niente look da ERP: tabelle fittissime, testo minuscolo, form infiniti, grigio ovunque, densità che non serve al compito da campo.
|
||||
- **Consumer social/giocoso.** Niente estetica colorata/ludica da app social. Resta uno strumento professionale.
|
||||
- Valgono inoltre i divieti trasversali di impeccable: niente gradienti decorativi, niente testo con gradiente, niente glassmorphism di default, niente griglie di card tutte identiche, niente eyebrow maiuscoli su ogni sezione.
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Offline è la verità.** Lo stato di salvataggio e sincronizzazione non è mai ambiguo. Il rilevatore deve sempre poter capire, senza pensarci, cosa è salvato in locale e cosa è arrivato al server.
|
||||
- **Il pollice comanda.** Progettato per l'uso a una mano, con i guanti, in movimento: target di tocco ampi (≥44px), azioni primarie a portata di pollice, poco affollamento, niente bersagli piccoli.
|
||||
- **Leggibile al sole.** Contrasto alto e testo generoso come default, non come opzione. La leggibilità all'aperto viene prima dell'eleganza del grigio chiaro.
|
||||
- **Feedback immediato a ogni scan.** Ogni scansione e ogni tocco producono una risposta percepibile e istantanea; l'app conferma, non fa attendere.
|
||||
- **Meno passaggi, meno errori.** Il percorso più breve per completare una scheda corretta. Ogni schermata ha un compito primario evidente; le conferme prevengono l'errore invece di segnalarlo dopo.
|
||||
|
||||
## Accessibility & Inclusion
|
||||
|
||||
- **Uso all'aperto:** alto contrasto e dimensioni testo generose per la luce diretta del sole; evitare testo grigio chiaro su fondi tenui.
|
||||
- **Uso a una mano / con guanti:** target di tocco ≥44px, spaziatura sufficiente tra i controlli, azioni chiave raggiungibili in basso.
|
||||
- **Base WCAG AA come buona pratica:** corpo testo ≥4.5:1, focus visibili, rispetto di `prefers-reduced-motion`.
|
||||
- Interfaccia in **italiano** (locale `it-IT`).
|
||||
+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.
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using SteUp.Shared.Core.Entities;
|
||||
using SteUp.Shared.Core.Enum;
|
||||
using SteUp.Shared.Core.Helpers;
|
||||
@@ -17,8 +18,15 @@ public class IspezioniService(AppDbContext db) : IIspezioniService
|
||||
x.Data == data &&
|
||||
x.Rilevatore == rilevatore);
|
||||
|
||||
public Task<List<Ispezione>> GetAllIspezioniWithSchedeAsync() =>
|
||||
public Task<List<Ispezione>> GetAllIspezioni(string rilevatore) =>
|
||||
db.Ispezioni
|
||||
.Where(x => x.Rilevatore.ToLower() == rilevatore.ToLower())
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
public Task<List<Ispezione>> GetAllIspezioniWithSchedeAsync(string rilevatore) =>
|
||||
db.Ispezioni
|
||||
.Where(x => x.Rilevatore.ToLower() == rilevatore.ToLower())
|
||||
.Include(x => x.Schede)
|
||||
.ThenInclude(s => s.Articoli)
|
||||
.AsNoTracking()
|
||||
@@ -68,7 +76,8 @@ public class IspezioniService(AppDbContext db) : IIspezioniService
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateStatoIspezioneAsync(string codMdep, DateTime data, string rilevatore, StatusEnum stato)
|
||||
public async Task<bool> UpdateStatoIspezioneAsync(string codMdep, DateTime data, string rilevatore,
|
||||
StatusEnum stato)
|
||||
{
|
||||
var ispezione = await db.Ispezioni
|
||||
.FirstOrDefaultAsync(x =>
|
||||
@@ -108,6 +117,10 @@ public class IspezioniService(AppDbContext db) : IIspezioniService
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteIspezioneAsync(string codMdep, DateTime data, string rilevatore)
|
||||
{
|
||||
var schede = await GetAllSchedeOfIspezioneAsync(codMdep, data, rilevatore);
|
||||
if (schede.Count > 0)
|
||||
throw new Exception("Impossibile cancellare l'ispezione perché ci sono schede collegate.");
|
||||
|
||||
var ispezione = await db.Ispezioni
|
||||
.FirstOrDefaultAsync(x =>
|
||||
x.CodMdep == codMdep &&
|
||||
@@ -175,6 +188,17 @@ public class IspezioniService(AppDbContext db) : IIspezioniService
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateFileListSchedaAsync(int schedaId, List<string>? imageNames)
|
||||
{
|
||||
var scheda = await db.Schede.FirstOrDefaultAsync(x => x.Id == schedaId);
|
||||
if (scheda is null) return false;
|
||||
|
||||
scheda.ImageNames = imageNames;
|
||||
db.Schede.Update(scheda);
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteSchedaAsync(int schedaId)
|
||||
{
|
||||
var scheda = await db.Schede.FirstOrDefaultAsync(x => x.Id == schedaId);
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using SteUp.Data.LocalDb;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SteUp.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260916111422_AddBozzaScheda")]
|
||||
partial class AddBozzaScheda
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b =>
|
||||
{
|
||||
b.Property<string>("CodMdep")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Data")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Rilevatore")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ActivityId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Stato")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CodMdep", "Data", "Rilevatore");
|
||||
|
||||
b.ToTable("Ispezioni");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ActivityId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ActivityTypeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Bozza")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CodJfas")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CodMdep")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("Data")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DescrizioneReparto")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImageNames")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Note")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Responsabile")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Rilevatore")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Scadenza")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CodMdep", "Data", "Rilevatore");
|
||||
|
||||
b.ToTable("Schede");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.SchedaArticolo", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Barcode")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Descrizione")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SchedaId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SchedaId", "Barcode")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SchedaArticoli");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b =>
|
||||
{
|
||||
b.HasOne("SteUp.Shared.Core.Entities.Ispezione", "Ispezione")
|
||||
.WithMany("Schede")
|
||||
.HasForeignKey("CodMdep", "Data", "Rilevatore")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Ispezione");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.SchedaArticolo", b =>
|
||||
{
|
||||
b.HasOne("SteUp.Shared.Core.Entities.Scheda", "Scheda")
|
||||
.WithMany("Articoli")
|
||||
.HasForeignKey("SchedaId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Scheda");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b =>
|
||||
{
|
||||
b.Navigation("Schede");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.Scheda", b =>
|
||||
{
|
||||
b.Navigation("Articoli");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace SteUp.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBozzaScheda : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Bozza",
|
||||
table: "Schede",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Bozza",
|
||||
table: "Schede");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace SteUp.Data.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
|
||||
|
||||
modelBuilder.Entity("SteUp.Shared.Core.Entities.Ispezione", b =>
|
||||
{
|
||||
@@ -51,6 +51,9 @@ namespace SteUp.Data.Migrations
|
||||
b.Property<string>("ActivityTypeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Bozza")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("CodJfas")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_green" Version="2.1.11" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SteUp.Maui.Core.UtilityException;
|
||||
|
||||
namespace SteUp.Maui
|
||||
{
|
||||
public partial class App
|
||||
{
|
||||
public App()
|
||||
public App(ILogger<App> logger)
|
||||
{
|
||||
InitializeComponent();
|
||||
GlobalExceptionHandler.Register(logger);
|
||||
}
|
||||
|
||||
protected override Window CreateWindow(IActivationState? activationState)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SteUp.Data.LocalDb;
|
||||
using SteUp.Data.LocalDb.EntityServices;
|
||||
using SteUp.Maui.Core.Logger;
|
||||
using SteUp.Maui.Core.Services;
|
||||
using SteUp.Maui.Core.System;
|
||||
using SteUp.Maui.Core.System.Network;
|
||||
@@ -15,9 +17,8 @@ using SteUp.Shared.Core.Interface.IntegryApi;
|
||||
using SteUp.Shared.Core.Interface.LocalDb;
|
||||
using SteUp.Shared.Core.Interface.System;
|
||||
using SteUp.Shared.Core.Interface.System.Network;
|
||||
using SteUp.Shared.Core.Messages.Ispezione;
|
||||
using SteUp.Shared.Core.Messages.Scanner;
|
||||
using SteUp.Shared.Core.Messages.Scheda;
|
||||
using SteUp.Shared.Core.Messages.System;
|
||||
using SteUp.Shared.Core.Services;
|
||||
|
||||
namespace SteUp.Maui.Core;
|
||||
@@ -45,6 +46,7 @@ public static class CoreModule
|
||||
{
|
||||
builder.Services.AddSingleton<INetworkService, NetworkService>();
|
||||
builder.Services.AddSingleton<IAttachedService, AttachedService>();
|
||||
builder.Services.AddSingleton<IFileManager, FileManager>();
|
||||
builder.Services.AddSingleton<IBarcodeReaderService, HoneywellScannerService>();
|
||||
}
|
||||
|
||||
@@ -59,9 +61,9 @@ public static class CoreModule
|
||||
public void RegisterMessageServices()
|
||||
{
|
||||
builder.Services.AddSingleton<IMessenger, WeakReferenceMessenger>();
|
||||
builder.Services.AddSingleton<NewSchedaService>();
|
||||
builder.Services.AddSingleton<OnScannerService>();
|
||||
builder.Services.AddSingleton<CompleteInspectionService>();
|
||||
builder.Services.AddSingleton<BackButtonService>();
|
||||
builder.Services.AddSingleton<PageActionsService>();
|
||||
}
|
||||
|
||||
public void RegisterDbServices()
|
||||
@@ -75,5 +77,17 @@ public static class CoreModule
|
||||
builder.Services.AddSingleton<IDbInitializer, DbInitializer>();
|
||||
builder.Services.AddSingleton<IIspezioniService, IspezioniService>();
|
||||
}
|
||||
|
||||
public void RegisterLoggerServices()
|
||||
{
|
||||
var logPath = Path.Combine(FileSystem.AppDataDirectory, "logs");
|
||||
const string logFilePrefix = "SteUp-log";
|
||||
|
||||
builder.Services.AddLogging(loggingBuilder =>
|
||||
{
|
||||
loggingBuilder.AddProvider(new FileLoggerProvider(logPath, logFilePrefix));
|
||||
loggingBuilder.SetMinimumLevel(LogLevel.Information);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace SteUp.Maui.Core.Logger;
|
||||
|
||||
public class FileLogger : ILogger
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly string _fileNamePrefix;
|
||||
private readonly string _categoryName;
|
||||
private readonly Lock _lock = new();
|
||||
private readonly int _retentionDays;
|
||||
private string? _currentFileName;
|
||||
private DateTime _currentFileDate;
|
||||
private DateTime _lastCleanupDate;
|
||||
|
||||
public FileLogger(string path, string fileNamePrefix, string categoryName, int retentionDays = 60)
|
||||
{
|
||||
_path = path;
|
||||
_fileNamePrefix = fileNamePrefix;
|
||||
_retentionDays = retentionDays;
|
||||
_lastCleanupDate = DateTime.MinValue;
|
||||
_categoryName = categoryName;
|
||||
|
||||
if (!Directory.Exists(path))
|
||||
Directory.CreateDirectory(path);
|
||||
|
||||
UpdateCurrentFileName();
|
||||
TryCleanOldLogs();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Elimina i log più vecchi di <see cref="_retentionDays"/> giorni.
|
||||
/// Viene eseguita al massimo una volta al giorno.
|
||||
/// </summary>
|
||||
private void ClearOldLogs()
|
||||
{
|
||||
try
|
||||
{
|
||||
var cutoff = DateTime.Now.Date.AddDays(-_retentionDays);
|
||||
var logFiles = Directory.GetFiles(_path, $"{_fileNamePrefix}-*.log");
|
||||
|
||||
foreach (var file in logFiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
||||
|
||||
var datePart = fileName[(_fileNamePrefix.Length + 1)..];
|
||||
|
||||
if (!DateTime.TryParseExact(datePart, "yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None,
|
||||
out var fileDate) || fileDate >= cutoff) continue;
|
||||
File.Delete(file);
|
||||
Debug.WriteLine($"[FileLogger] Log eliminato: {file}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[FileLogger] Errore durante l'eliminazione del file {file}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[FileLogger] Errore durante la pulizia dei log: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Esegue la pulizia dei log solo se non è già stata eseguita oggi.
|
||||
/// </summary>
|
||||
private void TryCleanOldLogs()
|
||||
{
|
||||
var today = DateTime.Now.Date;
|
||||
if (_lastCleanupDate == today) return;
|
||||
_lastCleanupDate = today;
|
||||
ClearOldLogs();
|
||||
}
|
||||
|
||||
private void UpdateCurrentFileName()
|
||||
{
|
||||
var today = DateTime.Now.Date;
|
||||
if (_currentFileName != null && _currentFileDate == today) return;
|
||||
_currentFileDate = today;
|
||||
_currentFileName = $"{_fileNamePrefix}-{today:yyyy-MM-dd}.log";
|
||||
}
|
||||
|
||||
public IDisposable? BeginScope<TState>(TState state) => null;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => logLevel != LogLevel.None;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
if (!IsEnabled(logLevel))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
UpdateCurrentFileName();
|
||||
TryCleanOldLogs();
|
||||
|
||||
if (_currentFileName == null) return;
|
||||
|
||||
var fullPath = Path.Combine(_path, _currentFileName);
|
||||
var logEntry = BuildLogEntry(logLevel, eventId, state, exception, formatter);
|
||||
|
||||
File.AppendAllText(fullPath, logEntry + Environment.NewLine + Environment.NewLine);
|
||||
Debug.WriteLine($"[FileLogger] {logEntry}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[FileLogger] Errore durante la scrittura del log: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private string BuildLogEntry<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception? exception,
|
||||
Func<TState, Exception?, string> formatter)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
|
||||
sb.Append($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}]");
|
||||
sb.Append($" [{GetLogLevelShort(logLevel)}]");
|
||||
sb.Append($" [{_categoryName}]");
|
||||
|
||||
if (eventId.Id != 0 || !string.IsNullOrEmpty(eventId.Name))
|
||||
sb.Append($" [{eventId}]");
|
||||
|
||||
sb.Append($" {formatter(state, exception)}");
|
||||
|
||||
if (exception != null)
|
||||
AppendException(sb, exception);
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static void AppendException(StringBuilder sb, Exception exception, int depth = 0)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var indent = depth == 0 ? "" : " Inner ";
|
||||
sb.AppendLine();
|
||||
sb.Append($"{indent}Exception: {exception.GetType().FullName}: {exception.Message}");
|
||||
if (!string.IsNullOrWhiteSpace(exception.StackTrace))
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.Append($"{indent}StackTrace: {exception.StackTrace.Trim()}");
|
||||
}
|
||||
|
||||
if (exception.InnerException != null)
|
||||
{
|
||||
exception = exception.InnerException;
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetLogLevelShort(LogLevel level) => level switch
|
||||
{
|
||||
LogLevel.Trace => "TRC",
|
||||
LogLevel.Debug => "DBG",
|
||||
LogLevel.Information => "INF",
|
||||
LogLevel.Warning => "WRN",
|
||||
LogLevel.Error => "ERR",
|
||||
LogLevel.Critical => "CRT",
|
||||
_ => "???"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace SteUp.Maui.Core.Logger;
|
||||
|
||||
public class FileLoggerProvider(string path, string logFilePrefix, int retentionDays = 60)
|
||||
: ILoggerProvider
|
||||
{
|
||||
public ILogger CreateLogger(string categoryName)
|
||||
{
|
||||
return new FileLogger(path, logFilePrefix, categoryName, retentionDays);
|
||||
}
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
using SteUp.Shared.Core.Dto;
|
||||
using SteUp.Shared.Core.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SteUp.Maui.Core.Utility;
|
||||
using SteUp.Shared.Core.Dto;
|
||||
using SteUp.Shared.Core.Helpers;
|
||||
using SteUp.Shared.Core.Interface.System;
|
||||
|
||||
namespace SteUp.Maui.Core.Services;
|
||||
|
||||
public class AttachedService : IAttachedService
|
||||
public class AttachedService(ILogger<FileManager> logger) : IAttachedService
|
||||
{
|
||||
private static string AttachedRoot =>
|
||||
Path.Combine(FileSystem.CacheDirectory, "attached");
|
||||
|
||||
public async Task<AttachedDto?> SelectImageFromCamera()
|
||||
{
|
||||
var cameraPerm = await Permissions.RequestAsync<Permissions.Camera>();
|
||||
@@ -27,12 +25,13 @@ public class AttachedService : IAttachedService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, ex.Message);
|
||||
Console.WriteLine($"Errore cattura foto: {ex.Message}");
|
||||
SentrySdk.CaptureException(ex);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result is null ? null : await ConvertToDto(result, AttachedDto.TypeAttached.Image);
|
||||
return result is null ? null : await UtilityFile.ConvertToDto(result, AttachedDto.TypeAttached.Image);
|
||||
}
|
||||
|
||||
public async Task<List<AttachedDto>?> SelectImageFromGallery()
|
||||
@@ -48,6 +47,7 @@ public class AttachedService : IAttachedService
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, ex.Message);
|
||||
Console.WriteLine($"Errore selezione galleria: {ex.Message}");
|
||||
SentrySdk.CaptureException(ex);
|
||||
return null;
|
||||
@@ -58,308 +58,9 @@ public class AttachedService : IAttachedService
|
||||
List<AttachedDto> returnList = [];
|
||||
foreach (var fileResult in resultList)
|
||||
{
|
||||
returnList.Add(await ConvertToDto(fileResult, AttachedDto.TypeAttached.Image));
|
||||
returnList.Add(await UtilityFile.ConvertToDto(fileResult, AttachedDto.TypeAttached.Image));
|
||||
}
|
||||
|
||||
return returnList;
|
||||
}
|
||||
|
||||
private static async Task<AttachedDto> ConvertToDto(FileResult file, AttachedDto.TypeAttached type)
|
||||
{
|
||||
var stream = await file.OpenReadAsync();
|
||||
using var ms = new MemoryStream();
|
||||
await stream.CopyToAsync(ms);
|
||||
|
||||
return new AttachedDto
|
||||
{
|
||||
Name = file.FileName,
|
||||
Path = file.FullPath,
|
||||
MimeType = file.ContentType,
|
||||
DimensionBytes = ms.Length,
|
||||
FileBytes = ms.ToArray(),
|
||||
Type = type
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<AttachedDto> ConvertToDto(FileInfo file, AttachedDto.TypeAttached type, bool isFromToUpload)
|
||||
{
|
||||
var (origUrl, thumbUrl) = await SaveAndCreateThumbAsync(
|
||||
await File.ReadAllBytesAsync(file.FullName),
|
||||
file.Name
|
||||
);
|
||||
|
||||
return new AttachedDto
|
||||
{
|
||||
Name = file.Name,
|
||||
Path = file.FullName,
|
||||
TempPath = origUrl,
|
||||
ThumbPath = thumbUrl,
|
||||
Type = type,
|
||||
SavedOnAppData = true,
|
||||
ToUpload = isFromToUpload
|
||||
};
|
||||
}
|
||||
|
||||
private const string ToUploadFolderName = "toUpload";
|
||||
|
||||
private string GetInspectionBaseDir(Ispezione ispezione)
|
||||
{
|
||||
var baseDir = FileSystem.AppDataDirectory;
|
||||
return Path.Combine(baseDir, $"attached_{GetInspectionKey(ispezione)}");
|
||||
}
|
||||
|
||||
private string GetInspectionToUploadDir(Ispezione ispezione)
|
||||
=> Path.Combine(GetInspectionBaseDir(ispezione), ToUploadFolderName);
|
||||
|
||||
private string GetInspectionFinalDir(Ispezione ispezione)
|
||||
=> GetInspectionBaseDir(ispezione);
|
||||
|
||||
/// <summary>
|
||||
/// Ritorna i file dell'ispezione filtrati per nome.
|
||||
/// Per default include sia "final" sia "toUpload" (utile per UI).
|
||||
/// </summary>
|
||||
public async Task<List<AttachedDto>?> GetInspectionFiles(
|
||||
Ispezione ispezione,
|
||||
List<string> fileNameFilter,
|
||||
bool includeToUpload,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ispezione);
|
||||
ArgumentNullException.ThrowIfNull(fileNameFilter);
|
||||
|
||||
var baseDir = GetInspectionBaseDir(ispezione);
|
||||
if (!Directory.Exists(baseDir)) return null;
|
||||
|
||||
var result = new List<AttachedDto>();
|
||||
|
||||
var finalDir = GetInspectionFinalDir(ispezione);
|
||||
if (Directory.Exists(finalDir))
|
||||
{
|
||||
var finalFiles = new DirectoryInfo(finalDir)
|
||||
.GetFiles("*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
foreach (var file in finalFiles)
|
||||
{
|
||||
if (file.Directory?.Name == ToUploadFolderName)
|
||||
continue;
|
||||
|
||||
if (!fileNameFilter.Contains(file.Name))
|
||||
continue;
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
result.Add(await ConvertToDto(
|
||||
file,
|
||||
AttachedDto.TypeAttached.Image,
|
||||
isFromToUpload: false));
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeToUpload) return result;
|
||||
|
||||
var toUploadDir = GetInspectionToUploadDir(ispezione);
|
||||
if (!Directory.Exists(toUploadDir)) return result;
|
||||
|
||||
var toUploadFiles = new DirectoryInfo(toUploadDir)
|
||||
.GetFiles("*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
foreach (var file in toUploadFiles)
|
||||
{
|
||||
if (!fileNameFilter.Contains(file.Name))
|
||||
continue;
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
result.Add(await ConvertToDto(
|
||||
file,
|
||||
AttachedDto.TypeAttached.Image,
|
||||
isFromToUpload: true));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Salva SEMPRE in /toUpload.
|
||||
/// </summary>
|
||||
public async Task<string?> SaveInspectionFile(
|
||||
Ispezione ispezione,
|
||||
byte[] file,
|
||||
string fileName,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ispezione);
|
||||
ArgumentNullException.ThrowIfNull(file);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||
|
||||
var toUploadDir = GetInspectionToUploadDir(ispezione);
|
||||
Directory.CreateDirectory(toUploadDir);
|
||||
|
||||
var filePath = Path.Combine(toUploadDir, fileName);
|
||||
await File.WriteAllBytesAsync(filePath, file, ct);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
public Task<bool> MoveInspectionFileFromToUploadToFinal(
|
||||
Ispezione ispezione,
|
||||
string fileName,
|
||||
bool overwrite,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ispezione);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var toUploadDir = GetInspectionToUploadDir(ispezione);
|
||||
var finalDir = GetInspectionFinalDir(ispezione);
|
||||
|
||||
if (!Directory.Exists(toUploadDir)) return Task.FromResult(false);
|
||||
|
||||
var sourcePath = Path.Combine(toUploadDir, fileName);
|
||||
if (!File.Exists(sourcePath)) return Task.FromResult(false);
|
||||
|
||||
Directory.CreateDirectory(finalDir);
|
||||
|
||||
var destPath = Path.Combine(finalDir, fileName);
|
||||
|
||||
if (File.Exists(destPath))
|
||||
{
|
||||
if (!overwrite) return Task.FromResult(false);
|
||||
File.Delete(destPath);
|
||||
}
|
||||
|
||||
File.Move(sourcePath, destPath);
|
||||
|
||||
// Pulizia: se /toUpload resta vuota la elimino
|
||||
CleanupDirectoriesIfEmpty(ispezione);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rimuove un file cercandolo prima in /toUpload e poi in final (o viceversa).
|
||||
/// Default: prova a cancellare ovunque.
|
||||
/// </summary>
|
||||
public bool RemoveInspectionFile(
|
||||
Ispezione ispezione,
|
||||
string fileName,
|
||||
bool removeAlsoFromFinal,
|
||||
bool removeAlsoFromToUpload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ispezione);
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return false;
|
||||
|
||||
var removed = false;
|
||||
|
||||
if (removeAlsoFromToUpload)
|
||||
{
|
||||
var toUploadPath = Path.Combine(GetInspectionToUploadDir(ispezione), fileName);
|
||||
if (File.Exists(toUploadPath))
|
||||
{
|
||||
File.Delete(toUploadPath);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removeAlsoFromFinal)
|
||||
{
|
||||
var finalPath = Path.Combine(GetInspectionFinalDir(ispezione), fileName);
|
||||
if (File.Exists(finalPath))
|
||||
{
|
||||
File.Delete(finalPath);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed)
|
||||
CleanupDirectoriesIfEmpty(ispezione);
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
private void CleanupDirectoriesIfEmpty(Ispezione ispezione)
|
||||
{
|
||||
var baseDir = GetInspectionBaseDir(ispezione);
|
||||
var toUploadDir = GetInspectionToUploadDir(ispezione);
|
||||
|
||||
// 1) se /toUpload esiste e vuota => delete
|
||||
if (Directory.Exists(toUploadDir) && !Directory.EnumerateFileSystemEntries(toUploadDir).Any())
|
||||
Directory.Delete(toUploadDir);
|
||||
|
||||
// 2) se base dir vuota (attenzione: dopo delete toUpload) => delete
|
||||
if (Directory.Exists(baseDir) && !Directory.EnumerateFileSystemEntries(baseDir).Any())
|
||||
Directory.Delete(baseDir);
|
||||
}
|
||||
|
||||
public async Task<string> SaveToTempStorage(Stream file, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(file);
|
||||
|
||||
if (file.CanSeek)
|
||||
file.Position = 0;
|
||||
|
||||
fileName = Path.GetFileName(fileName);
|
||||
|
||||
var dir = FileSystem.CacheDirectory;
|
||||
var filePath = Path.Combine(dir, fileName);
|
||||
|
||||
await using var fileStream = File.Create(filePath);
|
||||
await file.CopyToAsync(fileStream, ct);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
public Task CleanTempStorageAsync(CancellationToken ct = default)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
if (Directory.Exists(AttachedRoot))
|
||||
Directory.Delete(AttachedRoot, true);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public Task OpenFile(string fileName, string filePath)
|
||||
{
|
||||
#if IOS
|
||||
throw new NotImplementedException();
|
||||
#else
|
||||
return Launcher.OpenAsync(new OpenFileRequest
|
||||
{
|
||||
Title = "Apri file",
|
||||
File = new ReadOnlyFile(filePath)
|
||||
});
|
||||
#endif
|
||||
}
|
||||
|
||||
public async Task<(string originalUrl, string thumbUrl)> SaveAndCreateThumbAsync(
|
||||
byte[] bytes, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
Directory.CreateDirectory(AttachedRoot);
|
||||
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var safeName = SanitizeFileName(fileName);
|
||||
|
||||
var originalFile = $"{id}_{safeName}";
|
||||
var thumbFile = $"{id}_thumb.jpg";
|
||||
|
||||
var originalPath = Path.Combine(AttachedRoot, originalFile);
|
||||
await File.WriteAllBytesAsync(originalPath, bytes, ct);
|
||||
|
||||
var thumbPath = Path.Combine(AttachedRoot, thumbFile);
|
||||
await ImageThumb.CreateThumbnailAsync(originalPath, thumbPath, maxSide: 320, quality: 70, ct);
|
||||
|
||||
return ($"https://localfiles/attached/{originalFile}",
|
||||
$"https://localfiles/attached/{thumbFile}");
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var name = Path.GetFileName(fileName);
|
||||
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
||||
}
|
||||
|
||||
private static string GetInspectionKey(Ispezione ispezione) =>
|
||||
$"{ispezione.CodMdep}_{ispezione.Data:ddMMyyyy}_{ispezione.Rilevatore.ToLower()}";
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SteUp.Data.LocalDb;
|
||||
using SteUp.Shared.Core.Dto;
|
||||
using SteUp.Shared.Core.Entities;
|
||||
using SteUp.Shared.Core.Helpers;
|
||||
using SteUp.Shared.Core.Interface.System;
|
||||
|
||||
namespace SteUp.Maui.Core.Services;
|
||||
|
||||
public class FileManager(
|
||||
IDbPathProvider dbPathProvider,
|
||||
ILogger<FileManager> logger) : IFileManager
|
||||
{
|
||||
private static string AttachedRoot =>
|
||||
Path.Combine(FileSystem.CacheDirectory, "attached");
|
||||
|
||||
private async Task<AttachedDto> ConvertToDto(FileInfo file, AttachedDto.TypeAttached type, bool isFromToUpload)
|
||||
{
|
||||
var (origUrl, thumbUrl) = await SaveAndCreateThumbAsync(
|
||||
await File.ReadAllBytesAsync(file.FullName),
|
||||
file.Name
|
||||
);
|
||||
|
||||
return new AttachedDto
|
||||
{
|
||||
Name = file.Name,
|
||||
Path = file.FullName,
|
||||
TempPath = origUrl,
|
||||
ThumbPath = thumbUrl,
|
||||
Type = type,
|
||||
SavedOnAppData = true,
|
||||
ToUpload = isFromToUpload
|
||||
};
|
||||
}
|
||||
|
||||
private const string ToUploadFolderName = "toUpload";
|
||||
|
||||
private string GetInspectionBaseDir(Ispezione ispezione)
|
||||
{
|
||||
var baseDir = FileSystem.AppDataDirectory;
|
||||
return Path.Combine(baseDir, "attached", $"inspection_{GetInspectionKey(ispezione)}");
|
||||
}
|
||||
|
||||
private string GetInspectionToUploadDir(Ispezione ispezione)
|
||||
=> Path.Combine(GetInspectionBaseDir(ispezione), ToUploadFolderName);
|
||||
|
||||
public string GetFileToUploadDir(Ispezione ispezione, string fileName) =>
|
||||
Path.Combine(GetInspectionToUploadDir(ispezione), fileName);
|
||||
|
||||
private string GetInspectionFinalDir(Ispezione ispezione)
|
||||
=> GetInspectionBaseDir(ispezione);
|
||||
|
||||
/// <summary>
|
||||
/// Ritorna i file dell'ispezione filtrati per nome.
|
||||
/// Per default include sia "final" sia "toUpload" (utile per UI).
|
||||
/// </summary>
|
||||
public async Task<List<AttachedDto>?> GetInspectionFiles(
|
||||
Ispezione ispezione,
|
||||
List<string> fileNameFilter,
|
||||
bool includeToUpload,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ispezione);
|
||||
ArgumentNullException.ThrowIfNull(fileNameFilter);
|
||||
|
||||
var baseDir = GetInspectionBaseDir(ispezione);
|
||||
if (!Directory.Exists(baseDir)) return null;
|
||||
|
||||
var result = new List<AttachedDto>();
|
||||
|
||||
var finalDir = GetInspectionFinalDir(ispezione);
|
||||
if (Directory.Exists(finalDir))
|
||||
{
|
||||
var finalFiles = new DirectoryInfo(finalDir)
|
||||
.GetFiles("*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
foreach (var file in finalFiles)
|
||||
{
|
||||
if (file.Directory?.Name == ToUploadFolderName)
|
||||
continue;
|
||||
|
||||
if (!fileNameFilter.Contains(file.FullName))
|
||||
continue;
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
result.Add(await ConvertToDto(
|
||||
file,
|
||||
AttachedDto.TypeAttached.Image,
|
||||
isFromToUpload: false));
|
||||
}
|
||||
}
|
||||
|
||||
if (!includeToUpload) return result;
|
||||
|
||||
var toUploadDir = GetInspectionToUploadDir(ispezione);
|
||||
if (!Directory.Exists(toUploadDir)) return result;
|
||||
|
||||
var toUploadFiles = new DirectoryInfo(toUploadDir)
|
||||
.GetFiles("*", SearchOption.TopDirectoryOnly);
|
||||
|
||||
foreach (var file in toUploadFiles)
|
||||
{
|
||||
if (!fileNameFilter.Contains(file.Name))
|
||||
continue;
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
result.Add(await ConvertToDto(
|
||||
file,
|
||||
AttachedDto.TypeAttached.Image,
|
||||
isFromToUpload: true));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Salva SEMPRE in /toUpload.
|
||||
/// </summary>
|
||||
public async Task<string?> SaveInspectionFile(
|
||||
Ispezione ispezione,
|
||||
byte[] file,
|
||||
string fileName,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ispezione);
|
||||
ArgumentNullException.ThrowIfNull(file);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||
|
||||
var toUploadDir = GetInspectionToUploadDir(ispezione);
|
||||
Directory.CreateDirectory(toUploadDir);
|
||||
|
||||
var filePath = Path.Combine(toUploadDir, fileName);
|
||||
await File.WriteAllBytesAsync(filePath, file, ct);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
public Task<string?> MoveInspectionFileFromToUploadToFinal(
|
||||
Ispezione ispezione,
|
||||
string fileName,
|
||||
bool overwrite,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ispezione);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var toUploadDir = GetInspectionToUploadDir(ispezione);
|
||||
var finalDir = GetInspectionFinalDir(ispezione);
|
||||
|
||||
if (!Directory.Exists(toUploadDir)) return Task.FromResult<string?>(null);
|
||||
|
||||
var sourcePath = Path.Combine(toUploadDir, fileName);
|
||||
if (!File.Exists(sourcePath)) return Task.FromResult<string?>(null);
|
||||
|
||||
Directory.CreateDirectory(finalDir);
|
||||
|
||||
var destPath = Path.Combine(finalDir, fileName);
|
||||
|
||||
if (File.Exists(destPath))
|
||||
{
|
||||
if (!overwrite) return Task.FromResult<string?>(null);
|
||||
File.Delete(destPath);
|
||||
}
|
||||
|
||||
File.Move(sourcePath, destPath);
|
||||
|
||||
// Pulizia: se /toUpload resta vuota la elimino
|
||||
CleanupDirectoriesIfEmpty(ispezione);
|
||||
|
||||
return Task.FromResult<string?>(destPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rimuove un file cercandolo prima in /toUpload e poi in final (o viceversa).
|
||||
/// Default: prova a cancellare ovunque.
|
||||
/// </summary>
|
||||
public bool RemoveInspectionFile(
|
||||
Ispezione ispezione,
|
||||
string fileName,
|
||||
bool removeAlsoFromFinal,
|
||||
bool removeAlsoFromToUpload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ispezione);
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return false;
|
||||
|
||||
var removed = false;
|
||||
|
||||
if (removeAlsoFromToUpload)
|
||||
{
|
||||
var toUploadPath = Path.Combine(GetInspectionToUploadDir(ispezione), fileName);
|
||||
if (File.Exists(toUploadPath))
|
||||
{
|
||||
File.Delete(toUploadPath);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removeAlsoFromFinal)
|
||||
{
|
||||
var finalPath = Path.Combine(GetInspectionFinalDir(ispezione), fileName);
|
||||
if (File.Exists(finalPath))
|
||||
{
|
||||
File.Delete(finalPath);
|
||||
removed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removed)
|
||||
CleanupDirectoriesIfEmpty(ispezione);
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
public bool RemoveInspection(Ispezione ispezione, bool removeAlsoFromFinal, bool removeAlsoFromToUpload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ispezione);
|
||||
|
||||
var removedAnything = false;
|
||||
|
||||
if (removeAlsoFromToUpload)
|
||||
TryDeleteDirectory(GetInspectionToUploadDir(ispezione));
|
||||
|
||||
if (removeAlsoFromFinal)
|
||||
TryDeleteDirectory(GetInspectionFinalDir(ispezione));
|
||||
|
||||
try
|
||||
{
|
||||
CleanupDirectoriesIfEmpty(ispezione);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
logger.LogError(e, e.Message);
|
||||
SentrySdk.CaptureException(e);
|
||||
}
|
||||
|
||||
return removedAnything;
|
||||
|
||||
void TryDeleteDirectory(string? dir)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dir)) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(dir)) return;
|
||||
|
||||
Directory.Delete(dir, recursive: true);
|
||||
removedAnything = true;
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
logger.LogError(e, e.Message);
|
||||
SentrySdk.CaptureException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupDirectoriesIfEmpty(Ispezione ispezione)
|
||||
{
|
||||
var baseDir = GetInspectionBaseDir(ispezione);
|
||||
var toUploadDir = GetInspectionToUploadDir(ispezione);
|
||||
|
||||
// 1) se /toUpload esiste e vuota => delete
|
||||
if (Directory.Exists(toUploadDir) && !Directory.EnumerateFileSystemEntries(toUploadDir).Any())
|
||||
Directory.Delete(toUploadDir);
|
||||
|
||||
// 2) se base dir vuota (attenzione: dopo delete toUpload) => delete
|
||||
if (Directory.Exists(baseDir) && !Directory.EnumerateFileSystemEntries(baseDir).Any())
|
||||
Directory.Delete(baseDir);
|
||||
}
|
||||
|
||||
public async Task<string> SaveToTempStorage(Stream file, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(file);
|
||||
|
||||
if (file.CanSeek)
|
||||
file.Position = 0;
|
||||
|
||||
fileName = Path.GetFileName(fileName);
|
||||
|
||||
var dir = FileSystem.CacheDirectory;
|
||||
var filePath = Path.Combine(dir, fileName);
|
||||
|
||||
await using var fileStream = File.Create(filePath);
|
||||
await file.CopyToAsync(fileStream, ct);
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
public Task CleanTempStorageAsync(CancellationToken ct = default)
|
||||
{
|
||||
return Task.Run(() =>
|
||||
{
|
||||
if (Directory.Exists(AttachedRoot))
|
||||
Directory.Delete(AttachedRoot, true);
|
||||
}, ct);
|
||||
}
|
||||
|
||||
public Task OpenFile(string fileName, string filePath)
|
||||
{
|
||||
#if IOS
|
||||
throw new NotImplementedException();
|
||||
#else
|
||||
return Launcher.OpenAsync(new OpenFileRequest
|
||||
{
|
||||
Title = "Apri file",
|
||||
File = new ReadOnlyFile(filePath)
|
||||
});
|
||||
#endif
|
||||
}
|
||||
|
||||
public async Task<(string originalUrl, string thumbUrl)> SaveAndCreateThumbAsync(
|
||||
byte[] bytes, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
Directory.CreateDirectory(AttachedRoot);
|
||||
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var safeName = SanitizeFileName(fileName);
|
||||
|
||||
var originalFile = $"{id}_{safeName}";
|
||||
var thumbFile = $"{id}_thumb.jpg";
|
||||
|
||||
var originalPath = Path.Combine(AttachedRoot, originalFile);
|
||||
await File.WriteAllBytesAsync(originalPath, bytes, ct);
|
||||
|
||||
var thumbPath = Path.Combine(AttachedRoot, thumbFile);
|
||||
await ImageThumb.CreateThumbnailAsync(originalPath, thumbPath, maxSide: 320, quality: 70, ct);
|
||||
|
||||
return ($"https://localfiles/attached/{originalFile}",
|
||||
$"https://localfiles/attached/{thumbFile}");
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var name = Path.GetFileName(fileName);
|
||||
return Path.GetInvalidFileNameChars().Aggregate(name, (current, c) => current.Replace(c, '_'));
|
||||
}
|
||||
|
||||
private static string GetInspectionKey(Ispezione ispezione) =>
|
||||
$"{ispezione.CodMdep}_{ispezione.Data:ddMMyyyy}_{ispezione.Rilevatore.ToLower()}";
|
||||
|
||||
public List<SendEmailDto.AttachmentsDto> GetFileForExport()
|
||||
{
|
||||
var attachments = new List<SendEmailDto.AttachmentsDto>();
|
||||
|
||||
// 1) log file singolo (se ti serve ancora)
|
||||
var logFile = RetrieveLogFile();
|
||||
if (!logFile.IsNullOrEmpty())
|
||||
{
|
||||
attachments.Add(new SendEmailDto.AttachmentsDto
|
||||
{
|
||||
FileName = $"logs_{DateTime.Today:yyyyMMdd}.zip",
|
||||
FileContent = CreateZipBytes(logFile!)
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
.EnumerateFiles("*", SearchOption.AllDirectories)
|
||||
.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,31 @@
|
||||
using SteUp.Maui.Core.Services;
|
||||
using SteUp.Shared.Core.Dto;
|
||||
|
||||
namespace SteUp.Maui.Core.Utility;
|
||||
|
||||
public static class UtilityFile
|
||||
{
|
||||
public static async Task<AttachedDto> ConvertToDto(FileResult file, AttachedDto.TypeAttached type)
|
||||
{
|
||||
var stream = await file.OpenReadAsync();
|
||||
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 = bytes.Length,
|
||||
FileBytes = bytes,
|
||||
Type = type
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace SteUp.Maui.Core.UtilityException;
|
||||
|
||||
public static class GlobalExceptionHandler
|
||||
{
|
||||
public static void Register(ILogger logger)
|
||||
{
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
|
||||
{
|
||||
var ex = args.ExceptionObject as Exception;
|
||||
logger.LogCritical(ex, "UnhandledException (AppDomain) — IsTerminating: {t}", args.IsTerminating);
|
||||
};
|
||||
|
||||
TaskScheduler.UnobservedTaskException += (_, args) =>
|
||||
{
|
||||
logger.LogCritical(args.Exception, "UnobservedTaskException");
|
||||
args.SetObserved();
|
||||
};
|
||||
|
||||
#if ANDROID
|
||||
Android.Runtime.AndroidEnvironment.UnhandledExceptionRaiser += (_, args) =>
|
||||
{
|
||||
logger.LogCritical(args.Exception, "Android UnhandledException");
|
||||
args.Handled = true;
|
||||
};
|
||||
#endif
|
||||
|
||||
#if IOS || MACCATALYST
|
||||
ObjCRuntime.Runtime.MarshalManagedException += (_, args) =>
|
||||
{
|
||||
logger.LogCritical(args.Exception, "iOS MarshalManagedException");
|
||||
args.ExceptionMode = ObjCRuntime.MarshalManagedExceptionMode.UnwindNativeCode;
|
||||
};
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -46,6 +46,7 @@ namespace SteUp.Maui
|
||||
builder.RegisterSystemService();
|
||||
builder.RegisterDbServices();
|
||||
builder.RegisterMessageServices();
|
||||
builder.RegisterLoggerServices();
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Android.OS;
|
||||
using Com.Honeywell.Aidc;
|
||||
using SteUp.Shared.Core.BarcodeReader.Dto;
|
||||
using SteUp.Shared.Core.BarcodeReader.Enum;
|
||||
|
||||
namespace SteUp.Maui.Core.System;
|
||||
|
||||
@@ -9,7 +10,7 @@ public partial class HoneywellScannerService
|
||||
private AidcManager? _aidcManager;
|
||||
private BarcodeReader? _barcodeReader;
|
||||
|
||||
private static readonly List<string> CompatibleModels = ["EDA50", "EDA51", "EDA52", "CT60"];
|
||||
private static readonly List<string> CompatibleModels = ["EDA50", "EDA51", "EDA52", "CT60", "CT40", "CT40XP", "CT70-X1"];
|
||||
|
||||
public partial bool IsRightAdapter()
|
||||
{
|
||||
@@ -87,13 +88,40 @@ public partial class HoneywellScannerService
|
||||
var dto = new BarcodeScanDto
|
||||
{
|
||||
StringValue = e.BarcodeData,
|
||||
Type = e.CodeId,
|
||||
Type = MapCodeId(e.CodeId),
|
||||
Name = e.CodeId
|
||||
};
|
||||
|
||||
OnScanSuccessful?.Invoke(dto);
|
||||
}
|
||||
|
||||
// Honeywell restituisce il simbologia tramite un Code ID di un solo carattere (getCodeId),
|
||||
// non un enum. Lo mappiamo su BarcodeType. Ai fini dell'app conta distinguere
|
||||
// EAN13 / CODE128 / EAN128 (vedi BarcodeManager.IsEanPeso); il resto è best-effort.
|
||||
private static BarcodeType MapCodeId(string? codeId) => codeId switch
|
||||
{
|
||||
"a" => BarcodeType.CODABAR,
|
||||
"b" => BarcodeType.CODE39,
|
||||
"c" => BarcodeType.UPCA,
|
||||
"d" => BarcodeType.EAN13,
|
||||
"D" => BarcodeType.EAN8,
|
||||
"e" => BarcodeType.INTERLEAVED_2OF5,
|
||||
"E" => BarcodeType.UPCE,
|
||||
"g" => BarcodeType.MSI,
|
||||
"h" => BarcodeType.CODE11,
|
||||
"i" => BarcodeType.CODE93,
|
||||
"j" => BarcodeType.CODE128, // Code 128 e GS1-128 condividono il Code ID 'j'
|
||||
"I" => BarcodeType.EAN128, // alcune generazioni riportano GS1-128 come 'I'
|
||||
"r" => BarcodeType.PDF417,
|
||||
"R" => BarcodeType.MICRO_PDF417,
|
||||
"s" => BarcodeType.QR,
|
||||
"w" => BarcodeType.DATAMATRIX,
|
||||
"x" => BarcodeType.MAXICODE,
|
||||
"y" => BarcodeType.GS1_DATABAR_14,
|
||||
"z" => BarcodeType.AZTEC,
|
||||
_ => BarcodeType.NIL
|
||||
};
|
||||
|
||||
private static Dictionary<string, Java.Lang.Object> GetDefaultProperties() => new()
|
||||
{
|
||||
{ BarcodeReader.PropertyEan8Enabled, true },
|
||||
@@ -136,7 +164,14 @@ public partial class HoneywellScannerService
|
||||
: Java.Lang.Object, BarcodeReader.IBarcodeListener
|
||||
{
|
||||
public void OnBarcodeEvent(BarcodeReadEvent? e) => service.DispatchEvent(e!);
|
||||
public void OnFailureEvent(BarcodeFailureEvent? e) =>
|
||||
service.OnScanFailed?.Invoke(new Exception($"Scan failure: {e}"));
|
||||
|
||||
// Honeywell emette BarcodeFailureEvent anche solo premendo e rilasciando il
|
||||
// trigger senza inquadrare nulla: e' la normale mancata lettura, non un guasto.
|
||||
// Trattarlo come eccezione faceva comparire un dialog bloccante col toString()
|
||||
// Java a ogni pressione a vuoto. Non c'e' nulla da comunicare: chi ha premuto
|
||||
// il trigger sa gia' di non aver letto niente e sta ripuntando lo scanner.
|
||||
public void OnFailureEvent(BarcodeFailureEvent? e)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,146 @@
|
||||
using Android.App;
|
||||
using Android.Content.PM;
|
||||
using Android.OS;
|
||||
using Android.Views;
|
||||
using AndroidX.Core.View;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using SteUp.Shared.Core.Messages.System;
|
||||
|
||||
namespace SteUp.Maui
|
||||
{
|
||||
[Activity(
|
||||
Theme = "@style/Maui.SplashTheme",
|
||||
Theme = "@style/SteUp.SplashTheme",
|
||||
MainLauncher = true,
|
||||
WindowSoftInputMode = SoftInput.AdjustResize | SoftInput.StateHidden,
|
||||
ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode |
|
||||
ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)]
|
||||
public class MainActivity : MauiAppCompatActivity
|
||||
{
|
||||
private BackButtonService? _backButton;
|
||||
private BlazorBackCallback? _backCallback;
|
||||
|
||||
protected override void OnCreate(Bundle? savedInstanceState)
|
||||
{
|
||||
base.OnCreate(savedInstanceState);
|
||||
|
||||
ApplySystemBarsAppearance();
|
||||
HookBackButton();
|
||||
|
||||
// Da Android 15 (API 35) il edge-to-edge e' forzato e adjustResize non ridimensiona
|
||||
// piu' la finestra: la tastiera si limita a coprire la WebView. Riduciamo noi il
|
||||
// content view dell'altezza dell'IME cosi' il layout Blazor (100vh) si ricalcola.
|
||||
if (FindViewById(Android.Resource.Id.Content) is { } content)
|
||||
ViewCompat.SetOnApplyWindowInsetsListener(content, new ImeInsetsListener());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Le barre di sistema si fondono con lo sfondo chiaro dell'app: icone
|
||||
/// scure su fondo chiaro. Senza questo la barra di stato resta tinta
|
||||
/// dell'accento, che nel sistema di design indica l'azione, non la
|
||||
/// cornice.
|
||||
/// </summary>
|
||||
private void ApplySystemBarsAppearance()
|
||||
{
|
||||
if (Window is null) return;
|
||||
|
||||
var controller = WindowCompat.GetInsetsController(Window, Window.DecorView);
|
||||
controller.AppearanceLightStatusBars = true;
|
||||
controller.AppearanceLightNavigationBars = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Il tasto indietro va intercettato sul dispatcher di AndroidX, non
|
||||
/// sovrascrivendo OnBackPressed: da AndroidX Activity 1.6 il gesto passa
|
||||
/// tutto di li' e il metodo dell'Activity non viene piu' invocato — la
|
||||
/// UI Blazor non riceveva mai il back e il lavoro non salvato del form
|
||||
/// se ne andava con la navigazione.
|
||||
///
|
||||
/// Il callback resta acceso solo finche' c'e' davvero qualcuno in
|
||||
/// ascolto: quando la UI non ha nulla da dire, il back torna a
|
||||
/// comportarsi come sempre invece di essere inghiottito.
|
||||
/// </summary>
|
||||
private void HookBackButton()
|
||||
{
|
||||
_backButton = IPlatformApplication.Current?.Services.GetService<BackButtonService>();
|
||||
if (_backButton is null) return;
|
||||
|
||||
_backCallback = new BlazorBackCallback(_backButton);
|
||||
OnBackPressedDispatcher.AddCallback(this, _backCallback);
|
||||
|
||||
_backCallback.Enabled = _backButton.HasHandlers;
|
||||
_backButton.HandlersChanged += OnBackHandlersChanged;
|
||||
}
|
||||
|
||||
private void OnBackHandlersChanged(bool hasHandlers)
|
||||
{
|
||||
if (_backCallback is null) return;
|
||||
|
||||
_backCallback.Enabled = hasHandlers;
|
||||
if (!hasHandlers) return;
|
||||
|
||||
// Il dispatcher consulta i callback dall'ultimo registrato al primo,
|
||||
// e BlazorWebView registra il suo (che fa tornare indietro la
|
||||
// cronologia della WebView) quando l'handler viene creato, cioe' dopo
|
||||
// OnCreate. Ri-registrandoci nel momento in cui la UI ha davvero
|
||||
// qualcosa da dire torniamo in cima e il back arriva prima a noi.
|
||||
_backCallback.Remove();
|
||||
OnBackPressedDispatcher.AddCallback(this, _backCallback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Con la navigazione a tre tasti il back arriva come evento di tastiera
|
||||
/// e la WebView lo intercetta per conto suo, tornando indietro nella
|
||||
/// propria cronologia: ne' l'Activity ne' il dispatcher lo vedono mai.
|
||||
/// Qui la chiave passa prima della gerarchia di view, quindi e' l'unico
|
||||
/// punto in cui possiamo fermarla mentre un form ha lavoro non salvato.
|
||||
/// Il callback sul dispatcher resta per la navigazione a gesti, dove
|
||||
/// evento di tastiera non ce n'e'.
|
||||
/// </summary>
|
||||
public override bool DispatchKeyEvent(KeyEvent? e)
|
||||
{
|
||||
if (e?.KeyCode == Keycode.Back && _backButton?.HasHandlers == true)
|
||||
{
|
||||
// Si consuma anche il DOWN: lasciandolo passare la WebView
|
||||
// reagirebbe lo stesso.
|
||||
if (e.Action == KeyEventActions.Up) _backButton.TryHandleBackPressed();
|
||||
return true;
|
||||
}
|
||||
|
||||
return base.DispatchKeyEvent(e);
|
||||
}
|
||||
|
||||
protected override void OnDestroy()
|
||||
{
|
||||
if (_backButton is not null) _backButton.HandlersChanged -= OnBackHandlersChanged;
|
||||
|
||||
base.OnDestroy();
|
||||
}
|
||||
|
||||
private sealed class BlazorBackCallback(BackButtonService service)
|
||||
: AndroidX.Activity.OnBackPressedCallback(false)
|
||||
{
|
||||
public override void HandleOnBackPressed() => service.TryHandleBackPressed();
|
||||
}
|
||||
|
||||
private sealed class ImeInsetsListener : Java.Lang.Object, IOnApplyWindowInsetsListener
|
||||
{
|
||||
public WindowInsetsCompat OnApplyWindowInsets(Android.Views.View? v, WindowInsetsCompat? insets)
|
||||
{
|
||||
if (v is null || insets is null)
|
||||
return insets!;
|
||||
|
||||
var ime = insets.GetInsets(WindowInsetsCompat.Type.Ime()).Bottom;
|
||||
var systemBars = insets.GetInsets(WindowInsetsCompat.Type.SystemBars()).Bottom;
|
||||
|
||||
// La barra di navigazione e' gia' gestita da SafeAreaEdges in MainPage:
|
||||
// teniamo solo la parte di tastiera che la eccede, per non sommare due volte.
|
||||
var bottom = System.Math.Max(0, ime - systemBars);
|
||||
|
||||
if (v.PaddingBottom != bottom)
|
||||
v.SetPadding(v.PaddingLeft, v.PaddingTop, v.PaddingRight, bottom);
|
||||
|
||||
return insets;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- windowLightNavigationBar esiste solo da API 27 in su. -->
|
||||
<style name="SteUp.SplashTheme" parent="Maui.SplashTheme">
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowLightNavigationBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,6 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPrimary">#ec4c41</color>
|
||||
<color name="colorPrimaryDark">#d02315</color>
|
||||
<color name="colorAccent">#d02315</color>
|
||||
<!-- Barra di stato: si fonde con lo sfondo dell'app. Il corallo e' il
|
||||
colore dell'azione, non una decorazione della cornice di sistema. -->
|
||||
<color name="colorPrimaryDark">#e9edf2</color>
|
||||
<color name="colorAccent">#ec4c41</color>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!--
|
||||
Tema dello splash. Deriva da quello generato da MAUI e cambia solo
|
||||
l'aspetto delle barre di sistema: senza questo lo splash mostra icone
|
||||
chiare, e all'apparire della UI Blazor - che le vuole scure - si vede
|
||||
un ribaltamento di colore a ogni avvio.
|
||||
-->
|
||||
<style name="SteUp.SplashTheme" parent="Maui.SplashTheme">
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -30,8 +30,8 @@
|
||||
<ApplicationId>it.integry.SteUp</ApplicationId>
|
||||
|
||||
<!-- Versions -->
|
||||
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>1</ApplicationVersion>
|
||||
<ApplicationDisplayVersion>1.2.0</ApplicationDisplayVersion>
|
||||
<ApplicationVersion>6</ApplicationVersion>
|
||||
|
||||
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||
<!-- <SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">-->
|
||||
@@ -98,7 +98,9 @@
|
||||
<MauiIcon Include="Resources\AppIcon\appicon.svg" ForegroundFile="Resources\AppIcon\appiconfg.svg" Color="#FDECEA" />
|
||||
|
||||
<!-- Splash Screen -->
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#FDECEA" BaseSize="128,128" />
|
||||
<!-- Lo splash e' la prima superficie dell'app: stesso fondo del workspace,
|
||||
cosi' l'avvio non lampeggia di rosa prima di mostrare la lista. -->
|
||||
<MauiSplashScreen Include="Resources\Splash\splash.svg" Color="#E9EDF2" BaseSize="112,112" />
|
||||
|
||||
<!-- Images -->
|
||||
<MauiImage Include="Resources\Images\*" />
|
||||
@@ -112,15 +114,15 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Maui" Version="14.0.0" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
|
||||
<PackageReference Include="IntegryApiClient.MAUI" Version="2.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.41" />
|
||||
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="10.0.41" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.41" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.3" />
|
||||
<PackageReference Include="Sentry.Maui" Version="6.1.0" />
|
||||
<PackageReference Include="CommunityToolkit.Maui" Version="14.1.0" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
|
||||
<PackageReference Include="IntegryApiClient.MAUI" Version="2.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="10.0.51" />
|
||||
<PackageReference Include="Microsoft.Maui.Controls.Compatibility" Version="10.0.51" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="10.0.51" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.7" />
|
||||
<PackageReference Include="Sentry.Maui" Version="6.4.1" />
|
||||
<PackageReference Include="SkiaSharp" Version="3.119.2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,49 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"/>
|
||||
<title>SteUp.Maui</title>
|
||||
<title>SteUP</title>
|
||||
<base href="/"/>
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap"
|
||||
rel="stylesheet">
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/fonts.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/tokens.css"/>
|
||||
|
||||
<link href="_content/SteUp.Shared/css/bootstrap/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="_content/SteUp.Shared/css/bootstrap/bootstrap-icons.min.css" rel="stylesheet"/>
|
||||
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet"/>
|
||||
|
||||
<link href="_content/CodeBeam.MudBlazor.Extensions/MudExtensions.min.css" rel="stylesheet"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/remixicon/remixicon.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/app.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/form.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/default-theme.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/custom-mudBlazor.css"/>
|
||||
<link rel="stylesheet" href="SteUp.Maui.styles.css"/>
|
||||
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/base.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/components.css"/>
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/forms.css"/>
|
||||
<!-- Dopo MudBlazor: riporta i controlli di libreria allo stile condiviso. -->
|
||||
<link rel="stylesheet" href="_content/SteUp.Shared/css/mudblazor-overrides.css"/>
|
||||
<link href="SteUp.Maui.styles.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<style>
|
||||
/* Primo fotogramma, prima che Blazor sia pronto: nessun lampo bianco,
|
||||
nessun salto di layout quando la schermata compare. */
|
||||
.boot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100dvh;
|
||||
background-color: var(--workspace, #e9edf2);
|
||||
}
|
||||
|
||||
.boot__spinner {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid rgba(0, 35, 57, 0.15);
|
||||
border-top-color: var(--primary, #ec4c41);
|
||||
animation: boot-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes boot-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.boot__spinner { animation-duration: 2s; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div class="status-bar-safe-area"></div>
|
||||
|
||||
<div id="app">
|
||||
<div class="spinner-container">
|
||||
<span class="loader"></span>
|
||||
<div class="boot">
|
||||
<div class="boot__spinner" role="status" aria-label="Caricamento"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui">
|
||||
An unhandled error has occurred.
|
||||
<a href="" class="reload">Reload</a>
|
||||
Si è verificato un errore imprevisto.
|
||||
<a href="" class="reload">Ricarica</a>
|
||||
<a class="dismiss">🗙</a>
|
||||
</div>
|
||||
|
||||
<script src="_framework/blazor.webview.js" autostart="false"></script>
|
||||
<script src="_content/SteUp.Shared/js/bootstrap/bootstrap.bundle.min.js"></script>
|
||||
<script src="_content/MudBlazor/MudBlazor.min.js"></script>
|
||||
<script src="_content/CodeBeam.MudBlazor.Extensions/MudExtensions.min.js"></script>
|
||||
<script src="_content/SteUp.Shared/js/main.js"></script>
|
||||
|
||||
@@ -1,68 +1,56 @@
|
||||
@using SteUp.Shared.Core.Helpers
|
||||
@inject IJSRuntime Js
|
||||
@inject IJSRuntime Js
|
||||
|
||||
<div class="@(Back ? "" : "container") header">
|
||||
<div class="header-content @(Back ? "with-back" : "no-back")">
|
||||
@if (!SmallHeader)
|
||||
@*
|
||||
Testata di schermata (STILE_UI §5): alta, titolo a sinistra, sfondo uguale
|
||||
alla pagina. Non e' una AppBar colorata col titolo centrato: quella e' una
|
||||
firma Android di serie, e il documento la esclude esplicitamente.
|
||||
*@
|
||||
|
||||
<header class="appbar @(Compact ? "appbar--compact" : "")">
|
||||
@if (Back || Cancel)
|
||||
{
|
||||
<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="appbar__titles">
|
||||
<h1 class="appbar__title">@Title</h1>
|
||||
@if (!string.IsNullOrWhiteSpace(Subtitle))
|
||||
{
|
||||
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>
|
||||
}
|
||||
|
||||
<h3 class="page-title">@Title</h3>
|
||||
|
||||
<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="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()
|
||||
{
|
||||
@@ -74,5 +62,4 @@
|
||||
|
||||
await Js.InvokeVoidAsync("goBack");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
.header {
|
||||
min-height: var(--mh-header);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
width: 100%;
|
||||
line-height: normal;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-content > .title { width: 100%; }
|
||||
|
||||
.header-content.with-back .page-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
font-size: larger;
|
||||
}
|
||||
|
||||
.left-section ::deep button, .right-section ::deep button { font-size: 1.1rem; }
|
||||
|
||||
.left-section ::deep .mud-button-icon-start { margin-right: 3px !important; }
|
||||
|
||||
.header-content.no-back .page-title { margin: 0; }
|
||||
@@ -3,30 +3,28 @@
|
||||
@using SteUp.Shared.Core.Interface.IntegryApi
|
||||
@using SteUp.Shared.Core.Interface.System.Network
|
||||
@inherits LayoutComponentBase
|
||||
@implements IDisposable
|
||||
@inject INetworkService NetworkService
|
||||
@inject IIntegryApiService IntegryApiService
|
||||
|
||||
<MudThemeProvider Theme="_currentTheme" @ref="@_mudThemeProvider" @bind-IsDarkMode="@IsDarkMode" />
|
||||
<MudThemeProvider Theme="_theme" IsDarkMode="false"/>
|
||||
<MudPopoverProvider/>
|
||||
<MudDialogProvider/>
|
||||
@* NoHeader NON va messo qui: e' il valore predefinito di tutti i dialog e
|
||||
sopprimerebbe il titolo dei MudMessageBox, che lo rendono nella testata.
|
||||
Le schermate a tutto schermo (form scheda, scelta punto vendita) lo
|
||||
chiedono gia' per conto loro nelle DialogOptions. *@
|
||||
<MudDialogProvider CloseButton="false" BackdropClick="false"/>
|
||||
<MudSnackbarProvider/>
|
||||
|
||||
<ConnectionState IsNetworkAvailable="IsNetworkAvailable" ServicesIsDown="ServicesIsDown" ShowWarning="ShowWarning" />
|
||||
<ConnectionState IsNetworkAvailable="IsNetworkAvailable" ServicesIsDown="ServicesIsDown" ShowWarning="ShowWarning"/>
|
||||
|
||||
<div class="page">
|
||||
<NavMenu/>
|
||||
|
||||
<main>
|
||||
<article>
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
<div class="app-shell">
|
||||
@Body
|
||||
</div>
|
||||
|
||||
<NavMenu/>
|
||||
|
||||
@code {
|
||||
private MudThemeProvider _mudThemeProvider = null!;
|
||||
private bool IsDarkMode { get; set; }
|
||||
private string _mainContentClass = "";
|
||||
|
||||
//Connection state
|
||||
private bool _isNetworkAvailable;
|
||||
@@ -71,58 +69,34 @@
|
||||
}
|
||||
}
|
||||
|
||||
private readonly MudTheme _currentTheme = new()
|
||||
// La palette resta quella di SteUP. Forma, ritmo e tipografia arrivano dai
|
||||
// token CSS (tokens.css), che pilotano anche le variabili di MudBlazor:
|
||||
// qui si dichiara solo il minimo che Mud deve conoscere lato C#.
|
||||
private readonly MudTheme _theme = new()
|
||||
{
|
||||
PaletteLight = new PaletteLight()
|
||||
PaletteLight = new PaletteLight
|
||||
{
|
||||
Primary = "#ec4c41",
|
||||
Secondary = "#002339",
|
||||
Tertiary = "#dff2ff",
|
||||
TextPrimary = "#000"
|
||||
Tertiary = "#dde5ee",
|
||||
Success = "#26b050",
|
||||
Error = "#e50000",
|
||||
Warning = "#b26a00",
|
||||
Info = "#002339",
|
||||
Background = "#e9edf2",
|
||||
Surface = "#ffffff",
|
||||
TextPrimary = "#000000",
|
||||
TextSecondary = "#465563",
|
||||
LinesDefault = "#d9e0e8",
|
||||
LinesInputs = "#d9e0e8",
|
||||
Divider = "#d9e0e8"
|
||||
},
|
||||
PaletteDark = new PaletteDark
|
||||
LayoutProperties = new LayoutProperties
|
||||
{
|
||||
Primary = "#ec4c41",
|
||||
Secondary = "#002339",
|
||||
Tertiary = "#dff2ff",
|
||||
Surface = "#000406",
|
||||
Background = "#000406",
|
||||
TextPrimary = "#fff",
|
||||
GrayDark = "#E0E0E0"
|
||||
DefaultBorderRadius = "16px"
|
||||
}
|
||||
};
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
// if (firstRender)
|
||||
// {
|
||||
// var isDarkMode = LocalStorage.GetString("isDarkMode");
|
||||
|
||||
// if (isDarkMode == null && _mudThemeProvider != null)
|
||||
// {
|
||||
// IsDarkMode = await _mudThemeProvider.GetSystemPreference();
|
||||
// await _mudThemeProvider.WatchSystemPreference(OnSystemPreferenceChanged);
|
||||
// LocalStorage.SetString("isDarkMode", IsDarkMode.ToString());
|
||||
// StateHasChanged();
|
||||
// }
|
||||
// else
|
||||
// {
|
||||
// IsDarkMode = bool.Parse(isDarkMode!);
|
||||
// }
|
||||
|
||||
// if (IsDarkMode)
|
||||
// {
|
||||
// _mainContentClass += "is-dark";
|
||||
// StateHasChanged();
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
private async Task OnSystemPreferenceChanged(bool newValue)
|
||||
{
|
||||
IsDarkMode = newValue;
|
||||
}
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_cts = new CancellationTokenSource();
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +1,95 @@
|
||||
@using CommunityToolkit.Mvvm.Messaging
|
||||
@using SteUp.Shared.Core.Entities
|
||||
@using SteUp.Shared.Core.Interface.IntegryApi
|
||||
@using SteUp.Shared.Core.Interface.System.Network
|
||||
@using SteUp.Shared.Core.Messages.Ispezione
|
||||
@using SteUp.Shared.Core.Messages.Scheda
|
||||
@inject INetworkService NetworkService
|
||||
@implements IDisposable
|
||||
@inject IDialogService Dialog
|
||||
@inject IMessenger Messenger
|
||||
@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 (ShowCompleteInspection)
|
||||
{
|
||||
<MudFabMenuItem Disabled="!NetworkService.IsNetworkAvailable()" OnClick="@CompleteInspection"
|
||||
Label="Concludi ispezione" 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="@NewScheda"
|
||||
Label="Nuova scheda" Color="Color.Surface"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudFabMenuItem Disabled="!NetworkService.IsNetworkAvailable()" OnClick="@NewActivity"
|
||||
Label="Nuova ispezione" Color="Color.Surface"/>
|
||||
}
|
||||
</MudFabMenu>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<button type="button" class="navbar__action" @onclick="RunAction"
|
||||
disabled="@(!NetworkService.IsNetworkAvailable())"
|
||||
aria-label="@ActionLabel"
|
||||
title="@(NetworkService.IsNetworkAvailable() ? ActionLabel : "Serve la connessione")">
|
||||
<i class="ri-add-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
<NavLink class="navbar__item pressable" href="user" Match="NavLinkMatch.All">
|
||||
<span class="navbar__icon">
|
||||
<i class="@(IsOn("user") ? "ri-user-fill" : "ri-user-line")" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="navbar__label">Profilo</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
@code
|
||||
{
|
||||
private bool IsVisible { get; set; } = true;
|
||||
private bool PlusVisible { get; set; } = true;
|
||||
private bool ShowCompleteInspection { get; set; }
|
||||
private bool SchedaVisible { get; set; }
|
||||
|
||||
protected override Task OnInitializedAsync()
|
||||
private static readonly string[] NavigableRoutes = ["ispezioni", "ispezione", "user"];
|
||||
|
||||
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;
|
||||
StateHasChanged();
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
PageActions.Changed += OnPageActionsChanged;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void NewActivity()
|
||||
private void OnPageActionsChanged() => InvokeAsync(StateHasChanged);
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs args) => Refresh();
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
_ = ModalHelper.OpenSelectShop(Dialog);
|
||||
}
|
||||
|
||||
private void CompleteInspection() =>
|
||||
Messenger.Send(new CompleteInspectionMessage());
|
||||
|
||||
private async Task NewScheda()
|
||||
{
|
||||
var ispezione = SteupDataService.InspectionPageState.Ispezione;
|
||||
var modal = await ModalHelper.OpenFormScheda(Dialog, 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);
|
||||
ShowCompleteInspection = !SteupDataService.InspectionPageState.Ispezione.ActivityId.IsNullOrEmpty();
|
||||
IsVisible = NavigableRoutes.Contains(CurrentRoute, StringComparer.OrdinalIgnoreCase);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// Il comando vale quello che la schermata gli ha dato; se non gliel'ha
|
||||
/// dato nessuno, vale la cosa che l'app fa piu' spesso.
|
||||
private Task RunAction() => PageActions.HasActions ? PageActions.Invoke() : NewInspection();
|
||||
|
||||
private async Task NewInspection()
|
||||
{
|
||||
if (!await SteupDataService.CanOpenNewInspection())
|
||||
{
|
||||
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,24 +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="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>
|
||||
@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>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<SpinnerLayout/>
|
||||
}
|
||||
</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 mostrato sotto lo spinner (es. "Invio scheda 3 di 7…").
|
||||
[Parameter] public string? Message { get; set; }
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
.success-checkmark {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
position: relative;
|
||||
border-radius: 50%;
|
||||
box-sizing: content-box;
|
||||
border: 4px solid var(--mud-palette-success);
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon::before {
|
||||
top: 3px;
|
||||
left: -2px;
|
||||
width: 30px;
|
||||
transform-origin: 100% 50%;
|
||||
border-radius: 100px 0 0 100px;
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon::after {
|
||||
top: 0;
|
||||
left: 30px;
|
||||
width: 60px;
|
||||
transform-origin: 0 50%;
|
||||
border-radius: 0 100px 100px 0;
|
||||
animation: rotate-circle 4.25s ease-in;
|
||||
}
|
||||
|
||||
.success-checkmark .check-icon::before,
|
||||
.success-checkmark .check-icon::after {
|
||||
content: '';
|
||||
height: 100px;
|
||||
position: absolute;
|
||||
transform: rotate(-45deg);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.icon-line {
|
||||
height: 5px;
|
||||
background-color: var(--mud-palette-success);
|
||||
display: block;
|
||||
border-radius: 2px;
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.icon-line.line-tip {
|
||||
top: 46px;
|
||||
left: 14px;
|
||||
width: 25px;
|
||||
transform: rotate(45deg);
|
||||
animation: icon-line-tip 0.75s;
|
||||
}
|
||||
|
||||
.icon-line.line-long {
|
||||
top: 38px;
|
||||
right: 8px;
|
||||
width: 47px;
|
||||
transform: rotate(-45deg);
|
||||
animation: icon-line-long 0.75s;
|
||||
}
|
||||
|
||||
|
||||
.icon-circle {
|
||||
top: -4px;
|
||||
left: -4px;
|
||||
z-index: 10;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
box-sizing: content-box;
|
||||
border: 4px solid var(--mud-palette-success);
|
||||
}
|
||||
|
||||
.icon-fix {
|
||||
top: 8px;
|
||||
width: 5px;
|
||||
left: 26px;
|
||||
z-index: 1;
|
||||
height: 85px;
|
||||
position: absolute;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
@keyframes rotate-circle {
|
||||
0% { transform: rotate(-45deg); }
|
||||
|
||||
5% { transform: rotate(-45deg); }
|
||||
|
||||
12% { transform: rotate(-405deg); }
|
||||
|
||||
100% { transform: rotate(-405deg); }
|
||||
}
|
||||
|
||||
@keyframes icon-line-tip {
|
||||
0% {
|
||||
width: 0;
|
||||
left: 1px;
|
||||
top: 19px;
|
||||
}
|
||||
|
||||
54% {
|
||||
width: 0;
|
||||
left: 1px;
|
||||
top: 19px;
|
||||
}
|
||||
|
||||
70% {
|
||||
width: 50px;
|
||||
left: -8px;
|
||||
top: 37px;
|
||||
}
|
||||
|
||||
84% {
|
||||
width: 17px;
|
||||
left: 21px;
|
||||
top: 48px;
|
||||
}
|
||||
|
||||
100% {
|
||||
width: 25px;
|
||||
left: 14px;
|
||||
top: 45px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes icon-line-long {
|
||||
0% {
|
||||
width: 0;
|
||||
right: 46px;
|
||||
top: 54px;
|
||||
}
|
||||
|
||||
65% {
|
||||
width: 0;
|
||||
right: 46px;
|
||||
top: 54px;
|
||||
}
|
||||
|
||||
84% {
|
||||
width: 55px;
|
||||
right: 0px;
|
||||
top: 35px;
|
||||
}
|
||||
|
||||
100% {
|
||||
width: 47px;
|
||||
right: 8px;
|
||||
top: 38px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
@using SteUp.Shared.Core.Messages.System
|
||||
@inject BackButtonService BackButton
|
||||
@implements IDisposable
|
||||
|
||||
@*
|
||||
Bottom sheet (STILE_UI §6.14): sale dal basso, maniglia di trascinamento,
|
||||
testata con titolo e chiusura, un'unica linea di struttura, corpo scorrevole.
|
||||
Sostituisce i menu flottanti: stessa funzione, bersagli molto piu' grandi.
|
||||
*@
|
||||
|
||||
@if (Open)
|
||||
{
|
||||
<div class="sheet-scrim" @onclick="Close" aria-hidden="true"></div>
|
||||
|
||||
<div class="sheet @(Tall ? "sheet--tall" : "")" role="dialog" aria-modal="true"
|
||||
aria-label="@Title">
|
||||
<div class="sheet__handle"></div>
|
||||
|
||||
<div class="sheet__head">
|
||||
<div class="sheet__titles">
|
||||
<h2 class="sheet__title">@Title</h2>
|
||||
@if (!string.IsNullOrWhiteSpace(Subtitle))
|
||||
{
|
||||
<p class="sheet__subtitle">@Subtitle</p>
|
||||
}
|
||||
</div>
|
||||
<MudIconButton Icon="@Icons.Material.Rounded.Close" Color="Color.Inherit"
|
||||
aria-label="Chiudi" title="Chiudi" OnClick="Close"/>
|
||||
</div>
|
||||
|
||||
<div class="sheet__divider"></div>
|
||||
|
||||
<div class="sheet__body">
|
||||
@ChildContent
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool Open { get; set; }
|
||||
[Parameter] public EventCallback<bool> OpenChanged { get; set; }
|
||||
[Parameter] public string? Title { get; set; }
|
||||
[Parameter] public string? Subtitle { get; set; }
|
||||
|
||||
/// Sheet di compilazione (~92% dello schermo) invece che di consultazione.
|
||||
[Parameter] public bool Tall { get; set; }
|
||||
|
||||
[Parameter] public RenderFragment? ChildContent { get; set; }
|
||||
|
||||
// Il tasto indietro del dispositivo chiude lo sheet invece di uscire dalla
|
||||
// schermata sotto: e' il gesto che l'utente si aspetta, ed e' anche la
|
||||
// ragione per cui i gestori sono impilati (vedi BackButtonService).
|
||||
private IDisposable? _backRegistration;
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (Open && _backRegistration is null)
|
||||
{
|
||||
_backRegistration = BackButton.Register(() => InvokeAsync(Close));
|
||||
}
|
||||
else if (!Open)
|
||||
{
|
||||
Unregister();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Close()
|
||||
{
|
||||
Unregister();
|
||||
Open = false;
|
||||
await OpenChanged.InvokeAsync(false);
|
||||
}
|
||||
|
||||
private void Unregister()
|
||||
{
|
||||
_backRegistration?.Dispose();
|
||||
_backRegistration = null;
|
||||
}
|
||||
|
||||
public void Dispose() => Unregister();
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<MudOverlay Visible="Visible" DarkBackground="false">
|
||||
<div class="overlay-container">
|
||||
<span>@(Text ?? "Caricamento")</span>
|
||||
|
||||
<MudProgressLinear Color="Color.Primary" Rounded="true" Size="Size.Medium" Indeterminate="true" />
|
||||
</div>
|
||||
</MudOverlay>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public bool Visible { get; set; }
|
||||
[Parameter] public string? Text { get; set; }
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
.overlay-container {
|
||||
background: var(--mud-palette-background);
|
||||
width: 20rem;
|
||||
height: 6rem;
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--custom-box-shadow);
|
||||
}
|
||||
|
||||
.overlay-container > span {
|
||||
text-align: center;
|
||||
font-size: medium;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
<div class="spinner-container @(FullScreen ? "" : "not-fullScreen")">
|
||||
<span class="loader"></span>
|
||||
@* Indicatore di attesa. A schermo pieno solo quando non c'e' ancora nulla da
|
||||
mostrare; dentro una sezione resta contenuto e non fa saltare il layout. *@
|
||||
|
||||
<div style="display:flex; align-items:center; justify-content:center; @(FullScreen ? "height:100dvh;" : "padding: var(--space-xl) 0;")">
|
||||
<span class="spinner @(FullScreen ? "spinner--lg" : "")" role="status" aria-label="Caricamento"></span>
|
||||
</div>
|
||||
|
||||
@code
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: calc(100vh - 10.1rem);
|
||||
align-items: center;
|
||||
color: var(--mud-palette-primary);
|
||||
}
|
||||
|
||||
.not-fullScreen {
|
||||
height: auto !important;
|
||||
padding: 2rem 0 !important;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 50px;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 50%;
|
||||
border: 8px solid #0000;
|
||||
border-right-color: var(--mud-palette-primary);
|
||||
position: relative;
|
||||
animation: l24 1s infinite linear;
|
||||
}
|
||||
|
||||
.loader:before,
|
||||
.loader:after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -8px;
|
||||
border-radius: 50%;
|
||||
border: inherit;
|
||||
animation: inherit;
|
||||
animation-duration: 2s;
|
||||
}
|
||||
|
||||
.loader:after {
|
||||
animation-duration: 4s;
|
||||
}
|
||||
|
||||
@keyframes l24 {
|
||||
100% {
|
||||
transform: rotate(1turn)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +1,296 @@
|
||||
@page "/ispezione"
|
||||
@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.Messages.Ispezione
|
||||
@using SteUp.Shared.Core.Messages.Scheda
|
||||
@inject NewSchedaService NewScheda
|
||||
@inject CompleteInspectionService CompleteInspection
|
||||
@using SteUp.Shared.Core.Interface.System
|
||||
@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">
|
||||
@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)
|
||||
{
|
||||
<MudExpansionPanel Class="expansion-panel" Expanded="true" Text="@group.Key.Descrizione">
|
||||
<TitleContent>
|
||||
<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>
|
||||
</div>
|
||||
<div class="action-scheda-group">
|
||||
@if (NetworkService.IsNetworkAvailable())
|
||||
{
|
||||
<MudFab StartIcon="@Icons.Material.Rounded.Add"
|
||||
Color="Color.Warning" Size="Size.Small"
|
||||
OnClick="@(() => CreateNewScheda(group.Key))"/>
|
||||
var reparto = group.Key;
|
||||
var chiuso = IsCollapsed(reparto);
|
||||
var daInviare = group.Value.CountDaInviare();
|
||||
|
||||
<MudFab StartIcon="@Icons.Material.Rounded.CloudSync" Label="Esporta reparto"
|
||||
Color="Color.Success" Size="Size.Small"
|
||||
OnClick="@(() => ExportReparto(group.Key))"/>
|
||||
}
|
||||
else
|
||||
<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)
|
||||
{
|
||||
<MudFab StartIcon="@Icons.Material.Rounded.Add"
|
||||
Label="@($"Nuova scheda su {group.Key.Descrizione}")"
|
||||
Color="Color.Warning" Size="Size.Medium"
|
||||
OnClick="@(() => CreateNewScheda(group.Key))"/>
|
||||
<MudButton Variant="Variant.Outlined" Size="Size.Small"
|
||||
StartIcon="@Icons.Material.Rounded.CloudUpload"
|
||||
aria-label="@($"Invia le schede del reparto {reparto.Descrizione}")"
|
||||
OnClick="@(() => ExportReparto(reparto))">
|
||||
Invia @daInviare
|
||||
</MudButton>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</TitleContent>
|
||||
<ChildContent>
|
||||
@foreach (var scheda in group.Value)
|
||||
{
|
||||
<SchedaCard Scheda="scheda" OnSchedaDeleted="OnSchedaDeleted"
|
||||
OnSchedaModified="OnSchedaModified"
|
||||
CodMdep="@SteupDataService.InspectionPageState.Ispezione.CodMdep"
|
||||
Data="@SteupDataService.InspectionPageState.Ispezione.Data"/>
|
||||
}
|
||||
</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>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
|
||||
<SpinnerOverlay VisibleOverlay="VisibleOverlay" SuccessAnimation="_success" Message="@_progressMessage"/>
|
||||
<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; } = [];
|
||||
private Dictionary<JtbFasiDto, List<Scheda>> SchedeGrouped { get; set; } = [];
|
||||
|
||||
private bool VisibleOverlay { get; set; }
|
||||
private bool _isBusy;
|
||||
private bool _success;
|
||||
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 () =>
|
||||
{
|
||||
@@ -102,8 +303,87 @@
|
||||
});
|
||||
}
|
||||
|
||||
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.
|
||||
if (_isBusy) return;
|
||||
_isBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
@@ -112,9 +392,112 @@
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
var ispezione = SteupDataService.InspectionPageState.Ispezione;
|
||||
var ispezione = Ispezione;
|
||||
|
||||
SteupDataService.InspectionPageState.Ispezione.Stato = StatusEnum.Completata;
|
||||
// Le bozze non sono inviabili: l'ispezione non si chiude finché ci sono.
|
||||
var bozze = SchedeList.CountBozze();
|
||||
if (bozze > 0)
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
await Dialog.ShowWarning(
|
||||
bozze == 1
|
||||
? "C'è 1 scheda da completare. Completala o eliminala prima di concludere l'ispezione."
|
||||
: $"Ci sono {bozze} schede da completare. Completale o eliminale prima di concludere l'ispezione."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var daInviare = ispezione.Schede.Where(x => x.ActivityId == null && !x.Bozza).ToList();
|
||||
var totale = daInviare.Count;
|
||||
var indice = 0;
|
||||
var fallite = 0;
|
||||
|
||||
foreach (var scheda in daInviare)
|
||||
{
|
||||
indice++;
|
||||
await SetProgress($"Invio scheda {indice} di {totale}…");
|
||||
|
||||
var apiResponse = await IntegrySteupService.SaveScheda(
|
||||
new SaveRequestDto
|
||||
{
|
||||
LocalIdScheda = scheda.Id,
|
||||
ActivityTypeId = scheda.ActivityTypeId,
|
||||
CodJfas = scheda.CodJfas,
|
||||
CodMdep = ispezione.CodMdep,
|
||||
DataCreazione = ispezione.Data,
|
||||
Note = scheda.Note,
|
||||
PersonaRif = scheda.Responsabile,
|
||||
Barcodes = scheda.Articoli.ConvertAll(x => x.Barcode),
|
||||
Scandeza = (ScadenzaEnum)scheda.Scadenza,
|
||||
// Aggancia all'ispezione esistente: dopo la prima scheda l'id e' noto,
|
||||
// così le successive non creano nuove ispezioni.
|
||||
ParentActivityId = ispezione.ActivityId
|
||||
}
|
||||
);
|
||||
|
||||
// Fallita: resta "Da inviare". Non completeremo l'ispezione finché tutte non sono arrivate.
|
||||
if (apiResponse == null)
|
||||
{
|
||||
fallite++;
|
||||
continue;
|
||||
}
|
||||
|
||||
scheda.ActivityId = apiResponse.ActivityIdScheda;
|
||||
await IspezioniService.UpdateSchedaAsync(scheda);
|
||||
|
||||
await IspezioniService.UpdateActivityIdIspezioneAsync(
|
||||
ispezione.CodMdep,
|
||||
ispezione.Data,
|
||||
ispezione.Rilevatore,
|
||||
apiResponse.ActivityIdIspezione
|
||||
);
|
||||
// Aggiorna l'id in memoria per l'iterazione successiva.
|
||||
ispezione.ActivityId = apiResponse.ActivityIdIspezione;
|
||||
|
||||
if (scheda.ImageNames == null) continue;
|
||||
|
||||
var fileList = (await FileManager.GetInspectionFiles(ispezione, scheda.ImageNames))?
|
||||
.Where(x => x.ToUpload).ToList();
|
||||
|
||||
if (fileList == null) continue;
|
||||
|
||||
var totFoto = fileList.Count;
|
||||
var indiceFoto = 0;
|
||||
|
||||
foreach (var file in fileList)
|
||||
{
|
||||
indiceFoto++;
|
||||
await SetProgress($"Scheda {indice}/{totale} · foto {indiceFoto}/{totFoto}…");
|
||||
await IntegrySteupService.UploadFile(scheda.ActivityId!, file.FileBytes!, file.Name!);
|
||||
}
|
||||
}
|
||||
|
||||
// Integrità dati: se anche una sola scheda non è arrivata al server, NON completare.
|
||||
// L'ispezione resta editabile e le schede fallite restano "Da inviare".
|
||||
if (fallite > 0)
|
||||
{
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
_progressMessage = null;
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
LoadSchede(); // rinfresca i badge: riuscite → "Inviato", fallite → restano "Da inviare"
|
||||
|
||||
await Dialog.ShowWarning(
|
||||
fallite == 1
|
||||
? "1 scheda non è stata inviata e resta salvata sul dispositivo. Controlla la connessione e riprova prima di concludere l'ispezione."
|
||||
: $"{fallite} schede non sono state inviate e restano salvate sul dispositivo. Controlla la connessione e riprova prima di concludere l'ispezione."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await SetProgress("Completamento ispezione…");
|
||||
|
||||
await IntegrySteupService.CompleteInspection(ispezione.ActivityId!);
|
||||
await IspezioniService.UpdateStatoIspezioneAsync(
|
||||
@@ -124,26 +507,41 @@
|
||||
StatusEnum.Completata
|
||||
);
|
||||
|
||||
ispezione.Stato = StatusEnum.Completata;
|
||||
|
||||
// Riscontro al momento più importante: la spunta di successo prima di chiudere l'overlay.
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
VisibleOverlay = false;
|
||||
_progressMessage = "Ispezione inviata";
|
||||
_success = true;
|
||||
StateHasChanged();
|
||||
});
|
||||
await Task.Delay(1800);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e.Message);
|
||||
|
||||
OnError(e, e.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isBusy = false;
|
||||
_success = false;
|
||||
_progressMessage = null;
|
||||
await InvokeAsync(() =>
|
||||
{
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
OnError(e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private Task SetProgress(string message) =>
|
||||
InvokeAsync(() =>
|
||||
{
|
||||
_progressMessage = message;
|
||||
StateHasChanged();
|
||||
});
|
||||
|
||||
private void GroupSchede()
|
||||
{
|
||||
SchedeGrouped = SchedeList
|
||||
@@ -158,7 +556,7 @@
|
||||
private void OnSchedaModified(Scheda obj)
|
||||
{
|
||||
var index = SchedeList.FindIndex(x => x.Id.Equals(obj.Id));
|
||||
if (index > 0) SchedeList[index] = obj;
|
||||
if (index >= 0) SchedeList[index] = obj;
|
||||
|
||||
GroupSchede();
|
||||
StateHasChanged();
|
||||
@@ -171,86 +569,101 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task CreateNewScheda(JtbFasiDto jtbFasi)
|
||||
{
|
||||
var modal = await ModalHelper.OpenFormScheda(
|
||||
Dialog,
|
||||
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.
|
||||
if (_isBusy) return;
|
||||
|
||||
var confirmed = await _exportRepartoBox.ShowAsync();
|
||||
if (confirmed is not true) return;
|
||||
|
||||
_isBusy = true;
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
|
||||
var ispezione = SteupDataService.InspectionPageState.Ispezione;
|
||||
|
||||
var saveRequest = SchedeGrouped[jtbFasi].ConvertAll(x =>
|
||||
try
|
||||
{
|
||||
return new SaveRequestDto
|
||||
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)
|
||||
{
|
||||
LocalIdScheda = x.Id,
|
||||
ActivityTypeId = x.ActivityTypeId,
|
||||
CodJfas = x.CodJfas,
|
||||
CodMdep = ispezione.CodMdep,
|
||||
DataCreazione = ispezione.Data,
|
||||
Note = x.Note,
|
||||
PersonaRif = x.Responsabile,
|
||||
Barcodes = x.Articoli.ConvertAll(y => y.Barcode),
|
||||
Scandeza = (ScadenzaEnum)x.Scadenza,
|
||||
ParentActivityId = x.Ispezione?.ActivityId
|
||||
};
|
||||
});
|
||||
Snackbar.Add("Nessuna scheda completa da inviare in questo reparto", Severity.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
var apiResponse = await IntegrySteupService.SaveMultipleSchede(saveRequest);
|
||||
var saveRequest = daEsportare.ConvertAll(x =>
|
||||
{
|
||||
return new SaveRequestDto
|
||||
{
|
||||
LocalIdScheda = x.Id,
|
||||
ActivityTypeId = x.ActivityTypeId,
|
||||
CodJfas = x.CodJfas,
|
||||
CodMdep = ispezione.CodMdep,
|
||||
DataCreazione = ispezione.Data,
|
||||
Note = x.Note,
|
||||
PersonaRif = x.Responsabile,
|
||||
Barcodes = x.Articoli.ConvertAll(y => y.Barcode),
|
||||
Scandeza = (ScadenzaEnum)x.Scadenza,
|
||||
// Aggancia all'ispezione esistente sul server (evita di crearne una nuova a ogni export).
|
||||
ParentActivityId = ispezione.ActivityId
|
||||
};
|
||||
});
|
||||
|
||||
if (apiResponse != null)
|
||||
{
|
||||
SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione;
|
||||
var apiResponse = await IntegrySteupService.SaveMultipleSchede(saveRequest);
|
||||
|
||||
if (apiResponse == null)
|
||||
{
|
||||
Snackbar.Add("Invio non riuscito. Le schede restano sul dispositivo, riprova.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
ispezione.ActivityId = apiResponse.ActivityIdIspezione;
|
||||
|
||||
await IspezioniService.UpdateActivityIdIspezioneAsync(ispezione.CodMdep, ispezione.Data,
|
||||
UserSession.User.Username, apiResponse.ActivityIdIspezione
|
||||
);
|
||||
|
||||
if (apiResponse.ActivityIdSchedaList.IsNullOrEmpty()) return;
|
||||
|
||||
foreach (var scheda in SchedeGrouped[jtbFasi])
|
||||
if (!apiResponse.ActivityIdSchedaList.IsNullOrEmpty())
|
||||
{
|
||||
scheda.ActivityId = apiResponse.ActivityIdScheda;
|
||||
await IspezioniService.UpdateActivityIdSchedaAsync(scheda.Id, scheda.ActivityId);
|
||||
foreach (var scheda in daEsportare)
|
||||
{
|
||||
var activityId = apiResponse.ActivityIdSchedaList!.Find(x =>
|
||||
x.LocalId != null && x.LocalId == scheda.Id
|
||||
)?.ActivityId;
|
||||
|
||||
var activityId = apiResponse.ActivityIdSchedaList!.Find(x =>
|
||||
x.LocalId != null && x.LocalId == scheda.Id
|
||||
)?.ActivityId;
|
||||
if (activityId == null) continue;
|
||||
|
||||
if (activityId == null) return;
|
||||
|
||||
scheda.ActivityId = activityId;
|
||||
await IspezioniService.UpdateActivityIdSchedaAsync(scheda.Id, scheda.ActivityId);
|
||||
scheda.ActivityId = activityId;
|
||||
await IspezioniService.UpdateActivityIdSchedaAsync(scheda.Id, scheda.ActivityId);
|
||||
}
|
||||
}
|
||||
|
||||
Snackbar.Add(daEsportare.Count == 1 ? "Scheda inviata" : $"{daEsportare.Count} schede inviate",
|
||||
Severity.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
OnError(e, e.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isBusy = false;
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void OnError(string? errorMessage)
|
||||
private void OnError(Exception? e, string? technicalMessage)
|
||||
{
|
||||
if (errorMessage == null) return;
|
||||
// Il dettaglio tecnico va nei log, non davanti al rilevatore in campo.
|
||||
if (e != null) Logger.LogError(e, technicalMessage);
|
||||
|
||||
_ = ModalHelper.ShowError(Dialog, errorMessage);
|
||||
}
|
||||
|
||||
void IDisposable.Dispose()
|
||||
{
|
||||
NewScheda.OnNewScheda -= LoadSchede;
|
||||
CompleteInspection.OnComplete -= HandleCompleteInspection;
|
||||
_ = Dialog.ShowError(
|
||||
"Invio non riuscito. Il lavoro resta salvato sul dispositivo: controlla la connessione e riprova."
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.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: space-around;
|
||||
}
|
||||
@@ -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();
|
||||
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,5 +1,5 @@
|
||||
@page "/login"
|
||||
@using SteUp.Shared.Components.Layout.Spinner
|
||||
@using Microsoft.Extensions.Logging
|
||||
@using SteUp.Shared.Core.BarcodeReader.Contracts
|
||||
@using SteUp.Shared.Core.Interface.System
|
||||
@using SteUp.Shared.Core.Services
|
||||
@@ -7,70 +7,94 @@
|
||||
@inject AppAuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject IGenericSystemService GenericSystemService
|
||||
@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()
|
||||
{
|
||||
@@ -80,35 +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)
|
||||
{
|
||||
Console.WriteLine(e.Message);
|
||||
|
||||
Spinner = false;
|
||||
StateHasChanged();
|
||||
|
||||
ErrorMessage = e.Message;
|
||||
_attemptFailed = true;
|
||||
Console.WriteLine(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
.login-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--mud-palette-surface);
|
||||
}
|
||||
|
||||
.container-top-logo > .logo {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.container-login > span {
|
||||
font-size: large;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.container-top-logo {
|
||||
height: 35vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.login-form-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.container-login {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 4px 16px 16px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-footer span {
|
||||
font-size: 9px;
|
||||
color: var(--mud-palette-gray-darker);
|
||||
}
|
||||
|
||||
.login-footer img {
|
||||
height: 15px;
|
||||
margin-left: 4px;
|
||||
}
|
||||
@@ -2,5 +2,19 @@
|
||||
@using SteUp.Shared.Components.Layout
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
<HeaderLayout Title="Pagina non trovata" Back="true"/>
|
||||
|
||||
<div class="app-scroll">
|
||||
<div class="page-width page-body">
|
||||
<div class="empty-state">
|
||||
<i class="empty-state__icon ri-compass-3-line" aria-hidden="true"></i>
|
||||
<h1 class="empty-state__title">Qui non c'è niente</h1>
|
||||
<p class="empty-state__text">
|
||||
La schermata che cercavi non esiste. Torna all'elenco delle ispezioni per riprendere il lavoro.
|
||||
</p>
|
||||
<div class="empty-state__action">
|
||||
<MudButton Variant="Variant.Outlined" Href="/ispezioni">Vai alle ispezioni</MudButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,98 +1,105 @@
|
||||
@page "/user"
|
||||
@attribute [Authorize]
|
||||
@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.Interface.System.Network
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Interface.IntegryApi
|
||||
@using SteUp.Shared.Core.Interface.System
|
||||
@using SteUp.Shared.Core.Services
|
||||
@using SteUp.Shared.Core.Utility
|
||||
@inject AppAuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject INetworkService NetworkService
|
||||
@inject IGenericSystemService GenericSystemService
|
||||
@inject IFileManager FileManager
|
||||
@inject IIntegryApiService IntegryApiService
|
||||
|
||||
<HeaderLayout Title="Profilo"/>
|
||||
|
||||
@if (IsLoggedIn)
|
||||
{
|
||||
<div class="container content pb-safe-area">
|
||||
<div class="container-primary-info mud-elevation-1">
|
||||
<div class="section-primary-info">
|
||||
<MudAvatar Style="height: 70px; width: 70px; font-size: 2rem; font-weight: bold"
|
||||
Color="Color.Secondary">
|
||||
@UtilityString.ExtractInitials(UserSession.User.Fullname)
|
||||
</MudAvatar>
|
||||
<div class="app-scroll">
|
||||
<div class="page-width page-body stack stack-md">
|
||||
|
||||
<div class="personal-info">
|
||||
<span class="info-nome">@UserSession.User.Fullname</span>
|
||||
@if (UserSession.User.KeyGroup is not null)
|
||||
{
|
||||
<span
|
||||
class="info-section">@(((KeyGroupEnum)UserSession.User.KeyGroup).ConvertToHumanReadable())</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div class="section-info">
|
||||
<div class="section-personal-info">
|
||||
<div>
|
||||
<span class="info-title">Profilo azienda</span>
|
||||
<span class="info-text">@CodHash</span>
|
||||
@if (IsLoggedIn)
|
||||
{
|
||||
@* §6.16 Intestazione profilo *@
|
||||
<section class="panel panel--lg">
|
||||
<div class="profile-head">
|
||||
<div class="profile-avatar" aria-hidden="true">
|
||||
@UtilityString.ExtractInitials(UserSession.User.Fullname)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-personal-info">
|
||||
<div>
|
||||
<span class="info-title">Status</span>
|
||||
@if (NetworkService.ConnectionAvailable)
|
||||
<div class="section-header__text">
|
||||
<h2 class="summary__title">@UserSession.User.Fullname</h2>
|
||||
@if (UserSession.User.KeyGroup is not null)
|
||||
{
|
||||
<div class="status online">
|
||||
<i class="ri-wifi-line"></i>
|
||||
<span>Online</span>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="status offline">
|
||||
<i class="ri-wifi-off-line"></i>
|
||||
<span>Offline</span>
|
||||
</div>
|
||||
<p class="summary__subtitle">
|
||||
@(((KeyGroupEnum)UserSession.User.KeyGroup).ConvertToHumanReadable())
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="container-button 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
|
||||
</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 mud-elevation-1">
|
||||
<MudButton Class="button-settings exit"
|
||||
FullWidth="true"
|
||||
Color="Color.Error"
|
||||
Size="Size.Medium"
|
||||
OnClick="@Logout"
|
||||
Variant="Variant.Outlined">
|
||||
Esci
|
||||
</MudButton>
|
||||
</div>
|
||||
<div class="nav-list__item" style="pointer-events: none;">
|
||||
<i class="nav-list__icon ri-cloud-line" aria-hidden="true"></i>
|
||||
<span class="nav-list__label">Connessione</span>
|
||||
<Badge Spec="@ConnessioneBadge"/>
|
||||
</div>
|
||||
|
||||
<AppVersion/>
|
||||
<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" Message="@_progressMessage"/>
|
||||
<ConfirmMessageBox @ref="_logoutBox" Title="Uscire dall'account?" NoText="Annulla"
|
||||
YesText="Esci" YesColor="Color.Error"
|
||||
Message="Uscendo servirà di nuovo la connessione per rientrare. Le ispezioni salvate sul dispositivo restano al loro posto."/>
|
||||
|
||||
@code {
|
||||
private bool IsLoggedIn { get; set; }
|
||||
private bool VisibleOverlay { get; set; }
|
||||
private string? _progressMessage;
|
||||
private string? CodHash { get; set; } = "";
|
||||
|
||||
private ConfirmMessageBox _logoutBox = null!;
|
||||
|
||||
private Core.Helpers.StatusBadgeHelper.BadgeSpec ConnessioneBadge => NetworkService.ConnectionAvailable
|
||||
? new Core.Helpers.StatusBadgeHelper.BadgeSpec("success", "ri-cloud-line", "Online")
|
||||
: new Core.Helpers.StatusBadgeHelper.BadgeSpec("danger", "ri-wifi-off-line", "Offline");
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
IsLoggedIn = await UserSession.IsLoggedIn();
|
||||
@@ -100,15 +107,74 @@
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private void UpdateDb()
|
||||
private async Task ExportLog()
|
||||
{
|
||||
_progressMessage = "Invio dei log…";
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
var profiloAzienda = LocalStorage.GetString("codHash");
|
||||
|
||||
var email = new SendEmailDto
|
||||
{
|
||||
FromName = "Integry Log",
|
||||
To = "developer@integry.it",
|
||||
Subject = $"SteUP - Log del {DateTime.Today:d} di {UserSession.User.Username}",
|
||||
IsHtml = true,
|
||||
MsgText = $"Username: <b>{UserSession.User.Username}</b><br/>" +
|
||||
$"Profilo azienda: <b>{profiloAzienda}</b><br/>" +
|
||||
$"ProfileDb: <b>{UserSession.ProfileDb}</b><br/>" +
|
||||
$"Versione app: <b>{GenericSystemService.GetCurrentAppVersion()}</b>",
|
||||
Attachments = FileManager.GetFileForExport()
|
||||
};
|
||||
|
||||
await IntegryApiService.SendEmail(email);
|
||||
Snackbar.Add("Log inviati allo sviluppo", Severity.Success);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Snackbar.Add("Invio dei log non riuscito. Controlla la connessione e riprova.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
VisibleOverlay = false;
|
||||
_progressMessage = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Logout()
|
||||
private async Task UpdateDb()
|
||||
{
|
||||
_progressMessage = "Sincronizzazione…";
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
await SteupDataService.CheckAndUpdateStatus();
|
||||
Snackbar.Add("Ispezioni aggiornate", Severity.Success);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Snackbar.Add("Sincronizzazione non riuscita. Controlla la connessione e riprova.", Severity.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
VisibleOverlay = false;
|
||||
_progressMessage = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task 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); }
|
||||
@@ -3,6 +3,7 @@
|
||||
@using SteUp.Shared.Core.BarcodeReader.Contracts
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IBarcodeManager BarcodeManager
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<ErrorBoundary @ref="ErrorBoundary">
|
||||
<ChildContent>
|
||||
@@ -57,7 +58,12 @@
|
||||
LoadData = true;
|
||||
StateHasChanged();
|
||||
|
||||
await SteupDataService.Init();
|
||||
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
var user = authState.User;
|
||||
|
||||
if (user.Identity is { IsAuthenticated: true })
|
||||
await SteupDataService.Init();
|
||||
|
||||
BarcodeManager.Init();
|
||||
|
||||
LoadData = false;
|
||||
|
||||
@@ -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,83 +0,0 @@
|
||||
.container-primary-info {
|
||||
/*background: var(--light-card-background);*/
|
||||
width: 100%;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
color: var(--mud-palette-gray-default);
|
||||
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,118 +1,263 @@
|
||||
@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
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject IIspezioniService IspezioniService
|
||||
@inject IIntegrySteupService IntegrySteupService
|
||||
@inject IDialogService Dialog
|
||||
@inject IFileManager FileManager
|
||||
@inject ILogger<SchedaCard> Logger
|
||||
|
||||
<div class="scheda-card">
|
||||
<div class="scheda-body-section">
|
||||
<div class="title-section">
|
||||
<MudText Class="scheda-title" Typo="Typo.subtitle1">
|
||||
<b>@Scheda.ActivityTypeId</b>
|
||||
</MudText>
|
||||
|
||||
<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 (NetworkService.IsNetworkAvailable())
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" StartIcon="@Icons.Material.Rounded.Edit" OnClick="@UpdateScheda"/>
|
||||
<MudFab Color="Color.Error" Size="Size.Small" StartIcon="@Icons.Material.Rounded.Delete" OnClick="@DeleteScheda"/>
|
||||
<MudFab Color="Color.Success" Size="Size.Small" StartIcon="@Icons.Material.Rounded.CloudSync" OnClick="@ExportScheda"/>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudButton Variant="Variant.Filled" StartIcon="@Icons.Material.Rounded.Edit"
|
||||
Color="Color.Info" Size="Size.Small" OnClick="@UpdateScheda">
|
||||
Modifica
|
||||
</MudButton>
|
||||
<MudButton Variant="Variant.Filled" StartIcon="@Icons.Material.Rounded.Delete"
|
||||
Color="Color.Error" Size="Size.Small" OnClick="@DeleteScheda">
|
||||
Cancella
|
||||
</MudButton>
|
||||
}
|
||||
<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"/>
|
||||
<ConfirmDeleteMessageBox @ref="_deleteMessageBox" 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 string CodMdep { get; set; } = string.Empty;
|
||||
[Parameter] public DateTime Data { get; set; }
|
||||
[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; }
|
||||
[Parameter] public EventCallback<bool> OnBusyChanged { get; set; }
|
||||
|
||||
private bool VisibleOverlay { get; set; }
|
||||
private ConfirmMessageBox _messageBox = null!;
|
||||
private bool _busy;
|
||||
|
||||
private ConfirmDeleteMessageBox _deleteMessageBox = null!;
|
||||
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()
|
||||
{
|
||||
var modal = await ModalHelper.OpenFormScheda(Dialog, CodMdep, Data, false, Scheda);
|
||||
var modal = await Dialog.OpenFormScheda(Ispezione.CodMdep, Ispezione.Data, false, Scheda);
|
||||
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 _deleteMessageBox.ShowAsync();
|
||||
var result = await _messageBox.ShowAsync();
|
||||
if (result is not true) return;
|
||||
|
||||
if (result is true)
|
||||
await SetBusy(true);
|
||||
|
||||
try
|
||||
{
|
||||
if (Scheda.ActivityId != null)
|
||||
await IntegrySteupService.DeleteScheda(Scheda.ActivityId);
|
||||
|
||||
if (Scheda.ImageNames != null)
|
||||
{
|
||||
foreach (var fileName in Scheda.ImageNames)
|
||||
{
|
||||
FileManager.RemoveInspectionFile(Ispezione, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
var deleteScheda = await IspezioniService.DeleteSchedaAsync(Scheda.Id);
|
||||
if (deleteScheda) await OnSchedaDeleted.InvokeAsync(Scheda);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportScheda()
|
||||
{
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
// Guardia anti doppio-tap: invio irreversibile al server.
|
||||
if (_busy) return;
|
||||
await SetBusy(true);
|
||||
|
||||
var apiResponse = await IntegrySteupService.SaveScheda(
|
||||
new SaveRequestDto
|
||||
{
|
||||
LocalIdScheda = Scheda.Id,
|
||||
ActivityTypeId = Scheda.ActivityTypeId,
|
||||
CodJfas = Scheda.CodJfas,
|
||||
CodMdep = CodMdep,
|
||||
DataCreazione = Data,
|
||||
Note = Scheda.Note,
|
||||
PersonaRif = Scheda.Responsabile,
|
||||
Barcodes = Scheda.Articoli.ConvertAll(x => x.Barcode),
|
||||
Scandeza = (ScadenzaEnum)Scheda.Scadenza,
|
||||
ParentActivityId = Scheda.Ispezione?.ActivityId
|
||||
}
|
||||
);
|
||||
|
||||
if (apiResponse != null)
|
||||
try
|
||||
{
|
||||
var apiResponse = await IntegrySteupService.SaveScheda(
|
||||
new SaveRequestDto
|
||||
{
|
||||
LocalIdScheda = Scheda.Id,
|
||||
ActivityTypeId = Scheda.ActivityTypeId,
|
||||
CodJfas = Scheda.CodJfas,
|
||||
CodMdep = Ispezione.CodMdep,
|
||||
DataCreazione = Ispezione.Data,
|
||||
Note = Scheda.Note,
|
||||
PersonaRif = Scheda.Responsabile,
|
||||
Barcodes = Scheda.Articoli.ConvertAll(x => x.Barcode),
|
||||
Scandeza = (ScadenzaEnum)Scheda.Scadenza,
|
||||
// Aggancia all'ispezione esistente sul server (evita di crearne una nuova a ogni invio).
|
||||
ParentActivityId = SteupDataService.InspectionPageState.Ispezione.ActivityId
|
||||
}
|
||||
);
|
||||
|
||||
if (apiResponse == null)
|
||||
{
|
||||
Snackbar.Add("Invio non riuscito. La scheda resta sul dispositivo, riprova.", Severity.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
Scheda.ActivityId = apiResponse.ActivityIdScheda;
|
||||
SteupDataService.InspectionPageState.Ispezione.ActivityId = apiResponse.ActivityIdIspezione;
|
||||
|
||||
await IspezioniService.UpdateActivityIdIspezioneAsync(CodMdep, Data,
|
||||
await IspezioniService.UpdateActivityIdIspezioneAsync(Ispezione.CodMdep, Ispezione.Data,
|
||||
UserSession.User.Username, apiResponse.ActivityIdIspezione
|
||||
);
|
||||
await IspezioniService.UpdateActivityIdSchedaAsync(Scheda.Id, Scheda.ActivityId);
|
||||
}
|
||||
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
// Notifica il genitore così il contatore "da inviare" del reparto si aggiorna.
|
||||
await OnSchedaModified.InvokeAsync(Scheda);
|
||||
|
||||
Snackbar.Add("Scheda inviata", Severity.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Snackbar.Add("Invio non riuscito. La scheda resta sul dispositivo, riprova.", Severity.Error);
|
||||
Logger.LogError(e, e.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SetBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExportImg()
|
||||
{
|
||||
if (_busy || Scheda.ImageNames == null) return;
|
||||
await SetBusy(true);
|
||||
|
||||
try
|
||||
{
|
||||
var fileList = (await FileManager.GetInspectionFiles(Ispezione, Scheda.ImageNames))?
|
||||
.Where(x => x.ToUpload).ToList();
|
||||
|
||||
if (fileList == null || fileList.Count == 0)
|
||||
{
|
||||
Snackbar.Add("Nessuna nuova foto da inviare", Severity.Info);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in fileList)
|
||||
{
|
||||
await IntegrySteupService.UploadFile(Scheda.ActivityId!, file.FileBytes!, file.Name!);
|
||||
}
|
||||
|
||||
Snackbar.Add(fileList.Count == 1 ? "Foto inviata" : $"{fileList.Count} foto inviate", Severity.Success);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Snackbar.Add("Invio foto non riuscito, riprova.", Severity.Error);
|
||||
Logger.LogError(e, e.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await SetBusy(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
.scheda-card{
|
||||
padding: .75rem 1.25rem;
|
||||
background-color: var(--light-card-background);
|
||||
border-radius: 1em;
|
||||
}
|
||||
|
||||
.scheda-body-section{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.title-section{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.scheda-card-action{
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -1,49 +1,67 @@
|
||||
@using SteUp.Shared.Components.Layout.Overlay
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Helpers
|
||||
@using SteUp.Shared.Core.Interface.LocalDb
|
||||
@inject IIspezioniService IspezioniService
|
||||
|
||||
<div class="shop-card ripple-container" @onclick="StartInspection">
|
||||
<div class="shop-body-section">
|
||||
<div class="title-section">
|
||||
<MudText Class="shop-title" Typo="Typo.subtitle1">
|
||||
<b>@PuntoVendita.CodMdep</b> - @PuntoVendita.Descrizione
|
||||
</MudText>
|
||||
</div>
|
||||
@*
|
||||
Riga di scelta di un punto vendita. E' un <button>: si raggiunge da
|
||||
tastiera, il lettore di schermo la annuncia per quello che e', e il tocco
|
||||
si comporta come quello nativo. Il chevron dice che porta altrove.
|
||||
*@
|
||||
|
||||
@if (!PuntoVendita.Indirizzo.IsNullOrEmpty())
|
||||
<button type="button" class="list-card shop-card panel--tappable" @onclick="StartInspection"
|
||||
aria-label="@AriaLabel">
|
||||
<span class="shop-card__text">
|
||||
<span class="t-ident">@PuntoVendita.CodMdep</span>
|
||||
<span class="list-card__title">@PuntoVendita.Descrizione</span>
|
||||
|
||||
@if (!Luogo.IsNullOrEmpty())
|
||||
{
|
||||
<div class="subtitle-section">
|
||||
<MudText Class="shop-title" Typo="Typo.subtitle1">
|
||||
@PuntoVendita.Indirizzo
|
||||
</MudText>
|
||||
</div>
|
||||
<div class="sub-info-section">
|
||||
<MudChip T="string" Icon="@Icons.Material.Rounded.LocationCity" Size="Size.Small" Color="Color.Default">
|
||||
@PuntoVendita.Citta
|
||||
</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">
|
||||
@PuntoVendita.Cap
|
||||
</MudChip>
|
||||
<MudChip T="string" Size="Size.Small" Color="Color.Default">
|
||||
@PuntoVendita.Provincia
|
||||
</MudChip>
|
||||
</div>
|
||||
<span class="list-card__meta">@Luogo</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SpinnerOverlay VisibleOverlay="VisibleOverlay"/>
|
||||
@if (Aperta)
|
||||
{
|
||||
@* Toccando si riapre l'ispezione di oggi invece di crearne una
|
||||
nuova: meglio dirlo prima del tocco. *@
|
||||
<span class="list-card__badges badge-row">
|
||||
<Badge Spec="@ApertaBadge"/>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
|
||||
<i class="shop-card__chevron ri-arrow-right-s-line" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@code {
|
||||
[Parameter] public PuntoVenditaDto PuntoVendita { get; set; } = null!;
|
||||
|
||||
private bool VisibleOverlay { get; set; }
|
||||
[Parameter] public EventCallback OnStarting { get; set; }
|
||||
|
||||
/// Vero se il rilevatore ha gia' un'ispezione aperta oggi su questo punto vendita.
|
||||
[Parameter] public bool Aperta { get; set; }
|
||||
|
||||
private static readonly StatusBadgeHelper.BadgeSpec ApertaBadge =
|
||||
new("info", "ri-play-circle-line", "Ispezione di oggi");
|
||||
|
||||
// Indirizzo, citta', CAP e provincia su una riga sola con separatore " · ":
|
||||
// sono metadati, non meritano tre chip che fanno rumore.
|
||||
private string Luogo => string.Join(" · ", new[]
|
||||
{
|
||||
PuntoVendita.Indirizzo,
|
||||
PuntoVendita.Citta,
|
||||
string.Join(" ", new[] { PuntoVendita.Cap, PuntoVendita.Provincia }
|
||||
.Where(x => !x.IsNullOrEmpty()))
|
||||
}
|
||||
.Where(x => !x.IsNullOrEmpty()));
|
||||
|
||||
private string AriaLabel => Aperta
|
||||
? $"Riprendi l'ispezione di oggi su {PuntoVendita.CodMdep} {PuntoVendita.Descrizione}"
|
||||
: $"Apri ispezione su {PuntoVendita.CodMdep} {PuntoVendita.Descrizione}";
|
||||
|
||||
private async Task StartInspection()
|
||||
{
|
||||
VisibleOverlay = true;
|
||||
StateHasChanged();
|
||||
await OnStarting.InvokeAsync();
|
||||
|
||||
SteupDataService.InspectionPageState.Ispezione = await IspezioniService.GetOrCreateIspezioneAsync(
|
||||
PuntoVendita.CodMdep!,
|
||||
@@ -51,10 +69,6 @@
|
||||
UserSession.User.Username
|
||||
);
|
||||
|
||||
VisibleOverlay = false;
|
||||
StateHasChanged();
|
||||
|
||||
NavigationManager.NavigateTo("/ispezione");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.shop-card{
|
||||
padding: .5rem 1rem;
|
||||
background-color: var(--mud-palette-background-gray);
|
||||
border-radius: 1em;
|
||||
}
|
||||
@@ -1,26 +1,35 @@
|
||||
<div class="Connection @(ShowWarning ? "Show" : "Hide") @(IsNetworkAvailable? ServicesIsDown ? "ServicesIsDown" : "SystemOk" : "NetworkKo")">
|
||||
@if (IsNetworkAvailable)
|
||||
{
|
||||
if(ServicesIsDown)
|
||||
{
|
||||
<i class="ri-cloud-off-fill"></i>
|
||||
<span>Servizi offline</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="ri-cloud-fill"></i>
|
||||
<span>Online</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="ri-wifi-off-line"></i>
|
||||
<span>Nessuna connessione</span>
|
||||
}
|
||||
@*
|
||||
La firma visiva di "Offline e' la verita'": una striscia sottile ancorata in
|
||||
cima che scende quando lo stato di rete cambia. Mai il solo colore: sempre
|
||||
icona piu' etichetta, leggibile anche in pieno sole.
|
||||
*@
|
||||
|
||||
<div class="connection @(ShowWarning ? "connection--show" : "") @ToneClass"
|
||||
role="status" aria-live="polite">
|
||||
<i class="@IconClass" aria-hidden="true"></i>
|
||||
<span>@Label</span>
|
||||
</div>
|
||||
|
||||
@code{
|
||||
[Parameter] public bool IsNetworkAvailable { get; set; }
|
||||
[Parameter] public bool ServicesIsDown { get; set; }
|
||||
[Parameter] public bool ShowWarning { get; set; }
|
||||
|
||||
private string ToneClass => !IsNetworkAvailable
|
||||
? "connection--offline"
|
||||
: ServicesIsDown
|
||||
? "connection--down"
|
||||
: "connection--ok";
|
||||
|
||||
private string IconClass => !IsNetworkAvailable
|
||||
? "ri-wifi-off-line"
|
||||
: ServicesIsDown
|
||||
? "ri-cloud-off-line"
|
||||
: "ri-cloud-line";
|
||||
|
||||
private string Label => !IsNetworkAvailable
|
||||
? "Nessuna connessione · il lavoro resta sul dispositivo"
|
||||
: ServicesIsDown
|
||||
? "Servizi non raggiungibili"
|
||||
: "Di nuovo online";
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<MudMessageBox @ref="_confirmDelete" Title="Attenzione!" CancelText="Annulla">
|
||||
<MessageContent>
|
||||
@Message
|
||||
</MessageContent>
|
||||
<YesButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Error">
|
||||
Cancella
|
||||
</MudButton>
|
||||
</YesButton>
|
||||
</MudMessageBox>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public string Message { get; set; } = string.Empty;
|
||||
|
||||
private MudMessageBox? _confirmDelete;
|
||||
|
||||
public async Task<bool?> ShowAsync()
|
||||
{
|
||||
if (_confirmDelete == null) return null;
|
||||
return await _confirmDelete.ShowAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
@* Conferma di un'azione. Il titolo dice cosa sta per succedere, il messaggio
|
||||
che conseguenze ha: davanti a un'azione irreversibile la domanda deve
|
||||
essere leggibile prima del testo, non dedotta da esso.
|
||||
|
||||
YesText -> true, NoText -> false, chiusura senza scelta -> null. *@
|
||||
<MudMessageBox @ref="_confirmDelete" Title="@Title" CancelText="@NoText">
|
||||
<MessageContent>
|
||||
@Message
|
||||
</MessageContent>
|
||||
<YesButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="YesColor">
|
||||
@YesText
|
||||
</MudButton>
|
||||
</YesButton>
|
||||
</MudMessageBox>
|
||||
|
||||
@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 Color YesColor { get; set; } = Color.Primary;
|
||||
|
||||
private MudMessageBox? _confirmDelete;
|
||||
|
||||
public async Task<bool?> ShowAsync()
|
||||
{
|
||||
if (_confirmDelete == null) return null;
|
||||
return await _confirmDelete.ShowAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
<MudMessageBox @ref="_confirmSave" Title="Attenzione!" CancelText="Non salvare">
|
||||
@* NoText -> false (salva bozza), YesText -> true (salva), chiusura senza scelta -> null.
|
||||
Niente CancelText: da qui non si esce buttando via il lavoro. *@
|
||||
<MudMessageBox @ref="_confirmSave" Title="Lavoro non salvato" NoText="Salva come bozza">
|
||||
<MessageContent>
|
||||
Sono state apportate delle modifiche. Vuoi salvarle prima di continuare?
|
||||
Hai delle modifiche non salvate. Salva la scheda, oppure tienila come bozza
|
||||
da completare più tardi: in ogni caso non perdi nulla.
|
||||
</MessageContent>
|
||||
<YesButton>
|
||||
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary">
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
<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>
|
||||
@using Microsoft.Extensions.Logging
|
||||
@inject ILogger<ExceptionModal> Logger
|
||||
|
||||
@*
|
||||
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>
|
||||
@@ -43,6 +54,8 @@
|
||||
{
|
||||
Message = Exception.Message;
|
||||
}
|
||||
|
||||
Logger.LogError(Exception, "Errore nel componente Blazor: {Message}", Message);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,23 +1,40 @@
|
||||
<MudDialog OnBackdropClick="Cancel">
|
||||
@*
|
||||
Dialog informativo (STILE_UI §6.10): cerchio-icona del tono, titolo,
|
||||
messaggio, un solo bottone a piena larghezza.
|
||||
*@
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<div class="exception-header mb-2">
|
||||
<i class="ri-emotion-unhappy-line"></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>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
[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,30 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: medium;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
code {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: var(--bs-gray-dark);
|
||||
}
|
||||
@@ -1,27 +1,35 @@
|
||||
@using SteUp.Shared.Components.Layout
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Dto
|
||||
@using SteUp.Shared.Core.Interface.System
|
||||
@inject IAttachedService AttachedService
|
||||
|
||||
<MudDialog Class="disable-safe-area">
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<HeaderLayout SmallHeader="true" Cancel="true" OnCancel="@(() => MudDialog.Cancel())" Title="Aggiungi allegati"/>
|
||||
<h2 class="dialog-title">Aggiungi una foto</h2>
|
||||
<p class="dialog-message">Scatta sul posto oppure scegli un'immagine già sul dispositivo.</p>
|
||||
|
||||
<div style="margin-bottom: 1rem;" class="content attached">
|
||||
<MudFab Size="Size.Small" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Rounded.CameraAlt"
|
||||
Label="Camera" OnClick="@OnCamera"/>
|
||||
<div class="stack stack-xs" style="margin-top: var(--space-md);">
|
||||
<button type="button" class="action-tile" @onclick="@OnCamera">
|
||||
<span class="action-tile__icon action-tile__icon--primary">
|
||||
<i class="ri-camera-line" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="action-tile__label">Scatta una foto</span>
|
||||
</button>
|
||||
|
||||
<MudFab Size="Size.Small" Color="Color.Primary"
|
||||
StartIcon="@Icons.Material.Rounded.Image"
|
||||
Label="Galleria" OnClick="@OnGallery"/>
|
||||
<button type="button" class="action-tile" @onclick="@OnGallery">
|
||||
<span class="action-tile__icon action-tile__icon--neutral">
|
||||
<i class="ri-image-line" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="action-tile__label">Scegli dalla galleria</span>
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@(() => MudDialog.Cancel())">Annulla</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = null!;
|
||||
[Parameter] public bool CanAddPosition { get; set; }
|
||||
|
||||
private List<AttachedDto>? Attached { get; set; }
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
.content.attached {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
padding: 1rem;
|
||||
height: unset;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,86 +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;
|
||||
StateHasChanged();
|
||||
return;
|
||||
}
|
||||
var source = SteupDataService.PuntiVenditaList;
|
||||
|
||||
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!))
|
||||
);
|
||||
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();
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
.shop-header{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.shop-title{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shop-body{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
@@ -1,24 +1,34 @@
|
||||
@using SteUp.Shared.Core.Dto
|
||||
|
||||
<MudDialog OnBackdropClick="Cancel">
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
<h2 class="dialog-title">Note descrittive</h2>
|
||||
<p class="dialog-message">Scegli una descrizione ricorrente per questo motivo.</p>
|
||||
|
||||
@if (!ActivityTypers.IsNullOrEmpty())
|
||||
{
|
||||
<MudList T="string" SelectedValueChanged="OnClickItem">
|
||||
<div class="stack stack-xs" style="margin-top: var(--space-md);">
|
||||
@foreach (var item in ActivityTypers!)
|
||||
{
|
||||
<MudListItem Text="@item.ActivityTypeDescription" Value="item.ActivityTypeDescription"/>
|
||||
<button type="button" class="action-tile"
|
||||
@onclick="@(() => OnClickItem(item.ActivityTypeDescription))">
|
||||
<span class="action-tile__label">@item.ActivityTypeDescription</span>
|
||||
</button>
|
||||
}
|
||||
</MudList>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="spinner-container" style="height: unset !important;">
|
||||
<MudIcon Size="Size.Large" Color="Color.Error" Icon="@Icons.Material.Rounded.Close"/>
|
||||
<MudText>Nessuna descrizione consigliata</MudText>
|
||||
<div class="empty-state">
|
||||
<i class="empty-state__icon ri-chat-off-line" aria-hidden="true"></i>
|
||||
<p class="empty-state__title">Nessuna descrizione consigliata</p>
|
||||
<p class="empty-state__text">Per questo motivo non ci sono note ricorrenti: scrivila a mano.</p>
|
||||
</div>
|
||||
}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton Variant="Variant.Outlined" OnClick="@Cancel">Annulla</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<div class="no-data opacity-75 d-flex flex-column align-items-center">
|
||||
<img src="@ImageSource" alt=""/>
|
||||
@* Stato vuoto (STILE_UI §6.12): suggerisce la mossa successiva, non si limita
|
||||
a dire che non c'e' niente. *@
|
||||
|
||||
<p class="mt-3">@Text</p>
|
||||
<div class="empty-state">
|
||||
<i class="empty-state__icon @Icon" aria-hidden="true"></i>
|
||||
<p class="empty-state__title">@Text</p>
|
||||
@if (!string.IsNullOrWhiteSpace(Hint))
|
||||
{
|
||||
<p class="empty-state__text">@Hint</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string ImageSource { get; set; } = string.Empty;
|
||||
[Parameter] public string Text { get; set; } = string.Empty;
|
||||
[Parameter] public string? Hint { get; set; }
|
||||
[Parameter] public string Icon { get; set; } = "ri-inbox-line";
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
.no-data {
|
||||
position: fixed;
|
||||
top: 35%;
|
||||
width: calc(100% - 3rem); /* remove page padding */
|
||||
}
|
||||
|
||||
.no-data img {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.no-data p {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
@typeparam TValue
|
||||
@using SteUp.Shared.Components.Layout.Sheet
|
||||
|
||||
@*
|
||||
Scelta a valore singolo. Al posto della tendina: una riga tappabile che
|
||||
apre un bottom sheet con le opzioni a tutta larghezza.
|
||||
|
||||
La tendina di serie e' un residuo del web — galleggia bianca su bianco
|
||||
sopra le card, si porta dietro la barra di scorrimento del desktop e offre
|
||||
bersagli alti quanto una riga di testo. Qui l'elenco arriva dal basso, ha
|
||||
un titolo, righe da 56 px e la scelta corrente segnata: si usa in piedi,
|
||||
con una mano, anche con i guanti.
|
||||
*@
|
||||
|
||||
<button type="button" class="select-field" disabled="@ReadOnly" @onclick="Open"
|
||||
aria-haspopup="dialog" aria-label="@($"{Title}: {(HasValue ? SelectedLabel : Placeholder)}")">
|
||||
<span class="select-field__value @(HasValue ? "" : "select-field__value--empty")">
|
||||
@(HasValue ? SelectedLabel : Placeholder)
|
||||
</span>
|
||||
@if (!ReadOnly)
|
||||
{
|
||||
<i class="select-field__icon ri-arrow-down-s-line" aria-hidden="true"></i>
|
||||
}
|
||||
</button>
|
||||
|
||||
<ActionSheet @bind-Open="_open" Title="@Title" Subtitle="@Subtitle">
|
||||
<div class="stack stack-xs">
|
||||
@foreach (var item in Items)
|
||||
{
|
||||
var selected = IsSelected(item);
|
||||
<button type="button" class="select-row select-row--single"
|
||||
aria-pressed="@(selected ? "true" : "false")"
|
||||
@onclick="@(() => Choose(item))">
|
||||
<span class="select-row__check" aria-hidden="true">
|
||||
<i class="ri-check-line"></i>
|
||||
</span>
|
||||
<span class="select-row__text">
|
||||
<span class="select-row__title">@ItemLabel(item)</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</ActionSheet>
|
||||
|
||||
@code {
|
||||
/// Titolo dello sheet: e' il nome del campo ("Reparto", "Motivo").
|
||||
[Parameter, EditorRequired] public string Title { get; set; } = "";
|
||||
|
||||
[Parameter] public string? Subtitle { get; set; }
|
||||
|
||||
[Parameter, EditorRequired] public IEnumerable<TValue> Items { get; set; } = [];
|
||||
|
||||
[Parameter] public TValue? Value { get; set; }
|
||||
[Parameter] public EventCallback<TValue?> ValueChanged { get; set; }
|
||||
|
||||
/// Come si scrive un'opzione. Deve essere fornita: il ToString() di un DTO
|
||||
/// non e' un'etichetta.
|
||||
[Parameter, EditorRequired] public Func<TValue, string?> ItemLabel { get; set; } = _ => null;
|
||||
|
||||
[Parameter] public string Placeholder { get; set; } = "Scegli";
|
||||
[Parameter] public bool ReadOnly { get; set; }
|
||||
|
||||
private bool _open;
|
||||
|
||||
private bool HasValue => Value is not null;
|
||||
private string? SelectedLabel => Value is null ? null : ItemLabel(Value);
|
||||
|
||||
// Il confronto passa per l'etichetta, non per l'oggetto: il valore corrente
|
||||
// arriva ricostruito dall'entita' salvata (Scheda.Reparto lo crea al volo da
|
||||
// codice e descrizione), quindi non e' mai lo stesso oggetto di quelli in
|
||||
// elenco e il confronto di riferimento non segnerebbe mai nulla.
|
||||
private bool IsSelected(TValue item) =>
|
||||
Value is not null &&
|
||||
string.Equals(ItemLabel(item), SelectedLabel, StringComparison.Ordinal);
|
||||
|
||||
private void Open()
|
||||
{
|
||||
if (!ReadOnly) _open = true;
|
||||
}
|
||||
|
||||
private async Task Choose(TValue item)
|
||||
{
|
||||
_open = false;
|
||||
Value = item;
|
||||
await ValueChanged.InvokeAsync(item);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using Microsoft.Maui.Devices.Sensors;
|
||||
using SteUp.Shared.Core.BarcodeReader.Contracts;
|
||||
using SteUp.Shared.Core.BarcodeReader.Dto;
|
||||
using SteUp.Shared.Core.BarcodeReader.Enum;
|
||||
using SteUp.Shared.Core.Messages.Scanner;
|
||||
|
||||
namespace SteUp.Shared.Core.BarcodeReader;
|
||||
@@ -19,14 +22,48 @@ public class BarcodeManager(
|
||||
scanner.Register(
|
||||
onScanSuccessful: dto =>
|
||||
{
|
||||
messenger.Send(new NewScannerMessage(dto.StringValue));
|
||||
var stringValue = dto.StringValue;
|
||||
|
||||
if (IsEanPeso(dto))
|
||||
stringValue = DecodeEanPeso(stringValue);
|
||||
else if (IsPlu(dto))
|
||||
stringValue = DecodePlu(stringValue);
|
||||
|
||||
messenger.Send(new NewScannerMessage(stringValue));
|
||||
},
|
||||
onScanFailed: ex =>
|
||||
{
|
||||
messenger.Send(new ErrorScannerMessage(ex.Message));
|
||||
}
|
||||
onScanFailed: ex => { messenger.Send(new ErrorScannerMessage(ex.Message)); }
|
||||
);
|
||||
|
||||
scanner.Init(() => { scanner.ChangeSettings([("TRIGGER_SCAN_MODE", "ONE_SHOT")]); });
|
||||
}
|
||||
|
||||
private static bool IsEanPeso(BarcodeScanDto barcodeScan) =>
|
||||
(IsEtichetta128(barcodeScan) || IsEan13(barcodeScan)) && barcodeScan.StringValue is { Length: 13 } &&
|
||||
barcodeScan.StringValue.StartsWith('2');
|
||||
|
||||
private static bool IsPlu(BarcodeScanDto barcodeScan) =>
|
||||
IsCode128(barcodeScan) && barcodeScan.StringValue is { Length: 7 } && barcodeScan.StringValue.StartsWith('2');
|
||||
|
||||
private static bool IsEtichetta128(BarcodeScanDto barcodeScan) =>
|
||||
barcodeScan.Type is BarcodeType.CODE128 or BarcodeType.EAN128;
|
||||
|
||||
private static bool IsCode128(BarcodeScanDto barcodeScan) =>
|
||||
barcodeScan.Type == BarcodeType.CODE128;
|
||||
|
||||
private static bool IsEan13(BarcodeScanDto barcodeScan) =>
|
||||
barcodeScan.Type == BarcodeType.EAN13;
|
||||
|
||||
private static string DecodeEanPeso(string? barcode)
|
||||
{
|
||||
return barcode is not { Length: 13 }
|
||||
? throw new Exception("Errore durante il parse del barcode (" + barcode + ")")
|
||||
: barcode.Substring(1, 6);
|
||||
}
|
||||
|
||||
private static string DecodePlu(string? barcode)
|
||||
{
|
||||
return barcode is not { Length: 7 }
|
||||
? throw new Exception("Errore durante il parse del barcode (" + barcode + ")")
|
||||
: barcode.Substring(1, 6);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
namespace SteUp.Shared.Core.BarcodeReader.Dto;
|
||||
using SteUp.Shared.Core.BarcodeReader.Enum;
|
||||
|
||||
namespace SteUp.Shared.Core.BarcodeReader.Dto;
|
||||
|
||||
public class BarcodeScanDto
|
||||
{
|
||||
public string? StringValue { get; set; }
|
||||
public string? Type { get; set; }
|
||||
public BarcodeType? Type { get; set; }
|
||||
public string? Name { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace SteUp.Shared.Core.BarcodeReader.Enum;
|
||||
|
||||
public enum BarcodeType
|
||||
{
|
||||
NIL = 0,
|
||||
AIRLINE_2OF5_13_DIGIT = 1,
|
||||
AIRLINE_2OF5_15_DIGIT = 2,
|
||||
AZTEC = 3,
|
||||
AUSTRALIAN_POSTAL = 4,
|
||||
BOOKLAND_EAN = 5,
|
||||
BPO = 6,
|
||||
CANPOST = 7,
|
||||
CHINAPOST = 8,
|
||||
CHINESE_2OF5 = 9,
|
||||
CODABAR = 10,
|
||||
CODABLOCK = 11,
|
||||
CODE11 = 12,
|
||||
CODE128 = 13,
|
||||
CODE16K = 14,
|
||||
CODE32 = 15,
|
||||
CODE39 = 16,
|
||||
CODE49 = 17,
|
||||
CODE93 = 18,
|
||||
COMPOSITE = 19,
|
||||
COUPON_CODE = 20,
|
||||
DATAMATRIX = 21,
|
||||
DISCRETE_2OF5 = 22,
|
||||
DUTCH_POSTAL = 23,
|
||||
EAN128 = 24,
|
||||
EAN13 = 25,
|
||||
EAN8 = 26,
|
||||
GS1_DATABAR_14 = 27,
|
||||
GS1_DATABAR_EXPANDED = 28,
|
||||
GS1_DATABAR_LIMITED = 29,
|
||||
HONGKONG_2OF5 = 30,
|
||||
IATA_2OF5 = 31,
|
||||
IDTAG = 32,
|
||||
INTERLEAVED_2OF5 = 33,
|
||||
ISBT128 = 34,
|
||||
JAPANESE_POSTAL = 35,
|
||||
KOREAN_POSTAL = 36,
|
||||
MATRIX_2OF5 = 37,
|
||||
MAXICODE = 38,
|
||||
MESA = 39,
|
||||
MICRO_PDF417 = 40,
|
||||
MICRO_QR = 41,
|
||||
MSI = 42,
|
||||
NEC_2OF5 = 43,
|
||||
OCR = 44,
|
||||
PDF417 = 45,
|
||||
PLESSEY = 46,
|
||||
POSICODE = 47,
|
||||
POST_US4 = 48,
|
||||
QR = 49,
|
||||
STRAIGHT_2OF5 = 50,
|
||||
STANDARD_2OF5 = 51,
|
||||
TELEPEN = 52,
|
||||
TLCODE39 = 53,
|
||||
TRIOPTIC = 54,
|
||||
UK_POSTAL = 55,
|
||||
UPCA = 56,
|
||||
UPCE = 57,
|
||||
UPCE1 = 58,
|
||||
US_PLANET = 59,
|
||||
US_POSTNET = 60,
|
||||
USPS_4CB = 61,
|
||||
RSS = 62,
|
||||
LABEL = 63,
|
||||
HANXIN = 64,
|
||||
GRIDMATRIX = 65,
|
||||
INFO_MAIL = 66,
|
||||
INTELLIGENT_MAIL = 67,
|
||||
SWEDENPOST = 68,
|
||||
LAST = 69
|
||||
}
|
||||
@@ -6,7 +6,9 @@ namespace SteUp.Shared.Core.Data.Contracts;
|
||||
public interface ISteupDataService
|
||||
{
|
||||
Task Init();
|
||||
Task<bool> CanOpenNewInspection();
|
||||
void RegisterAppVersion();
|
||||
Task CheckAndUpdateStatus();
|
||||
|
||||
List<PuntoVenditaDto> PuntiVenditaList { get; }
|
||||
InspectionPageState InspectionPageState { get; set; }
|
||||
|
||||
@@ -3,11 +3,12 @@ using IntegryApiClient.Core.Domain.Abstraction.Contracts.Device;
|
||||
using SteUp.Shared.Core.Data.Contracts;
|
||||
using SteUp.Shared.Core.Dto;
|
||||
using SteUp.Shared.Core.Dto.PageState;
|
||||
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.Interface.System.Network;
|
||||
|
||||
namespace SteUp.Shared.Core.Data;
|
||||
|
||||
@@ -17,13 +18,22 @@ public class SteupDataService(
|
||||
IDeviceService deviceService,
|
||||
IGenericSystemService genericSystemService,
|
||||
IIspezioniService ispezioniService,
|
||||
IFileManager fileManager,
|
||||
IDbInitializer dbInitializer) : ISteupDataService
|
||||
{
|
||||
public async Task Init()
|
||||
{
|
||||
await dbInitializer.InitializeAsync();
|
||||
await LoadDataAsync();
|
||||
await CheckAndUpdateStatus();
|
||||
|
||||
var ispezioni = await ispezioniService.GetAllIspezioniWithSchedeAsync(userSession.User.Username);
|
||||
|
||||
if (!ispezioni.IsNullOrEmpty())
|
||||
{
|
||||
await CheckAndUpdateStatus(ispezioni);
|
||||
await CleanOldClosedInspection(ispezioni);
|
||||
}
|
||||
|
||||
RegisterAppVersion();
|
||||
}
|
||||
|
||||
@@ -34,9 +44,28 @@ public class SteupDataService(
|
||||
);
|
||||
}
|
||||
|
||||
private async Task CheckAndUpdateStatus()
|
||||
private async Task CleanOldClosedInspection(List<Ispezione> ispezioni)
|
||||
{
|
||||
ispezioni = ispezioni.Where(x =>
|
||||
x.Stato == StatusEnum.Completata &&
|
||||
x.Data < DateTime.Now.AddDays(-60)
|
||||
).ToList();
|
||||
|
||||
foreach (var ispezione in ispezioni)
|
||||
{
|
||||
fileManager.RemoveInspection(ispezione);
|
||||
await ispezioniService.DeleteIspezioneAsync(ispezione.CodMdep, ispezione.Data, ispezione.Rilevatore);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CheckAndUpdateStatus()
|
||||
{
|
||||
var ispezioni = await ispezioniService.GetAllIspezioniWithSchedeAsync(userSession.User.Username);
|
||||
await CheckAndUpdateStatus(ispezioni);
|
||||
}
|
||||
|
||||
private async Task CheckAndUpdateStatus(List<Ispezione> ispezioni)
|
||||
{
|
||||
var ispezioni = await ispezioniService.GetAllIspezioniWithSchedeAsync();
|
||||
var listActivityId = ispezioni
|
||||
.Where(x => x.ActivityId != null)
|
||||
.Select(x => x.ActivityId!)
|
||||
@@ -76,6 +105,20 @@ public class SteupDataService(
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> CanOpenNewInspection()
|
||||
{
|
||||
var completedInspection = await ispezioniService.GetAllIspezioni(userSession.User.Username);
|
||||
|
||||
if (completedInspection.IsNullOrEmpty()) return true;
|
||||
|
||||
//Controllo se sono presenti attività più vecchie di 20 giorni non chiuse
|
||||
//Se presenti non si possono aprire nuove ispezioni
|
||||
return !completedInspection.Any(x =>
|
||||
x.Stato != StatusEnum.Completata &&
|
||||
x.Data < DateTime.Now.AddDays(-20)
|
||||
);
|
||||
}
|
||||
|
||||
private async Task LoadDataAsync()
|
||||
{
|
||||
if (!await userSession.IsLoggedIn()) return;
|
||||
|
||||
@@ -8,6 +8,9 @@ public class SaveRequestDto
|
||||
[JsonPropertyName("localIdScheda")]
|
||||
public int? LocalIdScheda { get; set; }
|
||||
|
||||
[JsonPropertyName("activityId")]
|
||||
public string? ActivityId { get; set; }
|
||||
|
||||
[JsonPropertyName("codMdep")]
|
||||
public string? CodMdep { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace SteUp.Shared.Core.Dto;
|
||||
|
||||
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")]
|
||||
public List<AttachmentsDto>? Attachments { get; set; }
|
||||
|
||||
public class AttachmentsDto
|
||||
{
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
public byte[] FileContent { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,12 @@ public class Scheda : EntityBase<Scheda>
|
||||
public List<SchedaArticolo> Articoli { get; set; } = [];
|
||||
public List<string>? ImageNames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Scheda salvata in locale ma non ancora completata (es. uscita dal form con lavoro in corso).
|
||||
/// Non viene mai inviata al server finché il rilevatore non la conclude.
|
||||
/// </summary>
|
||||
public bool Bozza { get; set; }
|
||||
|
||||
public string? DescrizioneReparto { get; set; }
|
||||
public string? ActivityTypeId { get; set; }
|
||||
public string? Note { get; set; }
|
||||
|
||||
@@ -6,129 +6,153 @@ using SteUp.Shared.Core.Entities;
|
||||
|
||||
namespace SteUp.Shared.Core.Helpers;
|
||||
|
||||
public abstract class ModalHelper
|
||||
public static class ModalHelper
|
||||
{
|
||||
public static async Task<DialogResult?> OpenSelectShop(IDialogService dialog)
|
||||
extension(IDialogService dialog)
|
||||
{
|
||||
var modal = await dialog.ShowAsync<ModalSelectShop>(
|
||||
"ModalSelectShop",
|
||||
new DialogParameters(),
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = false,
|
||||
CloseButton = false,
|
||||
NoHeader = true,
|
||||
BackdropClick = true
|
||||
}
|
||||
);
|
||||
public async Task<DialogResult?> OpenSelectShop()
|
||||
{
|
||||
var modal = await dialog.ShowAsync<ModalSelectShop>(
|
||||
"ModalSelectShop",
|
||||
new DialogParameters(),
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = true,
|
||||
CloseButton = false,
|
||||
NoHeader = true,
|
||||
BackdropClick = false
|
||||
}
|
||||
);
|
||||
|
||||
return await modal.Result;
|
||||
}
|
||||
return await modal.Result;
|
||||
}
|
||||
|
||||
public static async Task<DialogResult?> OpenFormScheda(IDialogService dialog, string codMdep, DateTime data,
|
||||
bool isNew = false, Scheda? scheda = null)
|
||||
{
|
||||
scheda = isNew && scheda == null ? new Scheda() : scheda;
|
||||
public async Task<DialogResult?> OpenFormScheda(string codMdep, DateTime data,
|
||||
bool isNew = false, Scheda? scheda = null, bool readOnly = false)
|
||||
{
|
||||
scheda = isNew && scheda == null ? new Scheda() : scheda;
|
||||
|
||||
var modal = await dialog.ShowAsync<ModalFormScheda>(
|
||||
"ModalFormScheda",
|
||||
new DialogParameters<ModalFormScheda>
|
||||
{
|
||||
{ x => x.CodMdep, codMdep },
|
||||
{ x => x.Data, data },
|
||||
{ x => x.IsNew, isNew },
|
||||
{ x => x.Scheda, scheda }
|
||||
},
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = true,
|
||||
CloseButton = false,
|
||||
NoHeader = true
|
||||
}
|
||||
);
|
||||
var modal = await dialog.ShowAsync<ModalFormScheda>(
|
||||
"ModalFormScheda",
|
||||
new DialogParameters<ModalFormScheda>
|
||||
{
|
||||
{ x => x.CodMdep, codMdep },
|
||||
{ x => x.Data, data },
|
||||
{ x => x.IsNew, isNew },
|
||||
{ x => x.ReadOnly, readOnly },
|
||||
{ x => x.Scheda, scheda }
|
||||
},
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = true,
|
||||
CloseButton = false,
|
||||
NoHeader = true
|
||||
}
|
||||
);
|
||||
|
||||
return await modal.Result;
|
||||
}
|
||||
return await modal.Result;
|
||||
}
|
||||
|
||||
public static async Task<DialogResult?> OpenAddAttached(IDialogService dialog)
|
||||
{
|
||||
var modal = await dialog.ShowAsync<ModalAddAttached>(
|
||||
"Add attached",
|
||||
new DialogParameters(),
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = false,
|
||||
CloseButton = false,
|
||||
NoHeader = true,
|
||||
BackdropClick = false
|
||||
}
|
||||
);
|
||||
public async Task<DialogResult?> OpenAddAttached()
|
||||
{
|
||||
var modal = await dialog.ShowAsync<ModalAddAttached>(
|
||||
"Add attached",
|
||||
new DialogParameters(),
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = false,
|
||||
CloseButton = false,
|
||||
NoHeader = true,
|
||||
BackdropClick = false
|
||||
}
|
||||
);
|
||||
|
||||
return await modal.Result;
|
||||
}
|
||||
return await modal.Result;
|
||||
}
|
||||
|
||||
public static async Task<DialogResult?> OpenSuggestActivityDescription(IDialogService dialog,
|
||||
List<StbActivityTyperDto>? activityTypers)
|
||||
{
|
||||
var modal = await dialog.ShowAsync<ModalSuggestDescription>(
|
||||
"Suggest activity description",
|
||||
new DialogParameters<ModalSuggestDescription>
|
||||
{
|
||||
{ x => x.ActivityTypers, activityTypers }
|
||||
},
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = false,
|
||||
CloseButton = false,
|
||||
NoHeader = true,
|
||||
BackdropClick = true
|
||||
}
|
||||
);
|
||||
public async Task<DialogResult?> OpenSuggestActivityDescription(List<StbActivityTyperDto>? activityTypers)
|
||||
{
|
||||
var modal = await dialog.ShowAsync<ModalSuggestDescription>(
|
||||
"Suggest activity description",
|
||||
new DialogParameters<ModalSuggestDescription>
|
||||
{
|
||||
{ x => x.ActivityTypers, activityTypers }
|
||||
},
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = false,
|
||||
CloseButton = false,
|
||||
NoHeader = true,
|
||||
BackdropClick = true
|
||||
}
|
||||
);
|
||||
|
||||
return await modal.Result;
|
||||
}
|
||||
return await modal.Result;
|
||||
}
|
||||
|
||||
public static async Task<DialogResult?> OpenSelectArt(IDialogService dialog, List<ArticoliInGrigliaDto>? articoli)
|
||||
{
|
||||
var modal = await dialog.ShowAsync<ModalSelectArt>(
|
||||
"ModalSelectArt",
|
||||
new DialogParameters<ModalSelectArt>
|
||||
{
|
||||
{ x => x.Articoli, articoli }
|
||||
},
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = false,
|
||||
CloseButton = false,
|
||||
NoHeader = true,
|
||||
BackdropClick = true,
|
||||
FullWidth = true,
|
||||
MaxWidth = MaxWidth.ExtraLarge
|
||||
}
|
||||
);
|
||||
public async Task<DialogResult?> OpenSelectArt(List<ArticoliInGrigliaDto>? articoli)
|
||||
{
|
||||
var modal = await dialog.ShowAsync<ModalSelectArt>(
|
||||
"ModalSelectArt",
|
||||
new DialogParameters<ModalSelectArt>
|
||||
{
|
||||
{ x => x.Articoli, articoli }
|
||||
},
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = true,
|
||||
CloseButton = false,
|
||||
NoHeader = true,
|
||||
BackdropClick = false
|
||||
}
|
||||
);
|
||||
|
||||
return await modal.Result;
|
||||
}
|
||||
return await modal.Result;
|
||||
}
|
||||
|
||||
public static async Task ShowError(IDialogService dialog, string message)
|
||||
{
|
||||
var modal = await dialog.ShowAsync<ModalError>(
|
||||
"ModalError",
|
||||
new DialogParameters<ModalError>
|
||||
{
|
||||
{ x => x.ErrorMessage, message }
|
||||
},
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = false,
|
||||
CloseButton = false,
|
||||
NoHeader = true,
|
||||
BackdropClick = true,
|
||||
FullWidth = true,
|
||||
MaxWidth = MaxWidth.ExtraLarge
|
||||
}
|
||||
);
|
||||
public async Task ShowError(string message)
|
||||
{
|
||||
var modal = await dialog.ShowAsync<ModalError>(
|
||||
"ModalError",
|
||||
new DialogParameters<ModalError>
|
||||
{
|
||||
{ x => x.ErrorMessage, message }
|
||||
},
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = false,
|
||||
CloseButton = false,
|
||||
NoHeader = true,
|
||||
BackdropClick = true,
|
||||
FullWidth = true,
|
||||
MaxWidth = MaxWidth.ExtraLarge
|
||||
}
|
||||
);
|
||||
|
||||
await modal.Result;
|
||||
await modal.Result;
|
||||
}
|
||||
|
||||
public async Task ShowWarning(string message)
|
||||
{
|
||||
var modal = await dialog.ShowAsync<ModalError>(
|
||||
"ModalError",
|
||||
new DialogParameters<ModalError>
|
||||
{
|
||||
{ x => x.ErrorMessage, message },
|
||||
{ x => x.IsWarning, true }
|
||||
},
|
||||
new DialogOptions
|
||||
{
|
||||
FullScreen = false,
|
||||
CloseButton = false,
|
||||
NoHeader = true,
|
||||
BackdropClick = true,
|
||||
FullWidth = true,
|
||||
MaxWidth = MaxWidth.ExtraLarge
|
||||
}
|
||||
);
|
||||
|
||||
await modal.Result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using MudBlazor;
|
||||
using SteUp.Shared.Core.Entities;
|
||||
|
||||
namespace SteUp.Shared.Core.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Stato di sincronizzazione di una scheda rispetto al server Integry.
|
||||
/// Principio "Offline è la verità": il rilevatore deve sempre vedere cosa è
|
||||
/// ancora solo sul dispositivo e cosa è già arrivato al server.
|
||||
/// </summary>
|
||||
public enum SyncState
|
||||
{
|
||||
/// Salvata solo in locale, non ancora inviata al server.
|
||||
DaInviare = 0,
|
||||
|
||||
/// Inviata al server (ha un ActivityId).
|
||||
Inviato = 1,
|
||||
|
||||
/// Salvata in locale ma non completata: da riprendere, non inviabile.
|
||||
Bozza = 2
|
||||
}
|
||||
|
||||
public static class SchedaSyncHelper
|
||||
{
|
||||
public static SyncState GetSyncState(this Scheda scheda) =>
|
||||
scheda.Bozza
|
||||
? SyncState.Bozza
|
||||
: string.IsNullOrEmpty(scheda.ActivityId)
|
||||
? SyncState.DaInviare
|
||||
: SyncState.Inviato;
|
||||
|
||||
public static string ToLabel(this SyncState state) =>
|
||||
state switch
|
||||
{
|
||||
SyncState.DaInviare => "Da inviare",
|
||||
SyncState.Inviato => "Inviato",
|
||||
SyncState.Bozza => "Da completare",
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Colore del badge. Corallo (Primary) = azione richiesta (da inviare);
|
||||
/// verde (Success) = fatto (inviato). Rispetta la Regola dell'Unico Accento.
|
||||
/// </summary>
|
||||
public static Color GetColor(this SyncState state) =>
|
||||
state switch
|
||||
{
|
||||
SyncState.DaInviare => Color.Primary,
|
||||
SyncState.Inviato => Color.Success,
|
||||
SyncState.Bozza => Color.Warning,
|
||||
_ => Color.Default
|
||||
};
|
||||
|
||||
public static string GetIcon(this SyncState state) =>
|
||||
state switch
|
||||
{
|
||||
SyncState.DaInviare => Icons.Material.Rounded.PhonelinkOff,
|
||||
SyncState.Inviato => Icons.Material.Rounded.CloudDone,
|
||||
SyncState.Bozza => Icons.Material.Rounded.EditNote,
|
||||
_ => string.Empty
|
||||
};
|
||||
|
||||
/// Numero di schede non ancora inviate in una lista. Le bozze non contano:
|
||||
/// non sono inviabili finché non vengono completate.
|
||||
public static int CountDaInviare(this IEnumerable<Scheda> schede) =>
|
||||
schede.Count(s => s.GetSyncState() == SyncState.DaInviare);
|
||||
|
||||
/// Numero di schede lasciate a metà, da riprendere.
|
||||
public static int CountBozze(this IEnumerable<Scheda> schede) =>
|
||||
schede.Count(s => s.GetSyncState() == SyncState.Bozza);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using SteUp.Shared.Core.Entities;
|
||||
using SteUp.Shared.Core.Enum;
|
||||
|
||||
namespace SteUp.Shared.Core.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Mappature di stato per i badge (STILE_UI §6.4): tono, icona ed etichetta di
|
||||
/// ogni stato vivono qui e solo qui. Le schermate non compongono badge a mano,
|
||||
/// così lo stesso stato si presenta identico ovunque e un nuovo stato si
|
||||
/// aggiunge in un punto solo.
|
||||
/// </summary>
|
||||
public static class StatusBadgeHelper
|
||||
{
|
||||
public readonly record struct BadgeSpec(string Tone, string Icon, string Label);
|
||||
|
||||
/// Stato di una scheda rispetto al server.
|
||||
public static BadgeSpec ToBadge(this SyncState state) =>
|
||||
state switch
|
||||
{
|
||||
SyncState.Bozza => new BadgeSpec("warning", "ri-edit-line", "Da completare"),
|
||||
SyncState.DaInviare => new BadgeSpec("danger", "ri-smartphone-line", "Da inviare"),
|
||||
SyncState.Inviato => new BadgeSpec("success", "ri-cloud-line", "Inviato"),
|
||||
_ => new BadgeSpec("neutral", "ri-question-line", "Sconosciuto")
|
||||
};
|
||||
|
||||
/// Stato di un'ispezione.
|
||||
public static BadgeSpec ToBadge(this StatusEnum stato) =>
|
||||
stato switch
|
||||
{
|
||||
StatusEnum.InCorso => new BadgeSpec("info", "ri-play-circle-line", "In corso"),
|
||||
StatusEnum.Completata => new BadgeSpec("success", "ri-check-double-line", "Conclusa"),
|
||||
StatusEnum.Verifica => new BadgeSpec("warning", "ri-search-eye-line", "In verifica"),
|
||||
StatusEnum.Annullata => new BadgeSpec("neutral", "ri-close-circle-line", "Annullata"),
|
||||
_ => new BadgeSpec("neutral", "ri-question-line", "Sconosciuto")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Sintesi di un'ispezione per i lettori di schermo: cosa è, a che punto
|
||||
/// sta e cosa resta da fare, in una frase sola.
|
||||
/// </summary>
|
||||
public static string ToSemanticLabel(this Ispezione ispezione)
|
||||
{
|
||||
var schede = ispezione.Schede.Count;
|
||||
var daInviare = ispezione.Schede.CountDaInviare();
|
||||
var bozze = ispezione.Schede.CountBozze();
|
||||
|
||||
var parti = new List<string>
|
||||
{
|
||||
$"Ispezione {ispezione.CodMdep}",
|
||||
ispezione.Stato.ToBadge().Label,
|
||||
schede == 1 ? "1 scheda" : $"{schede} schede"
|
||||
};
|
||||
|
||||
if (daInviare > 0) parti.Add($"{daInviare} da inviare");
|
||||
if (bozze > 0) parti.Add($"{bozze} da completare");
|
||||
|
||||
return string.Join(", ", parti);
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,13 @@ public static class StatusEnumHelper
|
||||
{
|
||||
public static string ConvertToHumanReadable(this StatusEnum enumValue)
|
||||
{
|
||||
// Sentence-case: l'enfasi la dà il peso del chip, non il maiuscolo (Regola del Peso, non del Maiuscolo).
|
||||
return enumValue switch
|
||||
{
|
||||
StatusEnum.InCorso => "IN CORSO",
|
||||
StatusEnum.Completata => "COMPLETATA",
|
||||
StatusEnum.Verifica => "VERIFICA",
|
||||
StatusEnum.Annullata => "ANNULLATA",
|
||||
StatusEnum.InCorso => "In corso",
|
||||
StatusEnum.Completata => "Completata",
|
||||
StatusEnum.Verifica => "Verifica",
|
||||
StatusEnum.Annullata => "Annullata",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(enumValue), enumValue, null)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,4 +7,6 @@ public interface IIntegryApiService
|
||||
Task<bool> SystemOk();
|
||||
|
||||
Task<List<StbActivityTyperDto>?> SuggestActivityDescription(string activityTypeId);
|
||||
|
||||
Task SendEmail(SendEmailDto sendEmail);
|
||||
}
|
||||
@@ -17,4 +17,6 @@ public interface IIntegrySteupService
|
||||
Task<SaveSchedaResponseDto?> SaveMultipleSchede(List<SaveRequestDto> request);
|
||||
Task CompleteInspection(string activityId);
|
||||
Task UploadFile(string activityId, byte[] file, string fileName);
|
||||
|
||||
Task DeleteScheda(string activityId);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user