16 Commits

Author SHA1 Message Date
Adriano Dal Pastro edb7eb382c Merge branch 'ui/restyling': login restyle, navbar macros, new brand icon
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 16:09:21 +00:00
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
28 changed files with 2580 additions and 1884 deletions
+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
+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":
+44 -45
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)
+1 -1
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 (
@@ -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(
+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)
+11 -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):
@@ -93,6 +102,7 @@ def create_app() -> Flask:
"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"), "auto_logout_minutes": session.get("auto_logout_minutes"),
"current_year": date.today().year,
} }
return app return app
+11 -15
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"])
@@ -444,4 +433,11 @@ def api_parse_technical_sheet(recipe_id: int):
timeout=90, timeout=90,
) )
return jsonify(resp.json()), resp.status_code try:
payload = resp.json()
except ValueError:
payload = {
"error": True,
"detail": resp.text or f"HTTP {resp.status_code}",
}
return jsonify(payload), resp.status_code
+8 -20
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__)
@@ -339,16 +338,17 @@ def validate_supervisor():
password = data.get("password", "") password = data.get("password", "")
if not username or not password: if not username or not password:
return jsonify({"error": True, "detail": "Username e password richiesti"}), 400 return jsonify({"error": True, "detail": _("Username e password richiesti")}), 400
resp = api_client.post("/api/auth/login", data={"username": username, "password": password}) resp = api_client.post("/api/auth/login", data={"username": username, "password": password})
if resp.get("error"): if resp.get("error"):
return jsonify({"error": True, "detail": "Credenziali non valide"}), 401 return jsonify({"error": True, "detail": _("Credenziali non valide")}), 401
user = resp.get("user", {}) user = resp.get("user", {})
if not user.get("is_admin"): is_supervisor = "Supervisor" in (user.get("roles") or [])
return jsonify({"error": True, "detail": "Utente non autorizzato (richiesto capoturno)"}), 403 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 return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
@@ -360,16 +360,4 @@ def validate_supervisor():
@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"),
)
@@ -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

@@ -956,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;
@@ -970,7 +970,8 @@ function annotationEditor() {
self.canvas.discardActiveObject(); self.canvas.discardActiveObject();
self.canvas.renderAll(); self.canvas.renderAll();
} }
}); };
document.addEventListener('keydown', this._onKeyDown);
}, },
// ---------------------------------------------------------------- // ----------------------------------------------------------------
@@ -1289,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;
@@ -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();
@@ -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,17 +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">
<div class="mx-auto mb-4 inline-flex justify-center text-slate-900 dark:text-white"> <div class="mx-auto mb-4 inline-flex justify-center text-slate-900 dark:text-white">
{% set logo_class = 'h-16 w-auto' %} {% set logo_class = 'h-14 w-14' %}
{% set wordmark_class = 'text-3xl' %}
{% set logo_id = 'login' %}
{% include 'components/_app_logo.html' %} {% include 'components/_app_logo.html' %}
</div> </div>
<p class="text-sm text-slate-600 dark:text-slate-400"> <p class="text-sm text-[var(--text-secondary)]">
{{ _('Accedi al sistema') }} {{ _('Accedi al sistema') }}
</p> </p>
</div> </div>
@@ -22,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>
@@ -36,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') }}
@@ -75,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') }}
@@ -84,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 %}">
@@ -1,35 +1,33 @@
<!-- <!--
Inline TieMeasureFlow logo. Text fills use `currentColor`, so wrap with a TieMeasureFlow logo: brand badge (SVG) + HTML wordmark.
parent that sets `color` (e.g. `text-slate-900 dark:text-white`) to make the The wordmark inherits `color` from the parent (set e.g. `text-slate-900
wordmark adapt to the active theme. The caliper/flow icon keeps its brand dark:text-white`); "Measure" keeps the brand blue.
blue regardless of theme.
-->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 260 48" fill="none"
class="{{ logo_class|default('h-10 w-auto') }}"
aria-label="TieMeasureFlow">
<!-- Caliper + flow icon (always brand color) -->
<g transform="translate(2, 6)">
<rect x="0" y="4" width="6" height="32" rx="1.5" fill="#2563EB"/>
<rect x="0" y="4" width="24" height="6" rx="1.5" fill="#2563EB"/>
<rect x="0" y="30" width="24" height="6" rx="1.5" fill="#2563EB"/>
<rect x="14" y="10" width="5" height="8" rx="1" fill="#1E40AF"/>
<rect x="14" y="22" width="5" height="8" rx="1" fill="#1E40AF"/>
<rect x="2" y="18" width="16" height="4" rx="1" fill="#3B82F6" opacity="0.6"/>
<rect x="7" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
<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"/>
<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>
<!-- Wordmark: themed via currentColor; "Measure" stays brand-tinted --> Parameters (set before include):
<g transform="translate(52, 0)" font-family="Inter, system-ui, sans-serif" letter-spacing="-0.5"> logo_class — badge size, default 'h-10 w-10'
<text x="0" y="34" font-size="28" font-weight="700" fill="currentColor">Tie</text> wordmark_class — wordmark text size, default 'text-xl'; '' hides the text
<text x="44" y="34" font-size="28" font-weight="500" fill="#2563EB">Measure</text> logo_id — unique suffix for the SVG gradient id when the logo
<text x="166" y="34" font-size="28" font-weight="700" fill="currentColor">Flow</text> appears more than once per page (default 'tmf')
</g> -->
</svg> {% 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>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,6 +1,45 @@
<!-- 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">
@@ -12,7 +51,9 @@
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-10 w-auto" <img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-10 w-auto"
onerror="this.style.display='none'"> onerror="this.style.display='none'">
{% else %} {% else %}
{% set logo_class = 'h-10 w-auto' %} {% set logo_class = 'h-9 w-9' %}
{% set wordmark_class = 'text-xl' %}
{% set logo_id = 'nav' %}
{% include 'components/_app_logo.html' %} {% include 'components/_app_logo.html' %}
{% endif %} {% endif %}
</a> </a>
@@ -21,103 +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>
<a href="{{ url_for('admin.settings_page') }}"
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.settings_page' %}
text-primary bg-primary-50 dark:bg-primary-900/20
{% endif %}">
<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="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"/>
</svg>
<span>{{ _('Impostazioni') }}</span>
</a>
{% endif %}
</div> </div>
{% endif %} {% endif %}
@@ -126,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 -->
@@ -265,77 +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>
<a href="{{ url_for('admin.settings_page') }}"
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="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"/>
</svg>
{{ _('Impostazioni') }}
</a>
{% endif %}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -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>
@@ -92,12 +92,9 @@
</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">
<!-- Search Input -->
<div class="sm:col-span-2">
<label class="tmf-label"> <label class="tmf-label">
<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"> <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">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
@@ -109,23 +106,47 @@
placeholder="{{ _('Nome, codice o descrizione...') }}" placeholder="{{ _('Nome, codice o descrizione...') }}"
class="tmf-input"> class="tmf-input">
</div> </div>
</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>
@@ -116,14 +116,14 @@
{# Fermo linea + Fine produzione (measurement tasks only) #} {# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="subtasks.length > 0"> <template x-if="subtasks.length > 0">
<div class="shrink-0 flex items-center gap-1.5"> <div class="shrink-0 flex items-center gap-1.5">
<button @click="showSupervisorModal = true; supervisorAction = 'fermo_linea'" <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"> 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"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg> </svg>
{{ _('Fermo linea') }} {{ _('Fermo linea') }}
</button> </button>
<button @click="showSupervisorModal = true; supervisorAction = 'fine_produzione'" <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"> 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"> <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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
@@ -584,13 +584,36 @@
class="btn btn-secondary flex-1 justify-center gap-2"> class="btn btn-secondary flex-1 justify-center gap-2">
{{ _('Riepilogo') }} {{ _('Riepilogo') }}
</button> </button>
<button @click="showCompletionOverlay = false; cycleConfirmed = true"
{# 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"> class="btn btn-primary flex-1 justify-center gap-2">
{{ _('Conferma ciclo') }} {{ _('Conferma ciclo') }}
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg> </svg>
</button> </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> </div>
</div> </div>
@@ -607,7 +630,7 @@
x-transition:leave-end="opacity-0" x-transition:leave-end="opacity-0"
x-cloak x-cloak
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click.self="showSupervisorModal = false"> @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="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="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"> <div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
@@ -636,7 +659,7 @@
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<button @click="showSupervisorModal = false; supervisorUsername = ''; supervisorPassword = ''; supervisorError = ''" <button @click="closeSupervisorModal()"
class="btn btn-secondary flex-1 text-sm"> class="btn btn-secondary flex-1 text-sm">
{{ _('Annulla') }} {{ _('Annulla') }}
</button> </button>
@@ -858,13 +881,20 @@ function taskExecute() {
// Out-of-tolerance: block advancement, require supervisor // Out-of-tolerance: block advancement, require supervisor
if (pf === 'fail') { if (pf === 'fail') {
this.pendingAdvance = true; this.pendingAdvance = true;
this.supervisorAction = 'out_of_tolerance'; this.openSupervisorModal('out_of_tolerance');
this.showSupervisorModal = true;
return; return;
} }
// Check if all done — show overlay but do NOT auto-navigate // Check if all done
if (this.completedCount >= this.totalSubtasks) { if (this.completedCount >= this.totalSubtasks) {
// START run (production not yet started): don't block with the
// "Misurazioni Complete" overlay — just advance to the next task.
// The overlay is shown only on the last task (to offer "Avvio
// Produzione") or once production is running ("Conferma ciclo").
if (!this.productionStarted && !this.isLastTask) {
this.goToNextTask();
return;
}
this.showCompletionOverlay = true; this.showCompletionOverlay = true;
return; return;
} }
@@ -981,6 +1011,25 @@ function taskExecute() {
// await fetch('/measure/api/gaia/start-production', { method: 'POST', ... }); // 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() { get timerDisplay() {
var m = Math.floor(this.timerRemaining / 60); var m = Math.floor(this.timerRemaining / 60);
var s = this.timerRemaining % 60; var s = this.timerRemaining % 60;
@@ -998,6 +1047,22 @@ function taskExecute() {
} }
}, },
// ---- 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 ---- // ---- Supervisor reason text ----
get supervisorReason() { get supervisorReason() {
if (this.supervisorAction === 'out_of_tolerance') return '{{ _("Misurazione fuori tolleranza") }}'; if (this.supervisorAction === 'out_of_tolerance') return '{{ _("Misurazione fuori tolleranza") }}';
@@ -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">
@@ -111,67 +122,62 @@
</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 whitespace-pre-wrap"> <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 %}
</div>
<!-- Meta Row --> <!-- Indicators (top-right): measurements + attachment -->
<div class="flex items-center gap-4 flex-wrap"> <div class="shrink-0 self-start flex items-center gap-2.5">
<!-- Subtask Count -->
{% if task.subtask_count is defined or task.subtasks %} {% if task.subtask_count is defined or task.subtasks %}
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]"> <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"> <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"/> <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> </svg>
<span class="font-medium"> <span class="font-medium font-mono">{% if task.subtask_count is defined %}{{ task.subtask_count }}{% elif task.subtasks %}{{ task.subtasks|length }}{% endif %}</span>
{% if task.subtask_count is defined %}
{{ task.subtask_count }}
{% elif task.subtasks %}
{{ task.subtasks|length }}
{% endif %}
{{ _('misurazioni') }}
</span>
</span> </span>
{% endif %} {% endif %}
<!-- File Attachment -->
{% if task.file_path %} {% if task.file_path %}
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]"> <span class="inline-flex items-center text-xs" title="{{ _('Allegato') }}">
{% if task.file_path.endswith('.pdf') %} {% 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"> <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"/> <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"/>
@@ -181,13 +187,12 @@
<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"/> <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> </svg>
{% endif %} {% endif %}
<span class="font-medium">{{ _('Allegato') }}</span>
</span> </span>
{% endif %} {% endif %}
</div> </div>
</div>
<!-- Action Button --> <!-- 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
@@ -202,6 +207,7 @@
</svg> </svg>
</a> </a>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -226,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 %}
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