Compare commits
31 Commits
fb96bad000
..
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 | |||
| d2bf0b5828 | |||
| 9bd605c958 | |||
| e05eb66a1c | |||
| 6bfe0a98e8 | |||
| 1da7e5c7af | |||
| 0748ce9b1e | |||
| b9c767fb0c | |||
| 6772e3166a | |||
| 182aa9fa9a | |||
| f1c7a28296 | |||
| 52d78ea5c1 |
@@ -35,6 +35,10 @@ MAX_UPLOAD_SIZE_MB=50
|
||||
# --- Setup Page ---
|
||||
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
|
||||
|
||||
# --- AI (OpenRouter) ---
|
||||
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
|
||||
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare
|
||||
|
||||
# --- Docker ---
|
||||
DB_ROOT_PASSWORD=root_password_change_me
|
||||
NGINX_PORT=80
|
||||
|
||||
@@ -123,7 +123,7 @@ uv lock # rigenera uv.lock
|
||||
### i18n (Traduzioni)
|
||||
```bash
|
||||
# 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
|
||||
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
||||
|
||||
+1
-1
@@ -93,4 +93,4 @@ networks:
|
||||
driver: bridge
|
||||
traefik-net:
|
||||
external: true
|
||||
name: root_default
|
||||
name: traefik
|
||||
|
||||
Binary file not shown.
@@ -30,6 +30,8 @@ server = [
|
||||
"plotly>=5.0.0",
|
||||
"kaleido>=0.2.0",
|
||||
"weasyprint>=62.0",
|
||||
"pdfplumber>=0.10.0",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
# Frontend (Flask tablet UI).
|
||||
|
||||
@@ -6,6 +6,7 @@ from src.backend.api.middleware.api_key import (
|
||||
require_maker,
|
||||
require_measurement_tec,
|
||||
require_metrologist,
|
||||
require_supervisor,
|
||||
require_admin_user,
|
||||
)
|
||||
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||
@@ -19,6 +20,7 @@ __all__ = [
|
||||
"require_maker",
|
||||
"require_measurement_tec",
|
||||
"require_metrologist",
|
||||
"require_supervisor",
|
||||
"require_admin_user",
|
||||
"AccessLogMiddleware",
|
||||
"RateLimitMiddleware",
|
||||
|
||||
@@ -68,4 +68,5 @@ def require_admin():
|
||||
require_maker = require_role("Maker")
|
||||
require_measurement_tec = require_role("MeasurementTec")
|
||||
require_metrologist = require_role("Metrologist")
|
||||
require_supervisor = require_role("Supervisor")
|
||||
require_admin_user = require_admin()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""File upload/download/delete router for images and PDFs."""
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
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.models.orm.user import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||
|
||||
# Allowed MIME types
|
||||
@@ -65,6 +67,37 @@ def sanitize_filename(filename: str) -> str:
|
||||
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)):
|
||||
"""Generate a thumbnail for an image."""
|
||||
try:
|
||||
@@ -73,7 +106,7 @@ def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[i
|
||||
img.save(thumbnail_path, quality=85)
|
||||
except Exception as e:
|
||||
# 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")
|
||||
@@ -164,30 +197,7 @@ async def get_file(
|
||||
|
||||
Requires authentication but no specific role.
|
||||
"""
|
||||
# Construct full 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",
|
||||
)
|
||||
|
||||
full_path = resolve_upload_path(file_path)
|
||||
return FileResponse(full_path)
|
||||
|
||||
|
||||
@@ -200,29 +210,7 @@ async def delete_file(
|
||||
|
||||
Also deletes associated thumbnail if it exists.
|
||||
"""
|
||||
# Construct full 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",
|
||||
)
|
||||
full_path = resolve_upload_path(file_path)
|
||||
|
||||
# Delete thumbnail if it exists
|
||||
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)
|
||||
async def get_measurements(
|
||||
recipe_id: int | None = Query(None),
|
||||
@@ -108,30 +146,10 @@ async def get_measurements(
|
||||
filters. Measurements carry no PII beyond numeric values + a recipe
|
||||
reference, so role-gating beyond authentication isn't justified.
|
||||
"""
|
||||
# Build filter conditions
|
||||
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)
|
||||
filters = _build_measurement_filters(
|
||||
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||
serial_number, date_from, date_to, pass_fail,
|
||||
)
|
||||
|
||||
# Build query
|
||||
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_separator = decimal_setting.setting_value if decimal_setting else "."
|
||||
|
||||
# Build filter conditions (same as get_measurements)
|
||||
filters = []
|
||||
if recipe_id is not None:
|
||||
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)
|
||||
filters = _build_measurement_filters(
|
||||
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||
serial_number, date_from, date_to, pass_fail,
|
||||
)
|
||||
|
||||
# Query all matching measurements
|
||||
query = select(Measurement).where(and_(*filters) if filters else True)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Recipe router - CRUD, versioning, barcode lookup."""
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
"""Recipe router - CRUD, versioning, barcode lookup, AI parsing."""
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.database import get_db
|
||||
@@ -164,3 +166,31 @@ async def get_measurement_count(
|
||||
"""
|
||||
count = await recipe_service.get_measurement_count(db, recipe_id, version_number)
|
||||
return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count}
|
||||
|
||||
|
||||
@router.post("/{recipe_id}/parse-technical-sheet")
|
||||
async def parse_technical_sheet(
|
||||
recipe_id: int,
|
||||
file: UploadFile = File(...),
|
||||
_user: User = Depends(require_maker),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Parse a PDF technical sheet using AI and return suggested tasks."""
|
||||
from src.backend.services import ai_service
|
||||
|
||||
if not file.content_type or "pdf" not in file.content_type:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Il file deve essere un PDF")
|
||||
|
||||
pdf_bytes = await file.read()
|
||||
if len(pdf_bytes) > 20 * 1024 * 1024:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File troppo grande (max 20MB)")
|
||||
|
||||
try:
|
||||
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error("AI parsing error: %s", e)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
|
||||
|
||||
return {"recipe_id": recipe_id, "suggested_tasks": tasks}
|
||||
|
||||
@@ -15,6 +15,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import inspect as sa_inspect, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from src.backend.api.routers.users import VALID_ROLES
|
||||
from src.backend.config import settings
|
||||
from src.backend.database import Base, engine, async_session_factory
|
||||
from src.backend.models.orm import (
|
||||
@@ -26,7 +27,7 @@ from src.backend.services.measurement_service import calculate_pass_fail
|
||||
|
||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||
|
||||
_templates_dir = Path(__file__).resolve().parent.parent / "templates"
|
||||
_templates_dir = Path(__file__).resolve().parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(_templates_dir))
|
||||
|
||||
|
||||
@@ -215,7 +216,7 @@ async def _seed_default_station(session: AsyncSession, admin_user: User) -> None
|
||||
async def setup_page(request: Request):
|
||||
"""Serve the setup page HTML."""
|
||||
_check_setup_enabled()
|
||||
return templates.TemplateResponse("setup/setup.html", {"request": request})
|
||||
return templates.TemplateResponse(request, "setup/setup.html")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
@@ -544,7 +545,6 @@ async def manage_user(body: ManageUserBody):
|
||||
"""Create or update a user. user_id=null creates new, user_id=int updates."""
|
||||
_check_password(body.password)
|
||||
|
||||
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist"}
|
||||
invalid = set(body.roles) - VALID_ROLES
|
||||
if invalid:
|
||||
raise HTTPException(
|
||||
|
||||
@@ -223,7 +223,10 @@ async def reorder_tasks(
|
||||
result = await db.execute(
|
||||
select(RecipeTask)
|
||||
.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()}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ from src.backend.services.auth_service import create_user, hash_password, regene
|
||||
|
||||
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])
|
||||
async def list_users(
|
||||
@@ -40,12 +44,11 @@ async def create_new_user(
|
||||
detail=f"Username '{data.username}' already exists",
|
||||
)
|
||||
# Validate roles
|
||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
||||
for role in data.roles:
|
||||
if role not in valid_roles:
|
||||
if role not in VALID_ROLES:
|
||||
raise HTTPException(
|
||||
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(
|
||||
db,
|
||||
@@ -77,12 +80,11 @@ async def update_user(
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
# Validate roles if provided
|
||||
if "roles" in update_data:
|
||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
||||
for role in update_data["roles"]:
|
||||
if role not in valid_roles:
|
||||
if role not in VALID_ROLES:
|
||||
raise HTTPException(
|
||||
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():
|
||||
setattr(user, field, value)
|
||||
|
||||
@@ -34,6 +34,10 @@ class Settings(BaseSettings):
|
||||
# Setup page (empty = disabled)
|
||||
setup_password: str | None = None
|
||||
|
||||
# AI / OpenRouter (for technical sheet parsing)
|
||||
openrouter_api_key: str | None = None
|
||||
openrouter_model: str = "anthropic/claude-sonnet-4"
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
"""Async MySQL connection string."""
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""add measurement_interval_minutes to recipes
|
||||
|
||||
Revision ID: 003_measurement_interval
|
||||
Revises: 002_add_stations
|
||||
Create Date: 2026-05-23
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = '003_measurement_interval'
|
||||
down_revision: Union[str, None] = '002_add_stations'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('recipes', sa.Column('measurement_interval_minutes', sa.SmallInteger(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('recipes', 'measurement_interval_minutes')
|
||||
@@ -14,6 +14,7 @@ class RecipeCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||
# Optional task-level fields for the initial technical drawing
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||
@@ -25,6 +26,7 @@ class RecipeUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = Field(None, max_length=500)
|
||||
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||
change_notes: Optional[str] = None
|
||||
# Task-level fields: saved to the first task of the new version
|
||||
file_path: Optional[str] = Field(None, max_length=500)
|
||||
@@ -55,6 +57,7 @@ class RecipeResponse(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image_path: Optional[str] = None
|
||||
measurement_interval_minutes: Optional[int] = None
|
||||
created_by: int
|
||||
created_at: datetime
|
||||
active: bool
|
||||
|
||||
@@ -51,6 +51,8 @@ class RecipeSummary(BaseModel):
|
||||
code: str
|
||||
name: str
|
||||
active: bool
|
||||
image_path: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class StationWithRecipesResponse(StationResponse):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, SmallInteger, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from src.backend.database import Base
|
||||
@@ -24,6 +24,7 @@ class Recipe(Base):
|
||||
DateTime, nullable=False, server_default=func.now()
|
||||
)
|
||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
|
||||
|
||||
# Relationships
|
||||
versions: Mapped[list["RecipeVersion"]] = relationship(
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""AI service for parsing technical sheets via OpenRouter."""
|
||||
import json
|
||||
import logging
|
||||
from io import BytesIO
|
||||
|
||||
import httpx
|
||||
import pdfplumber
|
||||
|
||||
from src.backend.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_PROMPT = """Sei un assistente specializzato nell'analisi di schede tecniche industriali.
|
||||
Analizza il testo estratto da una scheda tecnica e identifica i blocchi operativi distinti.
|
||||
Per ogni blocco, estrai:
|
||||
- title: titolo breve del blocco (es. "MATERIALI", "TEMPERATURA", "MISURA", "IMBALLO")
|
||||
- directive: istruzione operativa principale (es. "VERIFICARE ATTREZZATURE")
|
||||
- description: dettagli completi del blocco, preservando gli "a capo" originali
|
||||
|
||||
Rispondi SOLO con un array JSON valido, senza markdown o testo aggiuntivo.
|
||||
Esempio:
|
||||
[
|
||||
{"title": "MATERIALI", "directive": "Verificare materiali", "description": "RIGIDO (36): AFT9/UV CRI 7\\nMORBIDO (E38): M70"},
|
||||
{"title": "TEMPERATURA", "directive": "Verificare impostazioni", "description": "RIGIDO: TEMP: 170/170/170/170/170\\nMORBIDO: TEMP: 130-135-140-145"}
|
||||
]"""
|
||||
|
||||
|
||||
def extract_text_from_pdf(pdf_bytes: bytes) -> str:
|
||||
with pdfplumber.open(BytesIO(pdf_bytes)) as pdf:
|
||||
pages = []
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
pages.append(text)
|
||||
return "\n\n---\n\n".join(pages)
|
||||
|
||||
|
||||
async def parse_technical_sheet(pdf_bytes: bytes) -> list[dict]:
|
||||
text = extract_text_from_pdf(pdf_bytes)
|
||||
if not text.strip():
|
||||
return []
|
||||
|
||||
if not settings.openrouter_api_key:
|
||||
raise ValueError("OPENROUTER_API_KEY non configurata")
|
||||
|
||||
async with httpx.AsyncClient(timeout=60) as client:
|
||||
resp = await client.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.openrouter_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": settings.openrouter_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": f"Analizza questa scheda tecnica ed estrai i task:\n\n{text}"},
|
||||
],
|
||||
"temperature": 0.1,
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
data = resp.json()
|
||||
content = data["choices"][0]["message"]["content"].strip()
|
||||
|
||||
# Strip markdown fences if present
|
||||
if content.startswith("```"):
|
||||
content = content.split("\n", 1)[1]
|
||||
if content.endswith("```"):
|
||||
content = content[:-3]
|
||||
content = content.strip()
|
||||
|
||||
return json.loads(content)
|
||||
@@ -134,6 +134,7 @@ async def create_recipe(
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
image_path=data.image_path,
|
||||
measurement_interval_minutes=data.measurement_interval_minutes,
|
||||
created_by=user.id,
|
||||
)
|
||||
db.add(recipe)
|
||||
@@ -273,6 +274,8 @@ async def create_new_version(
|
||||
update_fields["description"] = data.description
|
||||
if data.image_path is not None:
|
||||
update_fields["image_path"] = data.image_path
|
||||
if data.measurement_interval_minutes is not None:
|
||||
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
@@ -369,6 +372,8 @@ async def update_current_version(
|
||||
update_fields["description"] = data.description
|
||||
if data.image_path is not None:
|
||||
update_fields["image_path"] = data.image_path
|
||||
if data.measurement_interval_minutes is not None:
|
||||
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
||||
if update_fields:
|
||||
await db.execute(
|
||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||
|
||||
@@ -559,6 +559,19 @@
|
||||
return data;
|
||||
}
|
||||
|
||||
/* Setup operations rotate api_keys (DB init, admin creation, password
|
||||
* change, user (de)activation). Any Flask session held in this browser
|
||||
* still carries the old key, so we clear it to force a fresh /auth/login. */
|
||||
async function invalidateClientSession() {
|
||||
try {
|
||||
await fetch('/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
redirect: 'manual',
|
||||
});
|
||||
} catch (_) { /* best-effort */ }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Check DB status */
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -635,6 +648,7 @@
|
||||
try {
|
||||
const data = await apiPost('/init-db', { password: setupPassword });
|
||||
showToast(data.message, 'success');
|
||||
await invalidateClientSession();
|
||||
await checkStatus();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
@@ -667,6 +681,7 @@
|
||||
admin_display_name: display,
|
||||
});
|
||||
showToast(data.message, 'success');
|
||||
await invalidateClientSession();
|
||||
// Clear form
|
||||
document.getElementById('admin-username').value = '';
|
||||
document.getElementById('admin-password').value = '';
|
||||
@@ -690,6 +705,7 @@
|
||||
if (data.measurements_created) msg += ', measurements: ' + data.measurements_created;
|
||||
msg += ')';
|
||||
showToast(msg, 'success');
|
||||
await invalidateClientSession();
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
@@ -876,6 +892,7 @@
|
||||
if (pwd) body.user_password = pwd;
|
||||
var data = await apiPost('/manage-user', body);
|
||||
showToast(data.message, 'success');
|
||||
await invalidateClientSession();
|
||||
closeModal('modal-user');
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
@@ -908,6 +925,7 @@
|
||||
new_password: newPwd,
|
||||
});
|
||||
showToast(data.message, 'success');
|
||||
await invalidateClientSession();
|
||||
closeModal('modal-password');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
@@ -924,6 +942,7 @@
|
||||
user_id: userId,
|
||||
});
|
||||
showToast(data.message, 'success');
|
||||
await invalidateClientSession();
|
||||
await loadUsers();
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""TieMeasureFlow Client - Flask Entry Point."""
|
||||
import json
|
||||
import os
|
||||
from datetime import date
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from flask import Flask, redirect, url_for, session, request
|
||||
from flask_babel import Babel
|
||||
@@ -63,7 +65,14 @@ def create_app() -> Flask:
|
||||
"""Set user's preferred language and store in session."""
|
||||
if lang in Config.LANGUAGES:
|
||||
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")
|
||||
def tojson_attr_filter(value):
|
||||
@@ -92,6 +101,8 @@ def create_app() -> Flask:
|
||||
"current_language": get_locale(),
|
||||
"languages": Config.LANGUAGES,
|
||||
"company_logo": session.get("company_logo"),
|
||||
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
||||
"current_year": date.today().year,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
@@ -43,6 +43,28 @@ def user_list():
|
||||
return render_template("admin/users.html", users=users)
|
||||
|
||||
|
||||
@admin_bp.route("/settings")
|
||||
@login_required
|
||||
@admin_required
|
||||
def settings_page():
|
||||
"""System settings page."""
|
||||
resp = api_client.get("/api/settings")
|
||||
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
|
||||
return render_template("admin/settings.html", settings=settings)
|
||||
|
||||
|
||||
@admin_bp.route("/api/settings", methods=["PUT"])
|
||||
@login_required
|
||||
@admin_required
|
||||
def api_update_settings():
|
||||
"""Proxy: Update system settings."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
resp = api_client.put("/api/settings", data=data)
|
||||
if isinstance(resp, dict) and resp.get("error"):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@admin_bp.route("/stations")
|
||||
@login_required
|
||||
@admin_required
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Authentication blueprint - login, logout, profile."""
|
||||
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 services.api_client import api_client
|
||||
@@ -39,15 +39,32 @@ def role_required(*roles):
|
||||
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"])
|
||||
def login():
|
||||
"""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"):
|
||||
try:
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
except Exception:
|
||||
return redirect(url_for("auth.profile"))
|
||||
return redirect(_post_login_landing())
|
||||
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
@@ -76,20 +93,23 @@ def login():
|
||||
if user.get("theme_pref"):
|
||||
session["theme"] = user["theme_pref"]
|
||||
|
||||
# Carica logo aziendale dalle impostazioni di sistema
|
||||
# Carica impostazioni di sistema
|
||||
settings_resp = api_client.get("/api/settings")
|
||||
if not settings_resp.get("error"):
|
||||
logo_path = settings_resp.get("company_logo_path")
|
||||
if logo_path:
|
||||
session["company_logo"] = logo_path
|
||||
auto_logout = settings_resp.get("auto_logout_minutes")
|
||||
if auto_logout:
|
||||
try:
|
||||
session["auto_logout_minutes"] = int(auto_logout)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
||||
|
||||
# Redirect dopo login
|
||||
try:
|
||||
return redirect(url_for("measure.select_recipe"))
|
||||
except Exception:
|
||||
return redirect(url_for("auth.profile"))
|
||||
# Redirect dopo login (in base al ruolo)
|
||||
return redirect(_post_login_landing())
|
||||
|
||||
except Exception as e:
|
||||
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
|
||||
@@ -113,6 +133,27 @@ def logout():
|
||||
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"])
|
||||
@login_required
|
||||
def profile():
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Maker blueprint - recipe creation and editing."""
|
||||
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 blueprints.auth import login_required, role_required
|
||||
from config import Config
|
||||
from services.api_client import api_client
|
||||
from services.file_proxy import proxy_file
|
||||
|
||||
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
||||
|
||||
@@ -380,19 +381,7 @@ def api_upload_file():
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
api_key = session.get("api_key", "")
|
||||
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"),
|
||||
)
|
||||
return proxy_file(file_path)
|
||||
|
||||
|
||||
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
|
||||
@@ -424,3 +413,31 @@ def api_get_measurement_count(recipe_id: int, version_number: int):
|
||||
return jsonify(resp), resp.get("status_code", 500)
|
||||
|
||||
return jsonify(resp), 200
|
||||
|
||||
|
||||
@maker_bp.route("/api/recipes/<int:recipe_id>/parse-technical-sheet", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("Maker")
|
||||
def api_parse_technical_sheet(recipe_id: int):
|
||||
"""Proxy: Parse PDF technical sheet with AI."""
|
||||
file = request.files.get("file")
|
||||
if not file:
|
||||
return jsonify({"error": True, "detail": "Nessun file caricato"}), 400
|
||||
|
||||
api_key = session.get("api_key", "")
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.post(
|
||||
f"{base_url}/api/recipes/{recipe_id}/parse-technical-sheet",
|
||||
headers={"X-API-Key": api_key},
|
||||
files={"file": (file.filename, file.stream, file.content_type)},
|
||||
timeout=90,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
payload = {
|
||||
"error": True,
|
||||
"detail": resp.text or f"HTTP {resp.status_code}",
|
||||
}
|
||||
return jsonify(payload), resp.status_code
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||
import requests as http_requests
|
||||
|
||||
from flask import (
|
||||
Blueprint, Response, flash, jsonify, redirect, render_template,
|
||||
Blueprint, flash, jsonify, redirect, render_template,
|
||||
request, session, url_for,
|
||||
)
|
||||
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 config import Config
|
||||
from services.api_client import api_client
|
||||
from services.file_proxy import proxy_file
|
||||
|
||||
measure_bp = Blueprint("measure", __name__)
|
||||
|
||||
@@ -135,11 +134,15 @@ def task_execute(task_id: int):
|
||||
# Load all task IDs for this recipe (ordered) for auto-advance
|
||||
recipe_id = task_resp.get("recipe_id")
|
||||
all_task_ids = []
|
||||
measurement_interval_minutes = None
|
||||
if recipe_id:
|
||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||
if isinstance(tasks_resp, list):
|
||||
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
|
||||
all_task_ids = [t["id"] for t in sorted_tasks]
|
||||
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||
if not recipe_resp.get("error"):
|
||||
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
||||
|
||||
return render_template(
|
||||
"measure/task_execute.html",
|
||||
@@ -147,6 +150,7 @@ def task_execute(task_id: int):
|
||||
lot_number=lot_number,
|
||||
serial_number=serial_number,
|
||||
all_task_ids=all_task_ids,
|
||||
measurement_interval_minutes=measurement_interval_minutes,
|
||||
)
|
||||
|
||||
|
||||
@@ -321,6 +325,34 @@ def save_measurement():
|
||||
return jsonify(resp), 201
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: Validate supervisor credentials (AJAX)
|
||||
# ---------------------------------------------------------------------------
|
||||
@measure_bp.route("/validate-supervisor", methods=["POST"])
|
||||
@login_required
|
||||
@role_required("MeasurementTec")
|
||||
def validate_supervisor():
|
||||
"""Validate supervisor (capoturno) credentials for out-of-tolerance authorization."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
username = data.get("username", "").strip()
|
||||
password = data.get("password", "")
|
||||
|
||||
if not username or not password:
|
||||
return jsonify({"error": True, "detail": _("Username e password richiesti")}), 400
|
||||
|
||||
resp = api_client.post("/api/auth/login", data={"username": username, "password": password})
|
||||
|
||||
if resp.get("error"):
|
||||
return jsonify({"error": True, "detail": _("Credenziali non valide")}), 401
|
||||
|
||||
user = resp.get("user", {})
|
||||
is_supervisor = "Supervisor" in (user.get("roles") or [])
|
||||
if not (is_supervisor or user.get("is_admin")):
|
||||
return jsonify({"error": True, "detail": _("Utente non autorizzato (richiesto capoturno)")}), 403
|
||||
|
||||
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route: File proxy (browser can't send X-API-Key directly)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -328,16 +360,4 @@ def save_measurement():
|
||||
@login_required
|
||||
def api_get_file(file_path: str):
|
||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||
api_key = session.get("api_key", "")
|
||||
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"),
|
||||
)
|
||||
return proxy_file(file_path)
|
||||
|
||||
@@ -56,11 +56,21 @@ class APIClient:
|
||||
try:
|
||||
error_body = response.json()
|
||||
detail = error_body.get("detail", str(error_body))
|
||||
# FastAPI 422 returns detail as a list of validation errors
|
||||
# FastAPI 422 returns detail as a list of validation errors:
|
||||
# [{"type": ..., "loc": ["body", "password"], "msg": "...", ...}, ...]
|
||||
if isinstance(detail, list):
|
||||
detail = "; ".join(
|
||||
e.get("msg", str(e)) for e in detail if isinstance(e, dict)
|
||||
) or str(detail)
|
||||
parts = []
|
||||
for e in detail:
|
||||
if not isinstance(e, dict):
|
||||
parts.append(str(e))
|
||||
continue
|
||||
msg = e.get("msg", str(e))
|
||||
loc = e.get("loc") or []
|
||||
# Strip leading "body"/"query"/"path" segments; keep field path.
|
||||
field_path = [str(p) for p in loc if p not in ("body", "query", "path")]
|
||||
field = ".".join(field_path) if field_path else None
|
||||
parts.append(f"{field}: {msg}" if field else msg)
|
||||
detail = "; ".join(parts) or str(detail)
|
||||
except Exception:
|
||||
detail = response.text or f"HTTP {response.status_code}"
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Shared file proxy: relay uploads from the FastAPI server to the browser.
|
||||
|
||||
The browser can't send the X-API-Key header directly, so blueprints expose
|
||||
a proxy route and delegate here.
|
||||
"""
|
||||
import requests as http_requests
|
||||
from flask import Response, session
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
def proxy_file(file_path: str) -> Response:
|
||||
"""Fetch a file from the API server and relay it with its content type."""
|
||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||
resp = http_requests.get(
|
||||
f"{base_url}/api/files/{file_path}",
|
||||
headers={"X-API-Key": session.get("api_key", "")},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return Response(resp.text, status=resp.status_code)
|
||||
return Response(
|
||||
resp.content,
|
||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
@@ -69,6 +69,23 @@
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Brand details
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.dark ::selection {
|
||||
background: rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================
|
||||
Global Transitions
|
||||
============================================================ */
|
||||
@@ -151,6 +168,7 @@ textarea:focus-visible {
|
||||
|
||||
.tmf-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
border-color: var(--scrollbar-thumb);
|
||||
}
|
||||
|
||||
.tmf-card-header {
|
||||
@@ -250,11 +268,21 @@ textarea:focus-visible {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: #FFFFFF;
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<!-- Stylized Caliper Icon for Favicon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||
<!-- 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 -->
|
||||
<rect x="2" y="3" width="5" height="26" rx="1.5" fill="#2563EB"/>
|
||||
<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"/>
|
||||
<!-- Badge -->
|
||||
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadge)"/>
|
||||
|
||||
<!-- Sliding jaw -->
|
||||
<rect x="12" y="8" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
|
||||
<rect x="12" y="17.5" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
|
||||
<!-- Measurement end bars -->
|
||||
<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"/>
|
||||
|
||||
<!-- Depth rod -->
|
||||
<rect x="4" y="14" width="12" height="3.5" rx="1" fill="#3B82F6" opacity="0.55"/>
|
||||
|
||||
<!-- Scale markings -->
|
||||
<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"/>
|
||||
<!-- Double-headed arrow (measured span) -->
|
||||
<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>
|
||||
|
||||
|
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">
|
||||
<!-- Stylized Caliper Icon -->
|
||||
<g transform="translate(2, 2)">
|
||||
<!-- Caliper outer frame -->
|
||||
<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"/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 340 64" fill="none">
|
||||
<!-- TieMeasureFlow full logo: brand badge + wordmark (standalone asset) -->
|
||||
<defs>
|
||||
<linearGradient id="tmfBadgeFull" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3B82F6"/>
|
||||
<stop offset="1" stop-color="#1D4ED8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Caliper sliding jaw -->
|
||||
<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"/>
|
||||
<!-- Badge -->
|
||||
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadgeFull)"/>
|
||||
<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 -->
|
||||
<rect x="2" y="18" width="16" height="4" rx="1" fill="#3B82F6" opacity="0.6"/>
|
||||
|
||||
<!-- Scale markings -->
|
||||
<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"/>
|
||||
|
||||
<!-- 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>
|
||||
<!-- Wordmark: single text element with flowing tspans (cannot be clipped/overlap) -->
|
||||
<text x="76" y="42" font-family="Inter, system-ui, -apple-system, sans-serif"
|
||||
font-size="30" letter-spacing="-0.5" fill="#0F172A">
|
||||
<tspan font-weight="700">Tie</tspan><tspan font-weight="600" fill="#2563EB">Measure</tspan><tspan font-weight="700">Flow</tspan>
|
||||
</text>
|
||||
</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.
|
||||
* 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() {
|
||||
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;
|
||||
try {
|
||||
stored = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -70,6 +82,7 @@
|
||||
toggle: function () {
|
||||
this.dark = !this.dark;
|
||||
this._apply();
|
||||
this._persist();
|
||||
},
|
||||
|
||||
set: function (dark) {
|
||||
@@ -77,6 +90,24 @@
|
||||
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 () {
|
||||
if (this.dark) {
|
||||
document.documentElement.classList.add('dark');
|
||||
|
||||
@@ -671,6 +671,53 @@ function annotationEditor() {
|
||||
return rect;
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Object creation - Text
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
addText(x, y, textContent, fontSize) {
|
||||
var size = fontSize || 18;
|
||||
var itext = new fabric.IText(textContent || 'Testo', {
|
||||
left: x,
|
||||
top: y,
|
||||
fontSize: size,
|
||||
fill: this.currentColor,
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
selectable: true,
|
||||
evented: true,
|
||||
hasControls: false,
|
||||
hasBorders: true,
|
||||
lockRotation: true,
|
||||
lockScalingX: true,
|
||||
lockScalingY: true,
|
||||
objectType: 'text',
|
||||
textColor: this.currentColor,
|
||||
textFontSize: size,
|
||||
});
|
||||
|
||||
var self = this;
|
||||
itext.on('editing:exited', function () {
|
||||
if (!itext.text || itext.text.trim() === '') {
|
||||
self.canvas.remove(itext);
|
||||
}
|
||||
itext.textColor = itext.fill;
|
||||
self.isDirty = true;
|
||||
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
|
||||
});
|
||||
|
||||
itext.on('changed', function () {
|
||||
self.isDirty = true;
|
||||
});
|
||||
|
||||
this.canvas.add(itext);
|
||||
itext.setCoords();
|
||||
this.canvas.setActiveObject(itext);
|
||||
this.isDirty = true;
|
||||
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
|
||||
return itext;
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Toolbar / mode switching
|
||||
// ----------------------------------------------------------------
|
||||
@@ -678,7 +725,7 @@ function annotationEditor() {
|
||||
/**
|
||||
* Set the current interaction mode.
|
||||
*
|
||||
* @param {'select'|'marker'|'arrow'|'rect'} mode
|
||||
* @param {'select'|'marker'|'arrow'|'rect'|'text'} mode
|
||||
*/
|
||||
setMode(mode) {
|
||||
this.activeMode = mode;
|
||||
@@ -731,6 +778,13 @@ function annotationEditor() {
|
||||
self.addMarker(pointer.x, pointer.y);
|
||||
self.setMode('select');
|
||||
window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
|
||||
} else if (self.activeMode === 'text') {
|
||||
var textObj = self.addText(pointer.x, pointer.y);
|
||||
self.setMode('select');
|
||||
window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
|
||||
self.canvas.setActiveObject(textObj);
|
||||
textObj.enterEditing();
|
||||
textObj.selectAll();
|
||||
} else if (self.activeMode === 'arrow') {
|
||||
isDrawing = true;
|
||||
drawStartX = pointer.x;
|
||||
@@ -902,7 +956,7 @@ function annotationEditor() {
|
||||
setupKeyboard() {
|
||||
var self = this;
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
this._onKeyDown = function (e) {
|
||||
// Skip when typing in form fields
|
||||
var tag = e.target.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
@@ -916,7 +970,8 @@ function annotationEditor() {
|
||||
self.canvas.discardActiveObject();
|
||||
self.canvas.renderAll();
|
||||
}
|
||||
});
|
||||
};
|
||||
document.addEventListener('keydown', this._onKeyDown);
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
@@ -1023,6 +1078,13 @@ function annotationEditor() {
|
||||
if (obj.arrowLineDash) data.lineDash = obj.arrowLineDash;
|
||||
if (obj.areaLineDash) data.lineDash = obj.areaLineDash;
|
||||
|
||||
// Text-specific
|
||||
if (obj.objectType === 'text') {
|
||||
data.textContent = obj.text;
|
||||
data.textColor = obj.textColor || obj.fill;
|
||||
data.fontSize = obj.textFontSize || obj.fontSize;
|
||||
}
|
||||
|
||||
// Arrow-specific: store original endpoints
|
||||
if (obj.objectType === 'arrow') {
|
||||
data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null;
|
||||
@@ -1120,6 +1182,11 @@ function annotationEditor() {
|
||||
this.currentColor = savedColor3;
|
||||
this.currentStrokeWidth = savedWidth2;
|
||||
this.currentLineDash = savedDash2;
|
||||
} else if (obj.type === 'text') {
|
||||
var savedColor4 = this.currentColor;
|
||||
if (obj.textColor) this.currentColor = obj.textColor;
|
||||
this.addText(obj.left * cs, obj.top * cs, obj.textContent, (obj.fontSize || 18) * cs);
|
||||
this.currentColor = savedColor4;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1223,6 +1290,9 @@ function annotationEditor() {
|
||||
if (this._onImageLoaded) {
|
||||
window.removeEventListener('image-loaded', this._onImageLoaded);
|
||||
}
|
||||
if (this._onKeyDown) {
|
||||
document.removeEventListener('keydown', this._onKeyDown);
|
||||
}
|
||||
if (this.canvas) {
|
||||
this.canvas.dispose();
|
||||
this.canvas = null;
|
||||
|
||||
@@ -436,6 +436,8 @@ function _drawEditorAnnotations(ctx, data, scale) {
|
||||
_drawPreviewArrow(ctx, obj, annoScale);
|
||||
} else if (obj.type === 'area') {
|
||||
_drawPreviewArea(ctx, obj, annoScale);
|
||||
} else if (obj.type === 'text') {
|
||||
_drawPreviewText(ctx, obj, annoScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -514,6 +516,24 @@ function _drawPreviewArea(ctx, obj, scale) {
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
function _drawPreviewText(ctx, obj, scale) {
|
||||
var x = obj.left * scale;
|
||||
var y = obj.top * scale;
|
||||
var color = obj.textColor || '#000000';
|
||||
var fontSize = Math.max(10, Math.round((obj.fontSize || 18) * scale));
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = 'bold ' + fontSize + 'px Inter, sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
var lines = (obj.textContent || '').split('\n');
|
||||
var lineHeight = fontSize * 1.2;
|
||||
for (var j = 0; j < lines.length; j++) {
|
||||
ctx.fillText(lines[j], x, y + j * lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw annotations in the legacy format (markers: point/rectangle).
|
||||
*/
|
||||
|
||||
@@ -163,6 +163,17 @@ function numpad() {
|
||||
* @param {KeyboardEvent} e - The keyboard event
|
||||
*/
|
||||
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
|
||||
if (e.key >= '0' && e.key <= '9') {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
||||
x-data="systemSettings()">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Settings Card -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-header flex items-center gap-2">
|
||||
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Sessione e Sicurezza') }}
|
||||
</div>
|
||||
|
||||
<div class="tmf-card-body space-y-5">
|
||||
<!-- Auto-logout -->
|
||||
<div>
|
||||
<label class="tmf-label" for="auto-logout">{{ _('Auto-logout per inattività (minuti)') }}</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="auto-logout"
|
||||
type="number"
|
||||
x-model.number="autoLogoutMinutes"
|
||||
class="tmf-input w-32"
|
||||
min="1"
|
||||
max="480"
|
||||
placeholder="—">
|
||||
<button @click="autoLogoutMinutes = null"
|
||||
class="text-xs text-[var(--text-muted)] hover:text-red-500 transition-colors"
|
||||
x-show="autoLogoutMinutes">
|
||||
{{ _('Disabilita') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">
|
||||
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tmf-card-footer flex items-center justify-between">
|
||||
<span x-show="saved" x-transition class="text-sm text-emerald-600 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ _('Salvato') }}
|
||||
</span>
|
||||
<span x-show="errorMessage" x-transition class="text-sm text-red-600" x-text="errorMessage"></span>
|
||||
<div class="flex-1"></div>
|
||||
<button @click="save()"
|
||||
:disabled="saving"
|
||||
class="btn btn-primary gap-1.5">
|
||||
<svg x-show="!saving" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<svg x-show="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
{{ _('Salva impostazioni') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function systemSettings() {
|
||||
return {
|
||||
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
|
||||
saving: false,
|
||||
saved: false,
|
||||
errorMessage: '',
|
||||
|
||||
async save() {
|
||||
this.saving = true;
|
||||
this.saved = false;
|
||||
this.errorMessage = '';
|
||||
|
||||
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
|
||||
var payload = {
|
||||
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : ''
|
||||
};
|
||||
|
||||
try {
|
||||
var resp = await fetch('/admin/api/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.error) {
|
||||
this.errorMessage = data.detail || data.error;
|
||||
} else {
|
||||
this.saved = true;
|
||||
setTimeout(() => { this.saved = false; }, 3000);
|
||||
}
|
||||
} catch (err) {
|
||||
this.errorMessage = {{ _('Errore di connessione al server')|tojson }};
|
||||
}
|
||||
this.saving = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -258,9 +258,9 @@
|
||||
</svg>
|
||||
{{ _('Ricette disponibili') }} (<span x-text="filteredUnassignedRecipes.length"></span>)
|
||||
</h3>
|
||||
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
|
||||
<div class="border border-[var(--border-color)] rounded-lg p-2 space-y-2 h-96 overflow-y-auto">
|
||||
<template x-for="r in filteredUnassignedRecipes" :key="r.id">
|
||||
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<div class="flex items-center justify-between px-3 py-2 border border-[var(--border-color)] rounded-md hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
|
||||
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
|
||||
@@ -292,9 +292,9 @@
|
||||
</svg>
|
||||
{{ _('Assegnate alla stazione') }} (<span x-text="filteredAssignedRecipes.length"></span>)
|
||||
</h3>
|
||||
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
|
||||
<div class="border border-[var(--border-color)] rounded-lg p-2 space-y-2 h-96 overflow-y-auto">
|
||||
<template x-for="r in filteredAssignedRecipes" :key="r.id">
|
||||
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<div class="flex items-center justify-between px-3 py-2 border border-[var(--border-color)] rounded-md hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
|
||||
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
|
||||
|
||||
@@ -78,7 +78,8 @@
|
||||
:class="{
|
||||
'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-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>
|
||||
</template>
|
||||
@@ -233,6 +234,11 @@
|
||||
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||
<span class="text-sm text-[var(--text-primary)]">Metrologist</span>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -2,20 +2,26 @@
|
||||
{% block title %}Login — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% 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="max-w-md w-full space-y-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">
|
||||
|
||||
<!-- 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 -->
|
||||
<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 -->
|
||||
<div class="text-center mb-8">
|
||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
||||
alt="TieMeasureFlow Logo"
|
||||
class="mx-auto h-16 w-auto mb-4"
|
||||
onerror="this.style.display='none'">
|
||||
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
TieMeasureFlow
|
||||
</h2>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
<div class="mx-auto mb-4 inline-flex justify-center text-slate-900 dark:text-white">
|
||||
{% set logo_class = 'h-14 w-14' %}
|
||||
{% set wordmark_class = 'text-3xl' %}
|
||||
{% set logo_id = 'login' %}
|
||||
{% include 'components/_app_logo.html' %}
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
{{ _('Accedi al sistema') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -25,12 +31,12 @@
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Username -->
|
||||
<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') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
@@ -39,36 +45,52 @@
|
||||
name="username"
|
||||
required
|
||||
autofocus
|
||||
autocomplete="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>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
<div x-data="{ show: false }">
|
||||
<label for="password" class="tmf-label">
|
||||
{{ _('Password') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<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" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="password"
|
||||
<input :type="show ? 'text' : 'password'"
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
autocomplete="current-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>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div>
|
||||
<button type="submit"
|
||||
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 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<button type="submit" class="btn btn-primary btn-lg w-full shadow-sm">
|
||||
<svg class="h-5 w-5" 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" />
|
||||
</svg>
|
||||
{{ _('Accedi') }}
|
||||
@@ -78,7 +100,7 @@
|
||||
|
||||
<!-- Help Text -->
|
||||
<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?') }}
|
||||
<br>
|
||||
{{ _('Contatta l\'amministratore') }}
|
||||
@@ -87,8 +109,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
<p>TieMeasureFlow © 2025 - {{ _('Sistema di misurazione industriale') }}</p>
|
||||
<div class="text-center text-xs text-[var(--text-muted)]">
|
||||
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
|
||||
</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
|
||||
{% 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
|
||||
{% 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 %}
|
||||
bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600
|
||||
{% endif %}">
|
||||
|
||||
@@ -24,16 +24,19 @@
|
||||
<!-- Theme CSS Variables -->
|
||||
<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) -->
|
||||
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body class="h-full bg-[var(--bg-primary)] text-[var(--text-primary)] font-sans antialiased transition-colors duration-300">
|
||||
<body class="{% block body_class %}h-full{% endblock %} bg-[var(--bg-primary)] text-[var(--text-primary)] font-sans antialiased transition-colors duration-300">
|
||||
|
||||
<!-- Alpine.js App Wrapper -->
|
||||
<div x-data class="min-h-full flex flex-col">
|
||||
<div x-data class="{% block wrapper_class %}min-h-full{% endblock %} flex flex-col">
|
||||
|
||||
<!-- Navbar -->
|
||||
{% include "components/navbar.html" %}
|
||||
@@ -98,21 +101,91 @@
|
||||
{% endwith %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1">
|
||||
<main class="{% block main_class %}flex-1{% endblock %}">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
{% block footer %}
|
||||
<!-- Footer -->
|
||||
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
|
||||
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
|
||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic
|
||||
</p>
|
||||
</footer>
|
||||
{% endblock %}
|
||||
|
||||
</div>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
{% if current_user and auto_logout_minutes %}
|
||||
<script>
|
||||
function inactivityTimer() {
|
||||
var timeoutMs = {{ auto_logout_minutes }} * 60 * 1000;
|
||||
var warningMs = 60 * 1000;
|
||||
return {
|
||||
lastActivity: Date.now(),
|
||||
showWarning: false,
|
||||
remainingSeconds: 60,
|
||||
_interval: null,
|
||||
init() {
|
||||
var self = this;
|
||||
this._interval = setInterval(function() { self._check(); }, 5000);
|
||||
var lastUpdate = 0;
|
||||
var handler = function() {
|
||||
var now = Date.now();
|
||||
if (now - lastUpdate > 3000) {
|
||||
lastUpdate = now;
|
||||
self.lastActivity = now;
|
||||
self.showWarning = false;
|
||||
}
|
||||
};
|
||||
['mousemove','mousedown','keypress','touchstart','scroll'].forEach(function(evt) {
|
||||
window.addEventListener(evt, handler, { passive: true });
|
||||
});
|
||||
},
|
||||
resetTimer() {
|
||||
this.lastActivity = Date.now();
|
||||
this.showWarning = false;
|
||||
},
|
||||
_check() {
|
||||
var elapsed = Date.now() - this.lastActivity;
|
||||
var remaining = timeoutMs - elapsed;
|
||||
if (remaining <= 0) {
|
||||
clearInterval(this._interval);
|
||||
window.location.href = '{{ url_for("auth.logout") }}';
|
||||
} else if (remaining <= warningMs) {
|
||||
this.showWarning = true;
|
||||
this.remainingSeconds = Math.ceil(remaining / 1000);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<div x-data="inactivityTimer()" class="contents">
|
||||
<template x-if="showWarning">
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" @click.self="resetTimer()">
|
||||
<div class="bg-[var(--bg-primary)] rounded-xl p-6 shadow-2xl max-w-sm mx-4 border border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{{ _('Sessione in scadenza') }}</h3>
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-5">
|
||||
{{ _('Sarai disconnesso tra') }} <span class="font-mono font-bold text-amber-600" x-text="remainingSeconds"></span> {{ _('secondi per inattività.') }}
|
||||
</p>
|
||||
<button @click="resetTimer()" class="btn btn-primary w-full">
|
||||
{{ _('Continua a lavorare') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<!--
|
||||
TieMeasureFlow logo: brand badge (SVG) + HTML wordmark.
|
||||
The wordmark inherits `color` from the parent (set e.g. `text-slate-900
|
||||
dark:text-white`); "Measure" keeps the brand blue.
|
||||
|
||||
Parameters (set before include):
|
||||
logo_class — badge size, default 'h-10 w-10'
|
||||
wordmark_class — wordmark text size, default 'text-xl'; '' hides the text
|
||||
logo_id — unique suffix for the SVG gradient id when the logo
|
||||
appears more than once per page (default 'tmf')
|
||||
-->
|
||||
{% set _logo_id = logo_id|default('tmf') %}
|
||||
<span class="inline-flex items-center gap-2.5 select-none whitespace-nowrap">
|
||||
<svg class="{{ logo_class|default('h-10 w-10') }} shrink-0" viewBox="0 0 64 64" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="tmfBadge-{{ _logo_id }}" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#3B82F6"/>
|
||||
<stop offset="1" stop-color="#1D4ED8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadge-{{ _logo_id }})"/>
|
||||
<rect x="14" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||
<rect x="45" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||
<path d="M24 32 H40" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M28 26.5 L22.5 32 L28 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M36 26.5 L41.5 32 L36 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
{% if wordmark_class is not defined or wordmark_class %}
|
||||
<span class="{{ wordmark_class|default('text-xl') }} font-bold tracking-tight leading-none"
|
||||
aria-label="TieMeasureFlow">Tie<span class="text-primary-600 dark:text-primary-400">Measure</span>Flow</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -1,19 +1,60 @@
|
||||
<!-- TieMeasureFlow Navbar -->
|
||||
|
||||
{# 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"
|
||||
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="flex items-center justify-between h-14">
|
||||
|
||||
<!-- Left: Logo -->
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ url_for('index') }}" class="flex items-center gap-2.5 group">
|
||||
<a href="{{ url_for('index') }}"
|
||||
class="flex items-center gap-2.5 group text-slate-900 dark:text-white">
|
||||
{% if company_logo %}
|
||||
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-8 w-auto"
|
||||
onerror="this.src='{{ url_for('static', filename='img/tmflow-logo.svg') }}'">
|
||||
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-10 w-auto"
|
||||
onerror="this.style.display='none'">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
||||
alt="TieMeasureFlow"
|
||||
class="h-8 w-auto">
|
||||
{% set logo_class = 'h-9 w-9' %}
|
||||
{% set wordmark_class = 'text-xl' %}
|
||||
{% set logo_id = 'nav' %}
|
||||
{% include 'components/_app_logo.html' %}
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
@@ -21,89 +62,7 @@
|
||||
<!-- Center: Navigation Links (Desktop) -->
|
||||
{% if current_user %}
|
||||
<div class="hidden md:flex items-center gap-1">
|
||||
|
||||
{# MeasurementTec: Misure #}
|
||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('measure.') %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Clipboard Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
<span>{{ _('Misure') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Maker: Ricette #}
|
||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
||||
<a href="{{ url_for('maker.recipe_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('maker.') %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Edit/Pencil Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
<span>{{ _('Ricette') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Metrologist: Statistiche #}
|
||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||
<a href="{{ url_for('statistics.dashboard') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('statistics.') %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Chart Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
<span>{{ _('Statistiche') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Admin: Utenti #}
|
||||
{% if current_user.get('is_admin') %}
|
||||
<a href="{{ url_for('admin.user_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint == 'admin.user_list' %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Users Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
</svg>
|
||||
<span>{{ _('Utenti') }}</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.station_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint == 'admin.station_list' %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Station / Workstation Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
||||
</svg>
|
||||
<span>{{ _('Stazioni') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{{ nav_links() }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -112,24 +71,17 @@
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<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
|
||||
{% if current_language == 'it' %}
|
||||
{% if current_language == lang_code %}
|
||||
bg-primary text-white
|
||||
{% else %}
|
||||
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
||||
{% endif %}">
|
||||
IT
|
||||
</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
|
||||
{{ lang_code|upper }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
@@ -199,6 +151,15 @@
|
||||
|
||||
<div class="border-t border-[var(--border-color)] my-1"></div>
|
||||
|
||||
{% if request.endpoint == 'measure.task_execute' %}
|
||||
<span class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-muted)] opacity-50 cursor-not-allowed"
|
||||
title="{{ _('Logout bloccato durante le misurazioni') }}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
{{ _('Logout') }}
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.logout') }}"
|
||||
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail
|
||||
hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
@@ -207,6 +168,7 @@
|
||||
</svg>
|
||||
{{ _('Logout') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -241,66 +203,7 @@
|
||||
x-cloak
|
||||
class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]">
|
||||
<div class="px-4 py-3 space-y-1">
|
||||
|
||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
{{ _('Misure') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
||||
<a href="{{ url_for('maker.recipe_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
{{ _('Ricette') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||
<a href="{{ url_for('statistics.dashboard') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
{{ _('Statistiche') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Admin: Utenti + Stazioni #}
|
||||
{% if current_user.get('is_admin') %}
|
||||
<a href="{{ url_for('admin.user_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
</svg>
|
||||
{{ _('Utenti') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.station_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
||||
</svg>
|
||||
{{ _('Stazioni') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{{ nav_links(mobile=true) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -231,6 +231,23 @@
|
||||
rows="3"
|
||||
placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Intervallo di misura -->
|
||||
<div>
|
||||
<label class="tmf-label" for="recipe-interval">
|
||||
{{ _('Intervallo misura (minuti)') }}
|
||||
</label>
|
||||
<input id="recipe-interval"
|
||||
type="number"
|
||||
x-model.number="measurementIntervalMinutes"
|
||||
class="tmf-input"
|
||||
min="1"
|
||||
max="1440"
|
||||
placeholder="{{ _('Es. 30') }}">
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">
|
||||
{{ _('Timer cicalino per ricordare la misurazione periodica') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -466,6 +483,9 @@ function recipeEditor() {
|
||||
versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }},
|
||||
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
|
||||
|
||||
// ---- Measurement interval ----
|
||||
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
|
||||
|
||||
// ---- File upload (preview image) ----
|
||||
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
|
||||
uploadingFile: false,
|
||||
@@ -487,6 +507,11 @@ function recipeEditor() {
|
||||
description: this.description.trim(),
|
||||
};
|
||||
|
||||
// Measurement interval
|
||||
if (this.measurementIntervalMinutes) {
|
||||
payload.measurement_interval_minutes = parseInt(this.measurementIntervalMinutes, 10);
|
||||
}
|
||||
|
||||
// Include image_path for preview thumbnail
|
||||
if (this.currentFilePath) {
|
||||
payload.image_path = this.currentFilePath;
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
</div>
|
||||
<input type="text"
|
||||
x-model="search"
|
||||
class="tmf-input pl-10"
|
||||
class="tmf-input !pl-10"
|
||||
:placeholder="'{{ _('Cerca per nome, codice o descrizione...') }}'">
|
||||
</div>
|
||||
</div>
|
||||
@@ -152,11 +152,11 @@
|
||||
<div class="p-5 sm:p-6">
|
||||
<!-- Card Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-start gap-4 mb-4">
|
||||
<!-- Thumbnail -->
|
||||
<!-- Thumbnail (fit, no crop) -->
|
||||
<template x-if="recipe.image_path">
|
||||
<div class="w-20 h-20 rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0">
|
||||
<div class="w-20 h-20 rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0 flex items-center justify-center">
|
||||
<img :src="'/maker/api/files/' + recipe.image_path"
|
||||
class="w-full h-full object-cover"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
loading="lazy"
|
||||
onerror="this.parentElement.style.display='none'">
|
||||
</div>
|
||||
|
||||
@@ -247,6 +247,18 @@
|
||||
<span class="hidden sm:inline">{{ _('Rettangolo') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Text Tool -->
|
||||
<button type="button"
|
||||
class="anno-btn"
|
||||
:class="{ 'active': annoTool === 'text' }"
|
||||
@click="setAnnoTool('text')"
|
||||
title="{{ _('Testo') }}">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 6v2m16-2v2M7 6v12m0 0h2m-2 0H5m12-12v12m0 0h2m-2 0h-2"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ _('Testo') }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="w-px h-6 bg-[var(--border-color)] mx-1"></div>
|
||||
|
||||
|
||||
@@ -221,14 +221,24 @@
|
||||
<span x-text="tasks.length"></span>
|
||||
<span x-text="tasks.length === 1 ? '{{ _('task') }}' : '{{ _('task') }}'"></span>
|
||||
</p>
|
||||
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
|
||||
x-show="!showAddTask"
|
||||
class="btn btn-primary gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{{ _('Aggiungi Task') }}
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
|
||||
x-show="!showAddTask"
|
||||
class="btn btn-primary gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{{ _('Aggiungi Task') }}
|
||||
</button>
|
||||
<button @click="showAiImport = true"
|
||||
x-show="!showAddTask"
|
||||
class="btn btn-secondary gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
{{ _('Importa da PDF') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
@@ -269,12 +279,12 @@
|
||||
placeholder="{{ _('Es. Seguire procedura ISO 2768') }}">
|
||||
</div>
|
||||
<!-- Descrizione -->
|
||||
<div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||
<input type="text"
|
||||
x-model="newTask.description"
|
||||
class="tmf-input"
|
||||
placeholder="{{ _('Descrizione opzionale...') }}">
|
||||
<textarea x-model="newTask.description"
|
||||
class="tmf-input text-sm"
|
||||
rows="3"
|
||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -497,7 +507,7 @@
|
||||
<template x-if="task.description">
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide shrink-0 mt-0.5">{{ _('Descrizione') }}:</span>
|
||||
<span class="text-sm text-[var(--text-secondary)]" x-text="task.description"></span>
|
||||
<span class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap" x-text="task.description"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -513,12 +523,12 @@
|
||||
class="tmf-input text-sm"
|
||||
placeholder="{{ _('Direttiva opzionale...') }}">
|
||||
</div>
|
||||
<div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||
<input type="text"
|
||||
x-model="editTaskData.description"
|
||||
class="tmf-input text-sm"
|
||||
placeholder="{{ _('Descrizione opzionale...') }}">
|
||||
<textarea x-model="editTaskData.description"
|
||||
class="tmf-input text-sm"
|
||||
rows="3"
|
||||
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -930,6 +940,118 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
AI IMPORT MODAL — Upload PDF + preview suggested tasks
|
||||
================================================================ #}
|
||||
<div x-show="showAiImport"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
@click.self="showAiImport = false">
|
||||
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl max-w-2xl w-full mx-4 max-h-[85vh] flex flex-col border border-[var(--border-color)]">
|
||||
|
||||
{# Header #}
|
||||
<div class="p-5 border-b border-[var(--border-color)] shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-[var(--text-primary)]">{{ _('Importa da Scheda Tecnica') }}</h3>
|
||||
<p class="text-xs text-[var(--text-secondary)]">{{ _('Carica un PDF e l\'AI estrarrà i task automaticamente') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Body #}
|
||||
<div class="p-5 overflow-y-auto flex-1">
|
||||
{# Upload area (before analysis) #}
|
||||
<div x-show="!aiSuggestions.length && !aiParsing">
|
||||
<label class="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-[var(--border-color)] rounded-xl cursor-pointer
|
||||
hover:border-primary hover:bg-primary-50/50 dark:hover:bg-primary-900/10 transition-colors">
|
||||
<svg class="w-10 h-10 text-[var(--text-muted)] mb-2" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Clicca per caricare un PDF') }}</span>
|
||||
<span class="text-xs text-[var(--text-muted)] mt-1">{{ _('Max 20MB') }}</span>
|
||||
<input type="file" accept=".pdf,application/pdf" class="hidden" @change="uploadAndParse($event)">
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{# Loading #}
|
||||
<div x-show="aiParsing" class="flex flex-col items-center justify-center py-12">
|
||||
<svg class="w-10 h-10 animate-spin text-primary mb-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Analisi in corso con AI...') }}</p>
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Potrebbe richiedere fino a 30 secondi') }}</p>
|
||||
</div>
|
||||
|
||||
{# Error #}
|
||||
<div x-show="aiError" class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
|
||||
<p class="text-sm text-red-700 dark:text-red-300" x-text="aiError"></p>
|
||||
<button @click="aiError = ''; aiSuggestions = []" class="text-xs text-red-600 underline mt-1">{{ _('Riprova') }}</button>
|
||||
</div>
|
||||
|
||||
{# Suggested tasks (editable preview) #}
|
||||
<div x-show="aiSuggestions.length > 0" class="space-y-3">
|
||||
<p class="text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
<span x-text="aiSuggestions.length"></span> {{ _('task suggeriti — modifica o rimuovi prima di confermare') }}
|
||||
</p>
|
||||
<template x-for="(suggestion, idx) in aiSuggestions" :key="idx">
|
||||
<div class="tmf-card">
|
||||
<div class="p-4 space-y-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="text-xs font-bold text-primary">Task <span x-text="idx + 1"></span></span>
|
||||
<button @click="aiSuggestions.splice(idx, 1)" class="text-xs text-red-500 hover:text-red-700">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<input type="text" x-model="suggestion.title" class="tmf-input text-sm font-semibold"
|
||||
placeholder="{{ _('Titolo') }}">
|
||||
<input type="text" x-model="suggestion.directive" class="tmf-input text-sm"
|
||||
placeholder="{{ _('Direttiva') }}">
|
||||
<textarea x-model="suggestion.description" class="tmf-input text-sm" rows="3"
|
||||
placeholder="{{ _('Descrizione') }}"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Footer #}
|
||||
<div class="p-4 border-t border-[var(--border-color)] shrink-0 flex items-center justify-between gap-2">
|
||||
<button @click="showAiImport = false; aiSuggestions = []; aiError = ''"
|
||||
class="btn btn-secondary text-sm">
|
||||
{{ _('Annulla') }}
|
||||
</button>
|
||||
<button x-show="aiSuggestions.length > 0"
|
||||
@click="createTasksFromSuggestions()"
|
||||
:disabled="aiCreating"
|
||||
class="btn btn-primary text-sm gap-1.5">
|
||||
<svg x-show="!aiCreating" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<svg x-show="aiCreating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
{{ _('Crea') }} <span x-text="aiSuggestions.length"></span> {{ _('task') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -983,6 +1105,13 @@ function taskEditor() {
|
||||
// ---- Drag & Drop state ----
|
||||
dragState: { dragging: null, over: null, position: null },
|
||||
|
||||
// ---- AI Import ----
|
||||
showAiImport: false,
|
||||
aiParsing: false,
|
||||
aiCreating: false,
|
||||
aiSuggestions: [],
|
||||
aiError: '',
|
||||
|
||||
// ============================================================
|
||||
// Init
|
||||
// ============================================================
|
||||
@@ -1081,6 +1210,80 @@ function taskEditor() {
|
||||
this.newTask = { title: '', directive: '', description: '' };
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// AI Import
|
||||
// ============================================================
|
||||
async uploadAndParse(event) {
|
||||
var file = event.target.files[0];
|
||||
if (!file) return;
|
||||
this.aiParsing = true;
|
||||
this.aiError = '';
|
||||
this.aiSuggestions = [];
|
||||
|
||||
try {
|
||||
var formData = new FormData();
|
||||
formData.append('file', file);
|
||||
var resp = await fetch('/maker/api/recipes/' + this.recipeId + '/parse-technical-sheet', {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRFToken': this.csrfToken() },
|
||||
body: formData
|
||||
});
|
||||
var data = await resp.json();
|
||||
|
||||
if (!resp.ok || data.error) {
|
||||
this.aiError = data.detail || {{ _("Errore nell'analisi del PDF")|tojson }};
|
||||
} else {
|
||||
this.aiSuggestions = data.suggested_tasks || [];
|
||||
if (!this.aiSuggestions.length) {
|
||||
this.aiError = {{ _("Nessun task identificato nel PDF")|tojson }};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('AI parse error:', err);
|
||||
this.aiError = {{ _("Errore di connessione al server")|tojson }};
|
||||
}
|
||||
|
||||
this.aiParsing = false;
|
||||
event.target.value = '';
|
||||
},
|
||||
|
||||
async createTasksFromSuggestions() {
|
||||
this.aiCreating = true;
|
||||
var created = 0;
|
||||
|
||||
for (var i = 0; i < this.aiSuggestions.length; i++) {
|
||||
var s = this.aiSuggestions[i];
|
||||
if (!s.title || !s.title.trim()) continue;
|
||||
|
||||
try {
|
||||
var resp = await fetch('/maker/api/recipes/' + this.recipeId + '/tasks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken() },
|
||||
body: JSON.stringify({
|
||||
title: s.title.trim(),
|
||||
directive: (s.directive || '').trim() || null,
|
||||
description: (s.description || '').trim() || null
|
||||
})
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (!data.error) {
|
||||
if (!data.subtasks) data.subtasks = [];
|
||||
this.tasks.push(data);
|
||||
created++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Create task error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
this.aiCreating = false;
|
||||
this.showAiImport = false;
|
||||
this.aiSuggestions = [];
|
||||
if (created > 0) {
|
||||
this.successMessage = created + ' ' + {{ _("task creati dalla scheda tecnica")|tojson }};
|
||||
}
|
||||
},
|
||||
|
||||
async addTask() {
|
||||
if (!this.newTask.title.trim()) return;
|
||||
this.saving = true;
|
||||
|
||||
@@ -92,40 +92,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Traceability Bar -->
|
||||
<div class="tmf-card mb-6">
|
||||
<!-- Search Bar (filter) -->
|
||||
<div class="tmf-card mb-4">
|
||||
<div class="p-4 sm:p-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="tmf-label">
|
||||
<svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{{ _('Cerca ricetta') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="search"
|
||||
placeholder="{{ _('Nome, codice o descrizione...') }}"
|
||||
class="tmf-input">
|
||||
</div>
|
||||
<label class="tmf-label">
|
||||
<svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{{ _('Cerca ricetta') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="search"
|
||||
placeholder="{{ _('Nome, codice o descrizione...') }}"
|
||||
class="tmf-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Traceability (data entry, NOT a search filter) -->
|
||||
<div class="tmf-card mb-6 border-l-4 border-l-primary">
|
||||
<div class="p-4 sm:p-5">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<svg class="w-5 h-5 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
<div>
|
||||
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}</h2>
|
||||
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<!-- Lot Number -->
|
||||
<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"
|
||||
x-model="lot_number"
|
||||
placeholder="{{ _('Opzionale') }}"
|
||||
class="tmf-input">
|
||||
placeholder="{{ _('Es. LOT-2026-001 (opzionale)') }}"
|
||||
class="tmf-input font-mono">
|
||||
</div>
|
||||
|
||||
<!-- Serial Number -->
|
||||
<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"
|
||||
x-model="serial_number"
|
||||
placeholder="{{ _('Opzionale') }}"
|
||||
class="tmf-input">
|
||||
placeholder="{{ _('Es. SN-000123 (opzionale)') }}"
|
||||
class="tmf-input font-mono">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,15 +178,30 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Recipe Name -->
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2 leading-snug"
|
||||
x-text="recipe.name">
|
||||
</h3>
|
||||
<!-- Title + Thumbnail row -->
|
||||
<div class="flex items-start gap-3 mb-2">
|
||||
<!-- Thumbnail (fit, no crop) -->
|
||||
<template x-if="recipe.image_path">
|
||||
<div class="w-16 h-16 rounded-md overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0 flex items-center justify-center">
|
||||
<img :src="'/measure/api/files/' + recipe.image_path"
|
||||
class="max-w-full max-h-full object-contain"
|
||||
loading="lazy"
|
||||
onerror="this.parentElement.style.display='none'">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
|
||||
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'">
|
||||
</p>
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Recipe Name -->
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] leading-snug"
|
||||
x-text="recipe.name">
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
|
||||
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'">
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta Info -->
|
||||
<div class="mt-4 flex items-center gap-3 text-xs text-[var(--text-muted)]">
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{# Full-screen layout: no body scroll, no footer, main fills available space #}
|
||||
{% block body_class %}h-screen overflow-hidden{% endblock %}
|
||||
{% block wrapper_class %}h-full{% endblock %}
|
||||
{% block main_class %}flex-1 min-h-0{% endblock %}
|
||||
{% block footer %}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* Annotation viewer container */
|
||||
@@ -57,7 +63,7 @@
|
||||
window.__allTaskIds = {{ all_task_ids|tojson }};
|
||||
</script>
|
||||
|
||||
<div class="h-screen flex flex-col overflow-hidden"
|
||||
<div class="h-full flex flex-col overflow-hidden"
|
||||
x-data="taskExecute()"
|
||||
x-init="init()"
|
||||
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
||||
@@ -78,7 +84,7 @@
|
||||
</a>
|
||||
|
||||
{# Task badge + title #}
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
|
||||
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
|
||||
border border-primary-200 dark:border-primary-800">
|
||||
@@ -89,6 +95,45 @@
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{# Lista task + Riepilogo buttons #}
|
||||
<div class="shrink-0 flex items-center gap-1.5">
|
||||
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
||||
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
{{ _('Lista task') }}
|
||||
</a>
|
||||
<button @click="goToSummary()"
|
||||
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||
</svg>
|
||||
{{ _('Riepilogo') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{# Fermo linea + Fine produzione (measurement tasks only) #}
|
||||
<template x-if="subtasks.length > 0">
|
||||
<div class="shrink-0 flex items-center gap-1.5">
|
||||
<button @click="openSupervisorModal('fermo_linea')"
|
||||
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-amber-500 text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Fermo linea') }}
|
||||
</button>
|
||||
<button @click="openSupervisorModal('fine_produzione')"
|
||||
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-red-500 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
|
||||
</svg>
|
||||
{{ _('Fine Produzione') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{# Lot + Serial badges #}
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{% if lot_number %}
|
||||
@@ -124,9 +169,10 @@
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
LEFT SIDEBAR — Marker list (vertical)
|
||||
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
|
||||
<div x-show="subtasks.length > 0"
|
||||
class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
|
||||
<template x-for="(st, idx) in subtasks" :key="st.id">
|
||||
<button @click="goToSubtask(idx)"
|
||||
class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
|
||||
@@ -213,9 +259,10 @@
|
||||
</div>
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
RIGHT PANEL — Info + tolerances + numpad
|
||||
RIGHT PANEL — Info + tolerances + numpad (hidden when no subtasks)
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
|
||||
<div x-show="subtasks.length > 0"
|
||||
class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
|
||||
|
||||
{# ---- Subtask header ---- #}
|
||||
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
||||
@@ -310,7 +357,7 @@
|
||||
</div>
|
||||
|
||||
{# ---- Measurement feedback ---- #}
|
||||
<div class="px-3 pt-2" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
|
||||
<div class="px-3 pt-2" x-show="currentSubtask" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
|
||||
get utl() { return currentSubtask?.utl || 0; },
|
||||
get uwl() { return currentSubtask?.uwl || 0; },
|
||||
get lwl() { return currentSubtask?.lwl || 0; },
|
||||
@@ -319,8 +366,8 @@
|
||||
{% include "components/measurement_feedback.html" %}
|
||||
</div>
|
||||
|
||||
{# ---- Numpad ---- #}
|
||||
<div class="px-3 py-2 relative flex-1">
|
||||
{# ---- Numpad (only when a subtask is active) ---- #}
|
||||
<div class="px-3 py-2 relative flex-1" x-show="currentSubtask">
|
||||
{# Saving overlay #}
|
||||
<div x-show="saving"
|
||||
x-transition
|
||||
@@ -338,10 +385,11 @@
|
||||
</div>
|
||||
|
||||
{# ---- Next measurement indicator ---- #}
|
||||
<div class="px-3 pb-2">
|
||||
<div class="px-3 pb-2" x-show="currentSubtask">
|
||||
{% include "components/next_measurement.html" %}
|
||||
</div>
|
||||
|
||||
|
||||
{# ---- Error message ---- #}
|
||||
<div x-show="errorMessage"
|
||||
x-transition
|
||||
@@ -358,6 +406,62 @@
|
||||
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
MEASUREMENT TIMER BANNER
|
||||
================================================================ #}
|
||||
<div x-show="timerActive"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="shrink-0 bg-amber-50 dark:bg-amber-900/20 border-t border-amber-300 dark:border-amber-700 px-4 py-2">
|
||||
<div class="flex items-center justify-center gap-3">
|
||||
<svg class="w-5 h-5 text-amber-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||
{{ _('Prossima misurazione tra') }}
|
||||
</span>
|
||||
<span class="text-lg font-bold font-mono text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 rounded"
|
||||
x-text="timerDisplay"></span>
|
||||
<span class="text-xs text-amber-600 dark:text-amber-400">
|
||||
({{ _('Ciclo') }} #<span x-text="cycleCount"></span>)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
AVVIO PRODUZIONE — visible after first cycle, before production started
|
||||
================================================================ #}
|
||||
<div x-show="cycleConfirmed && cycleCount === 1 && !productionStarted && subtasks.length > 0"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="shrink-0 border-t border-[var(--border-color)] bg-[var(--bg-card)] px-4 py-3">
|
||||
<div class="flex items-center justify-center">
|
||||
<button @click="startProduction()"
|
||||
class="btn gap-2 px-6 py-2.5 text-sm font-bold
|
||||
bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg hover:shadow-xl
|
||||
rounded-xl transition-all duration-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||
</svg>
|
||||
{{ _('Avvio Produzione') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-center text-[var(--text-muted)] mt-1.5">
|
||||
{{ _('Invia segnale al gestionale per avviare il timer della linea') }}
|
||||
</p>
|
||||
</div>
|
||||
<div x-show="productionStarted && subtasks.length > 0"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="shrink-0 border-t border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20 px-4 py-1.5">
|
||||
<div class="flex items-center justify-center gap-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<span class="font-medium">{{ _('Produzione avviata') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
FOOTER — Progress bar + navigation
|
||||
================================================================ #}
|
||||
@@ -388,19 +492,47 @@
|
||||
x-text="Math.round(progressPercent) + '%'"></span>
|
||||
</div>
|
||||
|
||||
{# Right: Summary button #}
|
||||
<button x-show="isComplete"
|
||||
x-transition
|
||||
@click="goToSummary()"
|
||||
class="btn btn-primary text-xs shrink-0 gap-1 py-1.5 px-2.5 shadow-md">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Riepilogo') }}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
{# Right: Fine ciclo misura (measurement tasks) / Completato (non-measurement or after cycle) #}
|
||||
<template x-if="subtasks.length > 0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<button x-show="isComplete && !cycleConfirmed"
|
||||
x-transition
|
||||
@click="confirmCycle()"
|
||||
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
||||
bg-primary text-white hover:bg-primary-700">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Fine ciclo misura') }}
|
||||
</button>
|
||||
<button x-show="cycleConfirmed"
|
||||
x-transition
|
||||
@click="goToNextTask()"
|
||||
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
||||
bg-measure-pass text-white hover:opacity-90">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ _('Completato') }}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="subtasks.length === 0">
|
||||
<button @click="goToNextTask()"
|
||||
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
||||
bg-measure-pass text-white hover:opacity-90">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ _('Completato') }}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -447,13 +579,100 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="goToSummary()"
|
||||
class="btn btn-primary w-full justify-center gap-2">
|
||||
{{ _('Vai al Riepilogo') }}
|
||||
<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="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="flex gap-2">
|
||||
<button @click="goToSummary()"
|
||||
class="btn btn-secondary flex-1 justify-center gap-2">
|
||||
{{ _('Riepilogo') }}
|
||||
</button>
|
||||
|
||||
{# Production phase: confirm the cycle (starts the interval timer) #}
|
||||
<button x-show="productionStarted"
|
||||
@click="showCompletionOverlay = false; cycleConfirmed = true"
|
||||
class="btn btn-primary flex-1 justify-center gap-2">
|
||||
{{ _('Conferma ciclo') }}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{# START phase, more tasks to go: advance without blocking or exiting #}
|
||||
<button x-show="!productionStarted && !isLastTask"
|
||||
@click="goToNextTask()"
|
||||
class="btn btn-primary flex-1 justify-center gap-2">
|
||||
{{ _('Task successivo') }}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{# START phase, last task: start production #}
|
||||
<button x-show="!productionStarted && isLastTask"
|
||||
@click="startProductionFromOverlay()"
|
||||
class="btn flex-1 justify-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||
{{ _('Avvio Produzione') }}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
SUPERVISOR LOGIN MODAL
|
||||
================================================================ #}
|
||||
<div x-show="showSupervisorModal"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
@click.self="closeSupervisorModal()">
|
||||
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-6 max-w-sm mx-4 w-full border border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-[var(--text-primary)]">{{ _('Autorizzazione capoturno') }}</h3>
|
||||
<p class="text-xs text-[var(--text-secondary)]" x-text="supervisorReason"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-5">
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
||||
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
|
||||
placeholder="{{ _('Username capoturno') }}" @keydown.enter="validateSupervisor()">
|
||||
</div>
|
||||
<div>
|
||||
<label class="tmf-label text-xs">{{ _('Password') }}</label>
|
||||
<input type="password" x-model="supervisorPassword" class="tmf-input text-sm"
|
||||
placeholder="••••••••" @keydown.enter="validateSupervisor()">
|
||||
</div>
|
||||
<p x-show="supervisorError" class="text-xs text-red-600" x-text="supervisorError"></p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button @click="closeSupervisorModal()"
|
||||
class="btn btn-secondary flex-1 text-sm">
|
||||
{{ _('Annulla') }}
|
||||
</button>
|
||||
<button @click="validateSupervisor()"
|
||||
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
|
||||
class="btn btn-primary flex-1 text-sm gap-1">
|
||||
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
{{ _('Autorizza') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -489,6 +708,24 @@ function taskExecute() {
|
||||
errorMessage: '',
|
||||
showCompletionOverlay: false,
|
||||
|
||||
// ---- Measurement timer ----
|
||||
measurementIntervalMinutes: {{ measurement_interval_minutes|tojson if measurement_interval_minutes else 'null' }},
|
||||
timerActive: false,
|
||||
timerRemaining: 0,
|
||||
_timerInterval: null,
|
||||
cycleCount: 0,
|
||||
productionStarted: false,
|
||||
|
||||
// ---- Cycle & workflow state ----
|
||||
cycleConfirmed: false,
|
||||
showSupervisorModal: false,
|
||||
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
||||
supervisorUsername: '',
|
||||
supervisorPassword: '',
|
||||
supervisorError: '',
|
||||
supervisorValidating: false,
|
||||
pendingAdvance: false,
|
||||
|
||||
// ---- Value from numpad / caliper ----
|
||||
currentValue: null,
|
||||
|
||||
@@ -641,15 +878,24 @@ function taskExecute() {
|
||||
// Pause to show result feedback
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Out-of-tolerance: block advancement, require supervisor
|
||||
if (pf === 'fail') {
|
||||
this.pendingAdvance = true;
|
||||
this.openSupervisorModal('out_of_tolerance');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if all done
|
||||
if (this.completedCount >= this.totalSubtasks) {
|
||||
const taskIds = window.__allTaskIds || [];
|
||||
const currentIdx = taskIds.indexOf(this.task.id);
|
||||
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
|
||||
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
|
||||
} else {
|
||||
this.showCompletionOverlay = true;
|
||||
// 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;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -696,6 +942,177 @@ function taskExecute() {
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Confirm measurement cycle (Fine ciclo misura) ----
|
||||
confirmCycle() {
|
||||
this.cycleConfirmed = true;
|
||||
this.showCompletionOverlay = false;
|
||||
this.cycleCount++;
|
||||
|
||||
// Start measurement timer if recipe has an interval
|
||||
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
||||
this.startMeasurementTimer();
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Measurement timer ----
|
||||
startMeasurementTimer() {
|
||||
this.stopMeasurementTimer();
|
||||
this.timerRemaining = this.measurementIntervalMinutes * 60;
|
||||
this.timerActive = true;
|
||||
var self = this;
|
||||
this._timerInterval = setInterval(function () {
|
||||
self.timerRemaining--;
|
||||
if (self.timerRemaining <= 0) {
|
||||
self.onTimerExpired();
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
stopMeasurementTimer() {
|
||||
if (this._timerInterval) {
|
||||
clearInterval(this._timerInterval);
|
||||
this._timerInterval = null;
|
||||
}
|
||||
this.timerActive = false;
|
||||
},
|
||||
|
||||
onTimerExpired() {
|
||||
this.stopMeasurementTimer();
|
||||
this.playBuzzer();
|
||||
// Reset for new measurement cycle
|
||||
this.cycleConfirmed = false;
|
||||
this.measurements = [];
|
||||
this.currentIndex = 0;
|
||||
this.currentValue = null;
|
||||
},
|
||||
|
||||
playBuzzer() {
|
||||
try {
|
||||
var ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
// 3 short beeps
|
||||
[0, 0.25, 0.5].forEach(function (delay) {
|
||||
var osc = ctx.createOscillator();
|
||||
var gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 880;
|
||||
osc.type = 'square';
|
||||
gain.gain.value = 0.3;
|
||||
osc.start(ctx.currentTime + delay);
|
||||
osc.stop(ctx.currentTime + delay + 0.15);
|
||||
});
|
||||
} catch (_) {}
|
||||
},
|
||||
|
||||
// ---- Avvio Produzione (GAIA placeholder) ----
|
||||
startProduction() {
|
||||
this.productionStarted = true;
|
||||
// TODO: integrazione GAIA — inviare segnale per avviare timer linea
|
||||
// await fetch('/measure/api/gaia/start-production', { method: 'POST', ... });
|
||||
},
|
||||
|
||||
// Is the current task the last one in the recipe sequence?
|
||||
get isLastTask() {
|
||||
const taskIds = window.__allTaskIds || [];
|
||||
const idx = taskIds.indexOf(this.task.id);
|
||||
return idx === -1 || idx >= taskIds.length - 1;
|
||||
},
|
||||
|
||||
// Start production from the completion overlay (last START task) and begin
|
||||
// the measurement-interval cycle if the recipe defines one.
|
||||
startProductionFromOverlay() {
|
||||
this.showCompletionOverlay = false;
|
||||
this.startProduction();
|
||||
this.cycleConfirmed = true;
|
||||
this.cycleCount++;
|
||||
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
||||
this.startMeasurementTimer();
|
||||
}
|
||||
},
|
||||
|
||||
get timerDisplay() {
|
||||
var m = Math.floor(this.timerRemaining / 60);
|
||||
var s = this.timerRemaining % 60;
|
||||
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
},
|
||||
|
||||
// ---- Navigate to next task (Completato) ----
|
||||
goToNextTask() {
|
||||
const taskIds = window.__allTaskIds || [];
|
||||
const currentIdx = taskIds.indexOf(this.task.id);
|
||||
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
|
||||
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
|
||||
} else {
|
||||
this.goToSummary();
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Open/close supervisor modal (always reset fields) ----
|
||||
openSupervisorModal(action) {
|
||||
this.supervisorAction = action;
|
||||
this.supervisorUsername = '';
|
||||
this.supervisorPassword = '';
|
||||
this.supervisorError = '';
|
||||
this.showSupervisorModal = true;
|
||||
},
|
||||
|
||||
closeSupervisorModal() {
|
||||
this.showSupervisorModal = false;
|
||||
this.supervisorUsername = '';
|
||||
this.supervisorPassword = '';
|
||||
this.supervisorError = '';
|
||||
},
|
||||
|
||||
// ---- Supervisor reason text ----
|
||||
get supervisorReason() {
|
||||
if (this.supervisorAction === 'out_of_tolerance') return '{{ _("Misurazione fuori tolleranza") }}';
|
||||
if (this.supervisorAction === 'fermo_linea') return '{{ _("Fermo linea richiesto") }}';
|
||||
if (this.supervisorAction === 'fine_produzione') return '{{ _("Fine produzione richiesta") }}';
|
||||
return '';
|
||||
},
|
||||
|
||||
// ---- Validate supervisor credentials ----
|
||||
async validateSupervisor() {
|
||||
this.supervisorError = '';
|
||||
this.supervisorValidating = true;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
const resp = await fetch('{{ url_for("measure.validate_supervisor") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify({ username: this.supervisorUsername, password: this.supervisorPassword })
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
if (!resp.ok || data.error) {
|
||||
this.supervisorError = data.detail || '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
||||
this.supervisorValidating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Authorized — close modal and proceed
|
||||
this.showSupervisorModal = false;
|
||||
this.supervisorUsername = '';
|
||||
this.supervisorPassword = '';
|
||||
this.supervisorValidating = false;
|
||||
|
||||
if (this.supervisorAction === 'out_of_tolerance') {
|
||||
this.pendingAdvance = false;
|
||||
if (this.completedCount >= this.totalSubtasks) {
|
||||
this.showCompletionOverlay = true;
|
||||
} else {
|
||||
this.advanceToNext();
|
||||
}
|
||||
}
|
||||
// fermo_linea and fine_produzione are handled by GAIA integration (future)
|
||||
|
||||
} catch (err) {
|
||||
this.supervisorError = '{{ _("Errore di connessione") }}';
|
||||
this.supervisorValidating = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Go to summary ----
|
||||
goToSummary() {
|
||||
const recipeId = this.task.recipe_id || 0;
|
||||
|
||||
@@ -27,6 +27,17 @@
|
||||
</ol>
|
||||
</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 -->
|
||||
<div class="tmf-card mb-8">
|
||||
<div class="p-5 sm:p-6">
|
||||
@@ -60,8 +71,19 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Traceability Badges -->
|
||||
<div class="flex flex-col gap-2 sm:items-end shrink-0">
|
||||
<!-- Right: AVVIA + Traceability -->
|
||||
<div class="flex flex-col gap-3 sm:items-end shrink-0">
|
||||
{% if tasks %}
|
||||
<a href="{{ url_for('measure.task_execute', task_id=tasks[0].id) }}"
|
||||
class="btn gap-3 w-full sm:w-auto justify-center text-lg font-bold
|
||||
bg-red-600 hover:bg-red-700 text-white shadow-lg hover:shadow-xl
|
||||
px-8 py-3 rounded-xl transition-all duration-200">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||
</svg>
|
||||
{{ _('AVVIA') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if lot_number %}
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||
@@ -100,83 +122,77 @@
|
||||
</h2>
|
||||
<span class="badge badge-neutral">
|
||||
{{ 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>
|
||||
</div>
|
||||
|
||||
<!-- Task Cards -->
|
||||
{% if tasks %}
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2.5">
|
||||
{% for task in tasks %}
|
||||
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
|
||||
<div class="p-5 sm:p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div class="p-3 sm:p-4">
|
||||
<div class="flex flex-row items-center gap-3">
|
||||
|
||||
<!-- 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
|
||||
text-primary-700 dark:text-primary-300 font-bold text-lg">
|
||||
text-primary-700 dark:text-primary-300 font-bold text-base">
|
||||
{{ loop.index }}
|
||||
</div>
|
||||
|
||||
<!-- Task Info -->
|
||||
<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 -->
|
||||
<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) }}
|
||||
</h3>
|
||||
|
||||
<!-- Directive -->
|
||||
{% if task.directive or task.description %}
|
||||
<p class="text-sm text-[var(--text-secondary)] leading-relaxed mb-3 line-clamp-2">
|
||||
<p class="text-xs text-[var(--text-secondary)] leading-snug mt-0.5 line-clamp-2">
|
||||
{{ task.directive or task.description }}
|
||||
</p>
|
||||
{% 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>
|
||||
|
||||
<!-- 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">
|
||||
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
||||
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
|
||||
@@ -185,12 +201,13 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Inizia Misure') }}
|
||||
{{ _('Visualizza Task') }}
|
||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,33 +232,5 @@
|
||||
</div>
|
||||
{% 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>
|
||||
{% 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
@@ -554,6 +554,65 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "48.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssselect2"
|
||||
version = "0.9.0"
|
||||
@@ -1063,6 +1122,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfminer-six"
|
||||
version = "20251230"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "charset-normalizer" },
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfplumber"
|
||||
version = "0.11.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pdfminer-six" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pypdfium2" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
@@ -1330,6 +1416,35 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pypdfium2"
|
||||
version = "5.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/3d/dc934d3b606c51c3ecc95b6731d84b7dd7ab8e513a50b0e98a4da6c8a719/pypdfium2-5.8.0.tar.gz", hash = "sha256:049397c647e50f83115ee951c49394dab9e9ba52ebdd5a11ab1109390eb3d34e", size = 271934, upload-time = "2026-05-04T17:39:43.794Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/8c/6b75b923cb81368fa3ea7c48a0616b839620a3aeff899885bd930449b89e/pypdfium2-5.8.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:f67b6c74b716d9ac725ad1af49ae786ad813ac20823d45606d59f1fc06caa8af", size = 3374554, upload-time = "2026-05-04T17:39:05.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/61/a885c7f36efba89ec98e3d1fe95c83b48c2d6dea321e9194ac6460e7a834/pypdfium2-5.8.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:53e82bf3e6a2da170b1bda83f93b7eec57cb6efe3cacd05cba78823879a85203", size = 2831667, upload-time = "2026-05-04T17:39:08.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/1f/04b5627f6dba312d3e707e5b019c9f24d8b03b5aa366866a9e02ec00f8d4/pypdfium2-5.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:085e633dcc89b65ff4035a4787e98ce7ae636836eb39c83dd0db26113d9774bc", size = 3450815, upload-time = "2026-05-04T17:39:09.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/77/8e3a2aba2bc4aef5abe1b1306d05b00588dc0bf7f5c850d1adf6164c786b/pypdfium2-5.8.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:bc84b7c6efede88fcfb9467f81daf416f26b973a54fc1cf4d3410d622fda6d7a", size = 3634395, upload-time = "2026-05-04T17:39:11.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/11/6f2b1847d9fa457b3b7251afc2bba2706d104a0c6f01431dfae5d679a839/pypdfium2-5.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a63bf09b2e13ba8545c930d243f0650c664a1b51314daa3b5f38df6d1a17b4bc", size = 3617413, upload-time = "2026-05-04T17:39:13.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/fd/99ce639de5ca06d21743c740dd988cd209dda623bc763ae10b8a162022e1/pypdfium2-5.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:937881c1698456749ed203a58db1895baa5eb7178cdb837ef84867790638da28", size = 3347639, upload-time = "2026-05-04T17:39:15.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/47/82864cc6e26dd8969d5594c168635acb16458d35cf5fed65d6b2e32abb42/pypdfium2-5.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6be9dc2b84a8694ad7e626bab133244e8241014d5ed1930d865a9bdf90df1e24", size = 3746404, upload-time = "2026-05-04T17:39:17.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/58/e41e49bba951f61921bac7289e67fe02af5ac57192d0bbfb5f459dc3691d/pypdfium2-5.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f27bd82891ae302dd02d736b14809661f6d1220ee1e96dbed9b23e2811922a3", size = 4177893, upload-time = "2026-05-04T17:39:18.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/15/fa7031010d5cf6853dadb4864680a0bfb7782c5bb6a1a401e0c25c4fca87/pypdfium2-5.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26c1089cdbbdc7fe1248f6d17fe3f30214be4f287dd0196b31aaee18a1564240", size = 3665152, upload-time = "2026-05-04T17:39:20.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/6a/5a3520a8b0cfa8d7fdc3f03a07ad9d6146c28ffd519330706f64fd8939a8/pypdfium2-5.8.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c038a9290864aaa4862dd32e591993d82551ca4d152b4e8ce6d43ba37dc04a8", size = 3095365, upload-time = "2026-05-04T17:39:22.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d3/845bae4de3cfa36865959046156edb5bf9baea400ccdecdd84fdd911b0f5/pypdfium2-5.8.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f104bc1a6d8bfc1ff088aa50db13b9729cfdb3722b44975c3c457e9a7b9c7318", size = 2961801, upload-time = "2026-05-04T17:39:23.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/76/cf54eabee4a172241dfcfe63533bd1e11e2162114a983453a5a40bfec114/pypdfium2-5.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:04ca7c57a553facf8d46c6ea8ba6fa557e698670cfa4a58e0e01fdae2f6be87d", size = 4133067, upload-time = "2026-05-04T17:39:25.619Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/66/dcf871d19187ca04ea184a99801a6e7e556d8347aa49540fee33cda6dfc5/pypdfium2-5.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ad42b9c22477b32dbedcbc8232833f385d92fd0cf92822547b02383cf9a476d7", size = 3749100, upload-time = "2026-05-04T17:39:27.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/67/0d456c79660959ca45ad307b4d67161d29f9ed4083ee1e8fe8c6925b7c82/pypdfium2-5.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:388e3119cf5ca0979b7d5f6d40b7fcd5ab49e17ed4e6de6af89ba116061acfda", size = 4339212, upload-time = "2026-05-04T17:39:29.277Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/89/e5b0e0f7936be341c91c0f45cd70d693878894ed62aed93a6ee32e9c43c4/pypdfium2-5.8.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:aa05bbfa485ce7916217aa78d856c9f9cd86b08b20846c650392a67975ee72e9", size = 4383943, upload-time = "2026-05-04T17:39:31.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/21/4502ed255f082f579cd3537c2971cf1a57778d43703a08bcd1a92253189f/pypdfium2-5.8.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:f0813a16bb39d5ebd173ea5484430bb67a89b4b181db0a636c73b64ad063c3ea", size = 3925680, upload-time = "2026-05-04T17:39:33.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/4f/2e59723e7a07779439bd885c1b4960079c9710603308888d29ac926ae69a/pypdfium2-5.8.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:a3c78f7d20dd821bec6c072efdb21a1370b9efe10fdeeb68c969e67608e25385", size = 4269560, upload-time = "2026-05-04T17:39:34.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/4e/7b6b1bde3788c8b880d4b8131d95d9d339cebafb3ad9102d82e234bb65be/pypdfium2-5.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:86d302e207c138c827b885a72784f7b306d840646ebeae07e8efdbc39321c629", size = 4182434, upload-time = "2026-05-04T17:39:36.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/7b/6ed4782e0d7a5278330598ce8c4b2df7255f4585a0b3d04520fa580d6507/pypdfium2-5.8.0-py3-none-win32.whl", hash = "sha256:3f25fd436920a907291462b41bdc0ab9f8235c3944b4c9c15398da595ffd1fed", size = 3636680, upload-time = "2026-05-04T17:39:38.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/55/da7223d4202b2461f4f889b0baf10dddec3db7f88e6fd8c52db4a516eecd/pypdfium2-5.8.0-py3-none-win_amd64.whl", hash = "sha256:55592af0bddd2d62bed18e0053c546c9b72041430c5115e54870f7f6163125b0", size = 3754962, upload-time = "2026-05-04T17:39:40.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/7a/f3dcefe6ee7389aad3ca1488c177e8fbf978206de21c7a99ccf487ea38ab/pypdfium2-5.8.0-py3-none-win_arm64.whl", hash = "sha256:3f17ed97ae8a5a1705301ca93af256a5b02f9009dee4e99c5e175831d46ebd7c", size = 3548362, upload-time = "2026-05-04T17:39:42.304Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyphen"
|
||||
version = "0.17.2"
|
||||
@@ -1643,8 +1758,10 @@ server = [
|
||||
{ name = "asyncmy" },
|
||||
{ name = "bcrypt" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "kaleido" },
|
||||
{ name = "pdfplumber" },
|
||||
{ name = "pillow" },
|
||||
{ name = "plotly" },
|
||||
{ name = "python-multipart" },
|
||||
@@ -1666,8 +1783,10 @@ requires-dist = [
|
||||
{ name = "flask-wtf", marker = "extra == 'client'", specifier = ">=1.2.0" },
|
||||
{ name = "gunicorn", marker = "extra == 'client'", specifier = ">=21.0.0" },
|
||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
|
||||
{ name = "httpx", marker = "extra == 'server'", specifier = ">=0.27.0" },
|
||||
{ name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.0" },
|
||||
{ name = "kaleido", marker = "extra == 'server'", specifier = ">=0.2.0" },
|
||||
{ name = "pdfplumber", marker = "extra == 'server'", specifier = ">=0.10.0" },
|
||||
{ name = "pillow", marker = "extra == 'server'", specifier = ">=10.0.0" },
|
||||
{ name = "plotly", marker = "extra == 'server'", specifier = ">=5.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
|
||||
Reference in New Issue
Block a user