Compare commits
20 Commits
d2bf0b5828
...
V2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| edb7eb382c | |||
| 8c61a557f8 | |||
| 821c7c39d5 | |||
| 83f8e2d332 | |||
| f3e593610b | |||
| fd571c479e | |||
| 25a788f430 | |||
| 61c265b38b | |||
| 74b348988e | |||
| a36a37aca0 | |||
| b8d04c54f7 | |||
| 34a72e6f1a | |||
| 4c75a32186 | |||
| fdfe1072d8 | |||
| 6ff78b2150 | |||
| bff577b461 | |||
| 5cc5123a0c | |||
| 2eb5c51353 | |||
| b325eb3512 | |||
| a7254d8932 |
@@ -123,7 +123,7 @@ uv lock # rigenera uv.lock
|
|||||||
### i18n (Traduzioni)
|
### i18n (Traduzioni)
|
||||||
```bash
|
```bash
|
||||||
# Estrai stringhe
|
# Estrai stringhe
|
||||||
cd src/frontend/flask_app && uv run pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
|
cd src/frontend/flask_app && uv run pybabel extract -F translations/babel.cfg -k _ -o translations/messages.pot .
|
||||||
|
|
||||||
# Aggiorna catalogo
|
# Aggiorna catalogo
|
||||||
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
||||||
|
|||||||
+1
-1
@@ -93,4 +93,4 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
traefik-net:
|
traefik-net:
|
||||||
external: true
|
external: true
|
||||||
name: root_default
|
name: traefik
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -223,7 +223,10 @@ async def reorder_tasks(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RecipeTask)
|
select(RecipeTask)
|
||||||
.where(RecipeTask.id.in_(data.task_ids))
|
.where(RecipeTask.id.in_(data.task_ids))
|
||||||
.options(selectinload(RecipeTask.subtasks))
|
.options(
|
||||||
|
selectinload(RecipeTask.subtasks),
|
||||||
|
selectinload(RecipeTask.version),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
tasks_map = {t.id: t for t in result.scalars().all()}
|
tasks_map = {t.id: t for t in result.scalars().all()}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Authentication blueprint - login, logout, profile."""
|
"""Authentication blueprint - login, logout, profile."""
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for
|
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from services.api_client import api_client
|
from services.api_client import api_client
|
||||||
@@ -39,15 +39,32 @@ def role_required(*roles):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _post_login_landing():
|
||||||
|
"""Pick the landing endpoint based on the logged-in user's roles.
|
||||||
|
|
||||||
|
A Maker-only user must not be bounced to /measure (MeasurementTec-only),
|
||||||
|
which would raise a 403 right after login. Order of preference follows the
|
||||||
|
primary operator flow first, then editing, analysis, admin.
|
||||||
|
"""
|
||||||
|
user = session.get("user", {})
|
||||||
|
roles = user.get("roles", [])
|
||||||
|
if "MeasurementTec" in roles:
|
||||||
|
return url_for("measure.select_recipe")
|
||||||
|
if "Maker" in roles:
|
||||||
|
return url_for("maker.recipe_list")
|
||||||
|
if "Metrologist" in roles:
|
||||||
|
return url_for("statistics.dashboard")
|
||||||
|
if user.get("is_admin"):
|
||||||
|
return url_for("admin.user_list")
|
||||||
|
return url_for("auth.profile")
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
"""Login page and form handler."""
|
"""Login page and form handler."""
|
||||||
# Se già loggato, redirect a home
|
# Se già loggato, redirect alla home in base al ruolo
|
||||||
if session.get("api_key"):
|
if session.get("api_key"):
|
||||||
try:
|
return redirect(_post_login_landing())
|
||||||
return redirect(url_for("measure.select_recipe"))
|
|
||||||
except Exception:
|
|
||||||
return redirect(url_for("auth.profile"))
|
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip()
|
username = request.form.get("username", "").strip()
|
||||||
@@ -91,11 +108,8 @@ def login():
|
|||||||
|
|
||||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
||||||
|
|
||||||
# Redirect dopo login
|
# Redirect dopo login (in base al ruolo)
|
||||||
try:
|
return redirect(_post_login_landing())
|
||||||
return redirect(url_for("measure.select_recipe"))
|
|
||||||
except Exception:
|
|
||||||
return redirect(url_for("auth.profile"))
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
|
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
|
||||||
@@ -119,6 +133,27 @@ def logout():
|
|||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/set-theme", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def set_theme():
|
||||||
|
"""Persist the user's theme toggle (light/dark) to session and profile.
|
||||||
|
|
||||||
|
Called via AJAX from the navbar theme switch so the choice survives
|
||||||
|
navigation and matches on the next login. Best-effort on the API side:
|
||||||
|
the session is updated regardless so the current browsing stays correct.
|
||||||
|
"""
|
||||||
|
theme = (request.get_json(silent=True) or {}).get("theme", "")
|
||||||
|
if theme not in ("light", "dark"):
|
||||||
|
return jsonify({"error": True, "detail": "invalid theme"}), 400
|
||||||
|
|
||||||
|
session["theme"] = theme
|
||||||
|
try:
|
||||||
|
api_client.put("/api/auth/me", data={"theme_pref": theme})
|
||||||
|
except Exception:
|
||||||
|
pass # Non-fatal: session theme is already updated for this session.
|
||||||
|
return jsonify({"ok": True, "theme": theme})
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/profile", methods=["GET", "POST"])
|
@auth_bp.route("/profile", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def profile():
|
def profile():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 |
@@ -16,9 +16,21 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the initial dark-mode state.
|
* Resolve the initial dark-mode state.
|
||||||
* Priority: localStorage > system preference > light (default).
|
* Priority: server (per-user theme_pref) > localStorage > system preference > light.
|
||||||
|
*
|
||||||
|
* The server value (window.__SERVER_THEME) is authoritative for logged-in
|
||||||
|
* users: it reflects the theme_pref of whoever is currently authenticated,
|
||||||
|
* so switching user actually switches theme instead of inheriting the
|
||||||
|
* previous user's localStorage value. It is empty for anonymous pages.
|
||||||
*/
|
*/
|
||||||
function getInitialDark() {
|
function getInitialDark() {
|
||||||
|
var server = (typeof window !== 'undefined' && window.__SERVER_THEME) || '';
|
||||||
|
if (server === 'dark' || server === 'light') {
|
||||||
|
// Keep localStorage in sync so the rest of the app stays consistent.
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, server); } catch (_) {}
|
||||||
|
return server === 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
var stored = null;
|
var stored = null;
|
||||||
try {
|
try {
|
||||||
stored = localStorage.getItem(STORAGE_KEY);
|
stored = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -70,6 +82,7 @@
|
|||||||
toggle: function () {
|
toggle: function () {
|
||||||
this.dark = !this.dark;
|
this.dark = !this.dark;
|
||||||
this._apply();
|
this._apply();
|
||||||
|
this._persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
set: function (dark) {
|
set: function (dark) {
|
||||||
@@ -77,6 +90,24 @@
|
|||||||
this._apply();
|
this._apply();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Persist an explicit user choice to the server (session + theme_pref),
|
||||||
|
// so it survives navigation and matches on the next login. Best-effort.
|
||||||
|
_persist: function () {
|
||||||
|
try {
|
||||||
|
var csrf = document.querySelector('meta[name=csrf-token]');
|
||||||
|
fetch('/auth/set-theme', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': (csrf && csrf.content) || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ theme: this.dark ? 'dark' : 'light' })
|
||||||
|
}).catch(function () {});
|
||||||
|
} catch (_) {
|
||||||
|
// Non-fatal: localStorage already holds the choice for this browser.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_apply: function () {
|
_apply: function () {
|
||||||
if (this.dark) {
|
if (this.dark) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
|
|||||||
@@ -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 © 2025 - {{ _('Sistema di misurazione industriale') }}</p>
|
<p>TieMeasureFlow © {{ 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 %}">
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
<!-- Theme CSS Variables -->
|
<!-- Theme CSS Variables -->
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/themes.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/themes.css') }}">
|
||||||
|
|
||||||
|
<!-- Authoritative per-user theme from the server session (empty when anonymous) -->
|
||||||
|
<script>window.__SERVER_THEME = "{{ current_theme if current_user else '' }}";</script>
|
||||||
|
|
||||||
<!-- Alpine.js Theme Init (before Alpine loads) -->
|
<!-- Alpine.js Theme Init (before Alpine loads) -->
|
||||||
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>
|
||||||
|
|
||||||
|
|||||||
@@ -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,40 +92,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search + Traceability Bar -->
|
<!-- Search Bar (filter) -->
|
||||||
<div class="tmf-card mb-6">
|
<div class="tmf-card mb-4">
|
||||||
<div class="p-4 sm:p-5">
|
<div class="p-4 sm:p-5">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<label class="tmf-label">
|
||||||
<!-- Search Input -->
|
<svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
<div class="sm:col-span-2">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
<label class="tmf-label">
|
</svg>
|
||||||
<svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
{{ _('Cerca ricetta') }}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
</label>
|
||||||
</svg>
|
<input type="text"
|
||||||
{{ _('Cerca ricetta') }}
|
x-model="search"
|
||||||
</label>
|
placeholder="{{ _('Nome, codice o descrizione...') }}"
|
||||||
<input type="text"
|
class="tmf-input">
|
||||||
x-model="search"
|
</div>
|
||||||
placeholder="{{ _('Nome, codice o descrizione...') }}"
|
</div>
|
||||||
class="tmf-input">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Traceability (data entry, NOT a search filter) -->
|
||||||
|
<div class="tmf-card mb-6 border-l-4 border-l-primary">
|
||||||
|
<div class="p-4 sm:p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<svg class="w-5 h-5 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}</h2>
|
||||||
|
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<!-- Lot Number -->
|
<!-- Lot Number -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label">{{ _('Numero Lotto') }}</label>
|
<label class="tmf-label flex items-center gap-1.5">
|
||||||
|
<svg class="w-3.5 h-3.5 text-amber-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Numero Lotto') }}
|
||||||
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
x-model="lot_number"
|
x-model="lot_number"
|
||||||
placeholder="{{ _('Opzionale') }}"
|
placeholder="{{ _('Es. LOT-2026-001 (opzionale)') }}"
|
||||||
class="tmf-input">
|
class="tmf-input font-mono">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Serial Number -->
|
<!-- Serial Number -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label">{{ _('Numero Seriale') }}</label>
|
<label class="tmf-label flex items-center gap-1.5">
|
||||||
|
<svg class="w-3.5 h-3.5 text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Numero Seriale') }}
|
||||||
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
x-model="serial_number"
|
x-model="serial_number"
|
||||||
placeholder="{{ _('Opzionale') }}"
|
placeholder="{{ _('Es. SN-000123 (opzionale)') }}"
|
||||||
class="tmf-input">
|
class="tmf-input font-mono">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,83 +122,77 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<span class="badge badge-neutral">
|
<span class="badge badge-neutral">
|
||||||
{{ tasks|length }} task
|
{{ tasks|length }} task
|
||||||
|
{%- set total_subs = namespace(count=0) -%}
|
||||||
|
{%- for t in tasks -%}
|
||||||
|
{%- if t.subtask_count is defined -%}
|
||||||
|
{%- set total_subs.count = total_subs.count + t.subtask_count -%}
|
||||||
|
{%- elif t.subtasks -%}
|
||||||
|
{%- set total_subs.count = total_subs.count + t.subtasks|length -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- if total_subs.count > 0 %} · {{ 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 %}
|
||||||
|
|
||||||
<!-- Meta Row -->
|
|
||||||
<div class="flex items-center gap-4 flex-wrap">
|
|
||||||
<!-- Subtask Count -->
|
|
||||||
{% if task.subtask_count is defined or task.subtasks %}
|
|
||||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium">
|
|
||||||
{% if task.subtask_count is defined %}
|
|
||||||
{{ task.subtask_count }}
|
|
||||||
{% elif task.subtasks %}
|
|
||||||
{{ task.subtasks|length }}
|
|
||||||
{% endif %}
|
|
||||||
{{ _('misurazioni') }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- File Attachment -->
|
|
||||||
{% if task.file_path %}
|
|
||||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
|
||||||
{% if task.file_path.endswith('.pdf') %}
|
|
||||||
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
{% else %}
|
|
||||||
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
{% endif %}
|
|
||||||
<span class="font-medium">{{ _('Allegato') }}</span>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Button -->
|
<!-- Indicators (top-right): measurements + attachment -->
|
||||||
|
<div class="shrink-0 self-start flex items-center gap-2.5">
|
||||||
|
{% if task.subtask_count is defined or task.subtasks %}
|
||||||
|
<span class="inline-flex items-center gap-1 text-xs text-[var(--text-secondary)]"
|
||||||
|
title="{{ _('misurazioni') }}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium font-mono">{% if task.subtask_count is defined %}{{ task.subtask_count }}{% elif task.subtasks %}{{ task.subtasks|length }}{% endif %}</span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if task.file_path %}
|
||||||
|
<span class="inline-flex items-center text-xs" title="{{ _('Allegato') }}">
|
||||||
|
{% if task.file_path.endswith('.pdf') %}
|
||||||
|
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Button (Maker only — operators start the guided flow with AVVIA) -->
|
||||||
|
{% if current_user and 'Maker' in current_user.get('roles', []) %}
|
||||||
<div class="shrink-0 sm:ml-4">
|
<div class="shrink-0 sm:ml-4">
|
||||||
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
||||||
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
|
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
|
||||||
@@ -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 ·
|
|
||||||
{% 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
Reference in New Issue
Block a user