32 Commits

Author SHA1 Message Date
Adriano Dal Pastro 8c61a557f8 ui: redesign brand icon and fix clipped wordmark
- new mark: measurement-span glyph on rounded brand-gradient badge
  (favicon, inline logo, standalone tmflow-logo.svg)
- wordmark moved from SVG <text> with hardcoded x offsets (clipped
  "Flow" past the 260px viewBox, font-dependent overlap) to plain HTML
  spans — never clips, natural kerning, theme-adaptive
- _app_logo.html params: logo_class, wordmark_class, logo_id (unique
  gradient id when logo appears twice per page)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:01:08 +00:00
Adriano Dal Pastro 821c7c39d5 ui: restyle login, dedupe navbar with Jinja macros, touch polish
- login: theme variables instead of hardcoded slate colors, decorative
  brand background, show/hide password toggle, dynamic copyright year
- navbar: nav links rendered via shared nav_link/nav_links macros
  (desktop + mobile from one source), active state now also in mobile
  menu, aria-current on active link, fix nonexistent w-4.5 class
- themes.css: btn :active press feedback, btn-lg variant, card hover
  border accent, brand accent-color and ::selection
- fix tmf-input vs pl-10 cascade conflict (themes.css loads after
  tailwind.css) with important utilities — also fixes recipe search icon
- app.py: inject current_year in template globals
- i18n: add Mostra/Nascondi password (IT/EN)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:35:53 +00:00
Adriano Dal Pastro 83f8e2d332 docs: fix pybabel extract path in CLAUDE.md (babel.cfg lives in translations/)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:07:14 +00:00
Adriano Dal Pastro f3e593610b i18n: refresh Babel catalogs, complete EN translations
- pybabel extract + update against current templates/blueprints
- EN: translate 36 missing entries and fix 24 wrong fuzzy matches
  (supervisor flow, cycle/timer, PDF import, traceability, settings)
- IT: normalize 149 missing/fuzzy entries (msgstr = msgid, source is Italian)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:51:12 +00:00
Adriano Dal Pastro fd571c479e fix: code review — security fixes, dedup, cleanup
- setup: validate roles against users.VALID_ROLES (Supervisor was missing)
- files: fix path traversal prefix-match edge case (is_relative_to),
  dedupe path validation into resolve_upload_path(), use logging not print
- measurements: extract shared _build_measurement_filters() helper
- client app: prevent open redirect via Referer on /set-language
- maker: guard resp.json() in parse-technical-sheet proxy
- measure/maker: extract shared file proxy into services/file_proxy.py
- measure: localize supervisor validation error messages
- annotation-editor: remove global keydown listener in destroy()

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:16:29 +00:00
Adriano Dal Pastro 25a788f430 ui(measure): drop N/M prefix on task title, fix hidden directive
- Remove the "1/2"/"2/2" prefix before the task title; the number circle
  already shows the index.
- The directive showed only "…": line-clamp-1 + whitespace-pre-wrap clamped a
  leading blank line to just the ellipsis. Use line-clamp-2 without pre-wrap so
  the description is actually visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:52:18 +00:00
Adriano Dal Pastro 61c265b38b ui(measure): move measurement/attachment indicators to card top-right
Move the per-task measurement count and attachment indicator out of the info
column to the top-right of each card (self-start), keeping the title/directive
column clean. Measurement count shown as a compact number+icon with tooltip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:35:45 +00:00
Adriano Dal Pastro 74b348988e ui(measure): merge task/measurement counts at top, slimmer task cards
- Merge the bottom "N task · M misurazioni" indicator into the top header
  badge; remove the now-empty bottom navigation row.
- Make task cards shorter: smaller padding, smaller number circle, single-line
  title (with N/M inline) and one-line directive, tighter spacing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:34:07 +00:00
Adriano Dal Pastro a36a37aca0 ui(measure): move "Seleziona altra ricetta" to the top of the task list
The change-recipe button was only at the bottom of the task list, hard to
find. Move it just under the breadcrumb so operators can switch recipe
easily; the bottom row now keeps just the task/measurement count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:30:09 +00:00
Adriano Dal Pastro b8d04c54f7 fix(measure): hide per-task "Visualizza Task" link for non-Maker operators
On the task list, each task card exposed a "Visualizza Task" link that jumps
straight into an individual task. Pure operators (MeasurementTec without
Maker) should follow the guided flow and start only with AVVIA. Gate the
per-task link behind the Maker role; admins/makers who also measure keep it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 10:26:15 +00:00
Adriano Dal Pastro 34a72e6f1a fix(measure): skip completion overlay during START, auto-advance task
The previous change made the overlay phase-aware but it still popped up after
every task during the START run. Operators want to flow straight to the next
task. Skip showing the "Misurazioni Complete" overlay when production has not
started and this is not the last task — call goToNextTask() directly. The
overlay still appears on the last START task (for "Avvio Produzione") and in
production (for "Conferma ciclo").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:36:29 +00:00
Adriano Dal Pastro 4c75a32186 fix(measure): START cycle advances to next task instead of blocking
When all subtasks of a task were measured, the completion overlay only
offered "Riepilogo" (which leaves the work cycle) or "Conferma ciclo" (which
keeps the operator stuck on the same task and starts the interval timer).
During the START run — before production is started — neither lets the
operator simply move to the next task.

Make the overlay phase-aware:
- Production started: keep "Conferma ciclo".
- START, not the last task: primary action "Task successivo" -> goToNextTask().
- START, last task: "Avvio Produzione" -> startProductionFromOverlay(), which
  starts production and kicks off the interval cycle.

Add isLastTask getter and startProductionFromOverlay(); EN translation for the
new label.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:30:52 +00:00
Adriano Dal Pastro fdfe1072d8 fix(measure): supervisor modal — backspace works and fields reset
Two issues with the "Autorizzazione capoturno" modal:

1. Backspace and digits did not work in the username/password fields. The
   numpad component installs a window-level keydown handler (for the USB
   caliper / keyboard wedge) that preventDefault()s digits, Backspace, Enter
   etc. It swallowed those keys inside the modal inputs. Guard handleKeydown
   to ignore events whose target is an editable field (INPUT/TEXTAREA/SELECT/
   contentEditable); the numpad has no text inputs of its own.

2. The modal kept the previous username/password: opening only set the flag,
   and closing via backdrop click did not clear the fields. Add
   openSupervisorModal()/closeSupervisorModal() that always reset username,
   password and error, and use them for every open (fermo_linea,
   fine_produzione, out_of_tolerance) and close (backdrop, Annulla).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:11:31 +00:00
Adriano Dal Pastro 6ff78b2150 feat(roles): add dedicated Supervisor (capoturno) role
Until now the out-of-tolerance authorization ("Autorizzazione capoturno")
required is_admin, conflating shift-leader authority with full system
administration. Introduce a combinable Supervisor role so a capoturno can
authorize overrides without admin privileges (roadmap D-0.8 / Phase 2).

Backend:
- users router: add "Supervisor" to the allowed roles (centralized as
  VALID_ROLES, deduplicating the create/update validation).
- middleware: add require_supervisor dependency (admins still bypass).

Frontend:
- measure.validate_supervisor: authorize users with the Supervisor role OR
  is_admin (was is_admin only).
- admin/users: Supervisor checkbox + orange role badge.
- profile: explicit Supervisor badge styling.
- EN translation for the new role label.

roles is a free JSON column, so no migration is required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 09:02:24 +00:00
Adriano Dal Pastro bff577b461 fix(measure): separate traceability inputs from recipe search
On the recipe selection page the Lotto/Seriale fields shared a single
filter bar with the "Cerca ricetta" search box, so operators read them as
search filters instead of data-entry fields and never filled them in.

Split into two cards: a search filter, and a distinct "Tracciabilità"
section with heading, helper text, accent border and monospace inputs with
amber/indigo icons matching the lot/serial badges shown downstream. Add EN
translations for the new strings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:48:54 +00:00
Adriano Dal Pastro 5cc5123a0c fix(theme): apply per-user theme_pref on login, persist toggle to server
alpine-init.js resolved the theme purely from localStorage, so it never
reflected the logged-in user's theme_pref: switching user kept the previous
user's theme because localStorage persists per-browser.

- base.html injects window.__SERVER_THEME from the session theme (empty when
  anonymous, preserving OS-preference behaviour on the login page).
- alpine-init.js now treats the server value as authoritative for logged-in
  users (server > localStorage > OS > light) and syncs localStorage to it.
- The navbar toggle now persists the choice via POST /auth/set-theme, which
  updates the session and the user's theme_pref, so it survives navigation
  and matches on the next login.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:32:11 +00:00
Adriano Dal Pastro 2eb5c51353 fix(auth): role-aware post-login landing to avoid 403 for non-MeasurementTec
Login always redirected to measure.select_recipe, which is gated by
@role_required("MeasurementTec"). A Maker-only (or Metrologist-only) user
was therefore bounced straight to a 403 right after a successful login.

Add _post_login_landing() which selects the landing endpoint from the user's
roles (MeasurementTec -> measure, Maker -> maker recipes, Metrologist ->
statistics, admin -> users, else profile). Used for both fresh login and the
already-authenticated short-circuit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:24:20 +00:00
Adriano Dal Pastro b325eb3512 fix(deploy): attach prod stack to the real Traefik network
The prod compose joined the external network 'root_default', but the
running Traefik instance is configured with --providers.docker.network=traefik
and 'root_default' is empty. Traefik therefore had no reachable address for
the tmflow-web/tmflow-api routers and served 404 for tieflow.tielogic.xyz.

Point traefik-net at the external network 'traefik'. After redeploying with
docker-compose.yml the site routes correctly (HTTPS 200, valid ACME cert,
/api/ path-prefix router working).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:12:20 +00:00
Adriano Dal Pastro a7254d8932 fix(tasks): eager-load version in reorder endpoint to fix MissingGreenlet
The reorder_tasks endpoint loaded RecipeTask with selectinload(subtasks)
only. Serializing via TaskResponse reads RecipeTask.recipe_id, a @property
that traverses the version relationship, triggering a lazy load outside the
async greenlet context -> MissingGreenlet error.

Add selectinload(RecipeTask.version), matching the pattern already used in
the get-task flow. Fixes the previously order-dependent test_reorder_tasks
failure; full suite now 179 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:50:58 +00:00
Adriano Dal Pastro d2bf0b5828 feat(maker): AI parsing schede tecniche PDF via OpenRouter
- Nuovo servizio ai_service.py: estrae testo da PDF (pdfplumber) + analisi AI (OpenRouter)
- Endpoint POST /api/recipes/{id}/parse-technical-sheet con validazione file
- UI: bottone "Importa da PDF" nel task editor con modale upload + preview editabile
- Task suggeriti modificabili/rimovibili prima della creazione bulk
- Config: OPENROUTER_API_KEY e OPENROUTER_MODEL in .env
- Dipendenze: pdfplumber + httpx aggiunti a server deps

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:01:00 +00:00
Adriano Dal Pastro 9bd605c958 feat(measure): bottone Avvio Produzione placeholder dopo primo ciclo misura
- Bottone "Avvio Produzione" appare solo dopo il primo ciclo confermato
- Conferma visiva "Produzione avviata" dopo il click
- Placeholder per futura integrazione GAIA (TODO nel codice)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:32:48 +00:00
Adriano Dal Pastro e05eb66a1c feat(measure): timer cicalino con countdown e buzzer dopo fine ciclo misura
- Blueprint: passa measurement_interval_minutes dalla ricetta al template task_execute
- Dopo "Fine ciclo misura": countdown MM:SS con banner amber animato sopra footer
- Allo scadere: 3 beep sonori (Web Audio API 880Hz) + reset misurazioni per nuovo ciclo
- Contatore cicli visibile nel banner timer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 07:22:10 +00:00
Adriano Dal Pastro 6bfe0a98e8 docs: aggiunge PDF richieste cliente maggio 2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 06:45:26 +00:00
Adriano Dal Pastro 1da7e5c7af feat(measure): rework workflow operatore — AVVIA, capoturno, fine ciclo, blocco logout
- task_list: pulsante AVVIA sequenziale + rinomina "Inizia Misure" → "Visualizza Task"
- task_execute header: aggiunti "Lista task", "Riepilogo", "Fermo linea", "Fine Produzione"
- task_execute footer: "Fine ciclo misura" (attivo quando tutte le quote misurate) + "Completato"
- Rimosso auto-advance al task successivo, flusso ora esplicito via Completato
- Misura fuori tolleranza: blocco avanzamento + modale login capoturno (validate_supervisor)
- Blocco logout durante misurazioni attive (icona lucchetto su task_execute)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:54:37 +00:00
Adriano Dal Pastro 0748ce9b1e feat: timer intervallo misura su ricetta + auto-logout per inattività
- Recipe: nuovo campo measurement_interval_minutes (migration 003) con UI nel recipe editor
- Auto-logout: impostazione di sistema configurabile da admin/settings, timer inattività JS
  con warning modale 60s prima del logout, tracking eventi utente throttled
- Navbar: aggiunto link "Impostazioni" per admin (desktop + mobile)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 14:36:02 +00:00
Adriano Dal Pastro b9c767fb0c feat(ui): rinomina Misure→Attività, textarea descrizione task, tool Testo annotazioni
- Navbar e breadcrumb: label "Misure" rinominata "Attività" (IT) / "Activities" (EN)
- Descrizione task: input sostituito con textarea multilinea + display whitespace-pre-wrap
- Annotation editor: nuovo tool "Testo" con fabric.IText (inline editing, serializzazione, viewer)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:26:23 +00:00
Adriano Dal Pastro 6772e3166a fix(setup,ui): risolve errori setup + logo SVG theme-aware
- setup: corregge path templates (sibling di api/, non figlio) e firma TemplateResponse
- setup: invalida sessione client dopo operazioni che ruotano l'API key (init DB, creazione admin, cambio password, attivazione utente)
- api_client: include il nome del campo nei messaggi di errore 422 (FastAPI validation)
- ui: nuovo componente _app_logo.html (SVG inline theme-aware) usato su login e navbar
2026-05-20 07:42:49 +00:00
Adriano 182aa9fa9a feat(ui): recipe thumbnail su Select Recipe + fit (object-contain) su Gestione Ricette
- /measure/select: aggiunta miniatura 64×64 accanto al nome ricetta
  (object-contain per mantenere l'immagine intera, non croppata).
  Lo schema backend RecipeSummary esponeva solo id/code/name/active:
  aggiunti image_path e description così la card può rendere thumb
  e sottotitolo.
- /maker/recipes: la miniatura passa da object-cover (crop) a
  object-contain (fit), per visualizzare l'intera immagine della
  ricetta senza tagli.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 09:24:36 +02:00
Adriano f1c7a28296 style(admin/stations): use bordered cards instead of divide-y rows
Le liste "Ricette disponibili" e "Assegnate alla stazione" passano da
una lista con separatori interni (divide-y) a card individuali con
bordo arrotondato e spaziatura: migliora la leggibilità e la
percezione di trascinabilità dei singoli elementi.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:19:40 +02:00
Adriano 52d78ea5c1 fix(measure): task_execute full-screen layout + hide UI for tasks without measurements
Pagina /measure/execute/<task_id>:
- Il pannello destro (info subtask + tolerance + numpad) e la sidebar
  marker spariscono quando il task non ha subtask: l'utente vede solo
  intestazione + disegno a tutta larghezza, senza colonne vuote né
  tastierina numerica fuori contesto.
- Risolta la scrollbar verticale: la pagina ora occupa esattamente
  l'altezza del viewport. Il layout sfrutta nuovi block override in
  base.html (body_class, wrapper_class, main_class, footer) per
  disattivare scroll e footer solo su questa pagina, lasciando intatte
  le altre.

Cataloghi i18n: aggiunte traduzioni EN/IT mancanti e rimossi i flag
"fuzzy" auto-generati da pybabel update (Flask-Babel ignora i fuzzy,
mostrando il msgid italiano anche in EN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 19:19:34 +02:00
Adriano fb96bad000 fix(tests): align stale tests with current API + bypass identity-map cache
- test_recipes: split update test into in-place vs copy-on-write paths
  (PUT now updates in-place when current version has no measurements).
  Added _seed_measurement() helper to force copy-on-write where needed.
- test_tasks::reorder: expunge_all() between POST and GET so the shared
  test session re-fetches RecipeVersion.tasks instead of returning the
  cached collection. Prod uses one session per request, so identity-map
  staleness only affects tests.
- test_measure::save_measurement_proxy: payload aligned to current API
  (version_id is required; pass_fail/deviation are computed server-side).

All 179 tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:07:32 +02:00
Adriano 6a8931e108 fix(i18n): aggiunge traduzioni EN mancanti per GUI /admin/stations
Le ~42 stringhe di admin/stations.html e navbar (Stazioni, Postazione,
Gestione Stazioni, ...) non erano state estratte nel catalogo .pot dopo
l'aggiunta della GUI, quindi in EN si vedeva ancora italiano via fallback
msgid. pybabel update aveva poi auto-matchato alcune chiavi simili come
"fuzzy", producendo traduzioni assurde (Postazione → Settings, ecc.) che
Babel ignora a runtime.

Rigenerato messages.pot via pybabel extract, applicate traduzioni EN
corrette per tutte le entry fuzzy, rimossi flag fuzzy. IT lasciato con
msgstr vuoto (fallback su msgid = italiano nativo).

Tech debt: serve un test guard che faccia fail se pybabel extract produce
diff vs messages.pot committato.
2026-05-11 18:13:20 +02:00
57 changed files with 5490 additions and 2251 deletions
+4
View File
@@ -35,6 +35,10 @@ MAX_UPLOAD_SIZE_MB=50
# --- Setup Page --- # --- Setup Page ---
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
# --- AI (OpenRouter) ---
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare
# --- Docker --- # --- Docker ---
DB_ROOT_PASSWORD=root_password_change_me DB_ROOT_PASSWORD=root_password_change_me
NGINX_PORT=80 NGINX_PORT=80
+1 -1
View File
@@ -123,7 +123,7 @@ uv lock # rigenera uv.lock
### i18n (Traduzioni) ### i18n (Traduzioni)
```bash ```bash
# Estrai stringhe # Estrai stringhe
cd src/frontend/flask_app && uv run pybabel extract -F babel.cfg -k _ -o translations/messages.pot . cd src/frontend/flask_app && uv run pybabel extract -F translations/babel.cfg -k _ -o translations/messages.pot .
# Aggiorna catalogo # Aggiorna catalogo
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
+1 -1
View File
@@ -93,4 +93,4 @@ networks:
driver: bridge driver: bridge
traefik-net: traefik-net:
external: true external: true
name: root_default name: traefik
+2
View File
@@ -30,6 +30,8 @@ server = [
"plotly>=5.0.0", "plotly>=5.0.0",
"kaleido>=0.2.0", "kaleido>=0.2.0",
"weasyprint>=62.0", "weasyprint>=62.0",
"pdfplumber>=0.10.0",
"httpx>=0.27.0",
] ]
# Frontend (Flask tablet UI). # Frontend (Flask tablet UI).
+2
View File
@@ -6,6 +6,7 @@ from src.backend.api.middleware.api_key import (
require_maker, require_maker,
require_measurement_tec, require_measurement_tec,
require_metrologist, require_metrologist,
require_supervisor,
require_admin_user, require_admin_user,
) )
from src.backend.api.middleware.logging import AccessLogMiddleware from src.backend.api.middleware.logging import AccessLogMiddleware
@@ -19,6 +20,7 @@ __all__ = [
"require_maker", "require_maker",
"require_measurement_tec", "require_measurement_tec",
"require_metrologist", "require_metrologist",
"require_supervisor",
"require_admin_user", "require_admin_user",
"AccessLogMiddleware", "AccessLogMiddleware",
"RateLimitMiddleware", "RateLimitMiddleware",
+1
View File
@@ -68,4 +68,5 @@ def require_admin():
require_maker = require_role("Maker") require_maker = require_role("Maker")
require_measurement_tec = require_role("MeasurementTec") require_measurement_tec = require_role("MeasurementTec")
require_metrologist = require_role("Metrologist") require_metrologist = require_role("Metrologist")
require_supervisor = require_role("Supervisor")
require_admin_user = require_admin() require_admin_user = require_admin()
+37 -49
View File
@@ -1,5 +1,5 @@
"""File upload/download/delete router for images and PDFs.""" """File upload/download/delete router for images and PDFs."""
import os import logging
import re import re
from pathlib import Path from pathlib import Path
@@ -11,6 +11,8 @@ from src.backend.config import settings
from src.backend.api.middleware.api_key import get_current_user, require_maker from src.backend.api.middleware.api_key import get_current_user, require_maker
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/files", tags=["files"]) router = APIRouter(prefix="/api/files", tags=["files"])
# Allowed MIME types # Allowed MIME types
@@ -65,6 +67,37 @@ def sanitize_filename(filename: str) -> str:
return filename return filename
def resolve_upload_path(file_path: str) -> Path:
"""Resolve a user-supplied relative path inside the uploads directory.
Raises:
HTTPException: 404 if the path escapes the uploads directory,
cannot be resolved, or does not point to an existing file.
"""
try:
full_path = (settings.upload_path / file_path).resolve()
if not full_path.is_relative_to(settings.upload_path.resolve()):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
except HTTPException:
raise
except (OSError, RuntimeError, ValueError):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
if not full_path.exists() or not full_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
return full_path
def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)): def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)):
"""Generate a thumbnail for an image.""" """Generate a thumbnail for an image."""
try: try:
@@ -73,7 +106,7 @@ def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[i
img.save(thumbnail_path, quality=85) img.save(thumbnail_path, quality=85)
except Exception as e: except Exception as e:
# If thumbnail generation fails, we continue without it # If thumbnail generation fails, we continue without it
print(f"Thumbnail generation failed: {e}") logger.warning("Thumbnail generation failed for %s: %s", image_path, e)
@router.post("/upload") @router.post("/upload")
@@ -164,30 +197,7 @@ async def get_file(
Requires authentication but no specific role. Requires authentication but no specific role.
""" """
# Construct full path full_path = resolve_upload_path(file_path)
full_path = settings.upload_path / file_path
# Security: ensure path is within uploads directory
try:
full_path = full_path.resolve()
if not str(full_path).startswith(str(settings.upload_path.resolve())):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
except Exception:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
# Check if file exists
if not full_path.exists() or not full_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
return FileResponse(full_path) return FileResponse(full_path)
@@ -200,29 +210,7 @@ async def delete_file(
Also deletes associated thumbnail if it exists. Also deletes associated thumbnail if it exists.
""" """
# Construct full path full_path = resolve_upload_path(file_path)
full_path = settings.upload_path / file_path
# Security: ensure path is within uploads directory
try:
full_path = full_path.resolve()
if not str(full_path).startswith(str(settings.upload_path.resolve())):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied",
)
except Exception:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
# Check if file exists
if not full_path.exists() or not full_path.is_file():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found",
)
# Delete thumbnail if it exists # Delete thumbnail if it exists
if full_path.parent.name != "thumbnails": if full_path.parent.name != "thumbnails":
+46 -47
View File
@@ -84,6 +84,44 @@ async def create_measurement_batch(
) )
def _build_measurement_filters(
recipe_id: int | None,
version_id: int | None,
subtask_id: int | None,
measured_by: int | None,
lot_number: str | None,
serial_number: str | None,
date_from: datetime | None,
date_to: datetime | None,
pass_fail: str | None,
) -> list:
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
filters = []
if recipe_id is not None:
# Filter by recipe via subquery on RecipeVersion
version_ids = select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id
)
filters.append(Measurement.version_id.in_(version_ids))
if version_id is not None:
filters.append(Measurement.version_id == version_id)
if subtask_id is not None:
filters.append(Measurement.subtask_id == subtask_id)
if measured_by is not None:
filters.append(Measurement.measured_by == measured_by)
if lot_number is not None:
filters.append(Measurement.lot_number == lot_number)
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
if date_from is not None:
filters.append(Measurement.measured_at >= date_from)
if date_to is not None:
filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None:
filters.append(Measurement.pass_fail == pass_fail)
return filters
@router.get("/", response_model=MeasurementListResponse) @router.get("/", response_model=MeasurementListResponse)
async def get_measurements( async def get_measurements(
recipe_id: int | None = Query(None), recipe_id: int | None = Query(None),
@@ -108,30 +146,10 @@ async def get_measurements(
filters. Measurements carry no PII beyond numeric values + a recipe filters. Measurements carry no PII beyond numeric values + a recipe
reference, so role-gating beyond authentication isn't justified. reference, so role-gating beyond authentication isn't justified.
""" """
# Build filter conditions filters = _build_measurement_filters(
filters = [] recipe_id, version_id, subtask_id, measured_by, lot_number,
if recipe_id is not None: serial_number, date_from, date_to, pass_fail,
# Filter by recipe via subquery on RecipeVersion )
version_ids = select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id
)
filters.append(Measurement.version_id.in_(version_ids))
if version_id is not None:
filters.append(Measurement.version_id == version_id)
if subtask_id is not None:
filters.append(Measurement.subtask_id == subtask_id)
if measured_by is not None:
filters.append(Measurement.measured_by == measured_by)
if lot_number is not None:
filters.append(Measurement.lot_number == lot_number)
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
if date_from is not None:
filters.append(Measurement.measured_at >= date_from)
if date_to is not None:
filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None:
filters.append(Measurement.pass_fail == pass_fail)
# Build query # Build query
query = select(Measurement).where(and_(*filters) if filters else True) query = select(Measurement).where(and_(*filters) if filters else True)
@@ -196,29 +214,10 @@ async def export_measurements_csv(
decimal_setting = decimal_result.scalar_one_or_none() decimal_setting = decimal_result.scalar_one_or_none()
decimal_separator = decimal_setting.setting_value if decimal_setting else "." decimal_separator = decimal_setting.setting_value if decimal_setting else "."
# Build filter conditions (same as get_measurements) filters = _build_measurement_filters(
filters = [] recipe_id, version_id, subtask_id, measured_by, lot_number,
if recipe_id is not None: serial_number, date_from, date_to, pass_fail,
version_ids = select(RecipeVersion.id).where( )
RecipeVersion.recipe_id == recipe_id
)
filters.append(Measurement.version_id.in_(version_ids))
if version_id is not None:
filters.append(Measurement.version_id == version_id)
if subtask_id is not None:
filters.append(Measurement.subtask_id == subtask_id)
if measured_by is not None:
filters.append(Measurement.measured_by == measured_by)
if lot_number is not None:
filters.append(Measurement.lot_number == lot_number)
if serial_number is not None:
filters.append(Measurement.serial_number == serial_number)
if date_from is not None:
filters.append(Measurement.measured_at >= date_from)
if date_to is not None:
filters.append(Measurement.measured_at <= date_to)
if pass_fail is not None:
filters.append(Measurement.pass_fail == pass_fail)
# Query all matching measurements # Query all matching measurements
query = select(Measurement).where(and_(*filters) if filters else True) query = select(Measurement).where(and_(*filters) if filters else True)
+32 -2
View File
@@ -1,5 +1,7 @@
"""Recipe router - CRUD, versioning, barcode lookup.""" """Recipe router - CRUD, versioning, barcode lookup, AI parsing."""
from fastapi import APIRouter, Depends, Query import logging
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.database import get_db from src.backend.database import get_db
@@ -164,3 +166,31 @@ async def get_measurement_count(
""" """
count = await recipe_service.get_measurement_count(db, recipe_id, version_number) count = await recipe_service.get_measurement_count(db, recipe_id, version_number)
return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count} return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count}
@router.post("/{recipe_id}/parse-technical-sheet")
async def parse_technical_sheet(
recipe_id: int,
file: UploadFile = File(...),
_user: User = Depends(require_maker),
db: AsyncSession = Depends(get_db),
):
"""Parse a PDF technical sheet using AI and return suggested tasks."""
from src.backend.services import ai_service
if not file.content_type or "pdf" not in file.content_type:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Il file deve essere un PDF")
pdf_bytes = await file.read()
if len(pdf_bytes) > 20 * 1024 * 1024:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File troppo grande (max 20MB)")
try:
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logging.getLogger(__name__).error("AI parsing error: %s", e)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
return {"recipe_id": recipe_id, "suggested_tasks": tasks}
+3 -3
View File
@@ -15,6 +15,7 @@ from pydantic import BaseModel
from sqlalchemy import inspect as sa_inspect, select from sqlalchemy import inspect as sa_inspect, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.api.routers.users import VALID_ROLES
from src.backend.config import settings from src.backend.config import settings
from src.backend.database import Base, engine, async_session_factory from src.backend.database import Base, engine, async_session_factory
from src.backend.models.orm import ( from src.backend.models.orm import (
@@ -26,7 +27,7 @@ from src.backend.services.measurement_service import calculate_pass_fail
router = APIRouter(prefix="/api/setup", tags=["setup"]) router = APIRouter(prefix="/api/setup", tags=["setup"])
_templates_dir = Path(__file__).resolve().parent.parent / "templates" _templates_dir = Path(__file__).resolve().parent.parent.parent / "templates"
templates = Jinja2Templates(directory=str(_templates_dir)) templates = Jinja2Templates(directory=str(_templates_dir))
@@ -215,7 +216,7 @@ async def _seed_default_station(session: AsyncSession, admin_user: User) -> None
async def setup_page(request: Request): async def setup_page(request: Request):
"""Serve the setup page HTML.""" """Serve the setup page HTML."""
_check_setup_enabled() _check_setup_enabled()
return templates.TemplateResponse("setup/setup.html", {"request": request}) return templates.TemplateResponse(request, "setup/setup.html")
@router.get("/status") @router.get("/status")
@@ -544,7 +545,6 @@ async def manage_user(body: ManageUserBody):
"""Create or update a user. user_id=null creates new, user_id=int updates.""" """Create or update a user. user_id=null creates new, user_id=int updates."""
_check_password(body.password) _check_password(body.password)
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist"}
invalid = set(body.roles) - VALID_ROLES invalid = set(body.roles) - VALID_ROLES
if invalid: if invalid:
raise HTTPException( raise HTTPException(
+4 -1
View File
@@ -223,7 +223,10 @@ async def reorder_tasks(
result = await db.execute( result = await db.execute(
select(RecipeTask) select(RecipeTask)
.where(RecipeTask.id.in_(data.task_ids)) .where(RecipeTask.id.in_(data.task_ids))
.options(selectinload(RecipeTask.subtasks)) .options(
selectinload(RecipeTask.subtasks),
selectinload(RecipeTask.version),
)
) )
tasks_map = {t.id: t for t in result.scalars().all()} tasks_map = {t.id: t for t in result.scalars().all()}
+8 -6
View File
@@ -11,6 +11,10 @@ from src.backend.services.auth_service import create_user, hash_password, regene
router = APIRouter(prefix="/api/users", tags=["users"]) router = APIRouter(prefix="/api/users", tags=["users"])
# Combinable user roles. Supervisor (capoturno) authorizes out-of-tolerance
# overrides during measurement without needing full admin privileges.
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist", "Supervisor"}
@router.get("", response_model=list[UserResponse]) @router.get("", response_model=list[UserResponse])
async def list_users( async def list_users(
@@ -40,12 +44,11 @@ async def create_new_user(
detail=f"Username '{data.username}' already exists", detail=f"Username '{data.username}' already exists",
) )
# Validate roles # Validate roles
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
for role in data.roles: for role in data.roles:
if role not in valid_roles: if role not in VALID_ROLES:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}", detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
) )
user = await create_user( user = await create_user(
db, db,
@@ -77,12 +80,11 @@ async def update_user(
update_data = data.model_dump(exclude_unset=True) update_data = data.model_dump(exclude_unset=True)
# Validate roles if provided # Validate roles if provided
if "roles" in update_data: if "roles" in update_data:
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
for role in update_data["roles"]: for role in update_data["roles"]:
if role not in valid_roles: if role not in VALID_ROLES:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}", detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
) )
for field, value in update_data.items(): for field, value in update_data.items():
setattr(user, field, value) setattr(user, field, value)
+4
View File
@@ -34,6 +34,10 @@ class Settings(BaseSettings):
# Setup page (empty = disabled) # Setup page (empty = disabled)
setup_password: str | None = None setup_password: str | None = None
# AI / OpenRouter (for technical sheet parsing)
openrouter_api_key: str | None = None
openrouter_model: str = "anthropic/claude-sonnet-4"
@property @property
def database_url(self) -> str: def database_url(self) -> str:
"""Async MySQL connection string.""" """Async MySQL connection string."""
@@ -0,0 +1,24 @@
"""add measurement_interval_minutes to recipes
Revision ID: 003_measurement_interval
Revises: 002_add_stations
Create Date: 2026-05-23
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '003_measurement_interval'
down_revision: Union[str, None] = '002_add_stations'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('recipes', sa.Column('measurement_interval_minutes', sa.SmallInteger(), nullable=True))
def downgrade() -> None:
op.drop_column('recipes', 'measurement_interval_minutes')
+3
View File
@@ -14,6 +14,7 @@ class RecipeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255) name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
image_path: Optional[str] = Field(None, max_length=500) image_path: Optional[str] = Field(None, max_length=500)
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
# Optional task-level fields for the initial technical drawing # Optional task-level fields for the initial technical drawing
file_path: Optional[str] = Field(None, max_length=500) file_path: Optional[str] = Field(None, max_length=500)
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$") file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
@@ -25,6 +26,7 @@ class RecipeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=255) name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None description: Optional[str] = None
image_path: Optional[str] = Field(None, max_length=500) image_path: Optional[str] = Field(None, max_length=500)
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
change_notes: Optional[str] = None change_notes: Optional[str] = None
# Task-level fields: saved to the first task of the new version # Task-level fields: saved to the first task of the new version
file_path: Optional[str] = Field(None, max_length=500) file_path: Optional[str] = Field(None, max_length=500)
@@ -55,6 +57,7 @@ class RecipeResponse(BaseModel):
name: str name: str
description: Optional[str] = None description: Optional[str] = None
image_path: Optional[str] = None image_path: Optional[str] = None
measurement_interval_minutes: Optional[int] = None
created_by: int created_by: int
created_at: datetime created_at: datetime
active: bool active: bool
+2
View File
@@ -51,6 +51,8 @@ class RecipeSummary(BaseModel):
code: str code: str
name: str name: str
active: bool active: bool
image_path: Optional[str] = None
description: Optional[str] = None
class StationWithRecipesResponse(StationResponse): class StationWithRecipesResponse(StationResponse):
+2 -1
View File
@@ -2,7 +2,7 @@
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, SmallInteger, String, Text, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.backend.database import Base from src.backend.database import Base
@@ -24,6 +24,7 @@ class Recipe(Base):
DateTime, nullable=False, server_default=func.now() DateTime, nullable=False, server_default=func.now()
) )
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True) active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
# Relationships # Relationships
versions: Mapped[list["RecipeVersion"]] = relationship( versions: Mapped[list["RecipeVersion"]] = relationship(
+74
View File
@@ -0,0 +1,74 @@
"""AI service for parsing technical sheets via OpenRouter."""
import json
import logging
from io import BytesIO
import httpx
import pdfplumber
from src.backend.config import settings
logger = logging.getLogger(__name__)
SYSTEM_PROMPT = """Sei un assistente specializzato nell'analisi di schede tecniche industriali.
Analizza il testo estratto da una scheda tecnica e identifica i blocchi operativi distinti.
Per ogni blocco, estrai:
- title: titolo breve del blocco (es. "MATERIALI", "TEMPERATURA", "MISURA", "IMBALLO")
- directive: istruzione operativa principale (es. "VERIFICARE ATTREZZATURE")
- description: dettagli completi del blocco, preservando gli "a capo" originali
Rispondi SOLO con un array JSON valido, senza markdown o testo aggiuntivo.
Esempio:
[
{"title": "MATERIALI", "directive": "Verificare materiali", "description": "RIGIDO (36): AFT9/UV CRI 7\\nMORBIDO (E38): M70"},
{"title": "TEMPERATURA", "directive": "Verificare impostazioni", "description": "RIGIDO: TEMP: 170/170/170/170/170\\nMORBIDO: TEMP: 130-135-140-145"}
]"""
def extract_text_from_pdf(pdf_bytes: bytes) -> str:
with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
pages = []
for page in pdf.pages:
text = page.extract_text()
if text:
pages.append(text)
return "\n\n---\n\n".join(pages)
async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
text = extract_text_from_pdf(pdf_bytes)
if not text.strip():
return []
if not settings.openrouter_api_key:
raise ValueError("OPENROUTER_API_KEY non configurata")
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {settings.openrouter_api_key}",
"Content-Type": "application/json",
},
json={
"model": settings.openrouter_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Analizza questa scheda tecnica ed estrai i task:\n\n{text}"},
],
"temperature": 0.1,
},
)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"].strip()
# Strip markdown fences if present
if content.startswith("```"):
content = content.split("\n", 1)[1]
if content.endswith("```"):
content = content[:-3]
content = content.strip()
return json.loads(content)
+5
View File
@@ -134,6 +134,7 @@ async def create_recipe(
name=data.name, name=data.name,
description=data.description, description=data.description,
image_path=data.image_path, image_path=data.image_path,
measurement_interval_minutes=data.measurement_interval_minutes,
created_by=user.id, created_by=user.id,
) )
db.add(recipe) db.add(recipe)
@@ -273,6 +274,8 @@ async def create_new_version(
update_fields["description"] = data.description update_fields["description"] = data.description
if data.image_path is not None: if data.image_path is not None:
update_fields["image_path"] = data.image_path update_fields["image_path"] = data.image_path
if data.measurement_interval_minutes is not None:
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
if update_fields: if update_fields:
await db.execute( await db.execute(
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields) update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
@@ -369,6 +372,8 @@ async def update_current_version(
update_fields["description"] = data.description update_fields["description"] = data.description
if data.image_path is not None: if data.image_path is not None:
update_fields["image_path"] = data.image_path update_fields["image_path"] = data.image_path
if data.measurement_interval_minutes is not None:
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
if update_fields: if update_fields:
await db.execute( await db.execute(
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields) update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
+19
View File
@@ -559,6 +559,19 @@
return data; return data;
} }
/* Setup operations rotate api_keys (DB init, admin creation, password
* change, user (de)activation). Any Flask session held in this browser
* still carries the old key, so we clear it to force a fresh /auth/login. */
async function invalidateClientSession() {
try {
await fetch('/auth/logout', {
method: 'POST',
credentials: 'same-origin',
redirect: 'manual',
});
} catch (_) { /* best-effort */ }
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Check DB status */ /* Check DB status */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -635,6 +648,7 @@
try { try {
const data = await apiPost('/init-db', { password: setupPassword }); const data = await apiPost('/init-db', { password: setupPassword });
showToast(data.message, 'success'); showToast(data.message, 'success');
await invalidateClientSession();
await checkStatus(); await checkStatus();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -667,6 +681,7 @@
admin_display_name: display, admin_display_name: display,
}); });
showToast(data.message, 'success'); showToast(data.message, 'success');
await invalidateClientSession();
// Clear form // Clear form
document.getElementById('admin-username').value = ''; document.getElementById('admin-username').value = '';
document.getElementById('admin-password').value = ''; document.getElementById('admin-password').value = '';
@@ -690,6 +705,7 @@
if (data.measurements_created) msg += ', measurements: ' + data.measurements_created; if (data.measurements_created) msg += ', measurements: ' + data.measurements_created;
msg += ')'; msg += ')';
showToast(msg, 'success'); showToast(msg, 'success');
await invalidateClientSession();
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -876,6 +892,7 @@
if (pwd) body.user_password = pwd; if (pwd) body.user_password = pwd;
var data = await apiPost('/manage-user', body); var data = await apiPost('/manage-user', body);
showToast(data.message, 'success'); showToast(data.message, 'success');
await invalidateClientSession();
closeModal('modal-user'); closeModal('modal-user');
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
@@ -908,6 +925,7 @@
new_password: newPwd, new_password: newPwd,
}); });
showToast(data.message, 'success'); showToast(data.message, 'success');
await invalidateClientSession();
closeModal('modal-password'); closeModal('modal-password');
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
@@ -924,6 +942,7 @@
user_id: userId, user_id: userId,
}); });
showToast(data.message, 'success'); showToast(data.message, 'success');
await invalidateClientSession();
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
showToast(e.message, 'error'); showToast(e.message, 'error');
+76 -18
View File
@@ -1,12 +1,40 @@
"""Tests for recipes router (/api/recipes).""" """Tests for recipes router (/api/recipes)."""
import pytest import pytest
from httpx import AsyncClient from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.backend.models.orm.measurement import Measurement
from src.backend.models.orm.recipe import RecipeVersion
from src.backend.models.orm.task import RecipeSubtask
from src.backend.models.orm.user import User from src.backend.models.orm.user import User
from src.backend.tests.conftest import auth_headers, create_test_recipe from src.backend.tests.conftest import auth_headers, create_test_recipe
async def _seed_measurement(db: AsyncSession, recipe_id: int, user_id: int) -> None:
version_id = (await db.execute(
select(RecipeVersion.id).where(
RecipeVersion.recipe_id == recipe_id,
RecipeVersion.is_current == True, # noqa: E712
)
)).scalar_one()
subtask_id = (await db.execute(
select(RecipeSubtask.id)
.join(RecipeSubtask.task)
.where(RecipeSubtask.task.has(version_id=version_id))
)).scalar_one()
db.add(Measurement(
subtask_id=subtask_id,
version_id=version_id,
measured_by=user_id,
value=10.0,
pass_fail="pass",
deviation=0.0,
input_method="manual",
))
await db.flush()
class TestListRecipes: class TestListRecipes:
"""GET /api/recipes tests.""" """GET /api/recipes tests."""
@@ -160,18 +188,13 @@ class TestCreateRecipe:
class TestUpdateRecipe: class TestUpdateRecipe:
"""PUT /api/recipes/{recipe_id} tests.""" """PUT /api/recipes/{recipe_id} tests."""
async def test_update_recipe( async def test_update_recipe_in_place(
self, self,
client: AsyncClient, client: AsyncClient,
maker_user: User, maker_user: User,
db_session: AsyncSession, db_session: AsyncSession,
): ):
"""Updating a recipe creates a new version (copy-on-write). """Updating a recipe without measurements updates current version in-place."""
We verify the new version exists by listing versions, since
SQLAlchemy identity-map staleness with StaticPool can cause
``is_current`` to appear stale in some responses.
"""
recipe = await create_test_recipe(db_session, maker_user.id) recipe = await create_test_recipe(db_session, maker_user.id)
resp = await client.put( resp = await client.put(
f"/api/recipes/{recipe.id}", f"/api/recipes/{recipe.id}",
@@ -183,7 +206,9 @@ class TestUpdateRecipe:
) )
assert resp.status_code == 200 assert resp.status_code == 200
# Verify via versions list which always does a clean query # expire identity-map cache so subsequent GETs see fresh data
db_session.expunge_all()
versions_resp = await client.get( versions_resp = await client.get(
f"/api/recipes/{recipe.id}/versions", f"/api/recipes/{recipe.id}/versions",
headers=auth_headers(maker_user), headers=auth_headers(maker_user),
@@ -191,8 +216,38 @@ class TestUpdateRecipe:
assert versions_resp.status_code == 200 assert versions_resp.status_code == 200
versions = versions_resp.json() versions = versions_resp.json()
version_numbers = [v["version_number"] for v in versions] version_numbers = [v["version_number"] for v in versions]
assert 1 in version_numbers assert version_numbers == [1]
assert 2 in version_numbers
async def test_update_recipe_copy_on_write(
self,
client: AsyncClient,
maker_user: User,
db_session: AsyncSession,
):
"""Updating a recipe with measurements creates a new version (copy-on-write)."""
recipe = await create_test_recipe(db_session, maker_user.id)
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
resp = await client.put(
f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user),
json={
"name": "Updated Recipe",
"change_notes": "Triggers copy-on-write",
},
)
assert resp.status_code == 200
db_session.expunge_all()
versions_resp = await client.get(
f"/api/recipes/{recipe.id}/versions",
headers=auth_headers(maker_user),
)
assert versions_resp.status_code == 200
versions = versions_resp.json()
version_numbers = sorted(v["version_number"] for v in versions)
assert version_numbers == [1, 2]
class TestDeleteRecipe: class TestDeleteRecipe:
@@ -222,31 +277,34 @@ class TestRecipeVersioning:
maker_user: User, maker_user: User,
db_session: AsyncSession, db_session: AsyncSession,
): ):
"""Multiple updates create consecutive versions.""" """Each update creates a new version when measurements exist (copy-on-write)."""
recipe = await create_test_recipe(db_session, maker_user.id) recipe = await create_test_recipe(db_session, maker_user.id)
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
# Update once -> v2 # Update once -> v2 (v1 had measurements)
await client.put( await client.put(
f"/api/recipes/{recipe.id}", f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user), headers=auth_headers(maker_user),
json={"change_notes": "Version 2"}, json={"change_notes": "Version 2"},
) )
db_session.expunge_all()
await _seed_measurement(db_session, recipe.id, maker_user.id)
db_session.expunge_all()
# Update again -> v3 # Update again -> v3 (v2 now has measurements too)
await client.put( await client.put(
f"/api/recipes/{recipe.id}", f"/api/recipes/{recipe.id}",
headers=auth_headers(maker_user), headers=auth_headers(maker_user),
json={"change_notes": "Version 3"}, json={"change_notes": "Version 3"},
) )
db_session.expunge_all()
# List versions
resp = await client.get( resp = await client.get(
f"/api/recipes/{recipe.id}/versions", f"/api/recipes/{recipe.id}/versions",
headers=auth_headers(maker_user), headers=auth_headers(maker_user),
) )
assert resp.status_code == 200 assert resp.status_code == 200
versions = resp.json() versions = resp.json()
version_numbers = [v["version_number"] for v in versions] version_numbers = sorted(v["version_number"] for v in versions)
assert 1 in version_numbers assert version_numbers == [1, 2, 3]
assert 2 in version_numbers
assert 3 in version_numbers
+6 -2
View File
@@ -189,7 +189,7 @@ class TestReorderTasks:
"""Maker can reorder tasks by providing task IDs in desired order.""" """Maker can reorder tasks by providing task IDs in desired order."""
recipe = await create_test_recipe(db_session, maker_user.id) recipe = await create_test_recipe(db_session, maker_user.id)
# Add a second task via copy-on-write # Add a second task (no measurements → appended to current version)
add_resp = await client.post( add_resp = await client.post(
f"/api/recipes/{recipe.id}/tasks", f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user), headers=auth_headers(maker_user),
@@ -197,7 +197,11 @@ class TestReorderTasks:
) )
assert add_resp.status_code == 201 assert add_resp.status_code == 201
# Get tasks from new version # Force SQLA to re-fetch RecipeVersion.tasks: the shared test session
# caches the collection from create_test_recipe, so the GET below would
# otherwise return a stale list. In prod each request gets its own session.
db_session.expunge_all()
tasks_resp = await client.get( tasks_resp = await client.get(
f"/api/recipes/{recipe.id}/tasks", f"/api/recipes/{recipe.id}/tasks",
headers=auth_headers(maker_user), headers=auth_headers(maker_user),
+12 -1
View File
@@ -1,6 +1,8 @@
"""TieMeasureFlow Client - Flask Entry Point.""" """TieMeasureFlow Client - Flask Entry Point."""
import json import json
import os import os
from datetime import date
from urllib.parse import urlparse
from flask import Flask, redirect, url_for, session, request from flask import Flask, redirect, url_for, session, request
from flask_babel import Babel from flask_babel import Babel
@@ -63,7 +65,14 @@ def create_app() -> Flask:
"""Set user's preferred language and store in session.""" """Set user's preferred language and store in session."""
if lang in Config.LANGUAGES: if lang in Config.LANGUAGES:
session["language"] = lang session["language"] = lang
return redirect(request.referrer or url_for("auth.login")) # Only follow the referrer if it points back to this host
# (prevents open redirect via a forged Referer header).
referrer = request.referrer
if referrer:
parsed = urlparse(referrer)
if parsed.netloc and parsed.netloc != request.host:
referrer = None
return redirect(referrer or url_for("auth.login"))
@app.template_filter("tojson_attr") @app.template_filter("tojson_attr")
def tojson_attr_filter(value): def tojson_attr_filter(value):
@@ -92,6 +101,8 @@ def create_app() -> Flask:
"current_language": get_locale(), "current_language": get_locale(),
"languages": Config.LANGUAGES, "languages": Config.LANGUAGES,
"company_logo": session.get("company_logo"), "company_logo": session.get("company_logo"),
"auto_logout_minutes": session.get("auto_logout_minutes"),
"current_year": date.today().year,
} }
return app return app
@@ -43,6 +43,28 @@ def user_list():
return render_template("admin/users.html", users=users) return render_template("admin/users.html", users=users)
@admin_bp.route("/settings")
@login_required
@admin_required
def settings_page():
"""System settings page."""
resp = api_client.get("/api/settings")
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
return render_template("admin/settings.html", settings=settings)
@admin_bp.route("/api/settings", methods=["PUT"])
@login_required
@admin_required
def api_update_settings():
"""Proxy: Update system settings."""
data = request.get_json(silent=True) or {}
resp = api_client.put("/api/settings", data=data)
if isinstance(resp, dict) and resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@admin_bp.route("/stations") @admin_bp.route("/stations")
@login_required @login_required
@admin_required @admin_required
+53 -12
View File
@@ -1,7 +1,7 @@
"""Authentication blueprint - login, logout, profile.""" """Authentication blueprint - login, logout, profile."""
from functools import wraps from functools import wraps
from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from services.api_client import api_client from services.api_client import api_client
@@ -39,15 +39,32 @@ def role_required(*roles):
return decorator return decorator
def _post_login_landing():
"""Pick the landing endpoint based on the logged-in user's roles.
A Maker-only user must not be bounced to /measure (MeasurementTec-only),
which would raise a 403 right after login. Order of preference follows the
primary operator flow first, then editing, analysis, admin.
"""
user = session.get("user", {})
roles = user.get("roles", [])
if "MeasurementTec" in roles:
return url_for("measure.select_recipe")
if "Maker" in roles:
return url_for("maker.recipe_list")
if "Metrologist" in roles:
return url_for("statistics.dashboard")
if user.get("is_admin"):
return url_for("admin.user_list")
return url_for("auth.profile")
@auth_bp.route("/login", methods=["GET", "POST"]) @auth_bp.route("/login", methods=["GET", "POST"])
def login(): def login():
"""Login page and form handler.""" """Login page and form handler."""
# Se già loggato, redirect a home # Se già loggato, redirect alla home in base al ruolo
if session.get("api_key"): if session.get("api_key"):
try: return redirect(_post_login_landing())
return redirect(url_for("measure.select_recipe"))
except Exception:
return redirect(url_for("auth.profile"))
if request.method == "POST": if request.method == "POST":
username = request.form.get("username", "").strip() username = request.form.get("username", "").strip()
@@ -76,20 +93,23 @@ def login():
if user.get("theme_pref"): if user.get("theme_pref"):
session["theme"] = user["theme_pref"] session["theme"] = user["theme_pref"]
# Carica logo aziendale dalle impostazioni di sistema # Carica impostazioni di sistema
settings_resp = api_client.get("/api/settings") settings_resp = api_client.get("/api/settings")
if not settings_resp.get("error"): if not settings_resp.get("error"):
logo_path = settings_resp.get("company_logo_path") logo_path = settings_resp.get("company_logo_path")
if logo_path: if logo_path:
session["company_logo"] = logo_path session["company_logo"] = logo_path
auto_logout = settings_resp.get("auto_logout_minutes")
if auto_logout:
try:
session["auto_logout_minutes"] = int(auto_logout)
except (ValueError, TypeError):
pass
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success") flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
# Redirect dopo login # Redirect dopo login (in base al ruolo)
try: return redirect(_post_login_landing())
return redirect(url_for("measure.select_recipe"))
except Exception:
return redirect(url_for("auth.profile"))
except Exception as e: except Exception as e:
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error") flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
@@ -113,6 +133,27 @@ def logout():
return redirect(url_for("auth.login")) return redirect(url_for("auth.login"))
@auth_bp.route("/set-theme", methods=["POST"])
@login_required
def set_theme():
"""Persist the user's theme toggle (light/dark) to session and profile.
Called via AJAX from the navbar theme switch so the choice survives
navigation and matches on the next login. Best-effort on the API side:
the session is updated regardless so the current browsing stays correct.
"""
theme = (request.get_json(silent=True) or {}).get("theme", "")
if theme not in ("light", "dark"):
return jsonify({"error": True, "detail": "invalid theme"}), 400
session["theme"] = theme
try:
api_client.put("/api/auth/me", data={"theme_pref": theme})
except Exception:
pass # Non-fatal: session theme is already updated for this session.
return jsonify({"ok": True, "theme": theme})
@auth_bp.route("/profile", methods=["GET", "POST"]) @auth_bp.route("/profile", methods=["GET", "POST"])
@login_required @login_required
def profile(): def profile():
+31 -14
View File
@@ -1,12 +1,13 @@
"""Maker blueprint - recipe creation and editing.""" """Maker blueprint - recipe creation and editing."""
import requests as http_requests import requests as http_requests
from flask import Blueprint, Response, flash, jsonify, redirect, render_template, request, session, url_for from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
from flask_babel import gettext as _ from flask_babel import gettext as _
from blueprints.auth import login_required, role_required from blueprints.auth import login_required, role_required
from config import Config from config import Config
from services.api_client import api_client from services.api_client import api_client
from services.file_proxy import proxy_file
maker_bp = Blueprint("maker", __name__, url_prefix="/maker") maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
@@ -380,19 +381,7 @@ def api_upload_file():
@login_required @login_required
def api_get_file(file_path: str): def api_get_file(file_path: str):
"""Proxy: Serve file from API server (browser can't send X-API-Key).""" """Proxy: Serve file from API server (browser can't send X-API-Key)."""
api_key = session.get("api_key", "") return proxy_file(file_path)
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/files/{file_path}",
headers={"X-API-Key": api_key},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"]) @maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
@@ -424,3 +413,31 @@ def api_get_measurement_count(recipe_id: int, version_number: int):
return jsonify(resp), resp.get("status_code", 500) return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200 return jsonify(resp), 200
@maker_bp.route("/api/recipes/<int:recipe_id>/parse-technical-sheet", methods=["POST"])
@login_required
@role_required("Maker")
def api_parse_technical_sheet(recipe_id: int):
"""Proxy: Parse PDF technical sheet with AI."""
file = request.files.get("file")
if not file:
return jsonify({"error": True, "detail": "Nessun file caricato"}), 400
api_key = session.get("api_key", "")
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.post(
f"{base_url}/api/recipes/{recipe_id}/parse-technical-sheet",
headers={"X-API-Key": api_key},
files={"file": (file.filename, file.stream, file.content_type)},
timeout=90,
)
try:
payload = resp.json()
except ValueError:
payload = {
"error": True,
"detail": resp.text or f"HTTP {resp.status_code}",
}
return jsonify(payload), resp.status_code
+36 -16
View File
@@ -1,8 +1,6 @@
"""MeasurementTec blueprint - recipe selection and measurement execution.""" """MeasurementTec blueprint - recipe selection and measurement execution."""
import requests as http_requests
from flask import ( from flask import (
Blueprint, Response, flash, jsonify, redirect, render_template, Blueprint, flash, jsonify, redirect, render_template,
request, session, url_for, request, session, url_for,
) )
from flask_babel import gettext as _ from flask_babel import gettext as _
@@ -10,6 +8,7 @@ from flask_babel import gettext as _
from blueprints.auth import login_required, role_required from blueprints.auth import login_required, role_required
from config import Config from config import Config
from services.api_client import api_client from services.api_client import api_client
from services.file_proxy import proxy_file
measure_bp = Blueprint("measure", __name__) measure_bp = Blueprint("measure", __name__)
@@ -135,11 +134,15 @@ def task_execute(task_id: int):
# Load all task IDs for this recipe (ordered) for auto-advance # Load all task IDs for this recipe (ordered) for auto-advance
recipe_id = task_resp.get("recipe_id") recipe_id = task_resp.get("recipe_id")
all_task_ids = [] all_task_ids = []
measurement_interval_minutes = None
if recipe_id: if recipe_id:
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks") tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, list): if isinstance(tasks_resp, list):
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0)) sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
all_task_ids = [t["id"] for t in sorted_tasks] all_task_ids = [t["id"] for t in sorted_tasks]
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
if not recipe_resp.get("error"):
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
return render_template( return render_template(
"measure/task_execute.html", "measure/task_execute.html",
@@ -147,6 +150,7 @@ def task_execute(task_id: int):
lot_number=lot_number, lot_number=lot_number,
serial_number=serial_number, serial_number=serial_number,
all_task_ids=all_task_ids, all_task_ids=all_task_ids,
measurement_interval_minutes=measurement_interval_minutes,
) )
@@ -321,6 +325,34 @@ def save_measurement():
return jsonify(resp), 201 return jsonify(resp), 201
# ---------------------------------------------------------------------------
# Route: Validate supervisor credentials (AJAX)
# ---------------------------------------------------------------------------
@measure_bp.route("/validate-supervisor", methods=["POST"])
@login_required
@role_required("MeasurementTec")
def validate_supervisor():
"""Validate supervisor (capoturno) credentials for out-of-tolerance authorization."""
data = request.get_json(silent=True) or {}
username = data.get("username", "").strip()
password = data.get("password", "")
if not username or not password:
return jsonify({"error": True, "detail": _("Username e password richiesti")}), 400
resp = api_client.post("/api/auth/login", data={"username": username, "password": password})
if resp.get("error"):
return jsonify({"error": True, "detail": _("Credenziali non valide")}), 401
user = resp.get("user", {})
is_supervisor = "Supervisor" in (user.get("roles") or [])
if not (is_supervisor or user.get("is_admin")):
return jsonify({"error": True, "detail": _("Utente non autorizzato (richiesto capoturno)")}), 403
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Route: File proxy (browser can't send X-API-Key directly) # Route: File proxy (browser can't send X-API-Key directly)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -328,16 +360,4 @@ def save_measurement():
@login_required @login_required
def api_get_file(file_path: str): def api_get_file(file_path: str):
"""Proxy: Serve file from API server (browser can't send X-API-Key).""" """Proxy: Serve file from API server (browser can't send X-API-Key)."""
api_key = session.get("api_key", "") return proxy_file(file_path)
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/files/{file_path}",
headers={"X-API-Key": api_key},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
+14 -4
View File
@@ -56,11 +56,21 @@ class APIClient:
try: try:
error_body = response.json() error_body = response.json()
detail = error_body.get("detail", str(error_body)) detail = error_body.get("detail", str(error_body))
# FastAPI 422 returns detail as a list of validation errors # FastAPI 422 returns detail as a list of validation errors:
# [{"type": ..., "loc": ["body", "password"], "msg": "...", ...}, ...]
if isinstance(detail, list): if isinstance(detail, list):
detail = "; ".join( parts = []
e.get("msg", str(e)) for e in detail if isinstance(e, dict) for e in detail:
) or str(detail) if not isinstance(e, dict):
parts.append(str(e))
continue
msg = e.get("msg", str(e))
loc = e.get("loc") or []
# Strip leading "body"/"query"/"path" segments; keep field path.
field_path = [str(p) for p in loc if p not in ("body", "query", "path")]
field = ".".join(field_path) if field_path else None
parts.append(f"{field}: {msg}" if field else msg)
detail = "; ".join(parts) or str(detail)
except Exception: except Exception:
detail = response.text or f"HTTP {response.status_code}" detail = response.text or f"HTTP {response.status_code}"
@@ -0,0 +1,25 @@
"""Shared file proxy: relay uploads from the FastAPI server to the browser.
The browser can't send the X-API-Key header directly, so blueprints expose
a proxy route and delegate here.
"""
import requests as http_requests
from flask import Response, session
from config import Config
def proxy_file(file_path: str) -> Response:
"""Fetch a file from the API server and relay it with its content type."""
base_url = Config.API_SERVER_URL.rstrip("/")
resp = http_requests.get(
f"{base_url}/api/files/{file_path}",
headers={"X-API-Key": session.get("api_key", "")},
timeout=30,
)
if resp.status_code != 200:
return Response(resp.text, status=resp.status_code)
return Response(
resp.content,
content_type=resp.headers.get("content-type", "application/octet-stream"),
)
@@ -69,6 +69,23 @@
} }
/* ============================================================
Brand details
============================================================ */
:root {
accent-color: var(--color-primary);
}
::selection {
background: rgba(37, 99, 235, 0.2);
}
.dark ::selection {
background: rgba(96, 165, 250, 0.3);
}
/* ============================================================ /* ============================================================
Global Transitions Global Transitions
============================================================ */ ============================================================ */
@@ -151,6 +168,7 @@ textarea:focus-visible {
.tmf-card:hover { .tmf-card:hover {
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
border-color: var(--scrollbar-thumb);
} }
.tmf-card-header { .tmf-card-header {
@@ -250,11 +268,21 @@ textarea:focus-visible {
border: 1px solid transparent; border: 1px solid transparent;
} }
.btn:active:not(:disabled) {
transform: scale(0.97);
}
.btn:disabled { .btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
border-radius: 0.625rem;
}
.btn-primary { .btn-primary {
background: var(--color-primary); background: var(--color-primary);
color: #FFFFFF; color: #FFFFFF;
@@ -1,27 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<!-- Stylized Caliper Icon for Favicon --> <!-- TieMeasureFlow brand mark: measurement span glyph on brand-blue badge -->
<defs>
<linearGradient id="tmfBadge" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3B82F6"/>
<stop offset="1" stop-color="#1D4ED8"/>
</linearGradient>
</defs>
<!-- Caliper outer frame --> <!-- Badge -->
<rect x="2" y="3" width="5" height="26" rx="1.5" fill="#2563EB"/> <rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadge)"/>
<rect x="2" y="3" width="19" height="5" rx="1.5" fill="#2563EB"/>
<rect x="2" y="24" width="19" height="5" rx="1.5" fill="#2563EB"/>
<!-- Sliding jaw --> <!-- Measurement end bars -->
<rect x="12" y="8" width="4.5" height="6.5" rx="1" fill="#1E40AF"/> <rect x="14" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
<rect x="12" y="17.5" width="4.5" height="6.5" rx="1" fill="#1E40AF"/> <rect x="45" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
<!-- Depth rod --> <!-- Double-headed arrow (measured span) -->
<rect x="4" y="14" width="12" height="3.5" rx="1" fill="#3B82F6" opacity="0.55"/> <path d="M24 32 H40" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
<path d="M28 26.5 L22.5 32 L28 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Scale markings --> <path d="M36 26.5 L41.5 32 L36 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="8" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="11" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="14" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<rect x="17" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
<!-- Flow arrow -->
<path d="M22 16 C25 16, 26.5 11, 29 11"
stroke="#3B82F6" stroke-width="2.2" stroke-linecap="round" fill="none"/>
<path d="M27 8.5 L29.5 11 L27 13.5"
stroke="#3B82F6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 997 B

@@ -1,41 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 44" fill="none"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 340 64" fill="none">
<!-- Stylized Caliper Icon --> <!-- TieMeasureFlow full logo: brand badge + wordmark (standalone asset) -->
<g transform="translate(2, 2)"> <defs>
<!-- Caliper outer frame --> <linearGradient id="tmfBadgeFull" x1="0" y1="0" x2="1" y2="1">
<rect x="0" y="4" width="6" height="32" rx="1.5" fill="#2563EB"/> <stop offset="0" stop-color="#3B82F6"/>
<rect x="0" y="4" width="24" height="6" rx="1.5" fill="#2563EB"/> <stop offset="1" stop-color="#1D4ED8"/>
<rect x="0" y="30" width="24" height="6" rx="1.5" fill="#2563EB"/> </linearGradient>
</defs>
<!-- Caliper sliding jaw --> <!-- Badge -->
<rect x="14" y="10" width="5" height="8" rx="1" fill="#1E40AF"/> <rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadgeFull)"/>
<rect x="14" y="22" width="5" height="8" rx="1" fill="#1E40AF"/> <rect x="14" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
<rect x="45" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
<path d="M24 32 H40" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
<path d="M28 26.5 L22.5 32 L28 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M36 26.5 L41.5 32 L36 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<!-- Caliper depth rod --> <!-- Wordmark: single text element with flowing tspans (cannot be clipped/overlap) -->
<rect x="2" y="18" width="16" height="4" rx="1" fill="#3B82F6" opacity="0.6"/> <text x="76" y="42" font-family="Inter, system-ui, -apple-system, sans-serif"
font-size="30" letter-spacing="-0.5" fill="#0F172A">
<!-- Scale markings --> <tspan font-weight="700">Tie</tspan><tspan font-weight="600" fill="#2563EB">Measure</tspan><tspan font-weight="700">Flow</tspan>
<rect x="7" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/> </text>
<rect x="10" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="13" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="16" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<rect x="19" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<!-- Flow arrow (smooth curve) -->
<path d="M26 20 C30 20, 32 14, 36 14 C40 14, 40 20, 36 20"
stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none"/>
<path d="M34 17 L37 20 L34 23" stroke="#2563EB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</g>
<!-- Text: TieMeasureFlow -->
<g transform="translate(48, 0)">
<!-- "Tie" in bold primary -->
<text x="0" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
letter-spacing="-0.5">Tie</text>
<!-- "Measure" in medium dark -->
<text x="32" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="500" fill="#1E40AF"
letter-spacing="-0.5">Measure</text>
<!-- "Flow" in bold primary -->
<text x="120" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
letter-spacing="-0.5">Flow</text>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -16,9 +16,21 @@
/** /**
* Resolve the initial dark-mode state. * Resolve the initial dark-mode state.
* Priority: localStorage > system preference > light (default). * Priority: server (per-user theme_pref) > localStorage > system preference > light.
*
* The server value (window.__SERVER_THEME) is authoritative for logged-in
* users: it reflects the theme_pref of whoever is currently authenticated,
* so switching user actually switches theme instead of inheriting the
* previous user's localStorage value. It is empty for anonymous pages.
*/ */
function getInitialDark() { function getInitialDark() {
var server = (typeof window !== 'undefined' && window.__SERVER_THEME) || '';
if (server === 'dark' || server === 'light') {
// Keep localStorage in sync so the rest of the app stays consistent.
try { localStorage.setItem(STORAGE_KEY, server); } catch (_) {}
return server === 'dark';
}
var stored = null; var stored = null;
try { try {
stored = localStorage.getItem(STORAGE_KEY); stored = localStorage.getItem(STORAGE_KEY);
@@ -70,6 +82,7 @@
toggle: function () { toggle: function () {
this.dark = !this.dark; this.dark = !this.dark;
this._apply(); this._apply();
this._persist();
}, },
set: function (dark) { set: function (dark) {
@@ -77,6 +90,24 @@
this._apply(); this._apply();
}, },
// Persist an explicit user choice to the server (session + theme_pref),
// so it survives navigation and matches on the next login. Best-effort.
_persist: function () {
try {
var csrf = document.querySelector('meta[name=csrf-token]');
fetch('/auth/set-theme', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': (csrf && csrf.content) || ''
},
body: JSON.stringify({ theme: this.dark ? 'dark' : 'light' })
}).catch(function () {});
} catch (_) {
// Non-fatal: localStorage already holds the choice for this browser.
}
},
_apply: function () { _apply: function () {
if (this.dark) { if (this.dark) {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
@@ -671,6 +671,53 @@ function annotationEditor() {
return rect; return rect;
}, },
// ----------------------------------------------------------------
// Object creation - Text
// ----------------------------------------------------------------
addText(x, y, textContent, fontSize) {
var size = fontSize || 18;
var itext = new fabric.IText(textContent || 'Testo', {
left: x,
top: y,
fontSize: size,
fill: this.currentColor,
fontFamily: 'Inter, sans-serif',
fontWeight: 'bold',
selectable: true,
evented: true,
hasControls: false,
hasBorders: true,
lockRotation: true,
lockScalingX: true,
lockScalingY: true,
objectType: 'text',
textColor: this.currentColor,
textFontSize: size,
});
var self = this;
itext.on('editing:exited', function () {
if (!itext.text || itext.text.trim() === '') {
self.canvas.remove(itext);
}
itext.textColor = itext.fill;
self.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
});
itext.on('changed', function () {
self.isDirty = true;
});
this.canvas.add(itext);
itext.setCoords();
this.canvas.setActiveObject(itext);
this.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
return itext;
},
// ---------------------------------------------------------------- // ----------------------------------------------------------------
// Toolbar / mode switching // Toolbar / mode switching
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -678,7 +725,7 @@ function annotationEditor() {
/** /**
* Set the current interaction mode. * Set the current interaction mode.
* *
* @param {'select'|'marker'|'arrow'|'rect'} mode * @param {'select'|'marker'|'arrow'|'rect'|'text'} mode
*/ */
setMode(mode) { setMode(mode) {
this.activeMode = mode; this.activeMode = mode;
@@ -731,6 +778,13 @@ function annotationEditor() {
self.addMarker(pointer.x, pointer.y); self.addMarker(pointer.x, pointer.y);
self.setMode('select'); self.setMode('select');
window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } })); window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
} else if (self.activeMode === 'text') {
var textObj = self.addText(pointer.x, pointer.y);
self.setMode('select');
window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
self.canvas.setActiveObject(textObj);
textObj.enterEditing();
textObj.selectAll();
} else if (self.activeMode === 'arrow') { } else if (self.activeMode === 'arrow') {
isDrawing = true; isDrawing = true;
drawStartX = pointer.x; drawStartX = pointer.x;
@@ -902,7 +956,7 @@ function annotationEditor() {
setupKeyboard() { setupKeyboard() {
var self = this; var self = this;
document.addEventListener('keydown', function (e) { this._onKeyDown = function (e) {
// Skip when typing in form fields // Skip when typing in form fields
var tag = e.target.tagName; var tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
@@ -916,7 +970,8 @@ function annotationEditor() {
self.canvas.discardActiveObject(); self.canvas.discardActiveObject();
self.canvas.renderAll(); self.canvas.renderAll();
} }
}); };
document.addEventListener('keydown', this._onKeyDown);
}, },
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -1023,6 +1078,13 @@ function annotationEditor() {
if (obj.arrowLineDash) data.lineDash = obj.arrowLineDash; if (obj.arrowLineDash) data.lineDash = obj.arrowLineDash;
if (obj.areaLineDash) data.lineDash = obj.areaLineDash; if (obj.areaLineDash) data.lineDash = obj.areaLineDash;
// Text-specific
if (obj.objectType === 'text') {
data.textContent = obj.text;
data.textColor = obj.textColor || obj.fill;
data.fontSize = obj.textFontSize || obj.fontSize;
}
// Arrow-specific: store original endpoints // Arrow-specific: store original endpoints
if (obj.objectType === 'arrow') { if (obj.objectType === 'arrow') {
data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null; data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null;
@@ -1120,6 +1182,11 @@ function annotationEditor() {
this.currentColor = savedColor3; this.currentColor = savedColor3;
this.currentStrokeWidth = savedWidth2; this.currentStrokeWidth = savedWidth2;
this.currentLineDash = savedDash2; this.currentLineDash = savedDash2;
} else if (obj.type === 'text') {
var savedColor4 = this.currentColor;
if (obj.textColor) this.currentColor = obj.textColor;
this.addText(obj.left * cs, obj.top * cs, obj.textContent, (obj.fontSize || 18) * cs);
this.currentColor = savedColor4;
} }
} }
@@ -1223,6 +1290,9 @@ function annotationEditor() {
if (this._onImageLoaded) { if (this._onImageLoaded) {
window.removeEventListener('image-loaded', this._onImageLoaded); window.removeEventListener('image-loaded', this._onImageLoaded);
} }
if (this._onKeyDown) {
document.removeEventListener('keydown', this._onKeyDown);
}
if (this.canvas) { if (this.canvas) {
this.canvas.dispose(); this.canvas.dispose();
this.canvas = null; this.canvas = null;
@@ -436,6 +436,8 @@ function _drawEditorAnnotations(ctx, data, scale) {
_drawPreviewArrow(ctx, obj, annoScale); _drawPreviewArrow(ctx, obj, annoScale);
} else if (obj.type === 'area') { } else if (obj.type === 'area') {
_drawPreviewArea(ctx, obj, annoScale); _drawPreviewArea(ctx, obj, annoScale);
} else if (obj.type === 'text') {
_drawPreviewText(ctx, obj, annoScale);
} }
} }
} }
@@ -514,6 +516,24 @@ function _drawPreviewArea(ctx, obj, scale) {
ctx.setLineDash([]); ctx.setLineDash([]);
} }
function _drawPreviewText(ctx, obj, scale) {
var x = obj.left * scale;
var y = obj.top * scale;
var color = obj.textColor || '#000000';
var fontSize = Math.max(10, Math.round((obj.fontSize || 18) * scale));
ctx.fillStyle = color;
ctx.font = 'bold ' + fontSize + 'px Inter, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
var lines = (obj.textContent || '').split('\n');
var lineHeight = fontSize * 1.2;
for (var j = 0; j < lines.length; j++) {
ctx.fillText(lines[j], x, y + j * lineHeight);
}
}
/** /**
* Draw annotations in the legacy format (markers: point/rectangle). * Draw annotations in the legacy format (markers: point/rectangle).
*/ */
@@ -163,6 +163,17 @@ function numpad() {
* @param {KeyboardEvent} e - The keyboard event * @param {KeyboardEvent} e - The keyboard event
*/ */
handleKeydown(e) { handleKeydown(e) {
// Ignore keystrokes aimed at an editable field (e.g. the supervisor
// login modal). The numpad has no text inputs of its own, so this
// window-level capture is only meant for the USB caliper / keyboard
// wedge when no field is focused. Without this guard the numpad would
// swallow digits and Backspace inside those inputs.
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
t.tagName === 'SELECT' || t.isContentEditable)) {
return;
}
// Number keys // Number keys
if (e.key >= '0' && e.key <= '9') { if (e.key >= '0' && e.key <= '9') {
e.preventDefault(); e.preventDefault();
@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
x-data="systemSettings()">
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
</div>
<!-- Settings Card -->
<div class="tmf-card">
<div class="tmf-card-header flex items-center gap-2">
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Sessione e Sicurezza') }}
</div>
<div class="tmf-card-body space-y-5">
<!-- Auto-logout -->
<div>
<label class="tmf-label" for="auto-logout">{{ _('Auto-logout per inattività (minuti)') }}</label>
<div class="flex items-center gap-3">
<input id="auto-logout"
type="number"
x-model.number="autoLogoutMinutes"
class="tmf-input w-32"
min="1"
max="480"
placeholder="—">
<button @click="autoLogoutMinutes = null"
class="text-xs text-[var(--text-muted)] hover:text-red-500 transition-colors"
x-show="autoLogoutMinutes">
{{ _('Disabilita') }}
</button>
</div>
<p class="text-xs text-[var(--text-muted)] mt-1">
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
</p>
</div>
</div>
<div class="tmf-card-footer flex items-center justify-between">
<span x-show="saved" x-transition class="text-sm text-emerald-600 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Salvato') }}
</span>
<span x-show="errorMessage" x-transition class="text-sm text-red-600" x-text="errorMessage"></span>
<div class="flex-1"></div>
<button @click="save()"
:disabled="saving"
class="btn btn-primary gap-1.5">
<svg x-show="!saving" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<svg x-show="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
{{ _('Salva impostazioni') }}
</button>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function systemSettings() {
return {
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
saving: false,
saved: false,
errorMessage: '',
async save() {
this.saving = true;
this.saved = false;
this.errorMessage = '';
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
var payload = {
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : ''
};
try {
var resp = await fetch('/admin/api/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
body: JSON.stringify(payload)
});
var data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || data.error;
} else {
this.saved = true;
setTimeout(() => { this.saved = false; }, 3000);
}
} catch (err) {
this.errorMessage = {{ _('Errore di connessione al server')|tojson }};
}
this.saving = false;
}
};
}
</script>
{% endblock %}
@@ -258,9 +258,9 @@
</svg> </svg>
{{ _('Ricette disponibili') }} (<span x-text="filteredUnassignedRecipes.length"></span>) {{ _('Ricette disponibili') }} (<span x-text="filteredUnassignedRecipes.length"></span>)
</h3> </h3>
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto"> <div class="border border-[var(--border-color)] rounded-lg p-2 space-y-2 h-96 overflow-y-auto">
<template x-for="r in filteredUnassignedRecipes" :key="r.id"> <template x-for="r in filteredUnassignedRecipes" :key="r.id">
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors"> <div class="flex items-center justify-between px-3 py-2 border border-[var(--border-color)] rounded-md hover:bg-[var(--bg-secondary)] transition-colors">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div> <div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div> <div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
@@ -292,9 +292,9 @@
</svg> </svg>
{{ _('Assegnate alla stazione') }} (<span x-text="filteredAssignedRecipes.length"></span>) {{ _('Assegnate alla stazione') }} (<span x-text="filteredAssignedRecipes.length"></span>)
</h3> </h3>
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto"> <div class="border border-[var(--border-color)] rounded-lg p-2 space-y-2 h-96 overflow-y-auto">
<template x-for="r in filteredAssignedRecipes" :key="r.id"> <template x-for="r in filteredAssignedRecipes" :key="r.id">
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors"> <div class="flex items-center justify-between px-3 py-2 border border-[var(--border-color)] rounded-md hover:bg-[var(--bg-secondary)] transition-colors">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div> <div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div> <div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
@@ -78,7 +78,8 @@
:class="{ :class="{
'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300': role === 'Maker', 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300': role === 'Maker',
'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300': role === 'MeasurementTec', 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300': role === 'MeasurementTec',
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': role === 'Metrologist' 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': role === 'Metrologist',
'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300': role === 'Supervisor'
}" }"
x-text="role"></span> x-text="role"></span>
</template> </template>
@@ -233,6 +234,11 @@
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30"> class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm text-[var(--text-primary)]">Metrologist</span> <span class="text-sm text-[var(--text-primary)]">Metrologist</span>
</label> </label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" x-model="form.roles" value="Supervisor"
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
<span class="text-sm text-[var(--text-primary)]">{{ _('Supervisor (capoturno)') }}</span>
</label>
</div> </div>
</div> </div>
@@ -2,20 +2,26 @@
{% block title %}Login — TieMeasureFlow{% endblock %} {% block title %}Login — TieMeasureFlow{% endblock %}
{% block content %} {% block content %}
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 sm:px-6 lg:px-8"> <div class="relative min-h-screen flex items-center justify-center overflow-hidden bg-[var(--bg-primary)] px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full space-y-8">
<!-- Decorative brand background -->
<div class="pointer-events-none absolute inset-0" aria-hidden="true">
<div class="absolute -top-32 -left-32 w-96 h-96 rounded-full bg-primary-500/10 dark:bg-primary-400/10 blur-3xl"></div>
<div class="absolute -bottom-40 -right-24 w-[28rem] h-[28rem] rounded-full bg-primary-600/10 dark:bg-primary-500/10 blur-3xl"></div>
</div>
<div class="relative max-w-md w-full space-y-6">
<!-- Card --> <!-- Card -->
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-xl p-8"> <div class="bg-[var(--bg-card)] border border-[var(--border-color)] shadow-lg rounded-2xl p-8">
<!-- Logo --> <!-- Logo -->
<div class="text-center mb-8"> <div class="text-center mb-8">
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}" <div class="mx-auto mb-4 inline-flex justify-center text-slate-900 dark:text-white">
alt="TieMeasureFlow Logo" {% set logo_class = 'h-14 w-14' %}
class="mx-auto h-16 w-auto mb-4" {% set wordmark_class = 'text-3xl' %}
onerror="this.style.display='none'"> {% set logo_id = 'login' %}
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-2"> {% include 'components/_app_logo.html' %}
TieMeasureFlow </div>
</h2> <p class="text-sm text-[var(--text-secondary)]">
<p class="text-sm text-slate-600 dark:text-slate-400">
{{ _('Accedi al sistema') }} {{ _('Accedi al sistema') }}
</p> </p>
</div> </div>
@@ -25,12 +31,12 @@
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Username --> <!-- Username -->
<div> <div>
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"> <label for="username" class="tmf-label">
{{ _('Username') }} {{ _('Username') }}
</label> </label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-5 w-5 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg> </svg>
</div> </div>
@@ -39,36 +45,52 @@
name="username" name="username"
required required
autofocus autofocus
autocomplete="username"
placeholder="{{ _('Username') }}" placeholder="{{ _('Username') }}"
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors"> class="tmf-input !pl-10 !py-2.5">
</div> </div>
</div> </div>
<!-- Password --> <!-- Password -->
<div> <div x-data="{ show: false }">
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2"> <label for="password" class="tmf-label">
{{ _('Password') }} {{ _('Password') }}
</label> </label>
<div class="relative"> <div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-5 w-5 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg> </svg>
</div> </div>
<input type="password" <input :type="show ? 'text' : 'password'"
type="password"
id="password" id="password"
name="password" name="password"
required required
autocomplete="current-password"
placeholder="{{ _('Password') }}" placeholder="{{ _('Password') }}"
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors"> class="tmf-input !pl-10 !pr-12 !py-2.5">
<button type="button"
@click="show = !show"
:aria-label="show ? {{ _('Nascondi password')|tojson_attr }} : {{ _('Mostra password')|tojson_attr }}"
class="absolute inset-y-0 right-0 px-3 flex items-center text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors">
<!-- Eye (password hidden, click to show) -->
<svg x-show="!show" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<!-- Eye slash (password visible, click to hide) -->
<svg x-show="show" x-cloak class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12c1.292 4.338 5.31 7.5 10.066 7.5.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"/>
</svg>
</button>
</div> </div>
</div> </div>
<!-- Submit Button --> <!-- Submit Button -->
<div> <div>
<button type="submit" <button type="submit" class="btn btn-primary btn-lg w-full shadow-sm">
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200"> <svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg> </svg>
{{ _('Accedi') }} {{ _('Accedi') }}
@@ -78,7 +100,7 @@
<!-- Help Text --> <!-- Help Text -->
<div class="mt-6 text-center"> <div class="mt-6 text-center">
<p class="text-xs text-slate-500 dark:text-slate-400"> <p class="text-xs text-[var(--text-muted)]">
{{ _('Hai dimenticato la password?') }} {{ _('Hai dimenticato la password?') }}
<br> <br>
{{ _('Contatta l\'amministratore') }} {{ _('Contatta l\'amministratore') }}
@@ -87,8 +109,8 @@
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="text-center text-xs text-slate-500 dark:text-slate-400"> <div class="text-center text-xs text-[var(--text-muted)]">
<p>TieMeasureFlow &copy; 2025 - {{ _('Sistema di misurazione industriale') }}</p> <p>TieMeasureFlow &copy; {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -87,6 +87,8 @@
bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800
{% elif role == 'Metrologist' %} {% elif role == 'Metrologist' %}
bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-800 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-800
{% elif role == 'Supervisor' %}
bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800
{% else %} {% else %}
bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600
{% endif %}"> {% endif %}">
+76 -3
View File
@@ -24,16 +24,19 @@
<!-- Theme CSS Variables --> <!-- Theme CSS Variables -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/themes.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/themes.css') }}">
<!-- Authoritative per-user theme from the server session (empty when anonymous) -->
<script>window.__SERVER_THEME = "{{ current_theme if current_user else '' }}";</script>
<!-- Alpine.js Theme Init (before Alpine loads) --> <!-- Alpine.js Theme Init (before Alpine loads) -->
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script> <script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body class="h-full bg-[var(--bg-primary)] text-[var(--text-primary)] font-sans antialiased transition-colors duration-300"> <body class="{% block body_class %}h-full{% endblock %} bg-[var(--bg-primary)] text-[var(--text-primary)] font-sans antialiased transition-colors duration-300">
<!-- Alpine.js App Wrapper --> <!-- Alpine.js App Wrapper -->
<div x-data class="min-h-full flex flex-col"> <div x-data class="{% block wrapper_class %}min-h-full{% endblock %} flex flex-col">
<!-- Navbar --> <!-- Navbar -->
{% include "components/navbar.html" %} {% include "components/navbar.html" %}
@@ -98,21 +101,91 @@
{% endwith %} {% endwith %}
<!-- Main Content --> <!-- Main Content -->
<main class="flex-1"> <main class="{% block main_class %}flex-1{% endblock %}">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
{% block footer %}
<!-- Footer --> <!-- Footer -->
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300"> <footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
<p class="text-xs text-steel dark:text-steel-light tracking-wide"> <p class="text-xs text-steel dark:text-steel-light tracking-wide">
Powered by <span class="font-semibold">TieMeasureFlow</span> &mdash; Tielogic Powered by <span class="font-semibold">TieMeasureFlow</span> &mdash; Tielogic
</p> </p>
</footer> </footer>
{% endblock %}
</div> </div>
{% block extra_js %}{% endblock %} {% block extra_js %}{% endblock %}
{% if current_user and auto_logout_minutes %}
<script>
function inactivityTimer() {
var timeoutMs = {{ auto_logout_minutes }} * 60 * 1000;
var warningMs = 60 * 1000;
return {
lastActivity: Date.now(),
showWarning: false,
remainingSeconds: 60,
_interval: null,
init() {
var self = this;
this._interval = setInterval(function() { self._check(); }, 5000);
var lastUpdate = 0;
var handler = function() {
var now = Date.now();
if (now - lastUpdate > 3000) {
lastUpdate = now;
self.lastActivity = now;
self.showWarning = false;
}
};
['mousemove','mousedown','keypress','touchstart','scroll'].forEach(function(evt) {
window.addEventListener(evt, handler, { passive: true });
});
},
resetTimer() {
this.lastActivity = Date.now();
this.showWarning = false;
},
_check() {
var elapsed = Date.now() - this.lastActivity;
var remaining = timeoutMs - elapsed;
if (remaining <= 0) {
clearInterval(this._interval);
window.location.href = '{{ url_for("auth.logout") }}';
} else if (remaining <= warningMs) {
this.showWarning = true;
this.remainingSeconds = Math.ceil(remaining / 1000);
}
}
};
}
</script>
<div x-data="inactivityTimer()" class="contents">
<template x-if="showWarning">
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" @click.self="resetTimer()">
<div class="bg-[var(--bg-primary)] rounded-xl p-6 shadow-2xl max-w-sm mx-4 border border-[var(--border-color)]">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{{ _('Sessione in scadenza') }}</h3>
</div>
<p class="text-sm text-[var(--text-secondary)] mb-5">
{{ _('Sarai disconnesso tra') }} <span class="font-mono font-bold text-amber-600" x-text="remainingSeconds"></span> {{ _('secondi per inattività.') }}
</p>
<button @click="resetTimer()" class="btn btn-primary w-full">
{{ _('Continua a lavorare') }}
</button>
</div>
</div>
</template>
</div>
{% endif %}
<!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined --> <!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script> <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</body> </body>
@@ -0,0 +1,33 @@
<!--
TieMeasureFlow logo: brand badge (SVG) + HTML wordmark.
The wordmark inherits `color` from the parent (set e.g. `text-slate-900
dark:text-white`); "Measure" keeps the brand blue.
Parameters (set before include):
logo_class — badge size, default 'h-10 w-10'
wordmark_class — wordmark text size, default 'text-xl'; '' hides the text
logo_id — unique suffix for the SVG gradient id when the logo
appears more than once per page (default 'tmf')
-->
{% set _logo_id = logo_id|default('tmf') %}
<span class="inline-flex items-center gap-2.5 select-none whitespace-nowrap">
<svg class="{{ logo_class|default('h-10 w-10') }} shrink-0" viewBox="0 0 64 64" fill="none"
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<linearGradient id="tmfBadge-{{ _logo_id }}" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#3B82F6"/>
<stop offset="1" stop-color="#1D4ED8"/>
</linearGradient>
</defs>
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadge-{{ _logo_id }})"/>
<rect x="14" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
<rect x="45" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
<path d="M24 32 H40" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
<path d="M28 26.5 L22.5 32 L28 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M36 26.5 L41.5 32 L36 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{% if wordmark_class is not defined or wordmark_class %}
<span class="{{ wordmark_class|default('text-xl') }} font-bold tracking-tight leading-none"
aria-label="TieMeasureFlow">Tie<span class="text-primary-600 dark:text-primary-400">Measure</span>Flow</span>
{% endif %}
</span>
@@ -1,19 +1,60 @@
<!-- TieMeasureFlow Navbar --> <!-- TieMeasureFlow Navbar -->
{# SVG path data for nav icons, shared between desktop and mobile menus #}
{% set icon_measure %}<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>{% endset %}
{% set icon_recipes %}<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>{% endset %}
{% set icon_stats %}<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>{% endset %}
{% set icon_users %}<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>{% endset %}
{% set icon_stations %}<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>{% endset %}
{% set icon_settings %}<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>{% endset %}
{# Single nav link, desktop or mobile variant. `icon` is SVG path markup. #}
{% macro nav_link(href, label, icon, active=false, mobile=false) -%}
<a href="{{ href }}"
{% if active %}aria-current="page"{% endif %}
class="flex items-center {{ 'gap-3 px-3 py-2.5' if mobile else 'gap-2 px-3 py-2' }} rounded-lg text-sm font-medium transition-colors duration-200
{% if active %}text-primary bg-primary-50 dark:bg-primary-900/20{% else %}text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20{% endif %}">
<svg class="{{ 'w-5 h-5' if mobile else 'w-[18px] h-[18px]' }} shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">{{ icon }}</svg>
<span>{{ label }}</span>
</a>
{%- endmacro %}
{# Render all role-gated nav links for the current user #}
{% macro nav_links(mobile=false) -%}
{% set ep = request.endpoint or '' %}
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
{{ nav_link(url_for('measure.select_recipe'), _('Misure'), icon_measure, ep.startswith('measure.'), mobile) }}
{% endif %}
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
{{ nav_link(url_for('maker.recipe_list'), _('Ricette'), icon_recipes, ep.startswith('maker.'), mobile) }}
{% endif %}
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
{% endif %}
{% if current_user.get('is_admin') %}
{{ nav_link(url_for('admin.user_list'), _('Utenti'), icon_users, ep == 'admin.user_list', mobile) }}
{{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_list', mobile) }}
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
{% endif %}
{%- endmacro %}
<nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300" <nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300"
x-data="{ mobileOpen: false, userDropdown: false }"> x-data="{ mobileOpen: false }">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14"> <div class="flex items-center justify-between h-14">
<!-- Left: Logo --> <!-- Left: Logo -->
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<a href="{{ url_for('index') }}" class="flex items-center gap-2.5 group"> <a href="{{ url_for('index') }}"
class="flex items-center gap-2.5 group text-slate-900 dark:text-white">
{% if company_logo %} {% if company_logo %}
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-8 w-auto" <img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-10 w-auto"
onerror="this.src='{{ url_for('static', filename='img/tmflow-logo.svg') }}'"> onerror="this.style.display='none'">
{% else %} {% else %}
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}" {% set logo_class = 'h-9 w-9' %}
alt="TieMeasureFlow" {% set wordmark_class = 'text-xl' %}
class="h-8 w-auto"> {% set logo_id = 'nav' %}
{% include 'components/_app_logo.html' %}
{% endif %} {% endif %}
</a> </a>
</div> </div>
@@ -21,89 +62,7 @@
<!-- Center: Navigation Links (Desktop) --> <!-- Center: Navigation Links (Desktop) -->
{% if current_user %} {% if current_user %}
<div class="hidden md:flex items-center gap-1"> <div class="hidden md:flex items-center gap-1">
{{ nav_links() }}
{# MeasurementTec: Misure #}
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
<a href="{{ url_for('measure.select_recipe') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('measure.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Clipboard Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
<span>{{ _('Misure') }}</span>
</a>
{% endif %}
{# Maker: Ricette #}
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
<a href="{{ url_for('maker.recipe_list') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('maker.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Edit/Pencil Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
<span>{{ _('Ricette') }}</span>
</a>
{% endif %}
{# Metrologist: Statistiche #}
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
<a href="{{ url_for('statistics.dashboard') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint and request.endpoint.startswith('statistics.') %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Chart Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<span>{{ _('Statistiche') }}</span>
</a>
{% endif %}
{# Admin: Utenti #}
{% if current_user.get('is_admin') %}
<a href="{{ url_for('admin.user_list') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint == 'admin.user_list' %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Users Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
<span>{{ _('Utenti') }}</span>
</a>
<a href="{{ url_for('admin.station_list') }}"
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors duration-200
{% if request.endpoint == 'admin.station_list' %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<!-- Station / Workstation Icon -->
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
</svg>
<span>{{ _('Stazioni') }}</span>
</a>
{% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -112,24 +71,17 @@
<!-- Language Toggle --> <!-- Language Toggle -->
<div class="flex items-center border border-[var(--border-color)] rounded-lg overflow-hidden"> <div class="flex items-center border border-[var(--border-color)] rounded-lg overflow-hidden">
<a href="{{ url_for('set_language', lang='it') }}" {% for lang_code in ['it', 'en'] %}
<a href="{{ url_for('set_language', lang=lang_code) }}"
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200 class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
{% if current_language == 'it' %} {% if current_language == lang_code %}
bg-primary text-white bg-primary text-white
{% else %} {% else %}
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
{% endif %}"> {% endif %}">
IT {{ lang_code|upper }}
</a>
<a href="{{ url_for('set_language', lang='en') }}"
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
{% if current_language == 'en' %}
bg-primary text-white
{% else %}
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
{% endif %}">
EN
</a> </a>
{% endfor %}
</div> </div>
<!-- Theme Toggle --> <!-- Theme Toggle -->
@@ -199,6 +151,15 @@
<div class="border-t border-[var(--border-color)] my-1"></div> <div class="border-t border-[var(--border-color)] my-1"></div>
{% if request.endpoint == 'measure.task_execute' %}
<span class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-muted)] opacity-50 cursor-not-allowed"
title="{{ _('Logout bloccato durante le misurazioni') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
{{ _('Logout') }}
</span>
{% else %}
<a href="{{ url_for('auth.logout') }}" <a href="{{ url_for('auth.logout') }}"
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail
hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"> hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
@@ -207,6 +168,7 @@
</svg> </svg>
{{ _('Logout') }} {{ _('Logout') }}
</a> </a>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -241,66 +203,7 @@
x-cloak x-cloak
class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]"> class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]">
<div class="px-4 py-3 space-y-1"> <div class="px-4 py-3 space-y-1">
{{ nav_links(mobile=true) }}
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
<a href="{{ url_for('measure.select_recipe') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
{{ _('Misure') }}
</a>
{% endif %}
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
<a href="{{ url_for('maker.recipe_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
{{ _('Ricette') }}
</a>
{% endif %}
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
<a href="{{ url_for('statistics.dashboard') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
{{ _('Statistiche') }}
</a>
{% endif %}
{# Admin: Utenti + Stazioni #}
{% if current_user.get('is_admin') %}
<a href="{{ url_for('admin.user_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
</svg>
{{ _('Utenti') }}
</a>
<a href="{{ url_for('admin.station_list') }}"
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
</svg>
{{ _('Stazioni') }}
</a>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -231,6 +231,23 @@
rows="3" rows="3"
placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea> placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea>
</div> </div>
<!-- Intervallo di misura -->
<div>
<label class="tmf-label" for="recipe-interval">
{{ _('Intervallo misura (minuti)') }}
</label>
<input id="recipe-interval"
type="number"
x-model.number="measurementIntervalMinutes"
class="tmf-input"
min="1"
max="1440"
placeholder="{{ _('Es. 30') }}">
<p class="text-xs text-[var(--text-muted)] mt-1">
{{ _('Timer cicalino per ricordare la misurazione periodica') }}
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -466,6 +483,9 @@ function recipeEditor() {
versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }}, versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }},
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }}, currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
// ---- Measurement interval ----
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
// ---- File upload (preview image) ---- // ---- File upload (preview image) ----
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }}, currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
uploadingFile: false, uploadingFile: false,
@@ -487,6 +507,11 @@ function recipeEditor() {
description: this.description.trim(), description: this.description.trim(),
}; };
// Measurement interval
if (this.measurementIntervalMinutes) {
payload.measurement_interval_minutes = parseInt(this.measurementIntervalMinutes, 10);
}
// Include image_path for preview thumbnail // Include image_path for preview thumbnail
if (this.currentFilePath) { if (this.currentFilePath) {
payload.image_path = this.currentFilePath; payload.image_path = this.currentFilePath;
@@ -120,7 +120,7 @@
</div> </div>
<input type="text" <input type="text"
x-model="search" x-model="search"
class="tmf-input pl-10" class="tmf-input !pl-10"
:placeholder="'{{ _('Cerca per nome, codice o descrizione...') }}'"> :placeholder="'{{ _('Cerca per nome, codice o descrizione...') }}'">
</div> </div>
</div> </div>
@@ -152,11 +152,11 @@
<div class="p-5 sm:p-6"> <div class="p-5 sm:p-6">
<!-- Card Header --> <!-- Card Header -->
<div class="flex flex-col sm:flex-row sm:items-start gap-4 mb-4"> <div class="flex flex-col sm:flex-row sm:items-start gap-4 mb-4">
<!-- Thumbnail --> <!-- Thumbnail (fit, no crop) -->
<template x-if="recipe.image_path"> <template x-if="recipe.image_path">
<div class="w-20 h-20 rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0"> <div class="w-20 h-20 rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0 flex items-center justify-center">
<img :src="'/maker/api/files/' + recipe.image_path" <img :src="'/maker/api/files/' + recipe.image_path"
class="w-full h-full object-cover" class="max-w-full max-h-full object-contain"
loading="lazy" loading="lazy"
onerror="this.parentElement.style.display='none'"> onerror="this.parentElement.style.display='none'">
</div> </div>
@@ -247,6 +247,18 @@
<span class="hidden sm:inline">{{ _('Rettangolo') }}</span> <span class="hidden sm:inline">{{ _('Rettangolo') }}</span>
</button> </button>
<!-- Text Tool -->
<button type="button"
class="anno-btn"
:class="{ 'active': annoTool === 'text' }"
@click="setAnnoTool('text')"
title="{{ _('Testo') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 6v2m16-2v2M7 6v12m0 0h2m-2 0H5m12-12v12m0 0h2m-2 0h-2"/>
</svg>
<span class="hidden sm:inline">{{ _('Testo') }}</span>
</button>
<!-- Separator --> <!-- Separator -->
<div class="w-px h-6 bg-[var(--border-color)] mx-1"></div> <div class="w-px h-6 bg-[var(--border-color)] mx-1"></div>
@@ -221,14 +221,24 @@
<span x-text="tasks.length"></span> <span x-text="tasks.length"></span>
<span x-text="tasks.length === 1 ? '{{ _('task') }}' : '{{ _('task') }}'"></span> <span x-text="tasks.length === 1 ? '{{ _('task') }}' : '{{ _('task') }}'"></span>
</p> </p>
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())" <div class="flex items-center gap-2">
x-show="!showAddTask" <button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
class="btn btn-primary gap-1.5"> x-show="!showAddTask"
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> class="btn btn-primary gap-1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/> <svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
</svg> <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
{{ _('Aggiungi Task') }} </svg>
</button> {{ _('Aggiungi Task') }}
</button>
<button @click="showAiImport = true"
x-show="!showAddTask"
class="btn btn-secondary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _('Importa da PDF') }}
</button>
</div>
</div> </div>
<!-- ============================================================ <!-- ============================================================
@@ -269,12 +279,12 @@
placeholder="{{ _('Es. Seguire procedura ISO 2768') }}"> placeholder="{{ _('Es. Seguire procedura ISO 2768') }}">
</div> </div>
<!-- Descrizione --> <!-- Descrizione -->
<div> <div class="sm:col-span-2">
<label class="tmf-label">{{ _('Descrizione') }}</label> <label class="tmf-label">{{ _('Descrizione') }}</label>
<input type="text" <textarea x-model="newTask.description"
x-model="newTask.description" class="tmf-input text-sm"
class="tmf-input" rows="3"
placeholder="{{ _('Descrizione opzionale...') }}"> placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
</div> </div>
</div> </div>
</div> </div>
@@ -497,7 +507,7 @@
<template x-if="task.description"> <template x-if="task.description">
<div class="flex items-start gap-2"> <div class="flex items-start gap-2">
<span class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide shrink-0 mt-0.5">{{ _('Descrizione') }}:</span> <span class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide shrink-0 mt-0.5">{{ _('Descrizione') }}:</span>
<span class="text-sm text-[var(--text-secondary)]" x-text="task.description"></span> <span class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap" x-text="task.description"></span>
</div> </div>
</template> </template>
</div> </div>
@@ -513,12 +523,12 @@
class="tmf-input text-sm" class="tmf-input text-sm"
placeholder="{{ _('Direttiva opzionale...') }}"> placeholder="{{ _('Direttiva opzionale...') }}">
</div> </div>
<div> <div class="sm:col-span-2">
<label class="tmf-label">{{ _('Descrizione') }}</label> <label class="tmf-label">{{ _('Descrizione') }}</label>
<input type="text" <textarea x-model="editTaskData.description"
x-model="editTaskData.description" class="tmf-input text-sm"
class="tmf-input text-sm" rows="3"
placeholder="{{ _('Descrizione opzionale...') }}"> placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
</div> </div>
</div> </div>
</template> </template>
@@ -930,6 +940,118 @@
</div> </div>
</div> </div>
{# ================================================================
AI IMPORT MODAL — Upload PDF + preview suggested tasks
================================================================ #}
<div x-show="showAiImport"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click.self="showAiImport = false">
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl max-w-2xl w-full mx-4 max-h-[85vh] flex flex-col border border-[var(--border-color)]">
{# Header #}
<div class="p-5 border-b border-[var(--border-color)] shrink-0">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-[var(--text-primary)]">{{ _('Importa da Scheda Tecnica') }}</h3>
<p class="text-xs text-[var(--text-secondary)]">{{ _('Carica un PDF e l\'AI estrarrà i task automaticamente') }}</p>
</div>
</div>
</div>
{# Body #}
<div class="p-5 overflow-y-auto flex-1">
{# Upload area (before analysis) #}
<div x-show="!aiSuggestions.length && !aiParsing">
<label class="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-[var(--border-color)] rounded-xl cursor-pointer
hover:border-primary hover:bg-primary-50/50 dark:hover:bg-primary-900/10 transition-colors">
<svg class="w-10 h-10 text-[var(--text-muted)] mb-2" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
</svg>
<span class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Clicca per caricare un PDF') }}</span>
<span class="text-xs text-[var(--text-muted)] mt-1">{{ _('Max 20MB') }}</span>
<input type="file" accept=".pdf,application/pdf" class="hidden" @change="uploadAndParse($event)">
</label>
</div>
{# Loading #}
<div x-show="aiParsing" class="flex flex-col items-center justify-center py-12">
<svg class="w-10 h-10 animate-spin text-primary mb-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
<p class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Analisi in corso con AI...') }}</p>
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Potrebbe richiedere fino a 30 secondi') }}</p>
</div>
{# Error #}
<div x-show="aiError" class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
<p class="text-sm text-red-700 dark:text-red-300" x-text="aiError"></p>
<button @click="aiError = ''; aiSuggestions = []" class="text-xs text-red-600 underline mt-1">{{ _('Riprova') }}</button>
</div>
{# Suggested tasks (editable preview) #}
<div x-show="aiSuggestions.length > 0" class="space-y-3">
<p class="text-sm font-medium text-[var(--text-secondary)] mb-2">
<span x-text="aiSuggestions.length"></span> {{ _('task suggeriti — modifica o rimuovi prima di confermare') }}
</p>
<template x-for="(suggestion, idx) in aiSuggestions" :key="idx">
<div class="tmf-card">
<div class="p-4 space-y-2">
<div class="flex items-center justify-between gap-2">
<span class="text-xs font-bold text-primary">Task <span x-text="idx + 1"></span></span>
<button @click="aiSuggestions.splice(idx, 1)" class="text-xs text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
<input type="text" x-model="suggestion.title" class="tmf-input text-sm font-semibold"
placeholder="{{ _('Titolo') }}">
<input type="text" x-model="suggestion.directive" class="tmf-input text-sm"
placeholder="{{ _('Direttiva') }}">
<textarea x-model="suggestion.description" class="tmf-input text-sm" rows="3"
placeholder="{{ _('Descrizione') }}"></textarea>
</div>
</div>
</template>
</div>
</div>
{# Footer #}
<div class="p-4 border-t border-[var(--border-color)] shrink-0 flex items-center justify-between gap-2">
<button @click="showAiImport = false; aiSuggestions = []; aiError = ''"
class="btn btn-secondary text-sm">
{{ _('Annulla') }}
</button>
<button x-show="aiSuggestions.length > 0"
@click="createTasksFromSuggestions()"
:disabled="aiCreating"
class="btn btn-primary text-sm gap-1.5">
<svg x-show="!aiCreating" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<svg x-show="aiCreating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
{{ _('Crea') }} <span x-text="aiSuggestions.length"></span> {{ _('task') }}
</button>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -983,6 +1105,13 @@ function taskEditor() {
// ---- Drag & Drop state ---- // ---- Drag & Drop state ----
dragState: { dragging: null, over: null, position: null }, dragState: { dragging: null, over: null, position: null },
// ---- AI Import ----
showAiImport: false,
aiParsing: false,
aiCreating: false,
aiSuggestions: [],
aiError: '',
// ============================================================ // ============================================================
// Init // Init
// ============================================================ // ============================================================
@@ -1081,6 +1210,80 @@ function taskEditor() {
this.newTask = { title: '', directive: '', description: '' }; this.newTask = { title: '', directive: '', description: '' };
}, },
// ============================================================
// AI Import
// ============================================================
async uploadAndParse(event) {
var file = event.target.files[0];
if (!file) return;
this.aiParsing = true;
this.aiError = '';
this.aiSuggestions = [];
try {
var formData = new FormData();
formData.append('file', file);
var resp = await fetch('/maker/api/recipes/' + this.recipeId + '/parse-technical-sheet', {
method: 'POST',
headers: { 'X-CSRFToken': this.csrfToken() },
body: formData
});
var data = await resp.json();
if (!resp.ok || data.error) {
this.aiError = data.detail || {{ _("Errore nell'analisi del PDF")|tojson }};
} else {
this.aiSuggestions = data.suggested_tasks || [];
if (!this.aiSuggestions.length) {
this.aiError = {{ _("Nessun task identificato nel PDF")|tojson }};
}
}
} catch (err) {
console.error('AI parse error:', err);
this.aiError = {{ _("Errore di connessione al server")|tojson }};
}
this.aiParsing = false;
event.target.value = '';
},
async createTasksFromSuggestions() {
this.aiCreating = true;
var created = 0;
for (var i = 0; i < this.aiSuggestions.length; i++) {
var s = this.aiSuggestions[i];
if (!s.title || !s.title.trim()) continue;
try {
var resp = await fetch('/maker/api/recipes/' + this.recipeId + '/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken() },
body: JSON.stringify({
title: s.title.trim(),
directive: (s.directive || '').trim() || null,
description: (s.description || '').trim() || null
})
});
var data = await resp.json();
if (!data.error) {
if (!data.subtasks) data.subtasks = [];
this.tasks.push(data);
created++;
}
} catch (err) {
console.error('Create task error:', err);
}
}
this.aiCreating = false;
this.showAiImport = false;
this.aiSuggestions = [];
if (created > 0) {
this.successMessage = created + ' ' + {{ _("task creati dalla scheda tecnica")|tojson }};
}
},
async addTask() { async addTask() {
if (!this.newTask.title.trim()) return; if (!this.newTask.title.trim()) return;
this.saving = true; this.saving = true;
@@ -92,40 +92,61 @@
</div> </div>
</div> </div>
<!-- Search + Traceability Bar --> <!-- Search Bar (filter) -->
<div class="tmf-card mb-6"> <div class="tmf-card mb-4">
<div class="p-4 sm:p-5"> <div class="p-4 sm:p-5">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> <label class="tmf-label">
<!-- Search Input --> <svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<div class="sm:col-span-2"> <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
<label class="tmf-label"> </svg>
<svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> {{ _('Cerca ricetta') }}
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> </label>
</svg> <input type="text"
{{ _('Cerca ricetta') }} x-model="search"
</label> placeholder="{{ _('Nome, codice o descrizione...') }}"
<input type="text" class="tmf-input">
x-model="search" </div>
placeholder="{{ _('Nome, codice o descrizione...') }}" </div>
class="tmf-input">
</div>
<!-- Traceability (data entry, NOT a search filter) -->
<div class="tmf-card mb-6 border-l-4 border-l-primary">
<div class="p-4 sm:p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
<div>
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}</h2>
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Lot Number --> <!-- Lot Number -->
<div> <div>
<label class="tmf-label">{{ _('Numero Lotto') }}</label> <label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-amber-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
</svg>
{{ _('Numero Lotto') }}
</label>
<input type="text" <input type="text"
x-model="lot_number" x-model="lot_number"
placeholder="{{ _('Opzionale') }}" placeholder="{{ _('Es. LOT-2026-001 (opzionale)') }}"
class="tmf-input"> class="tmf-input font-mono">
</div> </div>
<!-- Serial Number --> <!-- Serial Number -->
<div> <div>
<label class="tmf-label">{{ _('Numero Seriale') }}</label> <label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
{{ _('Numero Seriale') }}
</label>
<input type="text" <input type="text"
x-model="serial_number" x-model="serial_number"
placeholder="{{ _('Opzionale') }}" placeholder="{{ _('Es. SN-000123 (opzionale)') }}"
class="tmf-input"> class="tmf-input font-mono">
</div> </div>
</div> </div>
</div> </div>
@@ -157,15 +178,30 @@
</span> </span>
</div> </div>
<!-- Recipe Name --> <!-- Title + Thumbnail row -->
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2 leading-snug" <div class="flex items-start gap-3 mb-2">
x-text="recipe.name"> <!-- Thumbnail (fit, no crop) -->
</h3> <template x-if="recipe.image_path">
<div class="w-16 h-16 rounded-md overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0 flex items-center justify-center">
<img :src="'/measure/api/files/' + recipe.image_path"
class="max-w-full max-h-full object-contain"
loading="lazy"
onerror="this.parentElement.style.display='none'">
</div>
</template>
<!-- Description --> <div class="min-w-0 flex-1">
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed" <!-- Recipe Name -->
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'"> <h3 class="text-lg font-semibold text-[var(--text-primary)] leading-snug"
</p> x-text="recipe.name">
</h3>
<!-- Description -->
<p class="mt-1 text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'">
</p>
</div>
</div>
<!-- Meta Info --> <!-- Meta Info -->
<div class="mt-4 flex items-center gap-3 text-xs text-[var(--text-muted)]"> <div class="mt-4 flex items-center gap-3 text-xs text-[var(--text-muted)]">
@@ -1,6 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %} {% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
{# Full-screen layout: no body scroll, no footer, main fills available space #}
{% block body_class %}h-screen overflow-hidden{% endblock %}
{% block wrapper_class %}h-full{% endblock %}
{% block main_class %}flex-1 min-h-0{% endblock %}
{% block footer %}{% endblock %}
{% block extra_head %} {% block extra_head %}
<style> <style>
/* Annotation viewer container */ /* Annotation viewer container */
@@ -57,7 +63,7 @@
window.__allTaskIds = {{ all_task_ids|tojson }}; window.__allTaskIds = {{ all_task_ids|tojson }};
</script> </script>
<div class="h-screen flex flex-col overflow-hidden" <div class="h-full flex flex-col overflow-hidden"
x-data="taskExecute()" x-data="taskExecute()"
x-init="init()" x-init="init()"
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)" @numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
@@ -78,7 +84,7 @@
</a> </a>
{# Task badge + title #} {# Task badge + title #}
<div class="flex items-center gap-2 min-w-0 flex-1"> <div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold <span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
border border-primary-200 dark:border-primary-800"> border border-primary-200 dark:border-primary-800">
@@ -89,6 +95,45 @@
</h1> </h1>
</div> </div>
{# Lista task + Riepilogo buttons #}
<div class="shrink-0 flex items-center gap-1.5">
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
{{ _('Lista task') }}
</a>
<button @click="goToSummary()"
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _('Riepilogo') }}
</button>
</div>
{# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="subtasks.length > 0">
<div class="shrink-0 flex items-center gap-1.5">
<button @click="openSupervisorModal('fermo_linea')"
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-amber-500 text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Fermo linea') }}
</button>
<button @click="openSupervisorModal('fine_produzione')"
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-red-500 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
</svg>
{{ _('Fine Produzione') }}
</button>
</div>
</template>
{# Lot + Serial badges #} {# Lot + Serial badges #}
<div class="shrink-0 flex items-center gap-2"> <div class="shrink-0 flex items-center gap-2">
{% if lot_number %} {% if lot_number %}
@@ -124,9 +169,10 @@
<div class="flex-1 flex overflow-hidden"> <div class="flex-1 flex overflow-hidden">
{# ────────────────────────────────────────────── {# ──────────────────────────────────────────────
LEFT SIDEBAR — Marker list (vertical) LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
────────────────────────────────────────────── #} ────────────────────────────────────────────── #}
<div class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto"> <div x-show="subtasks.length > 0"
class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
<template x-for="(st, idx) in subtasks" :key="st.id"> <template x-for="(st, idx) in subtasks" :key="st.id">
<button @click="goToSubtask(idx)" <button @click="goToSubtask(idx)"
class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200" class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
@@ -213,9 +259,10 @@
</div> </div>
{# ────────────────────────────────────────────── {# ──────────────────────────────────────────────
RIGHT PANEL — Info + tolerances + numpad RIGHT PANEL — Info + tolerances + numpad (hidden when no subtasks)
────────────────────────────────────────────── #} ────────────────────────────────────────────── #}
<div class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto"> <div x-show="subtasks.length > 0"
class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
{# ---- Subtask header ---- #} {# ---- Subtask header ---- #}
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask"> <div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
@@ -310,7 +357,7 @@
</div> </div>
{# ---- Measurement feedback ---- #} {# ---- Measurement feedback ---- #}
<div class="px-3 pt-2" x-data="{ get nominal() { return currentSubtask?.nominal || 0; }, <div class="px-3 pt-2" x-show="currentSubtask" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
get utl() { return currentSubtask?.utl || 0; }, get utl() { return currentSubtask?.utl || 0; },
get uwl() { return currentSubtask?.uwl || 0; }, get uwl() { return currentSubtask?.uwl || 0; },
get lwl() { return currentSubtask?.lwl || 0; }, get lwl() { return currentSubtask?.lwl || 0; },
@@ -319,8 +366,8 @@
{% include "components/measurement_feedback.html" %} {% include "components/measurement_feedback.html" %}
</div> </div>
{# ---- Numpad ---- #} {# ---- Numpad (only when a subtask is active) ---- #}
<div class="px-3 py-2 relative flex-1"> <div class="px-3 py-2 relative flex-1" x-show="currentSubtask">
{# Saving overlay #} {# Saving overlay #}
<div x-show="saving" <div x-show="saving"
x-transition x-transition
@@ -338,10 +385,11 @@
</div> </div>
{# ---- Next measurement indicator ---- #} {# ---- Next measurement indicator ---- #}
<div class="px-3 pb-2"> <div class="px-3 pb-2" x-show="currentSubtask">
{% include "components/next_measurement.html" %} {% include "components/next_measurement.html" %}
</div> </div>
{# ---- Error message ---- #} {# ---- Error message ---- #}
<div x-show="errorMessage" <div x-show="errorMessage"
x-transition x-transition
@@ -358,6 +406,62 @@
</div> </div>
{# ================================================================
MEASUREMENT TIMER BANNER
================================================================ #}
<div x-show="timerActive"
x-transition
x-cloak
class="shrink-0 bg-amber-50 dark:bg-amber-900/20 border-t border-amber-300 dark:border-amber-700 px-4 py-2">
<div class="flex items-center justify-center gap-3">
<svg class="w-5 h-5 text-amber-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span class="text-sm font-medium text-amber-800 dark:text-amber-200">
{{ _('Prossima misurazione tra') }}
</span>
<span class="text-lg font-bold font-mono text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 rounded"
x-text="timerDisplay"></span>
<span class="text-xs text-amber-600 dark:text-amber-400">
({{ _('Ciclo') }} #<span x-text="cycleCount"></span>)
</span>
</div>
</div>
{# ================================================================
AVVIO PRODUZIONE — visible after first cycle, before production started
================================================================ #}
<div x-show="cycleConfirmed && cycleCount === 1 && !productionStarted && subtasks.length > 0"
x-transition
x-cloak
class="shrink-0 border-t border-[var(--border-color)] bg-[var(--bg-card)] px-4 py-3">
<div class="flex items-center justify-center">
<button @click="startProduction()"
class="btn gap-2 px-6 py-2.5 text-sm font-bold
bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg hover:shadow-xl
rounded-xl transition-all duration-200">
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg>
{{ _('Avvio Produzione') }}
</button>
</div>
<p class="text-xs text-center text-[var(--text-muted)] mt-1.5">
{{ _('Invia segnale al gestionale per avviare il timer della linea') }}
</p>
</div>
<div x-show="productionStarted && subtasks.length > 0"
x-transition
x-cloak
class="shrink-0 border-t border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20 px-4 py-1.5">
<div class="flex items-center justify-center gap-2 text-xs text-emerald-700 dark:text-emerald-300">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<span class="font-medium">{{ _('Produzione avviata') }}</span>
</div>
</div>
{# ================================================================ {# ================================================================
FOOTER — Progress bar + navigation FOOTER — Progress bar + navigation
================================================================ #} ================================================================ #}
@@ -388,19 +492,47 @@
x-text="Math.round(progressPercent) + '%'"></span> x-text="Math.round(progressPercent) + '%'"></span>
</div> </div>
{# Right: Summary button #} {# Right: Fine ciclo misura (measurement tasks) / Completato (non-measurement or after cycle) #}
<button x-show="isComplete" <template x-if="subtasks.length > 0">
x-transition <div class="flex items-center gap-1.5">
@click="goToSummary()" <button x-show="isComplete && !cycleConfirmed"
class="btn btn-primary text-xs shrink-0 gap-1 py-1.5 px-2.5 shadow-md"> x-transition
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> @click="confirmCycle()"
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/> class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
</svg> bg-primary text-white hover:bg-primary-700">
{{ _('Riepilogo') }} <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/> </svg>
</svg> {{ _('Fine ciclo misura') }}
</button> </button>
<button x-show="cycleConfirmed"
x-transition
@click="goToNextTask()"
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
bg-measure-pass text-white hover:opacity-90">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
</div>
</template>
<template x-if="subtasks.length === 0">
<button @click="goToNextTask()"
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
bg-measure-pass text-white hover:opacity-90">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
</template>
</div> </div>
</div> </div>
@@ -447,13 +579,100 @@
</div> </div>
</div> </div>
<button @click="goToSummary()" <div class="flex gap-2">
class="btn btn-primary w-full justify-center gap-2"> <button @click="goToSummary()"
{{ _('Vai al Riepilogo') }} class="btn btn-secondary flex-1 justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> {{ _('Riepilogo') }}
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/> </button>
</svg>
</button> {# Production phase: confirm the cycle (starts the interval timer) #}
<button x-show="productionStarted"
@click="showCompletionOverlay = false; cycleConfirmed = true"
class="btn btn-primary flex-1 justify-center gap-2">
{{ _('Conferma ciclo') }}
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
</button>
{# START phase, more tasks to go: advance without blocking or exiting #}
<button x-show="!productionStarted && !isLastTask"
@click="goToNextTask()"
class="btn btn-primary flex-1 justify-center gap-2">
{{ _('Task successivo') }}
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg>
</button>
{# START phase, last task: start production #}
<button x-show="!productionStarted && isLastTask"
@click="startProductionFromOverlay()"
class="btn flex-1 justify-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white">
{{ _('Avvio Produzione') }}
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg>
</button>
</div>
</div>
</div>
{# ================================================================
SUPERVISOR LOGIN MODAL
================================================================ #}
<div x-show="showSupervisorModal"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click.self="closeSupervisorModal()">
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-6 max-w-sm mx-4 w-full border border-[var(--border-color)]">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-[var(--text-primary)]">{{ _('Autorizzazione capoturno') }}</h3>
<p class="text-xs text-[var(--text-secondary)]" x-text="supervisorReason"></p>
</div>
</div>
<div class="space-y-3 mb-5">
<div>
<label class="tmf-label text-xs">{{ _('Username') }}</label>
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
placeholder="{{ _('Username capoturno') }}" @keydown.enter="validateSupervisor()">
</div>
<div>
<label class="tmf-label text-xs">{{ _('Password') }}</label>
<input type="password" x-model="supervisorPassword" class="tmf-input text-sm"
placeholder="••••••••" @keydown.enter="validateSupervisor()">
</div>
<p x-show="supervisorError" class="text-xs text-red-600" x-text="supervisorError"></p>
</div>
<div class="flex gap-2">
<button @click="closeSupervisorModal()"
class="btn btn-secondary flex-1 text-sm">
{{ _('Annulla') }}
</button>
<button @click="validateSupervisor()"
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
class="btn btn-primary flex-1 text-sm gap-1">
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
{{ _('Autorizza') }}
</button>
</div>
</div> </div>
</div> </div>
@@ -489,6 +708,24 @@ function taskExecute() {
errorMessage: '', errorMessage: '',
showCompletionOverlay: false, showCompletionOverlay: false,
// ---- Measurement timer ----
measurementIntervalMinutes: {{ measurement_interval_minutes|tojson if measurement_interval_minutes else 'null' }},
timerActive: false,
timerRemaining: 0,
_timerInterval: null,
cycleCount: 0,
productionStarted: false,
// ---- Cycle & workflow state ----
cycleConfirmed: false,
showSupervisorModal: false,
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
supervisorUsername: '',
supervisorPassword: '',
supervisorError: '',
supervisorValidating: false,
pendingAdvance: false,
// ---- Value from numpad / caliper ---- // ---- Value from numpad / caliper ----
currentValue: null, currentValue: null,
@@ -641,15 +878,24 @@ function taskExecute() {
// Pause to show result feedback // Pause to show result feedback
await new Promise(r => setTimeout(r, 1000)); await new Promise(r => setTimeout(r, 1000));
// Out-of-tolerance: block advancement, require supervisor
if (pf === 'fail') {
this.pendingAdvance = true;
this.openSupervisorModal('out_of_tolerance');
return;
}
// Check if all done // Check if all done
if (this.completedCount >= this.totalSubtasks) { if (this.completedCount >= this.totalSubtasks) {
const taskIds = window.__allTaskIds || []; // START run (production not yet started): don't block with the
const currentIdx = taskIds.indexOf(this.task.id); // "Misurazioni Complete" overlay — just advance to the next task.
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) { // The overlay is shown only on the last task (to offer "Avvio
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]); // Produzione") or once production is running ("Conferma ciclo").
} else { if (!this.productionStarted && !this.isLastTask) {
this.showCompletionOverlay = true; this.goToNextTask();
return;
} }
this.showCompletionOverlay = true;
return; return;
} }
@@ -696,6 +942,177 @@ function taskExecute() {
} }
}, },
// ---- Confirm measurement cycle (Fine ciclo misura) ----
confirmCycle() {
this.cycleConfirmed = true;
this.showCompletionOverlay = false;
this.cycleCount++;
// Start measurement timer if recipe has an interval
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
this.startMeasurementTimer();
}
},
// ---- Measurement timer ----
startMeasurementTimer() {
this.stopMeasurementTimer();
this.timerRemaining = this.measurementIntervalMinutes * 60;
this.timerActive = true;
var self = this;
this._timerInterval = setInterval(function () {
self.timerRemaining--;
if (self.timerRemaining <= 0) {
self.onTimerExpired();
}
}, 1000);
},
stopMeasurementTimer() {
if (this._timerInterval) {
clearInterval(this._timerInterval);
this._timerInterval = null;
}
this.timerActive = false;
},
onTimerExpired() {
this.stopMeasurementTimer();
this.playBuzzer();
// Reset for new measurement cycle
this.cycleConfirmed = false;
this.measurements = [];
this.currentIndex = 0;
this.currentValue = null;
},
playBuzzer() {
try {
var ctx = new (window.AudioContext || window.webkitAudioContext)();
// 3 short beeps
[0, 0.25, 0.5].forEach(function (delay) {
var osc = ctx.createOscillator();
var gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 880;
osc.type = 'square';
gain.gain.value = 0.3;
osc.start(ctx.currentTime + delay);
osc.stop(ctx.currentTime + delay + 0.15);
});
} catch (_) {}
},
// ---- Avvio Produzione (GAIA placeholder) ----
startProduction() {
this.productionStarted = true;
// TODO: integrazione GAIA — inviare segnale per avviare timer linea
// await fetch('/measure/api/gaia/start-production', { method: 'POST', ... });
},
// Is the current task the last one in the recipe sequence?
get isLastTask() {
const taskIds = window.__allTaskIds || [];
const idx = taskIds.indexOf(this.task.id);
return idx === -1 || idx >= taskIds.length - 1;
},
// Start production from the completion overlay (last START task) and begin
// the measurement-interval cycle if the recipe defines one.
startProductionFromOverlay() {
this.showCompletionOverlay = false;
this.startProduction();
this.cycleConfirmed = true;
this.cycleCount++;
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
this.startMeasurementTimer();
}
},
get timerDisplay() {
var m = Math.floor(this.timerRemaining / 60);
var s = this.timerRemaining % 60;
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
},
// ---- Navigate to next task (Completato) ----
goToNextTask() {
const taskIds = window.__allTaskIds || [];
const currentIdx = taskIds.indexOf(this.task.id);
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
} else {
this.goToSummary();
}
},
// ---- Open/close supervisor modal (always reset fields) ----
openSupervisorModal(action) {
this.supervisorAction = action;
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorError = '';
this.showSupervisorModal = true;
},
closeSupervisorModal() {
this.showSupervisorModal = false;
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorError = '';
},
// ---- Supervisor reason text ----
get supervisorReason() {
if (this.supervisorAction === 'out_of_tolerance') return '{{ _("Misurazione fuori tolleranza") }}';
if (this.supervisorAction === 'fermo_linea') return '{{ _("Fermo linea richiesto") }}';
if (this.supervisorAction === 'fine_produzione') return '{{ _("Fine produzione richiesta") }}';
return '';
},
// ---- Validate supervisor credentials ----
async validateSupervisor() {
this.supervisorError = '';
this.supervisorValidating = true;
try {
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
const resp = await fetch('{{ url_for("measure.validate_supervisor") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ username: this.supervisorUsername, password: this.supervisorPassword })
});
const data = await resp.json();
if (!resp.ok || data.error) {
this.supervisorError = data.detail || '{{ _("Credenziali non valide o utente non autorizzato") }}';
this.supervisorValidating = false;
return;
}
// Authorized — close modal and proceed
this.showSupervisorModal = false;
this.supervisorUsername = '';
this.supervisorPassword = '';
this.supervisorValidating = false;
if (this.supervisorAction === 'out_of_tolerance') {
this.pendingAdvance = false;
if (this.completedCount >= this.totalSubtasks) {
this.showCompletionOverlay = true;
} else {
this.advanceToNext();
}
}
// fermo_linea and fine_produzione are handled by GAIA integration (future)
} catch (err) {
this.supervisorError = '{{ _("Errore di connessione") }}';
this.supervisorValidating = false;
}
},
// ---- Go to summary ---- // ---- Go to summary ----
goToSummary() { goToSummary() {
const recipeId = this.task.recipe_id || 0; const recipeId = this.task.recipe_id || 0;
@@ -27,6 +27,17 @@
</ol> </ol>
</nav> </nav>
<!-- Top action: change recipe (easy to find) -->
<div class="mb-4">
<a href="{{ url_for('measure.select_recipe') }}"
class="btn btn-secondary gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
{{ _('Seleziona altra ricetta') }}
</a>
</div>
<!-- Recipe Info Card --> <!-- Recipe Info Card -->
<div class="tmf-card mb-8"> <div class="tmf-card mb-8">
<div class="p-5 sm:p-6"> <div class="p-5 sm:p-6">
@@ -60,8 +71,19 @@
{% endif %} {% endif %}
</div> </div>
<!-- Right: Traceability Badges --> <!-- Right: AVVIA + Traceability -->
<div class="flex flex-col gap-2 sm:items-end shrink-0"> <div class="flex flex-col gap-3 sm:items-end shrink-0">
{% if tasks %}
<a href="{{ url_for('measure.task_execute', task_id=tasks[0].id) }}"
class="btn gap-3 w-full sm:w-auto justify-center text-lg font-bold
bg-red-600 hover:bg-red-700 text-white shadow-lg hover:shadow-xl
px-8 py-3 rounded-xl transition-all duration-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg>
{{ _('AVVIA') }}
</a>
{% endif %}
{% if lot_number %} {% if lot_number %}
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm <div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
@@ -100,83 +122,77 @@
</h2> </h2>
<span class="badge badge-neutral"> <span class="badge badge-neutral">
{{ tasks|length }} task {{ tasks|length }} task
{%- set total_subs = namespace(count=0) -%}
{%- for t in tasks -%}
{%- if t.subtask_count is defined -%}
{%- set total_subs.count = total_subs.count + t.subtask_count -%}
{%- elif t.subtasks -%}
{%- set total_subs.count = total_subs.count + t.subtasks|length -%}
{%- endif -%}
{%- endfor -%}
{%- if total_subs.count > 0 %} &middot; {{ total_subs.count }} {{ _('misurazioni totali') }}{% endif %}
</span> </span>
</div> </div>
<!-- Task Cards --> <!-- Task Cards -->
{% if tasks %} {% if tasks %}
<div class="space-y-4"> <div class="space-y-2.5">
{% for task in tasks %} {% for task in tasks %}
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group"> <div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
<div class="p-5 sm:p-6"> <div class="p-3 sm:p-4">
<div class="flex flex-col sm:flex-row sm:items-center gap-4"> <div class="flex flex-row items-center gap-3">
<!-- Task Number Circle --> <!-- Task Number Circle -->
<div class="flex items-center justify-center w-12 h-12 rounded-full shrink-0 <div class="flex items-center justify-center w-9 h-9 rounded-full shrink-0
bg-primary-50 dark:bg-primary-900/30 border-2 border-primary-200 dark:border-primary-700 bg-primary-50 dark:bg-primary-900/30 border-2 border-primary-200 dark:border-primary-700
text-primary-700 dark:text-primary-300 font-bold text-lg"> text-primary-700 dark:text-primary-300 font-bold text-base">
{{ loop.index }} {{ loop.index }}
</div> </div>
<!-- Task Info --> <!-- Task Info -->
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<!-- Task Header -->
<div class="flex items-center gap-2 mb-1">
<span class="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">
Task {{ loop.index }} {{ _('di') }} {{ tasks|length }}
</span>
</div>
<!-- Task Title --> <!-- Task Title -->
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1"> <h3 class="text-base font-semibold text-[var(--text-primary)] leading-tight truncate">
{{ task.title or task.name or (_('Task') ~ ' ' ~ loop.index) }} {{ task.title or task.name or (_('Task') ~ ' ' ~ loop.index) }}
</h3> </h3>
<!-- Directive --> <!-- Directive -->
{% if task.directive or task.description %} {% if task.directive or task.description %}
<p class="text-sm text-[var(--text-secondary)] leading-relaxed mb-3 line-clamp-2"> <p class="text-xs text-[var(--text-secondary)] leading-snug mt-0.5 line-clamp-2">
{{ task.directive or task.description }} {{ task.directive or task.description }}
</p> </p>
{% endif %} {% endif %}
<!-- Meta Row -->
<div class="flex items-center gap-4 flex-wrap">
<!-- Subtask Count -->
{% if task.subtask_count is defined or task.subtasks %}
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<span class="font-medium">
{% if task.subtask_count is defined %}
{{ task.subtask_count }}
{% elif task.subtasks %}
{{ task.subtasks|length }}
{% endif %}
{{ _('misurazioni') }}
</span>
</span>
{% endif %}
<!-- File Attachment -->
{% if task.file_path %}
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
{% if task.file_path.endswith('.pdf') %}
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{% endif %}
<span class="font-medium">{{ _('Allegato') }}</span>
</span>
{% endif %}
</div>
</div> </div>
<!-- Action Button --> <!-- Indicators (top-right): measurements + attachment -->
<div class="shrink-0 self-start flex items-center gap-2.5">
{% if task.subtask_count is defined or task.subtasks %}
<span class="inline-flex items-center gap-1 text-xs text-[var(--text-secondary)]"
title="{{ _('misurazioni') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
<span class="font-medium font-mono">{% if task.subtask_count is defined %}{{ task.subtask_count }}{% elif task.subtasks %}{{ task.subtasks|length }}{% endif %}</span>
</span>
{% endif %}
{% if task.file_path %}
<span class="inline-flex items-center text-xs" title="{{ _('Allegato') }}">
{% if task.file_path.endswith('.pdf') %}
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
</svg>
{% else %}
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{% endif %}
</span>
{% endif %}
</div>
<!-- Action Button (Maker only — operators start the guided flow with AVVIA) -->
{% if current_user and 'Maker' in current_user.get('roles', []) %}
<div class="shrink-0 sm:ml-4"> <div class="shrink-0 sm:ml-4">
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}" <a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
class="btn btn-primary gap-2 w-full sm:w-auto justify-center class="btn btn-primary gap-2 w-full sm:w-auto justify-center
@@ -185,12 +201,13 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg> </svg>
{{ _('Inizia Misure') }} {{ _('Visualizza Task') }}
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/> <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
</svg> </svg>
</a> </a>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -215,33 +232,5 @@
</div> </div>
{% endif %} {% endif %}
<!-- Bottom Navigation -->
<div class="mt-8 flex items-center justify-between">
<a href="{{ url_for('measure.select_recipe') }}"
class="btn btn-secondary gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
{{ _('Seleziona altra ricetta') }}
</a>
{% if tasks %}
<div class="text-sm text-[var(--text-secondary)]">
{{ tasks|length }} task &middot;
{% set total_subs = namespace(count=0) %}
{% for t in tasks %}
{% if t.subtask_count is defined %}
{% set total_subs.count = total_subs.count + t.subtask_count %}
{% elif t.subtasks %}
{% set total_subs.count = total_subs.count + t.subtasks|length %}
{% endif %}
{% endfor %}
{% if total_subs.count > 0 %}
{{ total_subs.count }} {{ _('misurazioni totali') }}
{% endif %}
</div>
{% endif %}
</div>
</div> </div>
{% endblock %} {% endblock %}
+3 -5
View File
@@ -61,7 +61,7 @@ class TestSaveMeasurement:
mock_api_client.post.return_value = { mock_api_client.post.return_value = {
"id": 1, "id": 1,
"subtask_id": 10, "subtask_id": 10,
"task_id": 5, "version_id": 3,
"value": 9.95, "value": 9.95,
"pass_fail": "pass", "pass_fail": "pass",
} }
@@ -70,10 +70,8 @@ class TestSaveMeasurement:
"/measure/save-measurement", "/measure/save-measurement",
json={ json={
"subtask_id": 10, "subtask_id": 10,
"task_id": 5, "version_id": 3,
"value": 9.95, "value": 9.95,
"pass_fail": "pass",
"deviation": 0.05,
}, },
content_type="application/json", content_type="application/json",
) )
@@ -86,7 +84,7 @@ class TestSaveMeasurement:
"""Missing required fields return 400.""" """Missing required fields return 400."""
resp = logged_in_client.post( resp = logged_in_client.post(
"/measure/save-measurement", "/measure/save-measurement",
json={"subtask_id": 10}, # missing task_id and value json={"subtask_id": 10}, # missing version_id and value
content_type="application/json", content_type="application/json",
) )
assert resp.status_code == 400 assert resp.status_code == 400
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Generated
+119
View File
@@ -554,6 +554,65 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
] ]
[[package]]
name = "cryptography"
version = "48.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
{ url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" },
{ url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" },
{ url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" },
{ url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
]
[[package]] [[package]]
name = "cssselect2" name = "cssselect2"
version = "0.9.0" version = "0.9.0"
@@ -1063,6 +1122,33 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
] ]
[[package]]
name = "pdfminer-six"
version = "20251230"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "charset-normalizer" },
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" },
]
[[package]]
name = "pdfplumber"
version = "0.11.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pdfminer-six" },
{ name = "pillow" },
{ name = "pypdfium2" },
]
sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" },
]
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "12.2.0" version = "12.2.0"
@@ -1330,6 +1416,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
] ]
[[package]]
name = "pypdfium2"
version = "5.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/3d/dc934d3b606c51c3ecc95b6731d84b7dd7ab8e513a50b0e98a4da6c8a719/pypdfium2-5.8.0.tar.gz", hash = "sha256:049397c647e50f83115ee951c49394dab9e9ba52ebdd5a11ab1109390eb3d34e", size = 271934, upload-time = "2026-05-04T17:39:43.794Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/8c/6b75b923cb81368fa3ea7c48a0616b839620a3aeff899885bd930449b89e/pypdfium2-5.8.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:f67b6c74b716d9ac725ad1af49ae786ad813ac20823d45606d59f1fc06caa8af", size = 3374554, upload-time = "2026-05-04T17:39:05.552Z" },
{ url = "https://files.pythonhosted.org/packages/ef/61/a885c7f36efba89ec98e3d1fe95c83b48c2d6dea321e9194ac6460e7a834/pypdfium2-5.8.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:53e82bf3e6a2da170b1bda83f93b7eec57cb6efe3cacd05cba78823879a85203", size = 2831667, upload-time = "2026-05-04T17:39:08.028Z" },
{ url = "https://files.pythonhosted.org/packages/86/1f/04b5627f6dba312d3e707e5b019c9f24d8b03b5aa366866a9e02ec00f8d4/pypdfium2-5.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:085e633dcc89b65ff4035a4787e98ce7ae636836eb39c83dd0db26113d9774bc", size = 3450815, upload-time = "2026-05-04T17:39:09.551Z" },
{ url = "https://files.pythonhosted.org/packages/a9/77/8e3a2aba2bc4aef5abe1b1306d05b00588dc0bf7f5c850d1adf6164c786b/pypdfium2-5.8.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:bc84b7c6efede88fcfb9467f81daf416f26b973a54fc1cf4d3410d622fda6d7a", size = 3634395, upload-time = "2026-05-04T17:39:11.225Z" },
{ url = "https://files.pythonhosted.org/packages/93/11/6f2b1847d9fa457b3b7251afc2bba2706d104a0c6f01431dfae5d679a839/pypdfium2-5.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a63bf09b2e13ba8545c930d243f0650c664a1b51314daa3b5f38df6d1a17b4bc", size = 3617413, upload-time = "2026-05-04T17:39:13.139Z" },
{ url = "https://files.pythonhosted.org/packages/ed/fd/99ce639de5ca06d21743c740dd988cd209dda623bc763ae10b8a162022e1/pypdfium2-5.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:937881c1698456749ed203a58db1895baa5eb7178cdb837ef84867790638da28", size = 3347639, upload-time = "2026-05-04T17:39:15.086Z" },
{ url = "https://files.pythonhosted.org/packages/fa/47/82864cc6e26dd8969d5594c168635acb16458d35cf5fed65d6b2e32abb42/pypdfium2-5.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6be9dc2b84a8694ad7e626bab133244e8241014d5ed1930d865a9bdf90df1e24", size = 3746404, upload-time = "2026-05-04T17:39:17.094Z" },
{ url = "https://files.pythonhosted.org/packages/82/58/e41e49bba951f61921bac7289e67fe02af5ac57192d0bbfb5f459dc3691d/pypdfium2-5.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f27bd82891ae302dd02d736b14809661f6d1220ee1e96dbed9b23e2811922a3", size = 4177893, upload-time = "2026-05-04T17:39:18.729Z" },
{ url = "https://files.pythonhosted.org/packages/b4/15/fa7031010d5cf6853dadb4864680a0bfb7782c5bb6a1a401e0c25c4fca87/pypdfium2-5.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26c1089cdbbdc7fe1248f6d17fe3f30214be4f287dd0196b31aaee18a1564240", size = 3665152, upload-time = "2026-05-04T17:39:20.207Z" },
{ url = "https://files.pythonhosted.org/packages/de/6a/5a3520a8b0cfa8d7fdc3f03a07ad9d6146c28ffd519330706f64fd8939a8/pypdfium2-5.8.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c038a9290864aaa4862dd32e591993d82551ca4d152b4e8ce6d43ba37dc04a8", size = 3095365, upload-time = "2026-05-04T17:39:22.054Z" },
{ url = "https://files.pythonhosted.org/packages/32/d3/845bae4de3cfa36865959046156edb5bf9baea400ccdecdd84fdd911b0f5/pypdfium2-5.8.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f104bc1a6d8bfc1ff088aa50db13b9729cfdb3722b44975c3c457e9a7b9c7318", size = 2961801, upload-time = "2026-05-04T17:39:23.817Z" },
{ url = "https://files.pythonhosted.org/packages/99/76/cf54eabee4a172241dfcfe63533bd1e11e2162114a983453a5a40bfec114/pypdfium2-5.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:04ca7c57a553facf8d46c6ea8ba6fa557e698670cfa4a58e0e01fdae2f6be87d", size = 4133067, upload-time = "2026-05-04T17:39:25.619Z" },
{ url = "https://files.pythonhosted.org/packages/77/66/dcf871d19187ca04ea184a99801a6e7e556d8347aa49540fee33cda6dfc5/pypdfium2-5.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ad42b9c22477b32dbedcbc8232833f385d92fd0cf92822547b02383cf9a476d7", size = 3749100, upload-time = "2026-05-04T17:39:27.203Z" },
{ url = "https://files.pythonhosted.org/packages/32/67/0d456c79660959ca45ad307b4d67161d29f9ed4083ee1e8fe8c6925b7c82/pypdfium2-5.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:388e3119cf5ca0979b7d5f6d40b7fcd5ab49e17ed4e6de6af89ba116061acfda", size = 4339212, upload-time = "2026-05-04T17:39:29.277Z" },
{ url = "https://files.pythonhosted.org/packages/76/89/e5b0e0f7936be341c91c0f45cd70d693878894ed62aed93a6ee32e9c43c4/pypdfium2-5.8.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:aa05bbfa485ce7916217aa78d856c9f9cd86b08b20846c650392a67975ee72e9", size = 4383943, upload-time = "2026-05-04T17:39:31.287Z" },
{ url = "https://files.pythonhosted.org/packages/82/21/4502ed255f082f579cd3537c2971cf1a57778d43703a08bcd1a92253189f/pypdfium2-5.8.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:f0813a16bb39d5ebd173ea5484430bb67a89b4b181db0a636c73b64ad063c3ea", size = 3925680, upload-time = "2026-05-04T17:39:33.241Z" },
{ url = "https://files.pythonhosted.org/packages/7d/4f/2e59723e7a07779439bd885c1b4960079c9710603308888d29ac926ae69a/pypdfium2-5.8.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:a3c78f7d20dd821bec6c072efdb21a1370b9efe10fdeeb68c969e67608e25385", size = 4269560, upload-time = "2026-05-04T17:39:34.926Z" },
{ url = "https://files.pythonhosted.org/packages/34/4e/7b6b1bde3788c8b880d4b8131d95d9d339cebafb3ad9102d82e234bb65be/pypdfium2-5.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:86d302e207c138c827b885a72784f7b306d840646ebeae07e8efdbc39321c629", size = 4182434, upload-time = "2026-05-04T17:39:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/11/7b/6ed4782e0d7a5278330598ce8c4b2df7255f4585a0b3d04520fa580d6507/pypdfium2-5.8.0-py3-none-win32.whl", hash = "sha256:3f25fd436920a907291462b41bdc0ab9f8235c3944b4c9c15398da595ffd1fed", size = 3636680, upload-time = "2026-05-04T17:39:38.49Z" },
{ url = "https://files.pythonhosted.org/packages/19/55/da7223d4202b2461f4f889b0baf10dddec3db7f88e6fd8c52db4a516eecd/pypdfium2-5.8.0-py3-none-win_amd64.whl", hash = "sha256:55592af0bddd2d62bed18e0053c546c9b72041430c5115e54870f7f6163125b0", size = 3754962, upload-time = "2026-05-04T17:39:40.13Z" },
{ url = "https://files.pythonhosted.org/packages/fc/7a/f3dcefe6ee7389aad3ca1488c177e8fbf978206de21c7a99ccf487ea38ab/pypdfium2-5.8.0-py3-none-win_arm64.whl", hash = "sha256:3f17ed97ae8a5a1705301ca93af256a5b02f9009dee4e99c5e175831d46ebd7c", size = 3548362, upload-time = "2026-05-04T17:39:42.304Z" },
]
[[package]] [[package]]
name = "pyphen" name = "pyphen"
version = "0.17.2" version = "0.17.2"
@@ -1643,8 +1758,10 @@ server = [
{ name = "asyncmy" }, { name = "asyncmy" },
{ name = "bcrypt" }, { name = "bcrypt" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" },
{ name = "jinja2" }, { name = "jinja2" },
{ name = "kaleido" }, { name = "kaleido" },
{ name = "pdfplumber" },
{ name = "pillow" }, { name = "pillow" },
{ name = "plotly" }, { name = "plotly" },
{ name = "python-multipart" }, { name = "python-multipart" },
@@ -1666,8 +1783,10 @@ requires-dist = [
{ name = "flask-wtf", marker = "extra == 'client'", specifier = ">=1.2.0" }, { name = "flask-wtf", marker = "extra == 'client'", specifier = ">=1.2.0" },
{ name = "gunicorn", marker = "extra == 'client'", specifier = ">=21.0.0" }, { name = "gunicorn", marker = "extra == 'client'", specifier = ">=21.0.0" },
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" }, { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
{ name = "httpx", marker = "extra == 'server'", specifier = ">=0.27.0" },
{ name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.0" }, { name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.0" },
{ name = "kaleido", marker = "extra == 'server'", specifier = ">=0.2.0" }, { name = "kaleido", marker = "extra == 'server'", specifier = ">=0.2.0" },
{ name = "pdfplumber", marker = "extra == 'server'", specifier = ">=0.10.0" },
{ name = "pillow", marker = "extra == 'server'", specifier = ">=10.0.0" }, { name = "pillow", marker = "extra == 'server'", specifier = ">=10.0.0" },
{ name = "plotly", marker = "extra == 'server'", specifier = ">=5.0.0" }, { name = "plotly", marker = "extra == 'server'", specifier = ">=5.0.0" },
{ name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic", specifier = ">=2.0.0" },