Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| fb96bad000 | |||
| 6a8931e108 |
@@ -35,6 +35,10 @@ MAX_UPLOAD_SIZE_MB=50
|
|||||||
# --- Setup Page ---
|
# --- Setup Page ---
|
||||||
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
|
SETUP_PASSWORD= # Password per /api/setup, vuoto = disabilitato
|
||||||
|
|
||||||
|
# --- AI (OpenRouter) ---
|
||||||
|
OPENROUTER_API_KEY= # API key per parsing schede tecniche, vuoto = disabilitato
|
||||||
|
OPENROUTER_MODEL=anthropic/claude-sonnet-4 # Modello AI da utilizzare
|
||||||
|
|
||||||
# --- Docker ---
|
# --- Docker ---
|
||||||
DB_ROOT_PASSWORD=root_password_change_me
|
DB_ROOT_PASSWORD=root_password_change_me
|
||||||
NGINX_PORT=80
|
NGINX_PORT=80
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ uv lock # rigenera uv.lock
|
|||||||
### i18n (Traduzioni)
|
### i18n (Traduzioni)
|
||||||
```bash
|
```bash
|
||||||
# Estrai stringhe
|
# Estrai stringhe
|
||||||
cd src/frontend/flask_app && uv run pybabel extract -F babel.cfg -k _ -o translations/messages.pot .
|
cd src/frontend/flask_app && uv run pybabel extract -F translations/babel.cfg -k _ -o translations/messages.pot .
|
||||||
|
|
||||||
# Aggiorna catalogo
|
# Aggiorna catalogo
|
||||||
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
cd src/frontend/flask_app && uv run pybabel update -i translations/messages.pot -d translations
|
||||||
|
|||||||
+1
-1
@@ -93,4 +93,4 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
traefik-net:
|
traefik-net:
|
||||||
external: true
|
external: true
|
||||||
name: root_default
|
name: traefik
|
||||||
|
|||||||
Binary file not shown.
@@ -30,6 +30,8 @@ server = [
|
|||||||
"plotly>=5.0.0",
|
"plotly>=5.0.0",
|
||||||
"kaleido>=0.2.0",
|
"kaleido>=0.2.0",
|
||||||
"weasyprint>=62.0",
|
"weasyprint>=62.0",
|
||||||
|
"pdfplumber>=0.10.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Frontend (Flask tablet UI).
|
# Frontend (Flask tablet UI).
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from src.backend.api.middleware.api_key import (
|
|||||||
require_maker,
|
require_maker,
|
||||||
require_measurement_tec,
|
require_measurement_tec,
|
||||||
require_metrologist,
|
require_metrologist,
|
||||||
|
require_supervisor,
|
||||||
require_admin_user,
|
require_admin_user,
|
||||||
)
|
)
|
||||||
from src.backend.api.middleware.logging import AccessLogMiddleware
|
from src.backend.api.middleware.logging import AccessLogMiddleware
|
||||||
@@ -19,6 +20,7 @@ __all__ = [
|
|||||||
"require_maker",
|
"require_maker",
|
||||||
"require_measurement_tec",
|
"require_measurement_tec",
|
||||||
"require_metrologist",
|
"require_metrologist",
|
||||||
|
"require_supervisor",
|
||||||
"require_admin_user",
|
"require_admin_user",
|
||||||
"AccessLogMiddleware",
|
"AccessLogMiddleware",
|
||||||
"RateLimitMiddleware",
|
"RateLimitMiddleware",
|
||||||
|
|||||||
@@ -68,4 +68,5 @@ def require_admin():
|
|||||||
require_maker = require_role("Maker")
|
require_maker = require_role("Maker")
|
||||||
require_measurement_tec = require_role("MeasurementTec")
|
require_measurement_tec = require_role("MeasurementTec")
|
||||||
require_metrologist = require_role("Metrologist")
|
require_metrologist = require_role("Metrologist")
|
||||||
|
require_supervisor = require_role("Supervisor")
|
||||||
require_admin_user = require_admin()
|
require_admin_user = require_admin()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"""File upload/download/delete router for images and PDFs."""
|
"""File upload/download/delete router for images and PDFs."""
|
||||||
import os
|
import logging
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -11,6 +11,8 @@ from src.backend.config import settings
|
|||||||
from src.backend.api.middleware.api_key import get_current_user, require_maker
|
from src.backend.api.middleware.api_key import get_current_user, require_maker
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/files", tags=["files"])
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||||
|
|
||||||
# Allowed MIME types
|
# Allowed MIME types
|
||||||
@@ -65,6 +67,37 @@ def sanitize_filename(filename: str) -> str:
|
|||||||
return filename
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_upload_path(file_path: str) -> Path:
|
||||||
|
"""Resolve a user-supplied relative path inside the uploads directory.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if the path escapes the uploads directory,
|
||||||
|
cannot be resolved, or does not point to an existing file.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
full_path = (settings.upload_path / file_path).resolve()
|
||||||
|
if not full_path.is_relative_to(settings.upload_path.resolve()):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access denied",
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except (OSError, RuntimeError, ValueError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="File not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not full_path.exists() or not full_path.is_file():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="File not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
return full_path
|
||||||
|
|
||||||
|
|
||||||
def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)):
|
def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[int, int] = (200, 200)):
|
||||||
"""Generate a thumbnail for an image."""
|
"""Generate a thumbnail for an image."""
|
||||||
try:
|
try:
|
||||||
@@ -73,7 +106,7 @@ def generate_thumbnail(image_path: Path, thumbnail_path: Path, max_size: tuple[i
|
|||||||
img.save(thumbnail_path, quality=85)
|
img.save(thumbnail_path, quality=85)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# If thumbnail generation fails, we continue without it
|
# If thumbnail generation fails, we continue without it
|
||||||
print(f"Thumbnail generation failed: {e}")
|
logger.warning("Thumbnail generation failed for %s: %s", image_path, e)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
@@ -164,30 +197,7 @@ async def get_file(
|
|||||||
|
|
||||||
Requires authentication but no specific role.
|
Requires authentication but no specific role.
|
||||||
"""
|
"""
|
||||||
# Construct full path
|
full_path = resolve_upload_path(file_path)
|
||||||
full_path = settings.upload_path / file_path
|
|
||||||
|
|
||||||
# Security: ensure path is within uploads directory
|
|
||||||
try:
|
|
||||||
full_path = full_path.resolve()
|
|
||||||
if not str(full_path).startswith(str(settings.upload_path.resolve())):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Access denied",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="File not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if file exists
|
|
||||||
if not full_path.exists() or not full_path.is_file():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="File not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
return FileResponse(full_path)
|
return FileResponse(full_path)
|
||||||
|
|
||||||
|
|
||||||
@@ -200,29 +210,7 @@ async def delete_file(
|
|||||||
|
|
||||||
Also deletes associated thumbnail if it exists.
|
Also deletes associated thumbnail if it exists.
|
||||||
"""
|
"""
|
||||||
# Construct full path
|
full_path = resolve_upload_path(file_path)
|
||||||
full_path = settings.upload_path / file_path
|
|
||||||
|
|
||||||
# Security: ensure path is within uploads directory
|
|
||||||
try:
|
|
||||||
full_path = full_path.resolve()
|
|
||||||
if not str(full_path).startswith(str(settings.upload_path.resolve())):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Access denied",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="File not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if file exists
|
|
||||||
if not full_path.exists() or not full_path.is_file():
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="File not found",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Delete thumbnail if it exists
|
# Delete thumbnail if it exists
|
||||||
if full_path.parent.name != "thumbnails":
|
if full_path.parent.name != "thumbnails":
|
||||||
|
|||||||
@@ -84,6 +84,44 @@ async def create_measurement_batch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_measurement_filters(
|
||||||
|
recipe_id: int | None,
|
||||||
|
version_id: int | None,
|
||||||
|
subtask_id: int | None,
|
||||||
|
measured_by: int | None,
|
||||||
|
lot_number: str | None,
|
||||||
|
serial_number: str | None,
|
||||||
|
date_from: datetime | None,
|
||||||
|
date_to: datetime | None,
|
||||||
|
pass_fail: str | None,
|
||||||
|
) -> list:
|
||||||
|
"""Build SQLAlchemy filter conditions shared by list and CSV export."""
|
||||||
|
filters = []
|
||||||
|
if recipe_id is not None:
|
||||||
|
# Filter by recipe via subquery on RecipeVersion
|
||||||
|
version_ids = select(RecipeVersion.id).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id
|
||||||
|
)
|
||||||
|
filters.append(Measurement.version_id.in_(version_ids))
|
||||||
|
if version_id is not None:
|
||||||
|
filters.append(Measurement.version_id == version_id)
|
||||||
|
if subtask_id is not None:
|
||||||
|
filters.append(Measurement.subtask_id == subtask_id)
|
||||||
|
if measured_by is not None:
|
||||||
|
filters.append(Measurement.measured_by == measured_by)
|
||||||
|
if lot_number is not None:
|
||||||
|
filters.append(Measurement.lot_number == lot_number)
|
||||||
|
if serial_number is not None:
|
||||||
|
filters.append(Measurement.serial_number == serial_number)
|
||||||
|
if date_from is not None:
|
||||||
|
filters.append(Measurement.measured_at >= date_from)
|
||||||
|
if date_to is not None:
|
||||||
|
filters.append(Measurement.measured_at <= date_to)
|
||||||
|
if pass_fail is not None:
|
||||||
|
filters.append(Measurement.pass_fail == pass_fail)
|
||||||
|
return filters
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=MeasurementListResponse)
|
@router.get("/", response_model=MeasurementListResponse)
|
||||||
async def get_measurements(
|
async def get_measurements(
|
||||||
recipe_id: int | None = Query(None),
|
recipe_id: int | None = Query(None),
|
||||||
@@ -108,30 +146,10 @@ async def get_measurements(
|
|||||||
filters. Measurements carry no PII beyond numeric values + a recipe
|
filters. Measurements carry no PII beyond numeric values + a recipe
|
||||||
reference, so role-gating beyond authentication isn't justified.
|
reference, so role-gating beyond authentication isn't justified.
|
||||||
"""
|
"""
|
||||||
# Build filter conditions
|
filters = _build_measurement_filters(
|
||||||
filters = []
|
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||||
if recipe_id is not None:
|
serial_number, date_from, date_to, pass_fail,
|
||||||
# Filter by recipe via subquery on RecipeVersion
|
)
|
||||||
version_ids = select(RecipeVersion.id).where(
|
|
||||||
RecipeVersion.recipe_id == recipe_id
|
|
||||||
)
|
|
||||||
filters.append(Measurement.version_id.in_(version_ids))
|
|
||||||
if version_id is not None:
|
|
||||||
filters.append(Measurement.version_id == version_id)
|
|
||||||
if subtask_id is not None:
|
|
||||||
filters.append(Measurement.subtask_id == subtask_id)
|
|
||||||
if measured_by is not None:
|
|
||||||
filters.append(Measurement.measured_by == measured_by)
|
|
||||||
if lot_number is not None:
|
|
||||||
filters.append(Measurement.lot_number == lot_number)
|
|
||||||
if serial_number is not None:
|
|
||||||
filters.append(Measurement.serial_number == serial_number)
|
|
||||||
if date_from is not None:
|
|
||||||
filters.append(Measurement.measured_at >= date_from)
|
|
||||||
if date_to is not None:
|
|
||||||
filters.append(Measurement.measured_at <= date_to)
|
|
||||||
if pass_fail is not None:
|
|
||||||
filters.append(Measurement.pass_fail == pass_fail)
|
|
||||||
|
|
||||||
# Build query
|
# Build query
|
||||||
query = select(Measurement).where(and_(*filters) if filters else True)
|
query = select(Measurement).where(and_(*filters) if filters else True)
|
||||||
@@ -196,29 +214,10 @@ async def export_measurements_csv(
|
|||||||
decimal_setting = decimal_result.scalar_one_or_none()
|
decimal_setting = decimal_result.scalar_one_or_none()
|
||||||
decimal_separator = decimal_setting.setting_value if decimal_setting else "."
|
decimal_separator = decimal_setting.setting_value if decimal_setting else "."
|
||||||
|
|
||||||
# Build filter conditions (same as get_measurements)
|
filters = _build_measurement_filters(
|
||||||
filters = []
|
recipe_id, version_id, subtask_id, measured_by, lot_number,
|
||||||
if recipe_id is not None:
|
serial_number, date_from, date_to, pass_fail,
|
||||||
version_ids = select(RecipeVersion.id).where(
|
)
|
||||||
RecipeVersion.recipe_id == recipe_id
|
|
||||||
)
|
|
||||||
filters.append(Measurement.version_id.in_(version_ids))
|
|
||||||
if version_id is not None:
|
|
||||||
filters.append(Measurement.version_id == version_id)
|
|
||||||
if subtask_id is not None:
|
|
||||||
filters.append(Measurement.subtask_id == subtask_id)
|
|
||||||
if measured_by is not None:
|
|
||||||
filters.append(Measurement.measured_by == measured_by)
|
|
||||||
if lot_number is not None:
|
|
||||||
filters.append(Measurement.lot_number == lot_number)
|
|
||||||
if serial_number is not None:
|
|
||||||
filters.append(Measurement.serial_number == serial_number)
|
|
||||||
if date_from is not None:
|
|
||||||
filters.append(Measurement.measured_at >= date_from)
|
|
||||||
if date_to is not None:
|
|
||||||
filters.append(Measurement.measured_at <= date_to)
|
|
||||||
if pass_fail is not None:
|
|
||||||
filters.append(Measurement.pass_fail == pass_fail)
|
|
||||||
|
|
||||||
# Query all matching measurements
|
# Query all matching measurements
|
||||||
query = select(Measurement).where(and_(*filters) if filters else True)
|
query = select(Measurement).where(and_(*filters) if filters else True)
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""Recipe router - CRUD, versioning, barcode lookup."""
|
"""Recipe router - CRUD, versioning, barcode lookup, AI parsing."""
|
||||||
from fastapi import APIRouter, Depends, Query
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.backend.database import get_db
|
from src.backend.database import get_db
|
||||||
@@ -164,3 +166,31 @@ async def get_measurement_count(
|
|||||||
"""
|
"""
|
||||||
count = await recipe_service.get_measurement_count(db, recipe_id, version_number)
|
count = await recipe_service.get_measurement_count(db, recipe_id, version_number)
|
||||||
return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count}
|
return {"recipe_id": recipe_id, "version_number": version_number, "measurement_count": count}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{recipe_id}/parse-technical-sheet")
|
||||||
|
async def parse_technical_sheet(
|
||||||
|
recipe_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
_user: User = Depends(require_maker),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Parse a PDF technical sheet using AI and return suggested tasks."""
|
||||||
|
from src.backend.services import ai_service
|
||||||
|
|
||||||
|
if not file.content_type or "pdf" not in file.content_type:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Il file deve essere un PDF")
|
||||||
|
|
||||||
|
pdf_bytes = await file.read()
|
||||||
|
if len(pdf_bytes) > 20 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="File troppo grande (max 20MB)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
tasks = await ai_service.parse_technical_sheet(pdf_bytes)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger(__name__).error("AI parsing error: %s", e)
|
||||||
|
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Errore nel servizio AI")
|
||||||
|
|
||||||
|
return {"recipe_id": recipe_id, "suggested_tasks": tasks}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy import inspect as sa_inspect, select
|
from sqlalchemy import inspect as sa_inspect, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.api.routers.users import VALID_ROLES
|
||||||
from src.backend.config import settings
|
from src.backend.config import settings
|
||||||
from src.backend.database import Base, engine, async_session_factory
|
from src.backend.database import Base, engine, async_session_factory
|
||||||
from src.backend.models.orm import (
|
from src.backend.models.orm import (
|
||||||
@@ -26,7 +27,7 @@ from src.backend.services.measurement_service import calculate_pass_fail
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
router = APIRouter(prefix="/api/setup", tags=["setup"])
|
||||||
|
|
||||||
_templates_dir = Path(__file__).resolve().parent.parent / "templates"
|
_templates_dir = Path(__file__).resolve().parent.parent.parent / "templates"
|
||||||
templates = Jinja2Templates(directory=str(_templates_dir))
|
templates = Jinja2Templates(directory=str(_templates_dir))
|
||||||
|
|
||||||
|
|
||||||
@@ -215,7 +216,7 @@ async def _seed_default_station(session: AsyncSession, admin_user: User) -> None
|
|||||||
async def setup_page(request: Request):
|
async def setup_page(request: Request):
|
||||||
"""Serve the setup page HTML."""
|
"""Serve the setup page HTML."""
|
||||||
_check_setup_enabled()
|
_check_setup_enabled()
|
||||||
return templates.TemplateResponse("setup/setup.html", {"request": request})
|
return templates.TemplateResponse(request, "setup/setup.html")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status")
|
||||||
@@ -544,7 +545,6 @@ async def manage_user(body: ManageUserBody):
|
|||||||
"""Create or update a user. user_id=null creates new, user_id=int updates."""
|
"""Create or update a user. user_id=null creates new, user_id=int updates."""
|
||||||
_check_password(body.password)
|
_check_password(body.password)
|
||||||
|
|
||||||
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist"}
|
|
||||||
invalid = set(body.roles) - VALID_ROLES
|
invalid = set(body.roles) - VALID_ROLES
|
||||||
if invalid:
|
if invalid:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -223,7 +223,10 @@ async def reorder_tasks(
|
|||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(RecipeTask)
|
select(RecipeTask)
|
||||||
.where(RecipeTask.id.in_(data.task_ids))
|
.where(RecipeTask.id.in_(data.task_ids))
|
||||||
.options(selectinload(RecipeTask.subtasks))
|
.options(
|
||||||
|
selectinload(RecipeTask.subtasks),
|
||||||
|
selectinload(RecipeTask.version),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
tasks_map = {t.id: t for t in result.scalars().all()}
|
tasks_map = {t.id: t for t in result.scalars().all()}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ from src.backend.services.auth_service import create_user, hash_password, regene
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/users", tags=["users"])
|
router = APIRouter(prefix="/api/users", tags=["users"])
|
||||||
|
|
||||||
|
# Combinable user roles. Supervisor (capoturno) authorizes out-of-tolerance
|
||||||
|
# overrides during measurement without needing full admin privileges.
|
||||||
|
VALID_ROLES = {"Maker", "MeasurementTec", "Metrologist", "Supervisor"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[UserResponse])
|
@router.get("", response_model=list[UserResponse])
|
||||||
async def list_users(
|
async def list_users(
|
||||||
@@ -40,12 +44,11 @@ async def create_new_user(
|
|||||||
detail=f"Username '{data.username}' already exists",
|
detail=f"Username '{data.username}' already exists",
|
||||||
)
|
)
|
||||||
# Validate roles
|
# Validate roles
|
||||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
|
||||||
for role in data.roles:
|
for role in data.roles:
|
||||||
if role not in valid_roles:
|
if role not in VALID_ROLES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
|
detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
|
||||||
)
|
)
|
||||||
user = await create_user(
|
user = await create_user(
|
||||||
db,
|
db,
|
||||||
@@ -77,12 +80,11 @@ async def update_user(
|
|||||||
update_data = data.model_dump(exclude_unset=True)
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
# Validate roles if provided
|
# Validate roles if provided
|
||||||
if "roles" in update_data:
|
if "roles" in update_data:
|
||||||
valid_roles = {"Maker", "MeasurementTec", "Metrologist"}
|
|
||||||
for role in update_data["roles"]:
|
for role in update_data["roles"]:
|
||||||
if role not in valid_roles:
|
if role not in VALID_ROLES:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=f"Invalid role '{role}'. Valid roles: {valid_roles}",
|
detail=f"Invalid role '{role}'. Valid roles: {VALID_ROLES}",
|
||||||
)
|
)
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(user, field, value)
|
setattr(user, field, value)
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ class Settings(BaseSettings):
|
|||||||
# Setup page (empty = disabled)
|
# Setup page (empty = disabled)
|
||||||
setup_password: str | None = None
|
setup_password: str | None = None
|
||||||
|
|
||||||
|
# AI / OpenRouter (for technical sheet parsing)
|
||||||
|
openrouter_api_key: str | None = None
|
||||||
|
openrouter_model: str = "anthropic/claude-sonnet-4"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def database_url(self) -> str:
|
def database_url(self) -> str:
|
||||||
"""Async MySQL connection string."""
|
"""Async MySQL connection string."""
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""add measurement_interval_minutes to recipes
|
||||||
|
|
||||||
|
Revision ID: 003_measurement_interval
|
||||||
|
Revises: 002_add_stations
|
||||||
|
Create Date: 2026-05-23
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = '003_measurement_interval'
|
||||||
|
down_revision: Union[str, None] = '002_add_stations'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('recipes', sa.Column('measurement_interval_minutes', sa.SmallInteger(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('recipes', 'measurement_interval_minutes')
|
||||||
@@ -14,6 +14,7 @@ class RecipeCreate(BaseModel):
|
|||||||
name: str = Field(..., min_length=1, max_length=255)
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
|
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||||
# Optional task-level fields for the initial technical drawing
|
# Optional task-level fields for the initial technical drawing
|
||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
file_type: Optional[str] = Field(None, pattern="^(image|pdf)$")
|
||||||
@@ -25,6 +26,7 @@ class RecipeUpdate(BaseModel):
|
|||||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image_path: Optional[str] = Field(None, max_length=500)
|
image_path: Optional[str] = Field(None, max_length=500)
|
||||||
|
measurement_interval_minutes: Optional[int] = Field(None, ge=1, le=1440)
|
||||||
change_notes: Optional[str] = None
|
change_notes: Optional[str] = None
|
||||||
# Task-level fields: saved to the first task of the new version
|
# Task-level fields: saved to the first task of the new version
|
||||||
file_path: Optional[str] = Field(None, max_length=500)
|
file_path: Optional[str] = Field(None, max_length=500)
|
||||||
@@ -55,6 +57,7 @@ class RecipeResponse(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
image_path: Optional[str] = None
|
image_path: Optional[str] = None
|
||||||
|
measurement_interval_minutes: Optional[int] = None
|
||||||
created_by: int
|
created_by: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
active: bool
|
active: bool
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class RecipeSummary(BaseModel):
|
|||||||
code: str
|
code: str
|
||||||
name: str
|
name: str
|
||||||
active: bool
|
active: bool
|
||||||
|
image_path: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class StationWithRecipesResponse(StationResponse):
|
class StationWithRecipesResponse(StationResponse):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, SmallInteger, String, Text, UniqueConstraint, func
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from src.backend.database import Base
|
from src.backend.database import Base
|
||||||
@@ -24,6 +24,7 @@ class Recipe(Base):
|
|||||||
DateTime, nullable=False, server_default=func.now()
|
DateTime, nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, index=True)
|
||||||
|
measurement_interval_minutes: Mapped[Optional[int]] = mapped_column(SmallInteger, nullable=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
versions: Mapped[list["RecipeVersion"]] = relationship(
|
versions: Mapped[list["RecipeVersion"]] = relationship(
|
||||||
|
|||||||
@@ -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,
|
name=data.name,
|
||||||
description=data.description,
|
description=data.description,
|
||||||
image_path=data.image_path,
|
image_path=data.image_path,
|
||||||
|
measurement_interval_minutes=data.measurement_interval_minutes,
|
||||||
created_by=user.id,
|
created_by=user.id,
|
||||||
)
|
)
|
||||||
db.add(recipe)
|
db.add(recipe)
|
||||||
@@ -273,6 +274,8 @@ async def create_new_version(
|
|||||||
update_fields["description"] = data.description
|
update_fields["description"] = data.description
|
||||||
if data.image_path is not None:
|
if data.image_path is not None:
|
||||||
update_fields["image_path"] = data.image_path
|
update_fields["image_path"] = data.image_path
|
||||||
|
if data.measurement_interval_minutes is not None:
|
||||||
|
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
||||||
if update_fields:
|
if update_fields:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||||
@@ -369,6 +372,8 @@ async def update_current_version(
|
|||||||
update_fields["description"] = data.description
|
update_fields["description"] = data.description
|
||||||
if data.image_path is not None:
|
if data.image_path is not None:
|
||||||
update_fields["image_path"] = data.image_path
|
update_fields["image_path"] = data.image_path
|
||||||
|
if data.measurement_interval_minutes is not None:
|
||||||
|
update_fields["measurement_interval_minutes"] = data.measurement_interval_minutes
|
||||||
if update_fields:
|
if update_fields:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||||
|
|||||||
@@ -559,6 +559,19 @@
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Setup operations rotate api_keys (DB init, admin creation, password
|
||||||
|
* change, user (de)activation). Any Flask session held in this browser
|
||||||
|
* still carries the old key, so we clear it to force a fresh /auth/login. */
|
||||||
|
async function invalidateClientSession() {
|
||||||
|
try {
|
||||||
|
await fetch('/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
} catch (_) { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Check DB status */
|
/* Check DB status */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -635,6 +648,7 @@
|
|||||||
try {
|
try {
|
||||||
const data = await apiPost('/init-db', { password: setupPassword });
|
const data = await apiPost('/init-db', { password: setupPassword });
|
||||||
showToast(data.message, 'success');
|
showToast(data.message, 'success');
|
||||||
|
await invalidateClientSession();
|
||||||
await checkStatus();
|
await checkStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
@@ -667,6 +681,7 @@
|
|||||||
admin_display_name: display,
|
admin_display_name: display,
|
||||||
});
|
});
|
||||||
showToast(data.message, 'success');
|
showToast(data.message, 'success');
|
||||||
|
await invalidateClientSession();
|
||||||
// Clear form
|
// Clear form
|
||||||
document.getElementById('admin-username').value = '';
|
document.getElementById('admin-username').value = '';
|
||||||
document.getElementById('admin-password').value = '';
|
document.getElementById('admin-password').value = '';
|
||||||
@@ -690,6 +705,7 @@
|
|||||||
if (data.measurements_created) msg += ', measurements: ' + data.measurements_created;
|
if (data.measurements_created) msg += ', measurements: ' + data.measurements_created;
|
||||||
msg += ')';
|
msg += ')';
|
||||||
showToast(msg, 'success');
|
showToast(msg, 'success');
|
||||||
|
await invalidateClientSession();
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
@@ -876,6 +892,7 @@
|
|||||||
if (pwd) body.user_password = pwd;
|
if (pwd) body.user_password = pwd;
|
||||||
var data = await apiPost('/manage-user', body);
|
var data = await apiPost('/manage-user', body);
|
||||||
showToast(data.message, 'success');
|
showToast(data.message, 'success');
|
||||||
|
await invalidateClientSession();
|
||||||
closeModal('modal-user');
|
closeModal('modal-user');
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -908,6 +925,7 @@
|
|||||||
new_password: newPwd,
|
new_password: newPwd,
|
||||||
});
|
});
|
||||||
showToast(data.message, 'success');
|
showToast(data.message, 'success');
|
||||||
|
await invalidateClientSession();
|
||||||
closeModal('modal-password');
|
closeModal('modal-password');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
@@ -924,6 +942,7 @@
|
|||||||
user_id: userId,
|
user_id: userId,
|
||||||
});
|
});
|
||||||
showToast(data.message, 'success');
|
showToast(data.message, 'success');
|
||||||
|
await invalidateClientSession();
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(e.message, 'error');
|
showToast(e.message, 'error');
|
||||||
|
|||||||
@@ -1,12 +1,40 @@
|
|||||||
"""Tests for recipes router (/api/recipes)."""
|
"""Tests for recipes router (/api/recipes)."""
|
||||||
import pytest
|
import pytest
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.backend.models.orm.measurement import Measurement
|
||||||
|
from src.backend.models.orm.recipe import RecipeVersion
|
||||||
|
from src.backend.models.orm.task import RecipeSubtask
|
||||||
from src.backend.models.orm.user import User
|
from src.backend.models.orm.user import User
|
||||||
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
from src.backend.tests.conftest import auth_headers, create_test_recipe
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_measurement(db: AsyncSession, recipe_id: int, user_id: int) -> None:
|
||||||
|
version_id = (await db.execute(
|
||||||
|
select(RecipeVersion.id).where(
|
||||||
|
RecipeVersion.recipe_id == recipe_id,
|
||||||
|
RecipeVersion.is_current == True, # noqa: E712
|
||||||
|
)
|
||||||
|
)).scalar_one()
|
||||||
|
subtask_id = (await db.execute(
|
||||||
|
select(RecipeSubtask.id)
|
||||||
|
.join(RecipeSubtask.task)
|
||||||
|
.where(RecipeSubtask.task.has(version_id=version_id))
|
||||||
|
)).scalar_one()
|
||||||
|
db.add(Measurement(
|
||||||
|
subtask_id=subtask_id,
|
||||||
|
version_id=version_id,
|
||||||
|
measured_by=user_id,
|
||||||
|
value=10.0,
|
||||||
|
pass_fail="pass",
|
||||||
|
deviation=0.0,
|
||||||
|
input_method="manual",
|
||||||
|
))
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
|
||||||
class TestListRecipes:
|
class TestListRecipes:
|
||||||
"""GET /api/recipes tests."""
|
"""GET /api/recipes tests."""
|
||||||
|
|
||||||
@@ -160,18 +188,13 @@ class TestCreateRecipe:
|
|||||||
class TestUpdateRecipe:
|
class TestUpdateRecipe:
|
||||||
"""PUT /api/recipes/{recipe_id} tests."""
|
"""PUT /api/recipes/{recipe_id} tests."""
|
||||||
|
|
||||||
async def test_update_recipe(
|
async def test_update_recipe_in_place(
|
||||||
self,
|
self,
|
||||||
client: AsyncClient,
|
client: AsyncClient,
|
||||||
maker_user: User,
|
maker_user: User,
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
):
|
):
|
||||||
"""Updating a recipe creates a new version (copy-on-write).
|
"""Updating a recipe without measurements updates current version in-place."""
|
||||||
|
|
||||||
We verify the new version exists by listing versions, since
|
|
||||||
SQLAlchemy identity-map staleness with StaticPool can cause
|
|
||||||
``is_current`` to appear stale in some responses.
|
|
||||||
"""
|
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
resp = await client.put(
|
resp = await client.put(
|
||||||
f"/api/recipes/{recipe.id}",
|
f"/api/recipes/{recipe.id}",
|
||||||
@@ -183,7 +206,9 @@ class TestUpdateRecipe:
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
# Verify via versions list which always does a clean query
|
# expire identity-map cache so subsequent GETs see fresh data
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
versions_resp = await client.get(
|
versions_resp = await client.get(
|
||||||
f"/api/recipes/{recipe.id}/versions",
|
f"/api/recipes/{recipe.id}/versions",
|
||||||
headers=auth_headers(maker_user),
|
headers=auth_headers(maker_user),
|
||||||
@@ -191,8 +216,38 @@ class TestUpdateRecipe:
|
|||||||
assert versions_resp.status_code == 200
|
assert versions_resp.status_code == 200
|
||||||
versions = versions_resp.json()
|
versions = versions_resp.json()
|
||||||
version_numbers = [v["version_number"] for v in versions]
|
version_numbers = [v["version_number"] for v in versions]
|
||||||
assert 1 in version_numbers
|
assert version_numbers == [1]
|
||||||
assert 2 in version_numbers
|
|
||||||
|
async def test_update_recipe_copy_on_write(
|
||||||
|
self,
|
||||||
|
client: AsyncClient,
|
||||||
|
maker_user: User,
|
||||||
|
db_session: AsyncSession,
|
||||||
|
):
|
||||||
|
"""Updating a recipe with measurements creates a new version (copy-on-write)."""
|
||||||
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
resp = await client.put(
|
||||||
|
f"/api/recipes/{recipe.id}",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
json={
|
||||||
|
"name": "Updated Recipe",
|
||||||
|
"change_notes": "Triggers copy-on-write",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
|
versions_resp = await client.get(
|
||||||
|
f"/api/recipes/{recipe.id}/versions",
|
||||||
|
headers=auth_headers(maker_user),
|
||||||
|
)
|
||||||
|
assert versions_resp.status_code == 200
|
||||||
|
versions = versions_resp.json()
|
||||||
|
version_numbers = sorted(v["version_number"] for v in versions)
|
||||||
|
assert version_numbers == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteRecipe:
|
class TestDeleteRecipe:
|
||||||
@@ -222,31 +277,34 @@ class TestRecipeVersioning:
|
|||||||
maker_user: User,
|
maker_user: User,
|
||||||
db_session: AsyncSession,
|
db_session: AsyncSession,
|
||||||
):
|
):
|
||||||
"""Multiple updates create consecutive versions."""
|
"""Each update creates a new version when measurements exist (copy-on-write)."""
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
# Update once -> v2
|
# Update once -> v2 (v1 had measurements)
|
||||||
await client.put(
|
await client.put(
|
||||||
f"/api/recipes/{recipe.id}",
|
f"/api/recipes/{recipe.id}",
|
||||||
headers=auth_headers(maker_user),
|
headers=auth_headers(maker_user),
|
||||||
json={"change_notes": "Version 2"},
|
json={"change_notes": "Version 2"},
|
||||||
)
|
)
|
||||||
|
db_session.expunge_all()
|
||||||
|
await _seed_measurement(db_session, recipe.id, maker_user.id)
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
# Update again -> v3
|
# Update again -> v3 (v2 now has measurements too)
|
||||||
await client.put(
|
await client.put(
|
||||||
f"/api/recipes/{recipe.id}",
|
f"/api/recipes/{recipe.id}",
|
||||||
headers=auth_headers(maker_user),
|
headers=auth_headers(maker_user),
|
||||||
json={"change_notes": "Version 3"},
|
json={"change_notes": "Version 3"},
|
||||||
)
|
)
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
# List versions
|
|
||||||
resp = await client.get(
|
resp = await client.get(
|
||||||
f"/api/recipes/{recipe.id}/versions",
|
f"/api/recipes/{recipe.id}/versions",
|
||||||
headers=auth_headers(maker_user),
|
headers=auth_headers(maker_user),
|
||||||
)
|
)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
versions = resp.json()
|
versions = resp.json()
|
||||||
version_numbers = [v["version_number"] for v in versions]
|
version_numbers = sorted(v["version_number"] for v in versions)
|
||||||
assert 1 in version_numbers
|
assert version_numbers == [1, 2, 3]
|
||||||
assert 2 in version_numbers
|
|
||||||
assert 3 in version_numbers
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ class TestReorderTasks:
|
|||||||
"""Maker can reorder tasks by providing task IDs in desired order."""
|
"""Maker can reorder tasks by providing task IDs in desired order."""
|
||||||
recipe = await create_test_recipe(db_session, maker_user.id)
|
recipe = await create_test_recipe(db_session, maker_user.id)
|
||||||
|
|
||||||
# Add a second task via copy-on-write
|
# Add a second task (no measurements → appended to current version)
|
||||||
add_resp = await client.post(
|
add_resp = await client.post(
|
||||||
f"/api/recipes/{recipe.id}/tasks",
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
headers=auth_headers(maker_user),
|
headers=auth_headers(maker_user),
|
||||||
@@ -197,7 +197,11 @@ class TestReorderTasks:
|
|||||||
)
|
)
|
||||||
assert add_resp.status_code == 201
|
assert add_resp.status_code == 201
|
||||||
|
|
||||||
# Get tasks from new version
|
# Force SQLA to re-fetch RecipeVersion.tasks: the shared test session
|
||||||
|
# caches the collection from create_test_recipe, so the GET below would
|
||||||
|
# otherwise return a stale list. In prod each request gets its own session.
|
||||||
|
db_session.expunge_all()
|
||||||
|
|
||||||
tasks_resp = await client.get(
|
tasks_resp = await client.get(
|
||||||
f"/api/recipes/{recipe.id}/tasks",
|
f"/api/recipes/{recipe.id}/tasks",
|
||||||
headers=auth_headers(maker_user),
|
headers=auth_headers(maker_user),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""TieMeasureFlow Client - Flask Entry Point."""
|
"""TieMeasureFlow Client - Flask Entry Point."""
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from datetime import date
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from flask import Flask, redirect, url_for, session, request
|
from flask import Flask, redirect, url_for, session, request
|
||||||
from flask_babel import Babel
|
from flask_babel import Babel
|
||||||
@@ -63,7 +65,14 @@ def create_app() -> Flask:
|
|||||||
"""Set user's preferred language and store in session."""
|
"""Set user's preferred language and store in session."""
|
||||||
if lang in Config.LANGUAGES:
|
if lang in Config.LANGUAGES:
|
||||||
session["language"] = lang
|
session["language"] = lang
|
||||||
return redirect(request.referrer or url_for("auth.login"))
|
# Only follow the referrer if it points back to this host
|
||||||
|
# (prevents open redirect via a forged Referer header).
|
||||||
|
referrer = request.referrer
|
||||||
|
if referrer:
|
||||||
|
parsed = urlparse(referrer)
|
||||||
|
if parsed.netloc and parsed.netloc != request.host:
|
||||||
|
referrer = None
|
||||||
|
return redirect(referrer or url_for("auth.login"))
|
||||||
|
|
||||||
@app.template_filter("tojson_attr")
|
@app.template_filter("tojson_attr")
|
||||||
def tojson_attr_filter(value):
|
def tojson_attr_filter(value):
|
||||||
@@ -92,6 +101,8 @@ def create_app() -> Flask:
|
|||||||
"current_language": get_locale(),
|
"current_language": get_locale(),
|
||||||
"languages": Config.LANGUAGES,
|
"languages": Config.LANGUAGES,
|
||||||
"company_logo": session.get("company_logo"),
|
"company_logo": session.get("company_logo"),
|
||||||
|
"auto_logout_minutes": session.get("auto_logout_minutes"),
|
||||||
|
"current_year": date.today().year,
|
||||||
}
|
}
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -43,6 +43,28 @@ def user_list():
|
|||||||
return render_template("admin/users.html", users=users)
|
return render_template("admin/users.html", users=users)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/settings")
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def settings_page():
|
||||||
|
"""System settings page."""
|
||||||
|
resp = api_client.get("/api/settings")
|
||||||
|
settings = resp if not isinstance(resp, dict) or not resp.get("error") else {}
|
||||||
|
return render_template("admin/settings.html", settings=settings)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route("/api/settings", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def api_update_settings():
|
||||||
|
"""Proxy: Update system settings."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
resp = api_client.put("/api/settings", data=data)
|
||||||
|
if isinstance(resp, dict) and resp.get("error"):
|
||||||
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.route("/stations")
|
@admin_bp.route("/stations")
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Authentication blueprint - login, logout, profile."""
|
"""Authentication blueprint - login, logout, profile."""
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import Blueprint, abort, flash, redirect, render_template, request, session, url_for
|
from flask import Blueprint, abort, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from services.api_client import api_client
|
from services.api_client import api_client
|
||||||
@@ -39,15 +39,32 @@ def role_required(*roles):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def _post_login_landing():
|
||||||
|
"""Pick the landing endpoint based on the logged-in user's roles.
|
||||||
|
|
||||||
|
A Maker-only user must not be bounced to /measure (MeasurementTec-only),
|
||||||
|
which would raise a 403 right after login. Order of preference follows the
|
||||||
|
primary operator flow first, then editing, analysis, admin.
|
||||||
|
"""
|
||||||
|
user = session.get("user", {})
|
||||||
|
roles = user.get("roles", [])
|
||||||
|
if "MeasurementTec" in roles:
|
||||||
|
return url_for("measure.select_recipe")
|
||||||
|
if "Maker" in roles:
|
||||||
|
return url_for("maker.recipe_list")
|
||||||
|
if "Metrologist" in roles:
|
||||||
|
return url_for("statistics.dashboard")
|
||||||
|
if user.get("is_admin"):
|
||||||
|
return url_for("admin.user_list")
|
||||||
|
return url_for("auth.profile")
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/login", methods=["GET", "POST"])
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
def login():
|
def login():
|
||||||
"""Login page and form handler."""
|
"""Login page and form handler."""
|
||||||
# Se già loggato, redirect a home
|
# Se già loggato, redirect alla home in base al ruolo
|
||||||
if session.get("api_key"):
|
if session.get("api_key"):
|
||||||
try:
|
return redirect(_post_login_landing())
|
||||||
return redirect(url_for("measure.select_recipe"))
|
|
||||||
except Exception:
|
|
||||||
return redirect(url_for("auth.profile"))
|
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip()
|
username = request.form.get("username", "").strip()
|
||||||
@@ -76,20 +93,23 @@ def login():
|
|||||||
if user.get("theme_pref"):
|
if user.get("theme_pref"):
|
||||||
session["theme"] = user["theme_pref"]
|
session["theme"] = user["theme_pref"]
|
||||||
|
|
||||||
# Carica logo aziendale dalle impostazioni di sistema
|
# Carica impostazioni di sistema
|
||||||
settings_resp = api_client.get("/api/settings")
|
settings_resp = api_client.get("/api/settings")
|
||||||
if not settings_resp.get("error"):
|
if not settings_resp.get("error"):
|
||||||
logo_path = settings_resp.get("company_logo_path")
|
logo_path = settings_resp.get("company_logo_path")
|
||||||
if logo_path:
|
if logo_path:
|
||||||
session["company_logo"] = logo_path
|
session["company_logo"] = logo_path
|
||||||
|
auto_logout = settings_resp.get("auto_logout_minutes")
|
||||||
|
if auto_logout:
|
||||||
|
try:
|
||||||
|
session["auto_logout_minutes"] = int(auto_logout)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
flash(_("Benvenuto, %(name)s!", name=user.get("display_name", username)), "success")
|
||||||
|
|
||||||
# Redirect dopo login
|
# Redirect dopo login (in base al ruolo)
|
||||||
try:
|
return redirect(_post_login_landing())
|
||||||
return redirect(url_for("measure.select_recipe"))
|
|
||||||
except Exception:
|
|
||||||
return redirect(url_for("auth.profile"))
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
|
flash(_("Errore di connessione al server: %(error)s", error=str(e)), "error")
|
||||||
@@ -113,6 +133,27 @@ def logout():
|
|||||||
return redirect(url_for("auth.login"))
|
return redirect(url_for("auth.login"))
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/set-theme", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def set_theme():
|
||||||
|
"""Persist the user's theme toggle (light/dark) to session and profile.
|
||||||
|
|
||||||
|
Called via AJAX from the navbar theme switch so the choice survives
|
||||||
|
navigation and matches on the next login. Best-effort on the API side:
|
||||||
|
the session is updated regardless so the current browsing stays correct.
|
||||||
|
"""
|
||||||
|
theme = (request.get_json(silent=True) or {}).get("theme", "")
|
||||||
|
if theme not in ("light", "dark"):
|
||||||
|
return jsonify({"error": True, "detail": "invalid theme"}), 400
|
||||||
|
|
||||||
|
session["theme"] = theme
|
||||||
|
try:
|
||||||
|
api_client.put("/api/auth/me", data={"theme_pref": theme})
|
||||||
|
except Exception:
|
||||||
|
pass # Non-fatal: session theme is already updated for this session.
|
||||||
|
return jsonify({"ok": True, "theme": theme})
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route("/profile", methods=["GET", "POST"])
|
@auth_bp.route("/profile", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def profile():
|
def profile():
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Maker blueprint - recipe creation and editing."""
|
"""Maker blueprint - recipe creation and editing."""
|
||||||
import requests as http_requests
|
import requests as http_requests
|
||||||
|
|
||||||
from flask import Blueprint, Response, flash, jsonify, redirect, render_template, request, session, url_for
|
from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
|
|
||||||
from blueprints.auth import login_required, role_required
|
from blueprints.auth import login_required, role_required
|
||||||
from config import Config
|
from config import Config
|
||||||
from services.api_client import api_client
|
from services.api_client import api_client
|
||||||
|
from services.file_proxy import proxy_file
|
||||||
|
|
||||||
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
|
||||||
|
|
||||||
@@ -380,19 +381,7 @@ def api_upload_file():
|
|||||||
@login_required
|
@login_required
|
||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||||
api_key = session.get("api_key", "")
|
return proxy_file(file_path)
|
||||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
|
||||||
resp = http_requests.get(
|
|
||||||
f"{base_url}/api/files/{file_path}",
|
|
||||||
headers={"X-API-Key": api_key},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
return Response(resp.text, status=resp.status_code)
|
|
||||||
return Response(
|
|
||||||
resp.content,
|
|
||||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
|
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
|
||||||
@@ -424,3 +413,31 @@ def api_get_measurement_count(recipe_id: int, version_number: int):
|
|||||||
return jsonify(resp), resp.get("status_code", 500)
|
return jsonify(resp), resp.get("status_code", 500)
|
||||||
|
|
||||||
return jsonify(resp), 200
|
return jsonify(resp), 200
|
||||||
|
|
||||||
|
|
||||||
|
@maker_bp.route("/api/recipes/<int:recipe_id>/parse-technical-sheet", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("Maker")
|
||||||
|
def api_parse_technical_sheet(recipe_id: int):
|
||||||
|
"""Proxy: Parse PDF technical sheet with AI."""
|
||||||
|
file = request.files.get("file")
|
||||||
|
if not file:
|
||||||
|
return jsonify({"error": True, "detail": "Nessun file caricato"}), 400
|
||||||
|
|
||||||
|
api_key = session.get("api_key", "")
|
||||||
|
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||||
|
resp = http_requests.post(
|
||||||
|
f"{base_url}/api/recipes/{recipe_id}/parse-technical-sheet",
|
||||||
|
headers={"X-API-Key": api_key},
|
||||||
|
files={"file": (file.filename, file.stream, file.content_type)},
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = resp.json()
|
||||||
|
except ValueError:
|
||||||
|
payload = {
|
||||||
|
"error": True,
|
||||||
|
"detail": resp.text or f"HTTP {resp.status_code}",
|
||||||
|
}
|
||||||
|
return jsonify(payload), resp.status_code
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
"""MeasurementTec blueprint - recipe selection and measurement execution."""
|
||||||
import requests as http_requests
|
|
||||||
|
|
||||||
from flask import (
|
from flask import (
|
||||||
Blueprint, Response, flash, jsonify, redirect, render_template,
|
Blueprint, flash, jsonify, redirect, render_template,
|
||||||
request, session, url_for,
|
request, session, url_for,
|
||||||
)
|
)
|
||||||
from flask_babel import gettext as _
|
from flask_babel import gettext as _
|
||||||
@@ -10,6 +8,7 @@ from flask_babel import gettext as _
|
|||||||
from blueprints.auth import login_required, role_required
|
from blueprints.auth import login_required, role_required
|
||||||
from config import Config
|
from config import Config
|
||||||
from services.api_client import api_client
|
from services.api_client import api_client
|
||||||
|
from services.file_proxy import proxy_file
|
||||||
|
|
||||||
measure_bp = Blueprint("measure", __name__)
|
measure_bp = Blueprint("measure", __name__)
|
||||||
|
|
||||||
@@ -135,11 +134,15 @@ def task_execute(task_id: int):
|
|||||||
# Load all task IDs for this recipe (ordered) for auto-advance
|
# Load all task IDs for this recipe (ordered) for auto-advance
|
||||||
recipe_id = task_resp.get("recipe_id")
|
recipe_id = task_resp.get("recipe_id")
|
||||||
all_task_ids = []
|
all_task_ids = []
|
||||||
|
measurement_interval_minutes = None
|
||||||
if recipe_id:
|
if recipe_id:
|
||||||
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||||
if isinstance(tasks_resp, list):
|
if isinstance(tasks_resp, list):
|
||||||
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
|
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
|
||||||
all_task_ids = [t["id"] for t in sorted_tasks]
|
all_task_ids = [t["id"] for t in sorted_tasks]
|
||||||
|
recipe_resp = api_client.get(f"/api/recipes/{recipe_id}")
|
||||||
|
if not recipe_resp.get("error"):
|
||||||
|
measurement_interval_minutes = recipe_resp.get("measurement_interval_minutes")
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"measure/task_execute.html",
|
"measure/task_execute.html",
|
||||||
@@ -147,6 +150,7 @@ def task_execute(task_id: int):
|
|||||||
lot_number=lot_number,
|
lot_number=lot_number,
|
||||||
serial_number=serial_number,
|
serial_number=serial_number,
|
||||||
all_task_ids=all_task_ids,
|
all_task_ids=all_task_ids,
|
||||||
|
measurement_interval_minutes=measurement_interval_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -321,6 +325,34 @@ def save_measurement():
|
|||||||
return jsonify(resp), 201
|
return jsonify(resp), 201
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Route: Validate supervisor credentials (AJAX)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@measure_bp.route("/validate-supervisor", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@role_required("MeasurementTec")
|
||||||
|
def validate_supervisor():
|
||||||
|
"""Validate supervisor (capoturno) credentials for out-of-tolerance authorization."""
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
username = data.get("username", "").strip()
|
||||||
|
password = data.get("password", "")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
return jsonify({"error": True, "detail": _("Username e password richiesti")}), 400
|
||||||
|
|
||||||
|
resp = api_client.post("/api/auth/login", data={"username": username, "password": password})
|
||||||
|
|
||||||
|
if resp.get("error"):
|
||||||
|
return jsonify({"error": True, "detail": _("Credenziali non valide")}), 401
|
||||||
|
|
||||||
|
user = resp.get("user", {})
|
||||||
|
is_supervisor = "Supervisor" in (user.get("roles") or [])
|
||||||
|
if not (is_supervisor or user.get("is_admin")):
|
||||||
|
return jsonify({"error": True, "detail": _("Utente non autorizzato (richiesto capoturno)")}), 403
|
||||||
|
|
||||||
|
return jsonify({"authorized": True, "supervisor": user.get("display_name", username)}), 200
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Route: File proxy (browser can't send X-API-Key directly)
|
# Route: File proxy (browser can't send X-API-Key directly)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -328,16 +360,4 @@ def save_measurement():
|
|||||||
@login_required
|
@login_required
|
||||||
def api_get_file(file_path: str):
|
def api_get_file(file_path: str):
|
||||||
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
"""Proxy: Serve file from API server (browser can't send X-API-Key)."""
|
||||||
api_key = session.get("api_key", "")
|
return proxy_file(file_path)
|
||||||
base_url = Config.API_SERVER_URL.rstrip("/")
|
|
||||||
resp = http_requests.get(
|
|
||||||
f"{base_url}/api/files/{file_path}",
|
|
||||||
headers={"X-API-Key": api_key},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
if resp.status_code != 200:
|
|
||||||
return Response(resp.text, status=resp.status_code)
|
|
||||||
return Response(
|
|
||||||
resp.content,
|
|
||||||
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -56,11 +56,21 @@ class APIClient:
|
|||||||
try:
|
try:
|
||||||
error_body = response.json()
|
error_body = response.json()
|
||||||
detail = error_body.get("detail", str(error_body))
|
detail = error_body.get("detail", str(error_body))
|
||||||
# FastAPI 422 returns detail as a list of validation errors
|
# FastAPI 422 returns detail as a list of validation errors:
|
||||||
|
# [{"type": ..., "loc": ["body", "password"], "msg": "...", ...}, ...]
|
||||||
if isinstance(detail, list):
|
if isinstance(detail, list):
|
||||||
detail = "; ".join(
|
parts = []
|
||||||
e.get("msg", str(e)) for e in detail if isinstance(e, dict)
|
for e in detail:
|
||||||
) or str(detail)
|
if not isinstance(e, dict):
|
||||||
|
parts.append(str(e))
|
||||||
|
continue
|
||||||
|
msg = e.get("msg", str(e))
|
||||||
|
loc = e.get("loc") or []
|
||||||
|
# Strip leading "body"/"query"/"path" segments; keep field path.
|
||||||
|
field_path = [str(p) for p in loc if p not in ("body", "query", "path")]
|
||||||
|
field = ".".join(field_path) if field_path else None
|
||||||
|
parts.append(f"{field}: {msg}" if field else msg)
|
||||||
|
detail = "; ".join(parts) or str(detail)
|
||||||
except Exception:
|
except Exception:
|
||||||
detail = response.text or f"HTTP {response.status_code}"
|
detail = response.text or f"HTTP {response.status_code}"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Shared file proxy: relay uploads from the FastAPI server to the browser.
|
||||||
|
|
||||||
|
The browser can't send the X-API-Key header directly, so blueprints expose
|
||||||
|
a proxy route and delegate here.
|
||||||
|
"""
|
||||||
|
import requests as http_requests
|
||||||
|
from flask import Response, session
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
|
||||||
|
def proxy_file(file_path: str) -> Response:
|
||||||
|
"""Fetch a file from the API server and relay it with its content type."""
|
||||||
|
base_url = Config.API_SERVER_URL.rstrip("/")
|
||||||
|
resp = http_requests.get(
|
||||||
|
f"{base_url}/api/files/{file_path}",
|
||||||
|
headers={"X-API-Key": session.get("api_key", "")},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return Response(resp.text, status=resp.status_code)
|
||||||
|
return Response(
|
||||||
|
resp.content,
|
||||||
|
content_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||||
|
)
|
||||||
@@ -69,6 +69,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Brand details
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(37, 99, 235, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::selection {
|
||||||
|
background: rgba(96, 165, 250, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Global Transitions
|
Global Transitions
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -151,6 +168,7 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.tmf-card:hover {
|
.tmf-card:hover {
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
|
border-color: var(--scrollbar-thumb);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tmf-card-header {
|
.tmf-card-header {
|
||||||
@@ -250,11 +268,21 @@ textarea:focus-visible {
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--color-primary);
|
background: var(--color-primary);
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
<!-- Stylized Caliper Icon for Favicon -->
|
<!-- TieMeasureFlow brand mark: measurement span glyph on brand-blue badge -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="tmfBadge" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#3B82F6"/>
|
||||||
|
<stop offset="1" stop-color="#1D4ED8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
<!-- Caliper outer frame -->
|
<!-- Badge -->
|
||||||
<rect x="2" y="3" width="5" height="26" rx="1.5" fill="#2563EB"/>
|
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadge)"/>
|
||||||
<rect x="2" y="3" width="19" height="5" rx="1.5" fill="#2563EB"/>
|
|
||||||
<rect x="2" y="24" width="19" height="5" rx="1.5" fill="#2563EB"/>
|
|
||||||
|
|
||||||
<!-- Sliding jaw -->
|
<!-- Measurement end bars -->
|
||||||
<rect x="12" y="8" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
|
<rect x="14" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||||
<rect x="12" y="17.5" width="4.5" height="6.5" rx="1" fill="#1E40AF"/>
|
<rect x="45" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||||
|
|
||||||
<!-- Depth rod -->
|
<!-- Double-headed arrow (measured span) -->
|
||||||
<rect x="4" y="14" width="12" height="3.5" rx="1" fill="#3B82F6" opacity="0.55"/>
|
<path d="M24 32 H40" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M28 26.5 L22.5 32 L28 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<!-- Scale markings -->
|
<path d="M36 26.5 L41.5 32 L36 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<rect x="8" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
|
||||||
<rect x="11" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
|
||||||
<rect x="14" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
|
||||||
<rect x="17" y="5" width="1" height="2.5" rx="0.5" fill="#FFFFFF" opacity="0.65"/>
|
|
||||||
|
|
||||||
<!-- Flow arrow -->
|
|
||||||
<path d="M22 16 C25 16, 26.5 11, 29 11"
|
|
||||||
stroke="#3B82F6" stroke-width="2.2" stroke-linecap="round" fill="none"/>
|
|
||||||
<path d="M27 8.5 L29.5 11 L27 13.5"
|
|
||||||
stroke="#3B82F6" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 997 B |
@@ -1,41 +1,23 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 44" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 340 64" fill="none">
|
||||||
<!-- Stylized Caliper Icon -->
|
<!-- TieMeasureFlow full logo: brand badge + wordmark (standalone asset) -->
|
||||||
<g transform="translate(2, 2)">
|
<defs>
|
||||||
<!-- Caliper outer frame -->
|
<linearGradient id="tmfBadgeFull" x1="0" y1="0" x2="1" y2="1">
|
||||||
<rect x="0" y="4" width="6" height="32" rx="1.5" fill="#2563EB"/>
|
<stop offset="0" stop-color="#3B82F6"/>
|
||||||
<rect x="0" y="4" width="24" height="6" rx="1.5" fill="#2563EB"/>
|
<stop offset="1" stop-color="#1D4ED8"/>
|
||||||
<rect x="0" y="30" width="24" height="6" rx="1.5" fill="#2563EB"/>
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
<!-- Caliper sliding jaw -->
|
<!-- Badge -->
|
||||||
<rect x="14" y="10" width="5" height="8" rx="1" fill="#1E40AF"/>
|
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadgeFull)"/>
|
||||||
<rect x="14" y="22" width="5" height="8" rx="1" fill="#1E40AF"/>
|
<rect x="14" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||||
|
<rect x="45" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||||
|
<path d="M24 32 H40" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M28 26.5 L22.5 32 L28 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M36 26.5 L41.5 32 L36 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
|
||||||
<!-- Caliper depth rod -->
|
<!-- Wordmark: single text element with flowing tspans (cannot be clipped/overlap) -->
|
||||||
<rect x="2" y="18" width="16" height="4" rx="1" fill="#3B82F6" opacity="0.6"/>
|
<text x="76" y="42" font-family="Inter, system-ui, -apple-system, sans-serif"
|
||||||
|
font-size="30" letter-spacing="-0.5" fill="#0F172A">
|
||||||
<!-- Scale markings -->
|
<tspan font-weight="700">Tie</tspan><tspan font-weight="600" fill="#2563EB">Measure</tspan><tspan font-weight="700">Flow</tspan>
|
||||||
<rect x="7" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
</text>
|
||||||
<rect x="10" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
|
||||||
<rect x="13" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
|
||||||
<rect x="16" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
|
||||||
<rect x="19" y="7" width="1" height="3" rx="0.5" fill="#FFFFFF" opacity="0.7"/>
|
|
||||||
|
|
||||||
<!-- Flow arrow (smooth curve) -->
|
|
||||||
<path d="M26 20 C30 20, 32 14, 36 14 C40 14, 40 20, 36 20"
|
|
||||||
stroke="#2563EB" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
|
||||||
<path d="M34 17 L37 20 L34 23" stroke="#2563EB" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Text: TieMeasureFlow -->
|
|
||||||
<g transform="translate(48, 0)">
|
|
||||||
<!-- "Tie" in bold primary -->
|
|
||||||
<text x="0" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
|
|
||||||
letter-spacing="-0.5">Tie</text>
|
|
||||||
<!-- "Measure" in medium dark -->
|
|
||||||
<text x="32" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="500" fill="#1E40AF"
|
|
||||||
letter-spacing="-0.5">Measure</text>
|
|
||||||
<!-- "Flow" in bold primary -->
|
|
||||||
<text x="120" y="28" font-family="Inter, system-ui, sans-serif" font-size="20" font-weight="700" fill="#2563EB"
|
|
||||||
letter-spacing="-0.5">Flow</text>
|
|
||||||
</g>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -16,9 +16,21 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the initial dark-mode state.
|
* Resolve the initial dark-mode state.
|
||||||
* Priority: localStorage > system preference > light (default).
|
* Priority: server (per-user theme_pref) > localStorage > system preference > light.
|
||||||
|
*
|
||||||
|
* The server value (window.__SERVER_THEME) is authoritative for logged-in
|
||||||
|
* users: it reflects the theme_pref of whoever is currently authenticated,
|
||||||
|
* so switching user actually switches theme instead of inheriting the
|
||||||
|
* previous user's localStorage value. It is empty for anonymous pages.
|
||||||
*/
|
*/
|
||||||
function getInitialDark() {
|
function getInitialDark() {
|
||||||
|
var server = (typeof window !== 'undefined' && window.__SERVER_THEME) || '';
|
||||||
|
if (server === 'dark' || server === 'light') {
|
||||||
|
// Keep localStorage in sync so the rest of the app stays consistent.
|
||||||
|
try { localStorage.setItem(STORAGE_KEY, server); } catch (_) {}
|
||||||
|
return server === 'dark';
|
||||||
|
}
|
||||||
|
|
||||||
var stored = null;
|
var stored = null;
|
||||||
try {
|
try {
|
||||||
stored = localStorage.getItem(STORAGE_KEY);
|
stored = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -70,6 +82,7 @@
|
|||||||
toggle: function () {
|
toggle: function () {
|
||||||
this.dark = !this.dark;
|
this.dark = !this.dark;
|
||||||
this._apply();
|
this._apply();
|
||||||
|
this._persist();
|
||||||
},
|
},
|
||||||
|
|
||||||
set: function (dark) {
|
set: function (dark) {
|
||||||
@@ -77,6 +90,24 @@
|
|||||||
this._apply();
|
this._apply();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Persist an explicit user choice to the server (session + theme_pref),
|
||||||
|
// so it survives navigation and matches on the next login. Best-effort.
|
||||||
|
_persist: function () {
|
||||||
|
try {
|
||||||
|
var csrf = document.querySelector('meta[name=csrf-token]');
|
||||||
|
fetch('/auth/set-theme', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': (csrf && csrf.content) || ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ theme: this.dark ? 'dark' : 'light' })
|
||||||
|
}).catch(function () {});
|
||||||
|
} catch (_) {
|
||||||
|
// Non-fatal: localStorage already holds the choice for this browser.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_apply: function () {
|
_apply: function () {
|
||||||
if (this.dark) {
|
if (this.dark) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
|
|||||||
@@ -671,6 +671,53 @@ function annotationEditor() {
|
|||||||
return rect;
|
return rect;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Object creation - Text
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
addText(x, y, textContent, fontSize) {
|
||||||
|
var size = fontSize || 18;
|
||||||
|
var itext = new fabric.IText(textContent || 'Testo', {
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
fontSize: size,
|
||||||
|
fill: this.currentColor,
|
||||||
|
fontFamily: 'Inter, sans-serif',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
selectable: true,
|
||||||
|
evented: true,
|
||||||
|
hasControls: false,
|
||||||
|
hasBorders: true,
|
||||||
|
lockRotation: true,
|
||||||
|
lockScalingX: true,
|
||||||
|
lockScalingY: true,
|
||||||
|
objectType: 'text',
|
||||||
|
textColor: this.currentColor,
|
||||||
|
textFontSize: size,
|
||||||
|
});
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
itext.on('editing:exited', function () {
|
||||||
|
if (!itext.text || itext.text.trim() === '') {
|
||||||
|
self.canvas.remove(itext);
|
||||||
|
}
|
||||||
|
itext.textColor = itext.fill;
|
||||||
|
self.isDirty = true;
|
||||||
|
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
itext.on('changed', function () {
|
||||||
|
self.isDirty = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canvas.add(itext);
|
||||||
|
itext.setCoords();
|
||||||
|
this.canvas.setActiveObject(itext);
|
||||||
|
this.isDirty = true;
|
||||||
|
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
|
||||||
|
return itext;
|
||||||
|
},
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
// Toolbar / mode switching
|
// Toolbar / mode switching
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
@@ -678,7 +725,7 @@ function annotationEditor() {
|
|||||||
/**
|
/**
|
||||||
* Set the current interaction mode.
|
* Set the current interaction mode.
|
||||||
*
|
*
|
||||||
* @param {'select'|'marker'|'arrow'|'rect'} mode
|
* @param {'select'|'marker'|'arrow'|'rect'|'text'} mode
|
||||||
*/
|
*/
|
||||||
setMode(mode) {
|
setMode(mode) {
|
||||||
this.activeMode = mode;
|
this.activeMode = mode;
|
||||||
@@ -731,6 +778,13 @@ function annotationEditor() {
|
|||||||
self.addMarker(pointer.x, pointer.y);
|
self.addMarker(pointer.x, pointer.y);
|
||||||
self.setMode('select');
|
self.setMode('select');
|
||||||
window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
|
window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
|
||||||
|
} else if (self.activeMode === 'text') {
|
||||||
|
var textObj = self.addText(pointer.x, pointer.y);
|
||||||
|
self.setMode('select');
|
||||||
|
window.dispatchEvent(new CustomEvent('anno-mode-changed', { detail: { mode: 'select' } }));
|
||||||
|
self.canvas.setActiveObject(textObj);
|
||||||
|
textObj.enterEditing();
|
||||||
|
textObj.selectAll();
|
||||||
} else if (self.activeMode === 'arrow') {
|
} else if (self.activeMode === 'arrow') {
|
||||||
isDrawing = true;
|
isDrawing = true;
|
||||||
drawStartX = pointer.x;
|
drawStartX = pointer.x;
|
||||||
@@ -902,7 +956,7 @@ function annotationEditor() {
|
|||||||
setupKeyboard() {
|
setupKeyboard() {
|
||||||
var self = this;
|
var self = this;
|
||||||
|
|
||||||
document.addEventListener('keydown', function (e) {
|
this._onKeyDown = function (e) {
|
||||||
// Skip when typing in form fields
|
// Skip when typing in form fields
|
||||||
var tag = e.target.tagName;
|
var tag = e.target.tagName;
|
||||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||||
@@ -916,7 +970,8 @@ function annotationEditor() {
|
|||||||
self.canvas.discardActiveObject();
|
self.canvas.discardActiveObject();
|
||||||
self.canvas.renderAll();
|
self.canvas.renderAll();
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
document.addEventListener('keydown', this._onKeyDown);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
@@ -1023,6 +1078,13 @@ function annotationEditor() {
|
|||||||
if (obj.arrowLineDash) data.lineDash = obj.arrowLineDash;
|
if (obj.arrowLineDash) data.lineDash = obj.arrowLineDash;
|
||||||
if (obj.areaLineDash) data.lineDash = obj.areaLineDash;
|
if (obj.areaLineDash) data.lineDash = obj.areaLineDash;
|
||||||
|
|
||||||
|
// Text-specific
|
||||||
|
if (obj.objectType === 'text') {
|
||||||
|
data.textContent = obj.text;
|
||||||
|
data.textColor = obj.textColor || obj.fill;
|
||||||
|
data.fontSize = obj.textFontSize || obj.fontSize;
|
||||||
|
}
|
||||||
|
|
||||||
// Arrow-specific: store original endpoints
|
// Arrow-specific: store original endpoints
|
||||||
if (obj.objectType === 'arrow') {
|
if (obj.objectType === 'arrow') {
|
||||||
data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null;
|
data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null;
|
||||||
@@ -1120,6 +1182,11 @@ function annotationEditor() {
|
|||||||
this.currentColor = savedColor3;
|
this.currentColor = savedColor3;
|
||||||
this.currentStrokeWidth = savedWidth2;
|
this.currentStrokeWidth = savedWidth2;
|
||||||
this.currentLineDash = savedDash2;
|
this.currentLineDash = savedDash2;
|
||||||
|
} else if (obj.type === 'text') {
|
||||||
|
var savedColor4 = this.currentColor;
|
||||||
|
if (obj.textColor) this.currentColor = obj.textColor;
|
||||||
|
this.addText(obj.left * cs, obj.top * cs, obj.textContent, (obj.fontSize || 18) * cs);
|
||||||
|
this.currentColor = savedColor4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1223,6 +1290,9 @@ function annotationEditor() {
|
|||||||
if (this._onImageLoaded) {
|
if (this._onImageLoaded) {
|
||||||
window.removeEventListener('image-loaded', this._onImageLoaded);
|
window.removeEventListener('image-loaded', this._onImageLoaded);
|
||||||
}
|
}
|
||||||
|
if (this._onKeyDown) {
|
||||||
|
document.removeEventListener('keydown', this._onKeyDown);
|
||||||
|
}
|
||||||
if (this.canvas) {
|
if (this.canvas) {
|
||||||
this.canvas.dispose();
|
this.canvas.dispose();
|
||||||
this.canvas = null;
|
this.canvas = null;
|
||||||
|
|||||||
@@ -436,6 +436,8 @@ function _drawEditorAnnotations(ctx, data, scale) {
|
|||||||
_drawPreviewArrow(ctx, obj, annoScale);
|
_drawPreviewArrow(ctx, obj, annoScale);
|
||||||
} else if (obj.type === 'area') {
|
} else if (obj.type === 'area') {
|
||||||
_drawPreviewArea(ctx, obj, annoScale);
|
_drawPreviewArea(ctx, obj, annoScale);
|
||||||
|
} else if (obj.type === 'text') {
|
||||||
|
_drawPreviewText(ctx, obj, annoScale);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -514,6 +516,24 @@ function _drawPreviewArea(ctx, obj, scale) {
|
|||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _drawPreviewText(ctx, obj, scale) {
|
||||||
|
var x = obj.left * scale;
|
||||||
|
var y = obj.top * scale;
|
||||||
|
var color = obj.textColor || '#000000';
|
||||||
|
var fontSize = Math.max(10, Math.round((obj.fontSize || 18) * scale));
|
||||||
|
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.font = 'bold ' + fontSize + 'px Inter, sans-serif';
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
|
||||||
|
var lines = (obj.textContent || '').split('\n');
|
||||||
|
var lineHeight = fontSize * 1.2;
|
||||||
|
for (var j = 0; j < lines.length; j++) {
|
||||||
|
ctx.fillText(lines[j], x, y + j * lineHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draw annotations in the legacy format (markers: point/rectangle).
|
* Draw annotations in the legacy format (markers: point/rectangle).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -163,6 +163,17 @@ function numpad() {
|
|||||||
* @param {KeyboardEvent} e - The keyboard event
|
* @param {KeyboardEvent} e - The keyboard event
|
||||||
*/
|
*/
|
||||||
handleKeydown(e) {
|
handleKeydown(e) {
|
||||||
|
// Ignore keystrokes aimed at an editable field (e.g. the supervisor
|
||||||
|
// login modal). The numpad has no text inputs of its own, so this
|
||||||
|
// window-level capture is only meant for the USB caliper / keyboard
|
||||||
|
// wedge when no field is focused. Without this guard the numpad would
|
||||||
|
// swallow digits and Backspace inside those inputs.
|
||||||
|
const t = e.target;
|
||||||
|
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' ||
|
||||||
|
t.tagName === 'SELECT' || t.isContentEditable)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Number keys
|
// Number keys
|
||||||
if (e.key >= '0' && e.key <= '9') {
|
if (e.key >= '0' && e.key <= '9') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ _('Impostazioni') }} - TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
||||||
|
x-data="systemSettings()">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Impostazioni di Sistema') }}</h1>
|
||||||
|
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Configura i parametri generali del sistema') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Card -->
|
||||||
|
<div class="tmf-card">
|
||||||
|
<div class="tmf-card-header flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Sessione e Sicurezza') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tmf-card-body space-y-5">
|
||||||
|
<!-- Auto-logout -->
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label" for="auto-logout">{{ _('Auto-logout per inattività (minuti)') }}</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input id="auto-logout"
|
||||||
|
type="number"
|
||||||
|
x-model.number="autoLogoutMinutes"
|
||||||
|
class="tmf-input w-32"
|
||||||
|
min="1"
|
||||||
|
max="480"
|
||||||
|
placeholder="—">
|
||||||
|
<button @click="autoLogoutMinutes = null"
|
||||||
|
class="text-xs text-[var(--text-muted)] hover:text-red-500 transition-colors"
|
||||||
|
x-show="autoLogoutMinutes">
|
||||||
|
{{ _('Disabilita') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-[var(--text-muted)] mt-1">
|
||||||
|
{{ _('L\'operatore verrà disconnesso automaticamente dopo il periodo di inattività indicato. Lasciare vuoto per disabilitare.') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tmf-card-footer flex items-center justify-between">
|
||||||
|
<span x-show="saved" x-transition class="text-sm text-emerald-600 flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Salvato') }}
|
||||||
|
</span>
|
||||||
|
<span x-show="errorMessage" x-transition class="text-sm text-red-600" x-text="errorMessage"></span>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<button @click="save()"
|
||||||
|
:disabled="saving"
|
||||||
|
class="btn btn-primary gap-1.5">
|
||||||
|
<svg x-show="!saving" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
<svg x-show="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Salva impostazioni') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function systemSettings() {
|
||||||
|
return {
|
||||||
|
autoLogoutMinutes: {{ (settings.auto_logout_minutes or '')|tojson }},
|
||||||
|
saving: false,
|
||||||
|
saved: false,
|
||||||
|
errorMessage: '',
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
this.saving = true;
|
||||||
|
this.saved = false;
|
||||||
|
this.errorMessage = '';
|
||||||
|
|
||||||
|
var csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
|
||||||
|
var payload = {
|
||||||
|
auto_logout_minutes: this.autoLogoutMinutes ? String(this.autoLogoutMinutes) : ''
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/admin/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken || '' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
var data = await resp.json();
|
||||||
|
if (data.error) {
|
||||||
|
this.errorMessage = data.detail || data.error;
|
||||||
|
} else {
|
||||||
|
this.saved = true;
|
||||||
|
setTimeout(() => { this.saved = false; }, 3000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.errorMessage = {{ _('Errore di connessione al server')|tojson }};
|
||||||
|
}
|
||||||
|
this.saving = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -258,9 +258,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ _('Ricette disponibili') }} (<span x-text="filteredUnassignedRecipes.length"></span>)
|
{{ _('Ricette disponibili') }} (<span x-text="filteredUnassignedRecipes.length"></span>)
|
||||||
</h3>
|
</h3>
|
||||||
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
|
<div class="border border-[var(--border-color)] rounded-lg p-2 space-y-2 h-96 overflow-y-auto">
|
||||||
<template x-for="r in filteredUnassignedRecipes" :key="r.id">
|
<template x-for="r in filteredUnassignedRecipes" :key="r.id">
|
||||||
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
|
<div class="flex items-center justify-between px-3 py-2 border border-[var(--border-color)] rounded-md hover:bg-[var(--bg-secondary)] transition-colors">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
|
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
|
||||||
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
|
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
|
||||||
@@ -292,9 +292,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ _('Assegnate alla stazione') }} (<span x-text="filteredAssignedRecipes.length"></span>)
|
{{ _('Assegnate alla stazione') }} (<span x-text="filteredAssignedRecipes.length"></span>)
|
||||||
</h3>
|
</h3>
|
||||||
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
|
<div class="border border-[var(--border-color)] rounded-lg p-2 space-y-2 h-96 overflow-y-auto">
|
||||||
<template x-for="r in filteredAssignedRecipes" :key="r.id">
|
<template x-for="r in filteredAssignedRecipes" :key="r.id">
|
||||||
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
|
<div class="flex items-center justify-between px-3 py-2 border border-[var(--border-color)] rounded-md hover:bg-[var(--bg-secondary)] transition-colors">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
|
<div class="font-mono text-xs font-semibold text-[var(--text-primary)] truncate" x-text="r.code"></div>
|
||||||
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
|
<div class="text-xs text-[var(--text-secondary)] truncate" x-text="r.name"></div>
|
||||||
|
|||||||
@@ -78,7 +78,8 @@
|
|||||||
:class="{
|
:class="{
|
||||||
'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300': role === 'Maker',
|
'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300': role === 'Maker',
|
||||||
'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300': role === 'MeasurementTec',
|
'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300': role === 'MeasurementTec',
|
||||||
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': role === 'Metrologist'
|
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': role === 'Metrologist',
|
||||||
|
'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300': role === 'Supervisor'
|
||||||
}"
|
}"
|
||||||
x-text="role"></span>
|
x-text="role"></span>
|
||||||
</template>
|
</template>
|
||||||
@@ -233,6 +234,11 @@
|
|||||||
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||||
<span class="text-sm text-[var(--text-primary)]">Metrologist</span>
|
<span class="text-sm text-[var(--text-primary)]">Metrologist</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" x-model="form.roles" value="Supervisor"
|
||||||
|
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||||
|
<span class="text-sm text-[var(--text-primary)]">{{ _('Supervisor (capoturno)') }}</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,26 @@
|
|||||||
{% block title %}Login — TieMeasureFlow{% endblock %}
|
{% block title %}Login — TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 sm:px-6 lg:px-8">
|
<div class="relative min-h-screen flex items-center justify-center overflow-hidden bg-[var(--bg-primary)] px-4 sm:px-6 lg:px-8">
|
||||||
<div class="max-w-md w-full space-y-8">
|
|
||||||
|
<!-- Decorative brand background -->
|
||||||
|
<div class="pointer-events-none absolute inset-0" aria-hidden="true">
|
||||||
|
<div class="absolute -top-32 -left-32 w-96 h-96 rounded-full bg-primary-500/10 dark:bg-primary-400/10 blur-3xl"></div>
|
||||||
|
<div class="absolute -bottom-40 -right-24 w-[28rem] h-[28rem] rounded-full bg-primary-600/10 dark:bg-primary-500/10 blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative max-w-md w-full space-y-6">
|
||||||
<!-- Card -->
|
<!-- Card -->
|
||||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-xl p-8">
|
<div class="bg-[var(--bg-card)] border border-[var(--border-color)] shadow-lg rounded-2xl p-8">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
<div class="mx-auto mb-4 inline-flex justify-center text-slate-900 dark:text-white">
|
||||||
alt="TieMeasureFlow Logo"
|
{% set logo_class = 'h-14 w-14' %}
|
||||||
class="mx-auto h-16 w-auto mb-4"
|
{% set wordmark_class = 'text-3xl' %}
|
||||||
onerror="this.style.display='none'">
|
{% set logo_id = 'login' %}
|
||||||
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
{% include 'components/_app_logo.html' %}
|
||||||
TieMeasureFlow
|
</div>
|
||||||
</h2>
|
<p class="text-sm text-[var(--text-secondary)]">
|
||||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
|
||||||
{{ _('Accedi al sistema') }}
|
{{ _('Accedi al sistema') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,12 +31,12 @@
|
|||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<!-- Username -->
|
<!-- Username -->
|
||||||
<div>
|
<div>
|
||||||
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
<label for="username" class="tmf-label">
|
||||||
{{ _('Username') }}
|
{{ _('Username') }}
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,36 +45,52 @@
|
|||||||
name="username"
|
name="username"
|
||||||
required
|
required
|
||||||
autofocus
|
autofocus
|
||||||
|
autocomplete="username"
|
||||||
placeholder="{{ _('Username') }}"
|
placeholder="{{ _('Username') }}"
|
||||||
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
|
class="tmf-input !pl-10 !py-2.5">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Password -->
|
<!-- Password -->
|
||||||
<div>
|
<div x-data="{ show: false }">
|
||||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
<label for="password" class="tmf-label">
|
||||||
{{ _('Password') }}
|
{{ _('Password') }}
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 text-[var(--text-muted)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input type="password"
|
<input :type="show ? 'text' : 'password'"
|
||||||
|
type="password"
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
required
|
required
|
||||||
|
autocomplete="current-password"
|
||||||
placeholder="{{ _('Password') }}"
|
placeholder="{{ _('Password') }}"
|
||||||
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
|
class="tmf-input !pl-10 !pr-12 !py-2.5">
|
||||||
|
<button type="button"
|
||||||
|
@click="show = !show"
|
||||||
|
:aria-label="show ? {{ _('Nascondi password')|tojson_attr }} : {{ _('Mostra password')|tojson_attr }}"
|
||||||
|
class="absolute inset-y-0 right-0 px-3 flex items-center text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors">
|
||||||
|
<!-- Eye (password hidden, click to show) -->
|
||||||
|
<svg x-show="!show" class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
<!-- Eye slash (password visible, click to hide) -->
|
||||||
|
<svg x-show="show" x-cloak class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12c1.292 4.338 5.31 7.5 10.066 7.5.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Submit Button -->
|
<!-- Submit Button -->
|
||||||
<div>
|
<div>
|
||||||
<button type="submit"
|
<button type="submit" class="btn btn-primary btn-lg w-full shadow-sm">
|
||||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||||
</svg>
|
</svg>
|
||||||
{{ _('Accedi') }}
|
{{ _('Accedi') }}
|
||||||
@@ -78,7 +100,7 @@
|
|||||||
|
|
||||||
<!-- Help Text -->
|
<!-- Help Text -->
|
||||||
<div class="mt-6 text-center">
|
<div class="mt-6 text-center">
|
||||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
<p class="text-xs text-[var(--text-muted)]">
|
||||||
{{ _('Hai dimenticato la password?') }}
|
{{ _('Hai dimenticato la password?') }}
|
||||||
<br>
|
<br>
|
||||||
{{ _('Contatta l\'amministratore') }}
|
{{ _('Contatta l\'amministratore') }}
|
||||||
@@ -87,8 +109,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="text-center text-xs text-slate-500 dark:text-slate-400">
|
<div class="text-center text-xs text-[var(--text-muted)]">
|
||||||
<p>TieMeasureFlow © 2025 - {{ _('Sistema di misurazione industriale') }}</p>
|
<p>TieMeasureFlow © {{ current_year }} - {{ _('Sistema di misurazione industriale') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,6 +87,8 @@
|
|||||||
bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800
|
bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800
|
||||||
{% elif role == 'Metrologist' %}
|
{% elif role == 'Metrologist' %}
|
||||||
bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-800
|
bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-800
|
||||||
|
{% elif role == 'Supervisor' %}
|
||||||
|
bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-200 border border-orange-200 dark:border-orange-800
|
||||||
{% else %}
|
{% else %}
|
||||||
bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600
|
bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600
|
||||||
{% endif %}">
|
{% endif %}">
|
||||||
|
|||||||
@@ -24,16 +24,19 @@
|
|||||||
<!-- Theme CSS Variables -->
|
<!-- Theme CSS Variables -->
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/themes.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/themes.css') }}">
|
||||||
|
|
||||||
|
<!-- Authoritative per-user theme from the server session (empty when anonymous) -->
|
||||||
|
<script>window.__SERVER_THEME = "{{ current_theme if current_user else '' }}";</script>
|
||||||
|
|
||||||
<!-- Alpine.js Theme Init (before Alpine loads) -->
|
<!-- Alpine.js Theme Init (before Alpine loads) -->
|
||||||
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>
|
||||||
|
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="h-full bg-[var(--bg-primary)] text-[var(--text-primary)] font-sans antialiased transition-colors duration-300">
|
<body class="{% block body_class %}h-full{% endblock %} bg-[var(--bg-primary)] text-[var(--text-primary)] font-sans antialiased transition-colors duration-300">
|
||||||
|
|
||||||
<!-- Alpine.js App Wrapper -->
|
<!-- Alpine.js App Wrapper -->
|
||||||
<div x-data class="min-h-full flex flex-col">
|
<div x-data class="{% block wrapper_class %}min-h-full{% endblock %} flex flex-col">
|
||||||
|
|
||||||
<!-- Navbar -->
|
<!-- Navbar -->
|
||||||
{% include "components/navbar.html" %}
|
{% include "components/navbar.html" %}
|
||||||
@@ -98,21 +101,91 @@
|
|||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="flex-1">
|
<main class="{% block main_class %}flex-1{% endblock %}">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{% block footer %}
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
|
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
|
||||||
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
|
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
|
||||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic
|
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% block extra_js %}{% endblock %}
|
{% block extra_js %}{% endblock %}
|
||||||
|
|
||||||
|
{% if current_user and auto_logout_minutes %}
|
||||||
|
<script>
|
||||||
|
function inactivityTimer() {
|
||||||
|
var timeoutMs = {{ auto_logout_minutes }} * 60 * 1000;
|
||||||
|
var warningMs = 60 * 1000;
|
||||||
|
return {
|
||||||
|
lastActivity: Date.now(),
|
||||||
|
showWarning: false,
|
||||||
|
remainingSeconds: 60,
|
||||||
|
_interval: null,
|
||||||
|
init() {
|
||||||
|
var self = this;
|
||||||
|
this._interval = setInterval(function() { self._check(); }, 5000);
|
||||||
|
var lastUpdate = 0;
|
||||||
|
var handler = function() {
|
||||||
|
var now = Date.now();
|
||||||
|
if (now - lastUpdate > 3000) {
|
||||||
|
lastUpdate = now;
|
||||||
|
self.lastActivity = now;
|
||||||
|
self.showWarning = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
['mousemove','mousedown','keypress','touchstart','scroll'].forEach(function(evt) {
|
||||||
|
window.addEventListener(evt, handler, { passive: true });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
resetTimer() {
|
||||||
|
this.lastActivity = Date.now();
|
||||||
|
this.showWarning = false;
|
||||||
|
},
|
||||||
|
_check() {
|
||||||
|
var elapsed = Date.now() - this.lastActivity;
|
||||||
|
var remaining = timeoutMs - elapsed;
|
||||||
|
if (remaining <= 0) {
|
||||||
|
clearInterval(this._interval);
|
||||||
|
window.location.href = '{{ url_for("auth.logout") }}';
|
||||||
|
} else if (remaining <= warningMs) {
|
||||||
|
this.showWarning = true;
|
||||||
|
this.remainingSeconds = Math.ceil(remaining / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<div x-data="inactivityTimer()" class="contents">
|
||||||
|
<template x-if="showWarning">
|
||||||
|
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" @click.self="resetTimer()">
|
||||||
|
<div class="bg-[var(--bg-primary)] rounded-xl p-6 shadow-2xl max-w-sm mx-4 border border-[var(--border-color)]">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-[var(--text-primary)]">{{ _('Sessione in scadenza') }}</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-[var(--text-secondary)] mb-5">
|
||||||
|
{{ _('Sarai disconnesso tra') }} <span class="font-mono font-bold text-amber-600" x-text="remainingSeconds"></span> {{ _('secondi per inattività.') }}
|
||||||
|
</p>
|
||||||
|
<button @click="resetTimer()" class="btn btn-primary w-full">
|
||||||
|
{{ _('Continua a lavorare') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined -->
|
<!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined -->
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!--
|
||||||
|
TieMeasureFlow logo: brand badge (SVG) + HTML wordmark.
|
||||||
|
The wordmark inherits `color` from the parent (set e.g. `text-slate-900
|
||||||
|
dark:text-white`); "Measure" keeps the brand blue.
|
||||||
|
|
||||||
|
Parameters (set before include):
|
||||||
|
logo_class — badge size, default 'h-10 w-10'
|
||||||
|
wordmark_class — wordmark text size, default 'text-xl'; '' hides the text
|
||||||
|
logo_id — unique suffix for the SVG gradient id when the logo
|
||||||
|
appears more than once per page (default 'tmf')
|
||||||
|
-->
|
||||||
|
{% set _logo_id = logo_id|default('tmf') %}
|
||||||
|
<span class="inline-flex items-center gap-2.5 select-none whitespace-nowrap">
|
||||||
|
<svg class="{{ logo_class|default('h-10 w-10') }} shrink-0" viewBox="0 0 64 64" fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="tmfBadge-{{ _logo_id }}" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#3B82F6"/>
|
||||||
|
<stop offset="1" stop-color="#1D4ED8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#tmfBadge-{{ _logo_id }})"/>
|
||||||
|
<rect x="14" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||||
|
<rect x="45" y="16" width="5" height="32" rx="2.5" fill="#FFFFFF"/>
|
||||||
|
<path d="M24 32 H40" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
<path d="M28 26.5 L22.5 32 L28 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M36 26.5 L41.5 32 L36 37.5" stroke="#FFFFFF" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
{% if wordmark_class is not defined or wordmark_class %}
|
||||||
|
<span class="{{ wordmark_class|default('text-xl') }} font-bold tracking-tight leading-none"
|
||||||
|
aria-label="TieMeasureFlow">Tie<span class="text-primary-600 dark:text-primary-400">Measure</span>Flow</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
@@ -1,19 +1,60 @@
|
|||||||
<!-- TieMeasureFlow Navbar -->
|
<!-- TieMeasureFlow Navbar -->
|
||||||
|
|
||||||
|
{# SVG path data for nav icons, shared between desktop and mobile menus #}
|
||||||
|
{% set icon_measure %}<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>{% endset %}
|
||||||
|
{% set icon_recipes %}<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>{% endset %}
|
||||||
|
{% set icon_stats %}<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>{% endset %}
|
||||||
|
{% set icon_users %}<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>{% endset %}
|
||||||
|
{% set icon_stations %}<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>{% endset %}
|
||||||
|
{% set icon_settings %}<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93.398.164.855.142 1.205-.108l.737-.527a1.125 1.125 0 011.45.12l.773.774c.39.389.44 1.002.12 1.45l-.527.737c-.25.35-.272.806-.107 1.204.165.397.505.71.93.78l.893.15c.543.09.94.56.94 1.109v1.094c0 .55-.397 1.02-.94 1.11l-.893.149c-.425.07-.765.383-.93.78-.165.398-.143.854.107 1.204l.527.738c.32.447.269 1.06-.12 1.45l-.774.773a1.125 1.125 0 01-1.449.12l-.738-.527c-.35-.25-.806-.272-1.204-.107-.397.165-.71.505-.78.929l-.15.894c-.09.542-.56.94-1.11.94h-1.094c-.55 0-1.019-.398-1.11-.94l-.148-.894c-.071-.424-.384-.764-.781-.93-.398-.164-.854-.142-1.204.108l-.738.527c-.447.32-1.06.269-1.45-.12l-.773-.774a1.125 1.125 0 01-.12-1.45l.527-.737c.25-.35.273-.806.108-1.204-.165-.397-.506-.71-.93-.78l-.894-.15c-.542-.09-.94-.56-.94-1.109v-1.094c0-.55.398-1.02.94-1.11l.894-.149c.424-.07.765-.383.93-.78.165-.398.143-.854-.107-1.204l-.527-.738a1.125 1.125 0 01.12-1.45l.773-.773a1.125 1.125 0 011.45-.12l.737.527c.35.25.807.272 1.204.107.397-.165.71-.505.78-.929l.15-.894z"/><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>{% endset %}
|
||||||
|
|
||||||
|
{# Single nav link, desktop or mobile variant. `icon` is SVG path markup. #}
|
||||||
|
{% macro nav_link(href, label, icon, active=false, mobile=false) -%}
|
||||||
|
<a href="{{ href }}"
|
||||||
|
{% if active %}aria-current="page"{% endif %}
|
||||||
|
class="flex items-center {{ 'gap-3 px-3 py-2.5' if mobile else 'gap-2 px-3 py-2' }} rounded-lg text-sm font-medium transition-colors duration-200
|
||||||
|
{% if active %}text-primary bg-primary-50 dark:bg-primary-900/20{% else %}text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20{% endif %}">
|
||||||
|
<svg class="{{ 'w-5 h-5' if mobile else 'w-[18px] h-[18px]' }} shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">{{ icon }}</svg>
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{# Render all role-gated nav links for the current user #}
|
||||||
|
{% macro nav_links(mobile=false) -%}
|
||||||
|
{% set ep = request.endpoint or '' %}
|
||||||
|
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
||||||
|
{{ nav_link(url_for('measure.select_recipe'), _('Misure'), icon_measure, ep.startswith('measure.'), mobile) }}
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
||||||
|
{{ nav_link(url_for('maker.recipe_list'), _('Ricette'), icon_recipes, ep.startswith('maker.'), mobile) }}
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||||
|
{{ nav_link(url_for('statistics.dashboard'), _('Statistiche'), icon_stats, ep.startswith('statistics.'), mobile) }}
|
||||||
|
{% endif %}
|
||||||
|
{% if current_user.get('is_admin') %}
|
||||||
|
{{ nav_link(url_for('admin.user_list'), _('Utenti'), icon_users, ep == 'admin.user_list', mobile) }}
|
||||||
|
{{ nav_link(url_for('admin.station_list'), _('Stazioni'), icon_stations, ep == 'admin.station_list', mobile) }}
|
||||||
|
{{ nav_link(url_for('admin.settings_page'), _('Impostazioni'), icon_settings, ep == 'admin.settings_page', mobile) }}
|
||||||
|
{% endif %}
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
<nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300"
|
<nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300"
|
||||||
x-data="{ mobileOpen: false, userDropdown: false }">
|
x-data="{ mobileOpen: false }">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between h-14">
|
<div class="flex items-center justify-between h-14">
|
||||||
|
|
||||||
<!-- Left: Logo -->
|
<!-- Left: Logo -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<a href="{{ url_for('index') }}" class="flex items-center gap-2.5 group">
|
<a href="{{ url_for('index') }}"
|
||||||
|
class="flex items-center gap-2.5 group text-slate-900 dark:text-white">
|
||||||
{% if company_logo %}
|
{% if company_logo %}
|
||||||
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-8 w-auto"
|
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-10 w-auto"
|
||||||
onerror="this.src='{{ url_for('static', filename='img/tmflow-logo.svg') }}'">
|
onerror="this.style.display='none'">
|
||||||
{% else %}
|
{% else %}
|
||||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
{% set logo_class = 'h-9 w-9' %}
|
||||||
alt="TieMeasureFlow"
|
{% set wordmark_class = 'text-xl' %}
|
||||||
class="h-8 w-auto">
|
{% set logo_id = 'nav' %}
|
||||||
|
{% include 'components/_app_logo.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,89 +62,7 @@
|
|||||||
<!-- Center: Navigation Links (Desktop) -->
|
<!-- Center: Navigation Links (Desktop) -->
|
||||||
{% if current_user %}
|
{% if current_user %}
|
||||||
<div class="hidden md:flex items-center gap-1">
|
<div class="hidden md:flex items-center gap-1">
|
||||||
|
{{ nav_links() }}
|
||||||
{# MeasurementTec: Misure #}
|
|
||||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
|
||||||
<a href="{{ url_for('measure.select_recipe') }}"
|
|
||||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors duration-200
|
|
||||||
{% if request.endpoint and request.endpoint.startswith('measure.') %}
|
|
||||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
|
||||||
{% endif %}">
|
|
||||||
<!-- Clipboard Icon -->
|
|
||||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ _('Misure') }}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Maker: Ricette #}
|
|
||||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
|
||||||
<a href="{{ url_for('maker.recipe_list') }}"
|
|
||||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors duration-200
|
|
||||||
{% if request.endpoint and request.endpoint.startswith('maker.') %}
|
|
||||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
|
||||||
{% endif %}">
|
|
||||||
<!-- Edit/Pencil Icon -->
|
|
||||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ _('Ricette') }}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Metrologist: Statistiche #}
|
|
||||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
|
||||||
<a href="{{ url_for('statistics.dashboard') }}"
|
|
||||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors duration-200
|
|
||||||
{% if request.endpoint and request.endpoint.startswith('statistics.') %}
|
|
||||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
|
||||||
{% endif %}">
|
|
||||||
<!-- Chart Icon -->
|
|
||||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ _('Statistiche') }}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Admin: Utenti #}
|
|
||||||
{% if current_user.get('is_admin') %}
|
|
||||||
<a href="{{ url_for('admin.user_list') }}"
|
|
||||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors duration-200
|
|
||||||
{% if request.endpoint == 'admin.user_list' %}
|
|
||||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
|
||||||
{% endif %}">
|
|
||||||
<!-- Users Icon -->
|
|
||||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ _('Utenti') }}</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="{{ url_for('admin.station_list') }}"
|
|
||||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors duration-200
|
|
||||||
{% if request.endpoint == 'admin.station_list' %}
|
|
||||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
|
||||||
{% endif %}">
|
|
||||||
<!-- Station / Workstation Icon -->
|
|
||||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ _('Stazioni') }}</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -112,24 +71,17 @@
|
|||||||
|
|
||||||
<!-- Language Toggle -->
|
<!-- Language Toggle -->
|
||||||
<div class="flex items-center border border-[var(--border-color)] rounded-lg overflow-hidden">
|
<div class="flex items-center border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||||
<a href="{{ url_for('set_language', lang='it') }}"
|
{% for lang_code in ['it', 'en'] %}
|
||||||
|
<a href="{{ url_for('set_language', lang=lang_code) }}"
|
||||||
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
|
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
|
||||||
{% if current_language == 'it' %}
|
{% if current_language == lang_code %}
|
||||||
bg-primary text-white
|
bg-primary text-white
|
||||||
{% else %}
|
{% else %}
|
||||||
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
||||||
{% endif %}">
|
{% endif %}">
|
||||||
IT
|
{{ lang_code|upper }}
|
||||||
</a>
|
|
||||||
<a href="{{ url_for('set_language', lang='en') }}"
|
|
||||||
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
|
|
||||||
{% if current_language == 'en' %}
|
|
||||||
bg-primary text-white
|
|
||||||
{% else %}
|
|
||||||
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
|
||||||
{% endif %}">
|
|
||||||
EN
|
|
||||||
</a>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme Toggle -->
|
<!-- Theme Toggle -->
|
||||||
@@ -199,6 +151,15 @@
|
|||||||
|
|
||||||
<div class="border-t border-[var(--border-color)] my-1"></div>
|
<div class="border-t border-[var(--border-color)] my-1"></div>
|
||||||
|
|
||||||
|
{% if request.endpoint == 'measure.task_execute' %}
|
||||||
|
<span class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-muted)] opacity-50 cursor-not-allowed"
|
||||||
|
title="{{ _('Logout bloccato durante le misurazioni') }}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Logout') }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
<a href="{{ url_for('auth.logout') }}"
|
<a href="{{ url_for('auth.logout') }}"
|
||||||
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail
|
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail
|
||||||
hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||||
@@ -207,6 +168,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
{{ _('Logout') }}
|
{{ _('Logout') }}
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -241,66 +203,7 @@
|
|||||||
x-cloak
|
x-cloak
|
||||||
class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]">
|
class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]">
|
||||||
<div class="px-4 py-3 space-y-1">
|
<div class="px-4 py-3 space-y-1">
|
||||||
|
{{ nav_links(mobile=true) }}
|
||||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
|
||||||
<a href="{{ url_for('measure.select_recipe') }}"
|
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Misure') }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
|
||||||
<a href="{{ url_for('maker.recipe_list') }}"
|
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Ricette') }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
|
||||||
<a href="{{ url_for('statistics.dashboard') }}"
|
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Statistiche') }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{# Admin: Utenti + Stazioni #}
|
|
||||||
{% if current_user.get('is_admin') %}
|
|
||||||
<a href="{{ url_for('admin.user_list') }}"
|
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Utenti') }}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="{{ url_for('admin.station_list') }}"
|
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Stazioni') }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -231,6 +231,23 @@
|
|||||||
rows="3"
|
rows="3"
|
||||||
placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea>
|
placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Intervallo di misura -->
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label" for="recipe-interval">
|
||||||
|
{{ _('Intervallo misura (minuti)') }}
|
||||||
|
</label>
|
||||||
|
<input id="recipe-interval"
|
||||||
|
type="number"
|
||||||
|
x-model.number="measurementIntervalMinutes"
|
||||||
|
class="tmf-input"
|
||||||
|
min="1"
|
||||||
|
max="1440"
|
||||||
|
placeholder="{{ _('Es. 30') }}">
|
||||||
|
<p class="text-xs text-[var(--text-muted)] mt-1">
|
||||||
|
{{ _('Timer cicalino per ricordare la misurazione periodica') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -466,6 +483,9 @@ function recipeEditor() {
|
|||||||
versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }},
|
versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }},
|
||||||
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
|
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
|
||||||
|
|
||||||
|
// ---- Measurement interval ----
|
||||||
|
measurementIntervalMinutes: {{ (recipe.measurement_interval_minutes if recipe and recipe.measurement_interval_minutes else 'null')|tojson }},
|
||||||
|
|
||||||
// ---- File upload (preview image) ----
|
// ---- File upload (preview image) ----
|
||||||
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
|
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
|
||||||
uploadingFile: false,
|
uploadingFile: false,
|
||||||
@@ -487,6 +507,11 @@ function recipeEditor() {
|
|||||||
description: this.description.trim(),
|
description: this.description.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Measurement interval
|
||||||
|
if (this.measurementIntervalMinutes) {
|
||||||
|
payload.measurement_interval_minutes = parseInt(this.measurementIntervalMinutes, 10);
|
||||||
|
}
|
||||||
|
|
||||||
// Include image_path for preview thumbnail
|
// Include image_path for preview thumbnail
|
||||||
if (this.currentFilePath) {
|
if (this.currentFilePath) {
|
||||||
payload.image_path = this.currentFilePath;
|
payload.image_path = this.currentFilePath;
|
||||||
|
|||||||
@@ -120,7 +120,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
x-model="search"
|
x-model="search"
|
||||||
class="tmf-input pl-10"
|
class="tmf-input !pl-10"
|
||||||
:placeholder="'{{ _('Cerca per nome, codice o descrizione...') }}'">
|
:placeholder="'{{ _('Cerca per nome, codice o descrizione...') }}'">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,11 +152,11 @@
|
|||||||
<div class="p-5 sm:p-6">
|
<div class="p-5 sm:p-6">
|
||||||
<!-- Card Header -->
|
<!-- Card Header -->
|
||||||
<div class="flex flex-col sm:flex-row sm:items-start gap-4 mb-4">
|
<div class="flex flex-col sm:flex-row sm:items-start gap-4 mb-4">
|
||||||
<!-- Thumbnail -->
|
<!-- Thumbnail (fit, no crop) -->
|
||||||
<template x-if="recipe.image_path">
|
<template x-if="recipe.image_path">
|
||||||
<div class="w-20 h-20 rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0">
|
<div class="w-20 h-20 rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0 flex items-center justify-center">
|
||||||
<img :src="'/maker/api/files/' + recipe.image_path"
|
<img :src="'/maker/api/files/' + recipe.image_path"
|
||||||
class="w-full h-full object-cover"
|
class="max-w-full max-h-full object-contain"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onerror="this.parentElement.style.display='none'">
|
onerror="this.parentElement.style.display='none'">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -247,6 +247,18 @@
|
|||||||
<span class="hidden sm:inline">{{ _('Rettangolo') }}</span>
|
<span class="hidden sm:inline">{{ _('Rettangolo') }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Text Tool -->
|
||||||
|
<button type="button"
|
||||||
|
class="anno-btn"
|
||||||
|
:class="{ 'active': annoTool === 'text' }"
|
||||||
|
@click="setAnnoTool('text')"
|
||||||
|
title="{{ _('Testo') }}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 6v2m16-2v2M7 6v12m0 0h2m-2 0H5m12-12v12m0 0h2m-2 0h-2"/>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">{{ _('Testo') }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Separator -->
|
<!-- Separator -->
|
||||||
<div class="w-px h-6 bg-[var(--border-color)] mx-1"></div>
|
<div class="w-px h-6 bg-[var(--border-color)] mx-1"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -221,14 +221,24 @@
|
|||||||
<span x-text="tasks.length"></span>
|
<span x-text="tasks.length"></span>
|
||||||
<span x-text="tasks.length === 1 ? '{{ _('task') }}' : '{{ _('task') }}'"></span>
|
<span x-text="tasks.length === 1 ? '{{ _('task') }}' : '{{ _('task') }}'"></span>
|
||||||
</p>
|
</p>
|
||||||
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
|
<div class="flex items-center gap-2">
|
||||||
x-show="!showAddTask"
|
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
|
||||||
class="btn btn-primary gap-1.5">
|
x-show="!showAddTask"
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
class="btn btn-primary gap-1.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
|
||||||
{{ _('Aggiungi Task') }}
|
</svg>
|
||||||
</button>
|
{{ _('Aggiungi Task') }}
|
||||||
|
</button>
|
||||||
|
<button @click="showAiImport = true"
|
||||||
|
x-show="!showAddTask"
|
||||||
|
class="btn btn-secondary gap-1.5">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Importa da PDF') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ============================================================
|
<!-- ============================================================
|
||||||
@@ -269,12 +279,12 @@
|
|||||||
placeholder="{{ _('Es. Seguire procedura ISO 2768') }}">
|
placeholder="{{ _('Es. Seguire procedura ISO 2768') }}">
|
||||||
</div>
|
</div>
|
||||||
<!-- Descrizione -->
|
<!-- Descrizione -->
|
||||||
<div>
|
<div class="sm:col-span-2">
|
||||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||||
<input type="text"
|
<textarea x-model="newTask.description"
|
||||||
x-model="newTask.description"
|
class="tmf-input text-sm"
|
||||||
class="tmf-input"
|
rows="3"
|
||||||
placeholder="{{ _('Descrizione opzionale...') }}">
|
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -497,7 +507,7 @@
|
|||||||
<template x-if="task.description">
|
<template x-if="task.description">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<span class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide shrink-0 mt-0.5">{{ _('Descrizione') }}:</span>
|
<span class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide shrink-0 mt-0.5">{{ _('Descrizione') }}:</span>
|
||||||
<span class="text-sm text-[var(--text-secondary)]" x-text="task.description"></span>
|
<span class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap" x-text="task.description"></span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -513,12 +523,12 @@
|
|||||||
class="tmf-input text-sm"
|
class="tmf-input text-sm"
|
||||||
placeholder="{{ _('Direttiva opzionale...') }}">
|
placeholder="{{ _('Direttiva opzionale...') }}">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="sm:col-span-2">
|
||||||
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
||||||
<input type="text"
|
<textarea x-model="editTaskData.description"
|
||||||
x-model="editTaskData.description"
|
class="tmf-input text-sm"
|
||||||
class="tmf-input text-sm"
|
rows="3"
|
||||||
placeholder="{{ _('Descrizione opzionale...') }}">
|
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -930,6 +940,118 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ================================================================
|
||||||
|
AI IMPORT MODAL — Upload PDF + preview suggested tasks
|
||||||
|
================================================================ #}
|
||||||
|
<div x-show="showAiImport"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
x-cloak
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||||
|
@click.self="showAiImport = false">
|
||||||
|
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl max-w-2xl w-full mx-4 max-h-[85vh] flex flex-col border border-[var(--border-color)]">
|
||||||
|
|
||||||
|
{# Header #}
|
||||||
|
<div class="p-5 border-b border-[var(--border-color)] shrink-0">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-[var(--text-primary)]">{{ _('Importa da Scheda Tecnica') }}</h3>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)]">{{ _('Carica un PDF e l\'AI estrarrà i task automaticamente') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Body #}
|
||||||
|
<div class="p-5 overflow-y-auto flex-1">
|
||||||
|
{# Upload area (before analysis) #}
|
||||||
|
<div x-show="!aiSuggestions.length && !aiParsing">
|
||||||
|
<label class="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-[var(--border-color)] rounded-xl cursor-pointer
|
||||||
|
hover:border-primary hover:bg-primary-50/50 dark:hover:bg-primary-900/10 transition-colors">
|
||||||
|
<svg class="w-10 h-10 text-[var(--text-muted)] mb-2" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Clicca per caricare un PDF') }}</span>
|
||||||
|
<span class="text-xs text-[var(--text-muted)] mt-1">{{ _('Max 20MB') }}</span>
|
||||||
|
<input type="file" accept=".pdf,application/pdf" class="hidden" @change="uploadAndParse($event)">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Loading #}
|
||||||
|
<div x-show="aiParsing" class="flex flex-col items-center justify-center py-12">
|
||||||
|
<svg class="w-10 h-10 animate-spin text-primary mb-3" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Analisi in corso con AI...') }}</p>
|
||||||
|
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Potrebbe richiedere fino a 30 secondi') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Error #}
|
||||||
|
<div x-show="aiError" class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-300" x-text="aiError"></p>
|
||||||
|
<button @click="aiError = ''; aiSuggestions = []" class="text-xs text-red-600 underline mt-1">{{ _('Riprova') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Suggested tasks (editable preview) #}
|
||||||
|
<div x-show="aiSuggestions.length > 0" class="space-y-3">
|
||||||
|
<p class="text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||||
|
<span x-text="aiSuggestions.length"></span> {{ _('task suggeriti — modifica o rimuovi prima di confermare') }}
|
||||||
|
</p>
|
||||||
|
<template x-for="(suggestion, idx) in aiSuggestions" :key="idx">
|
||||||
|
<div class="tmf-card">
|
||||||
|
<div class="p-4 space-y-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="text-xs font-bold text-primary">Task <span x-text="idx + 1"></span></span>
|
||||||
|
<button @click="aiSuggestions.splice(idx, 1)" class="text-xs text-red-500 hover:text-red-700">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input type="text" x-model="suggestion.title" class="tmf-input text-sm font-semibold"
|
||||||
|
placeholder="{{ _('Titolo') }}">
|
||||||
|
<input type="text" x-model="suggestion.directive" class="tmf-input text-sm"
|
||||||
|
placeholder="{{ _('Direttiva') }}">
|
||||||
|
<textarea x-model="suggestion.description" class="tmf-input text-sm" rows="3"
|
||||||
|
placeholder="{{ _('Descrizione') }}"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Footer #}
|
||||||
|
<div class="p-4 border-t border-[var(--border-color)] shrink-0 flex items-center justify-between gap-2">
|
||||||
|
<button @click="showAiImport = false; aiSuggestions = []; aiError = ''"
|
||||||
|
class="btn btn-secondary text-sm">
|
||||||
|
{{ _('Annulla') }}
|
||||||
|
</button>
|
||||||
|
<button x-show="aiSuggestions.length > 0"
|
||||||
|
@click="createTasksFromSuggestions()"
|
||||||
|
:disabled="aiCreating"
|
||||||
|
class="btn btn-primary text-sm gap-1.5">
|
||||||
|
<svg x-show="!aiCreating" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
<svg x-show="aiCreating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Crea') }} <span x-text="aiSuggestions.length"></span> {{ _('task') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -983,6 +1105,13 @@ function taskEditor() {
|
|||||||
// ---- Drag & Drop state ----
|
// ---- Drag & Drop state ----
|
||||||
dragState: { dragging: null, over: null, position: null },
|
dragState: { dragging: null, over: null, position: null },
|
||||||
|
|
||||||
|
// ---- AI Import ----
|
||||||
|
showAiImport: false,
|
||||||
|
aiParsing: false,
|
||||||
|
aiCreating: false,
|
||||||
|
aiSuggestions: [],
|
||||||
|
aiError: '',
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Init
|
// Init
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -1081,6 +1210,80 @@ function taskEditor() {
|
|||||||
this.newTask = { title: '', directive: '', description: '' };
|
this.newTask = { title: '', directive: '', description: '' };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AI Import
|
||||||
|
// ============================================================
|
||||||
|
async uploadAndParse(event) {
|
||||||
|
var file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
this.aiParsing = true;
|
||||||
|
this.aiError = '';
|
||||||
|
this.aiSuggestions = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
var resp = await fetch('/maker/api/recipes/' + this.recipeId + '/parse-technical-sheet', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRFToken': this.csrfToken() },
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
var data = await resp.json();
|
||||||
|
|
||||||
|
if (!resp.ok || data.error) {
|
||||||
|
this.aiError = data.detail || {{ _("Errore nell'analisi del PDF")|tojson }};
|
||||||
|
} else {
|
||||||
|
this.aiSuggestions = data.suggested_tasks || [];
|
||||||
|
if (!this.aiSuggestions.length) {
|
||||||
|
this.aiError = {{ _("Nessun task identificato nel PDF")|tojson }};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('AI parse error:', err);
|
||||||
|
this.aiError = {{ _("Errore di connessione al server")|tojson }};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.aiParsing = false;
|
||||||
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
async createTasksFromSuggestions() {
|
||||||
|
this.aiCreating = true;
|
||||||
|
var created = 0;
|
||||||
|
|
||||||
|
for (var i = 0; i < this.aiSuggestions.length; i++) {
|
||||||
|
var s = this.aiSuggestions[i];
|
||||||
|
if (!s.title || !s.title.trim()) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var resp = await fetch('/maker/api/recipes/' + this.recipeId + '/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken() },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: s.title.trim(),
|
||||||
|
directive: (s.directive || '').trim() || null,
|
||||||
|
description: (s.description || '').trim() || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
var data = await resp.json();
|
||||||
|
if (!data.error) {
|
||||||
|
if (!data.subtasks) data.subtasks = [];
|
||||||
|
this.tasks.push(data);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create task error:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.aiCreating = false;
|
||||||
|
this.showAiImport = false;
|
||||||
|
this.aiSuggestions = [];
|
||||||
|
if (created > 0) {
|
||||||
|
this.successMessage = created + ' ' + {{ _("task creati dalla scheda tecnica")|tojson }};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async addTask() {
|
async addTask() {
|
||||||
if (!this.newTask.title.trim()) return;
|
if (!this.newTask.title.trim()) return;
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
|
|||||||
@@ -92,40 +92,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search + Traceability Bar -->
|
<!-- Search Bar (filter) -->
|
||||||
<div class="tmf-card mb-6">
|
<div class="tmf-card mb-4">
|
||||||
<div class="p-4 sm:p-5">
|
<div class="p-4 sm:p-5">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<label class="tmf-label">
|
||||||
<!-- Search Input -->
|
<svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
<div class="sm:col-span-2">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||||
<label class="tmf-label">
|
</svg>
|
||||||
<svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
{{ _('Cerca ricetta') }}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
</label>
|
||||||
</svg>
|
<input type="text"
|
||||||
{{ _('Cerca ricetta') }}
|
x-model="search"
|
||||||
</label>
|
placeholder="{{ _('Nome, codice o descrizione...') }}"
|
||||||
<input type="text"
|
class="tmf-input">
|
||||||
x-model="search"
|
</div>
|
||||||
placeholder="{{ _('Nome, codice o descrizione...') }}"
|
</div>
|
||||||
class="tmf-input">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Traceability (data entry, NOT a search filter) -->
|
||||||
|
<div class="tmf-card mb-6 border-l-4 border-l-primary">
|
||||||
|
<div class="p-4 sm:p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<svg class="w-5 h-5 text-primary shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}</h2>
|
||||||
|
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<!-- Lot Number -->
|
<!-- Lot Number -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label">{{ _('Numero Lotto') }}</label>
|
<label class="tmf-label flex items-center gap-1.5">
|
||||||
|
<svg class="w-3.5 h-3.5 text-amber-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Numero Lotto') }}
|
||||||
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
x-model="lot_number"
|
x-model="lot_number"
|
||||||
placeholder="{{ _('Opzionale') }}"
|
placeholder="{{ _('Es. LOT-2026-001 (opzionale)') }}"
|
||||||
class="tmf-input">
|
class="tmf-input font-mono">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Serial Number -->
|
<!-- Serial Number -->
|
||||||
<div>
|
<div>
|
||||||
<label class="tmf-label">{{ _('Numero Seriale') }}</label>
|
<label class="tmf-label flex items-center gap-1.5">
|
||||||
|
<svg class="w-3.5 h-3.5 text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Numero Seriale') }}
|
||||||
|
</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
x-model="serial_number"
|
x-model="serial_number"
|
||||||
placeholder="{{ _('Opzionale') }}"
|
placeholder="{{ _('Es. SN-000123 (opzionale)') }}"
|
||||||
class="tmf-input">
|
class="tmf-input font-mono">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,15 +178,30 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recipe Name -->
|
<!-- Title + Thumbnail row -->
|
||||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2 leading-snug"
|
<div class="flex items-start gap-3 mb-2">
|
||||||
x-text="recipe.name">
|
<!-- Thumbnail (fit, no crop) -->
|
||||||
</h3>
|
<template x-if="recipe.image_path">
|
||||||
|
<div class="w-16 h-16 rounded-md overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0 flex items-center justify-center">
|
||||||
|
<img :src="'/measure/api/files/' + recipe.image_path"
|
||||||
|
class="max-w-full max-h-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.parentElement.style.display='none'">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Description -->
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
|
<!-- Recipe Name -->
|
||||||
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'">
|
<h3 class="text-lg font-semibold text-[var(--text-primary)] leading-snug"
|
||||||
</p>
|
x-text="recipe.name">
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p class="mt-1 text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
|
||||||
|
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'">
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Meta Info -->
|
<!-- Meta Info -->
|
||||||
<div class="mt-4 flex items-center gap-3 text-xs text-[var(--text-muted)]">
|
<div class="mt-4 flex items-center gap-3 text-xs text-[var(--text-muted)]">
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
|
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
|
||||||
|
|
||||||
|
{# Full-screen layout: no body scroll, no footer, main fills available space #}
|
||||||
|
{% block body_class %}h-screen overflow-hidden{% endblock %}
|
||||||
|
{% block wrapper_class %}h-full{% endblock %}
|
||||||
|
{% block main_class %}flex-1 min-h-0{% endblock %}
|
||||||
|
{% block footer %}{% endblock %}
|
||||||
|
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
<style>
|
<style>
|
||||||
/* Annotation viewer container */
|
/* Annotation viewer container */
|
||||||
@@ -57,7 +63,7 @@
|
|||||||
window.__allTaskIds = {{ all_task_ids|tojson }};
|
window.__allTaskIds = {{ all_task_ids|tojson }};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="h-screen flex flex-col overflow-hidden"
|
<div class="h-full flex flex-col overflow-hidden"
|
||||||
x-data="taskExecute()"
|
x-data="taskExecute()"
|
||||||
x-init="init()"
|
x-init="init()"
|
||||||
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
||||||
@@ -78,7 +84,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
{# Task badge + title #}
|
{# Task badge + title #}
|
||||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
|
<span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
|
||||||
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
|
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
|
||||||
border border-primary-200 dark:border-primary-800">
|
border border-primary-200 dark:border-primary-800">
|
||||||
@@ -89,6 +95,45 @@
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Lista task + Riepilogo buttons #}
|
||||||
|
<div class="shrink-0 flex items-center gap-1.5">
|
||||||
|
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
||||||
|
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Lista task') }}
|
||||||
|
</a>
|
||||||
|
<button @click="goToSummary()"
|
||||||
|
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Riepilogo') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Fermo linea + Fine produzione (measurement tasks only) #}
|
||||||
|
<template x-if="subtasks.length > 0">
|
||||||
|
<div class="shrink-0 flex items-center gap-1.5">
|
||||||
|
<button @click="openSupervisorModal('fermo_linea')"
|
||||||
|
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-amber-500 text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Fermo linea') }}
|
||||||
|
</button>
|
||||||
|
<button @click="openSupervisorModal('fine_produzione')"
|
||||||
|
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-red-500 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Fine Produzione') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
{# Lot + Serial badges #}
|
{# Lot + Serial badges #}
|
||||||
<div class="shrink-0 flex items-center gap-2">
|
<div class="shrink-0 flex items-center gap-2">
|
||||||
{% if lot_number %}
|
{% if lot_number %}
|
||||||
@@ -124,9 +169,10 @@
|
|||||||
<div class="flex-1 flex overflow-hidden">
|
<div class="flex-1 flex overflow-hidden">
|
||||||
|
|
||||||
{# ──────────────────────────────────────────────
|
{# ──────────────────────────────────────────────
|
||||||
LEFT SIDEBAR — Marker list (vertical)
|
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
|
||||||
────────────────────────────────────────────── #}
|
────────────────────────────────────────────── #}
|
||||||
<div class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
|
<div x-show="subtasks.length > 0"
|
||||||
|
class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
|
||||||
<template x-for="(st, idx) in subtasks" :key="st.id">
|
<template x-for="(st, idx) in subtasks" :key="st.id">
|
||||||
<button @click="goToSubtask(idx)"
|
<button @click="goToSubtask(idx)"
|
||||||
class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
|
class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
|
||||||
@@ -213,9 +259,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ──────────────────────────────────────────────
|
{# ──────────────────────────────────────────────
|
||||||
RIGHT PANEL — Info + tolerances + numpad
|
RIGHT PANEL — Info + tolerances + numpad (hidden when no subtasks)
|
||||||
────────────────────────────────────────────── #}
|
────────────────────────────────────────────── #}
|
||||||
<div class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
|
<div x-show="subtasks.length > 0"
|
||||||
|
class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
|
||||||
|
|
||||||
{# ---- Subtask header ---- #}
|
{# ---- Subtask header ---- #}
|
||||||
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
||||||
@@ -310,7 +357,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ---- Measurement feedback ---- #}
|
{# ---- Measurement feedback ---- #}
|
||||||
<div class="px-3 pt-2" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
|
<div class="px-3 pt-2" x-show="currentSubtask" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
|
||||||
get utl() { return currentSubtask?.utl || 0; },
|
get utl() { return currentSubtask?.utl || 0; },
|
||||||
get uwl() { return currentSubtask?.uwl || 0; },
|
get uwl() { return currentSubtask?.uwl || 0; },
|
||||||
get lwl() { return currentSubtask?.lwl || 0; },
|
get lwl() { return currentSubtask?.lwl || 0; },
|
||||||
@@ -319,8 +366,8 @@
|
|||||||
{% include "components/measurement_feedback.html" %}
|
{% include "components/measurement_feedback.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ---- Numpad ---- #}
|
{# ---- Numpad (only when a subtask is active) ---- #}
|
||||||
<div class="px-3 py-2 relative flex-1">
|
<div class="px-3 py-2 relative flex-1" x-show="currentSubtask">
|
||||||
{# Saving overlay #}
|
{# Saving overlay #}
|
||||||
<div x-show="saving"
|
<div x-show="saving"
|
||||||
x-transition
|
x-transition
|
||||||
@@ -338,10 +385,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# ---- Next measurement indicator ---- #}
|
{# ---- Next measurement indicator ---- #}
|
||||||
<div class="px-3 pb-2">
|
<div class="px-3 pb-2" x-show="currentSubtask">
|
||||||
{% include "components/next_measurement.html" %}
|
{% include "components/next_measurement.html" %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{# ---- Error message ---- #}
|
{# ---- Error message ---- #}
|
||||||
<div x-show="errorMessage"
|
<div x-show="errorMessage"
|
||||||
x-transition
|
x-transition
|
||||||
@@ -358,6 +406,62 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ================================================================
|
||||||
|
MEASUREMENT TIMER BANNER
|
||||||
|
================================================================ #}
|
||||||
|
<div x-show="timerActive"
|
||||||
|
x-transition
|
||||||
|
x-cloak
|
||||||
|
class="shrink-0 bg-amber-50 dark:bg-amber-900/20 border-t border-amber-300 dark:border-amber-700 px-4 py-2">
|
||||||
|
<div class="flex items-center justify-center gap-3">
|
||||||
|
<svg class="w-5 h-5 text-amber-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
||||||
|
{{ _('Prossima misurazione tra') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-lg font-bold font-mono text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 rounded"
|
||||||
|
x-text="timerDisplay"></span>
|
||||||
|
<span class="text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
({{ _('Ciclo') }} #<span x-text="cycleCount"></span>)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ================================================================
|
||||||
|
AVVIO PRODUZIONE — visible after first cycle, before production started
|
||||||
|
================================================================ #}
|
||||||
|
<div x-show="cycleConfirmed && cycleCount === 1 && !productionStarted && subtasks.length > 0"
|
||||||
|
x-transition
|
||||||
|
x-cloak
|
||||||
|
class="shrink-0 border-t border-[var(--border-color)] bg-[var(--bg-card)] px-4 py-3">
|
||||||
|
<div class="flex items-center justify-center">
|
||||||
|
<button @click="startProduction()"
|
||||||
|
class="btn gap-2 px-6 py-2.5 text-sm font-bold
|
||||||
|
bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg hover:shadow-xl
|
||||||
|
rounded-xl transition-all duration-200">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Avvio Produzione') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-center text-[var(--text-muted)] mt-1.5">
|
||||||
|
{{ _('Invia segnale al gestionale per avviare il timer della linea') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div x-show="productionStarted && subtasks.length > 0"
|
||||||
|
x-transition
|
||||||
|
x-cloak
|
||||||
|
class="shrink-0 border-t border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20 px-4 py-1.5">
|
||||||
|
<div class="flex items-center justify-center gap-2 text-xs text-emerald-700 dark:text-emerald-300">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium">{{ _('Produzione avviata') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ================================================================
|
{# ================================================================
|
||||||
FOOTER — Progress bar + navigation
|
FOOTER — Progress bar + navigation
|
||||||
================================================================ #}
|
================================================================ #}
|
||||||
@@ -388,19 +492,47 @@
|
|||||||
x-text="Math.round(progressPercent) + '%'"></span>
|
x-text="Math.round(progressPercent) + '%'"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Right: Summary button #}
|
{# Right: Fine ciclo misura (measurement tasks) / Completato (non-measurement or after cycle) #}
|
||||||
<button x-show="isComplete"
|
<template x-if="subtasks.length > 0">
|
||||||
x-transition
|
<div class="flex items-center gap-1.5">
|
||||||
@click="goToSummary()"
|
<button x-show="isComplete && !cycleConfirmed"
|
||||||
class="btn btn-primary text-xs shrink-0 gap-1 py-1.5 px-2.5 shadow-md">
|
x-transition
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
@click="confirmCycle()"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
||||||
</svg>
|
bg-primary text-white hover:bg-primary-700">
|
||||||
{{ _('Riepilogo') }}
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
</svg>
|
||||||
</svg>
|
{{ _('Fine ciclo misura') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button x-show="cycleConfirmed"
|
||||||
|
x-transition
|
||||||
|
@click="goToNextTask()"
|
||||||
|
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
||||||
|
bg-measure-pass text-white hover:opacity-90">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Completato') }}
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="subtasks.length === 0">
|
||||||
|
<button @click="goToNextTask()"
|
||||||
|
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
||||||
|
bg-measure-pass text-white hover:opacity-90">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Completato') }}
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -447,13 +579,100 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button @click="goToSummary()"
|
<div class="flex gap-2">
|
||||||
class="btn btn-primary w-full justify-center gap-2">
|
<button @click="goToSummary()"
|
||||||
{{ _('Vai al Riepilogo') }}
|
class="btn btn-secondary flex-1 justify-center gap-2">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
{{ _('Riepilogo') }}
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
</button>
|
||||||
</svg>
|
|
||||||
</button>
|
{# Production phase: confirm the cycle (starts the interval timer) #}
|
||||||
|
<button x-show="productionStarted"
|
||||||
|
@click="showCompletionOverlay = false; cycleConfirmed = true"
|
||||||
|
class="btn btn-primary flex-1 justify-center gap-2">
|
||||||
|
{{ _('Conferma ciclo') }}
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{# START phase, more tasks to go: advance without blocking or exiting #}
|
||||||
|
<button x-show="!productionStarted && !isLastTask"
|
||||||
|
@click="goToNextTask()"
|
||||||
|
class="btn btn-primary flex-1 justify-center gap-2">
|
||||||
|
{{ _('Task successivo') }}
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{# START phase, last task: start production #}
|
||||||
|
<button x-show="!productionStarted && isLastTask"
|
||||||
|
@click="startProductionFromOverlay()"
|
||||||
|
class="btn flex-1 justify-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white">
|
||||||
|
{{ _('Avvio Produzione') }}
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ================================================================
|
||||||
|
SUPERVISOR LOGIN MODAL
|
||||||
|
================================================================ #}
|
||||||
|
<div x-show="showSupervisorModal"
|
||||||
|
x-transition:enter="transition ease-out duration-200"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-150"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
x-cloak
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||||
|
@click.self="closeSupervisorModal()">
|
||||||
|
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-6 max-w-sm mx-4 w-full border border-[var(--border-color)]">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-[var(--text-primary)]">{{ _('Autorizzazione capoturno') }}</h3>
|
||||||
|
<p class="text-xs text-[var(--text-secondary)]" x-text="supervisorReason"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 mb-5">
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
||||||
|
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
|
||||||
|
placeholder="{{ _('Username capoturno') }}" @keydown.enter="validateSupervisor()">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="tmf-label text-xs">{{ _('Password') }}</label>
|
||||||
|
<input type="password" x-model="supervisorPassword" class="tmf-input text-sm"
|
||||||
|
placeholder="••••••••" @keydown.enter="validateSupervisor()">
|
||||||
|
</div>
|
||||||
|
<p x-show="supervisorError" class="text-xs text-red-600" x-text="supervisorError"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="closeSupervisorModal()"
|
||||||
|
class="btn btn-secondary flex-1 text-sm">
|
||||||
|
{{ _('Annulla') }}
|
||||||
|
</button>
|
||||||
|
<button @click="validateSupervisor()"
|
||||||
|
:disabled="!supervisorUsername || !supervisorPassword || supervisorValidating"
|
||||||
|
class="btn btn-primary flex-1 text-sm gap-1">
|
||||||
|
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Autorizza') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -489,6 +708,24 @@ function taskExecute() {
|
|||||||
errorMessage: '',
|
errorMessage: '',
|
||||||
showCompletionOverlay: false,
|
showCompletionOverlay: false,
|
||||||
|
|
||||||
|
// ---- Measurement timer ----
|
||||||
|
measurementIntervalMinutes: {{ measurement_interval_minutes|tojson if measurement_interval_minutes else 'null' }},
|
||||||
|
timerActive: false,
|
||||||
|
timerRemaining: 0,
|
||||||
|
_timerInterval: null,
|
||||||
|
cycleCount: 0,
|
||||||
|
productionStarted: false,
|
||||||
|
|
||||||
|
// ---- Cycle & workflow state ----
|
||||||
|
cycleConfirmed: false,
|
||||||
|
showSupervisorModal: false,
|
||||||
|
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
||||||
|
supervisorUsername: '',
|
||||||
|
supervisorPassword: '',
|
||||||
|
supervisorError: '',
|
||||||
|
supervisorValidating: false,
|
||||||
|
pendingAdvance: false,
|
||||||
|
|
||||||
// ---- Value from numpad / caliper ----
|
// ---- Value from numpad / caliper ----
|
||||||
currentValue: null,
|
currentValue: null,
|
||||||
|
|
||||||
@@ -641,15 +878,24 @@ function taskExecute() {
|
|||||||
// Pause to show result feedback
|
// Pause to show result feedback
|
||||||
await new Promise(r => setTimeout(r, 1000));
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
|
||||||
|
// Out-of-tolerance: block advancement, require supervisor
|
||||||
|
if (pf === 'fail') {
|
||||||
|
this.pendingAdvance = true;
|
||||||
|
this.openSupervisorModal('out_of_tolerance');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if all done
|
// Check if all done
|
||||||
if (this.completedCount >= this.totalSubtasks) {
|
if (this.completedCount >= this.totalSubtasks) {
|
||||||
const taskIds = window.__allTaskIds || [];
|
// START run (production not yet started): don't block with the
|
||||||
const currentIdx = taskIds.indexOf(this.task.id);
|
// "Misurazioni Complete" overlay — just advance to the next task.
|
||||||
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
|
// The overlay is shown only on the last task (to offer "Avvio
|
||||||
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
|
// Produzione") or once production is running ("Conferma ciclo").
|
||||||
} else {
|
if (!this.productionStarted && !this.isLastTask) {
|
||||||
this.showCompletionOverlay = true;
|
this.goToNextTask();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
this.showCompletionOverlay = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -696,6 +942,177 @@ function taskExecute() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Confirm measurement cycle (Fine ciclo misura) ----
|
||||||
|
confirmCycle() {
|
||||||
|
this.cycleConfirmed = true;
|
||||||
|
this.showCompletionOverlay = false;
|
||||||
|
this.cycleCount++;
|
||||||
|
|
||||||
|
// Start measurement timer if recipe has an interval
|
||||||
|
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
||||||
|
this.startMeasurementTimer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Measurement timer ----
|
||||||
|
startMeasurementTimer() {
|
||||||
|
this.stopMeasurementTimer();
|
||||||
|
this.timerRemaining = this.measurementIntervalMinutes * 60;
|
||||||
|
this.timerActive = true;
|
||||||
|
var self = this;
|
||||||
|
this._timerInterval = setInterval(function () {
|
||||||
|
self.timerRemaining--;
|
||||||
|
if (self.timerRemaining <= 0) {
|
||||||
|
self.onTimerExpired();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
|
||||||
|
stopMeasurementTimer() {
|
||||||
|
if (this._timerInterval) {
|
||||||
|
clearInterval(this._timerInterval);
|
||||||
|
this._timerInterval = null;
|
||||||
|
}
|
||||||
|
this.timerActive = false;
|
||||||
|
},
|
||||||
|
|
||||||
|
onTimerExpired() {
|
||||||
|
this.stopMeasurementTimer();
|
||||||
|
this.playBuzzer();
|
||||||
|
// Reset for new measurement cycle
|
||||||
|
this.cycleConfirmed = false;
|
||||||
|
this.measurements = [];
|
||||||
|
this.currentIndex = 0;
|
||||||
|
this.currentValue = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
playBuzzer() {
|
||||||
|
try {
|
||||||
|
var ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
// 3 short beeps
|
||||||
|
[0, 0.25, 0.5].forEach(function (delay) {
|
||||||
|
var osc = ctx.createOscillator();
|
||||||
|
var gain = ctx.createGain();
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.frequency.value = 880;
|
||||||
|
osc.type = 'square';
|
||||||
|
gain.gain.value = 0.3;
|
||||||
|
osc.start(ctx.currentTime + delay);
|
||||||
|
osc.stop(ctx.currentTime + delay + 0.15);
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Avvio Produzione (GAIA placeholder) ----
|
||||||
|
startProduction() {
|
||||||
|
this.productionStarted = true;
|
||||||
|
// TODO: integrazione GAIA — inviare segnale per avviare timer linea
|
||||||
|
// await fetch('/measure/api/gaia/start-production', { method: 'POST', ... });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Is the current task the last one in the recipe sequence?
|
||||||
|
get isLastTask() {
|
||||||
|
const taskIds = window.__allTaskIds || [];
|
||||||
|
const idx = taskIds.indexOf(this.task.id);
|
||||||
|
return idx === -1 || idx >= taskIds.length - 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Start production from the completion overlay (last START task) and begin
|
||||||
|
// the measurement-interval cycle if the recipe defines one.
|
||||||
|
startProductionFromOverlay() {
|
||||||
|
this.showCompletionOverlay = false;
|
||||||
|
this.startProduction();
|
||||||
|
this.cycleConfirmed = true;
|
||||||
|
this.cycleCount++;
|
||||||
|
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
||||||
|
this.startMeasurementTimer();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get timerDisplay() {
|
||||||
|
var m = Math.floor(this.timerRemaining / 60);
|
||||||
|
var s = this.timerRemaining % 60;
|
||||||
|
return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s;
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Navigate to next task (Completato) ----
|
||||||
|
goToNextTask() {
|
||||||
|
const taskIds = window.__allTaskIds || [];
|
||||||
|
const currentIdx = taskIds.indexOf(this.task.id);
|
||||||
|
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
|
||||||
|
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
|
||||||
|
} else {
|
||||||
|
this.goToSummary();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Open/close supervisor modal (always reset fields) ----
|
||||||
|
openSupervisorModal(action) {
|
||||||
|
this.supervisorAction = action;
|
||||||
|
this.supervisorUsername = '';
|
||||||
|
this.supervisorPassword = '';
|
||||||
|
this.supervisorError = '';
|
||||||
|
this.showSupervisorModal = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
closeSupervisorModal() {
|
||||||
|
this.showSupervisorModal = false;
|
||||||
|
this.supervisorUsername = '';
|
||||||
|
this.supervisorPassword = '';
|
||||||
|
this.supervisorError = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Supervisor reason text ----
|
||||||
|
get supervisorReason() {
|
||||||
|
if (this.supervisorAction === 'out_of_tolerance') return '{{ _("Misurazione fuori tolleranza") }}';
|
||||||
|
if (this.supervisorAction === 'fermo_linea') return '{{ _("Fermo linea richiesto") }}';
|
||||||
|
if (this.supervisorAction === 'fine_produzione') return '{{ _("Fine produzione richiesta") }}';
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---- Validate supervisor credentials ----
|
||||||
|
async validateSupervisor() {
|
||||||
|
this.supervisorError = '';
|
||||||
|
this.supervisorValidating = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||||
|
const resp = await fetch('{{ url_for("measure.validate_supervisor") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
||||||
|
body: JSON.stringify({ username: this.supervisorUsername, password: this.supervisorPassword })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (!resp.ok || data.error) {
|
||||||
|
this.supervisorError = data.detail || '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
||||||
|
this.supervisorValidating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorized — close modal and proceed
|
||||||
|
this.showSupervisorModal = false;
|
||||||
|
this.supervisorUsername = '';
|
||||||
|
this.supervisorPassword = '';
|
||||||
|
this.supervisorValidating = false;
|
||||||
|
|
||||||
|
if (this.supervisorAction === 'out_of_tolerance') {
|
||||||
|
this.pendingAdvance = false;
|
||||||
|
if (this.completedCount >= this.totalSubtasks) {
|
||||||
|
this.showCompletionOverlay = true;
|
||||||
|
} else {
|
||||||
|
this.advanceToNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// fermo_linea and fine_produzione are handled by GAIA integration (future)
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
this.supervisorError = '{{ _("Errore di connessione") }}';
|
||||||
|
this.supervisorValidating = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Go to summary ----
|
// ---- Go to summary ----
|
||||||
goToSummary() {
|
goToSummary() {
|
||||||
const recipeId = this.task.recipe_id || 0;
|
const recipeId = this.task.recipe_id || 0;
|
||||||
|
|||||||
@@ -27,6 +27,17 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<!-- Top action: change recipe (easy to find) -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<a href="{{ url_for('measure.select_recipe') }}"
|
||||||
|
class="btn btn-secondary gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('Seleziona altra ricetta') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Recipe Info Card -->
|
<!-- Recipe Info Card -->
|
||||||
<div class="tmf-card mb-8">
|
<div class="tmf-card mb-8">
|
||||||
<div class="p-5 sm:p-6">
|
<div class="p-5 sm:p-6">
|
||||||
@@ -60,8 +71,19 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Traceability Badges -->
|
<!-- Right: AVVIA + Traceability -->
|
||||||
<div class="flex flex-col gap-2 sm:items-end shrink-0">
|
<div class="flex flex-col gap-3 sm:items-end shrink-0">
|
||||||
|
{% if tasks %}
|
||||||
|
<a href="{{ url_for('measure.task_execute', task_id=tasks[0].id) }}"
|
||||||
|
class="btn gap-3 w-full sm:w-auto justify-center text-lg font-bold
|
||||||
|
bg-red-600 hover:bg-red-700 text-white shadow-lg hover:shadow-xl
|
||||||
|
px-8 py-3 rounded-xl transition-all duration-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
|
||||||
|
</svg>
|
||||||
|
{{ _('AVVIA') }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
{% if lot_number %}
|
{% if lot_number %}
|
||||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||||
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||||
@@ -100,83 +122,77 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<span class="badge badge-neutral">
|
<span class="badge badge-neutral">
|
||||||
{{ tasks|length }} task
|
{{ tasks|length }} task
|
||||||
|
{%- set total_subs = namespace(count=0) -%}
|
||||||
|
{%- for t in tasks -%}
|
||||||
|
{%- if t.subtask_count is defined -%}
|
||||||
|
{%- set total_subs.count = total_subs.count + t.subtask_count -%}
|
||||||
|
{%- elif t.subtasks -%}
|
||||||
|
{%- set total_subs.count = total_subs.count + t.subtasks|length -%}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endfor -%}
|
||||||
|
{%- if total_subs.count > 0 %} · {{ total_subs.count }} {{ _('misurazioni totali') }}{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task Cards -->
|
<!-- Task Cards -->
|
||||||
{% if tasks %}
|
{% if tasks %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-2.5">
|
||||||
{% for task in tasks %}
|
{% for task in tasks %}
|
||||||
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
|
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
|
||||||
<div class="p-5 sm:p-6">
|
<div class="p-3 sm:p-4">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
<div class="flex flex-row items-center gap-3">
|
||||||
|
|
||||||
<!-- Task Number Circle -->
|
<!-- Task Number Circle -->
|
||||||
<div class="flex items-center justify-center w-12 h-12 rounded-full shrink-0
|
<div class="flex items-center justify-center w-9 h-9 rounded-full shrink-0
|
||||||
bg-primary-50 dark:bg-primary-900/30 border-2 border-primary-200 dark:border-primary-700
|
bg-primary-50 dark:bg-primary-900/30 border-2 border-primary-200 dark:border-primary-700
|
||||||
text-primary-700 dark:text-primary-300 font-bold text-lg">
|
text-primary-700 dark:text-primary-300 font-bold text-base">
|
||||||
{{ loop.index }}
|
{{ loop.index }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task Info -->
|
<!-- Task Info -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<!-- Task Header -->
|
|
||||||
<div class="flex items-center gap-2 mb-1">
|
|
||||||
<span class="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">
|
|
||||||
Task {{ loop.index }} {{ _('di') }} {{ tasks|length }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Task Title -->
|
<!-- Task Title -->
|
||||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
<h3 class="text-base font-semibold text-[var(--text-primary)] leading-tight truncate">
|
||||||
{{ task.title or task.name or (_('Task') ~ ' ' ~ loop.index) }}
|
{{ task.title or task.name or (_('Task') ~ ' ' ~ loop.index) }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<!-- Directive -->
|
<!-- Directive -->
|
||||||
{% if task.directive or task.description %}
|
{% if task.directive or task.description %}
|
||||||
<p class="text-sm text-[var(--text-secondary)] leading-relaxed mb-3 line-clamp-2">
|
<p class="text-xs text-[var(--text-secondary)] leading-snug mt-0.5 line-clamp-2">
|
||||||
{{ task.directive or task.description }}
|
{{ task.directive or task.description }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Meta Row -->
|
|
||||||
<div class="flex items-center gap-4 flex-wrap">
|
|
||||||
<!-- Subtask Count -->
|
|
||||||
{% if task.subtask_count is defined or task.subtasks %}
|
|
||||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="font-medium">
|
|
||||||
{% if task.subtask_count is defined %}
|
|
||||||
{{ task.subtask_count }}
|
|
||||||
{% elif task.subtasks %}
|
|
||||||
{{ task.subtasks|length }}
|
|
||||||
{% endif %}
|
|
||||||
{{ _('misurazioni') }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- File Attachment -->
|
|
||||||
{% if task.file_path %}
|
|
||||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
|
||||||
{% if task.file_path.endswith('.pdf') %}
|
|
||||||
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
{% else %}
|
|
||||||
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
{% endif %}
|
|
||||||
<span class="font-medium">{{ _('Allegato') }}</span>
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Button -->
|
<!-- Indicators (top-right): measurements + attachment -->
|
||||||
|
<div class="shrink-0 self-start flex items-center gap-2.5">
|
||||||
|
{% if task.subtask_count is defined or task.subtasks %}
|
||||||
|
<span class="inline-flex items-center gap-1 text-xs text-[var(--text-secondary)]"
|
||||||
|
title="{{ _('misurazioni') }}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium font-mono">{% if task.subtask_count is defined %}{{ task.subtask_count }}{% elif task.subtasks %}{{ task.subtasks|length }}{% endif %}</span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if task.file_path %}
|
||||||
|
<span class="inline-flex items-center text-xs" title="{{ _('Allegato') }}">
|
||||||
|
{% if task.file_path.endswith('.pdf') %}
|
||||||
|
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Button (Maker only — operators start the guided flow with AVVIA) -->
|
||||||
|
{% if current_user and 'Maker' in current_user.get('roles', []) %}
|
||||||
<div class="shrink-0 sm:ml-4">
|
<div class="shrink-0 sm:ml-4">
|
||||||
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
||||||
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
|
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
|
||||||
@@ -185,12 +201,13 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ _('Inizia Misure') }}
|
{{ _('Visualizza Task') }}
|
||||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,33 +232,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Bottom Navigation -->
|
|
||||||
<div class="mt-8 flex items-center justify-between">
|
|
||||||
<a href="{{ url_for('measure.select_recipe') }}"
|
|
||||||
class="btn btn-secondary gap-2">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Seleziona altra ricetta') }}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{% if tasks %}
|
|
||||||
<div class="text-sm text-[var(--text-secondary)]">
|
|
||||||
{{ tasks|length }} task ·
|
|
||||||
{% set total_subs = namespace(count=0) %}
|
|
||||||
{% for t in tasks %}
|
|
||||||
{% if t.subtask_count is defined %}
|
|
||||||
{% set total_subs.count = total_subs.count + t.subtask_count %}
|
|
||||||
{% elif t.subtasks %}
|
|
||||||
{% set total_subs.count = total_subs.count + t.subtasks|length %}
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
{% if total_subs.count > 0 %}
|
|
||||||
{{ total_subs.count }} {{ _('misurazioni totali') }}
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class TestSaveMeasurement:
|
|||||||
mock_api_client.post.return_value = {
|
mock_api_client.post.return_value = {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"subtask_id": 10,
|
"subtask_id": 10,
|
||||||
"task_id": 5,
|
"version_id": 3,
|
||||||
"value": 9.95,
|
"value": 9.95,
|
||||||
"pass_fail": "pass",
|
"pass_fail": "pass",
|
||||||
}
|
}
|
||||||
@@ -70,10 +70,8 @@ class TestSaveMeasurement:
|
|||||||
"/measure/save-measurement",
|
"/measure/save-measurement",
|
||||||
json={
|
json={
|
||||||
"subtask_id": 10,
|
"subtask_id": 10,
|
||||||
"task_id": 5,
|
"version_id": 3,
|
||||||
"value": 9.95,
|
"value": 9.95,
|
||||||
"pass_fail": "pass",
|
|
||||||
"deviation": 0.05,
|
|
||||||
},
|
},
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
@@ -86,7 +84,7 @@ class TestSaveMeasurement:
|
|||||||
"""Missing required fields return 400."""
|
"""Missing required fields return 400."""
|
||||||
resp = logged_in_client.post(
|
resp = logged_in_client.post(
|
||||||
"/measure/save-measurement",
|
"/measure/save-measurement",
|
||||||
json={"subtask_id": 10}, # missing task_id and value
|
json={"subtask_id": 10}, # missing version_id and value
|
||||||
content_type="application/json",
|
content_type="application/json",
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 400
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -554,6 +554,65 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptography"
|
||||||
|
version = "48.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/d2/024b5e06be9d44cb021fb0e1a03d34d63989cf56a0fe62f3dfbab695b9b4/cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855", size = 3950391, upload-time = "2026-05-04T22:59:17.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/17/3861e17c56fa0fd37491a14a8673fdb77c57fc5693cafe745ea8b06dba75/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b", size = 4637126, upload-time = "2026-05-04T22:59:20.197Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/0a/7e226dbff530f21480727eb764973a7bff2b912f8e15cd4f129e71b56d1d/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13", size = 4667270, upload-time = "2026-05-04T22:59:22.647Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/f2/5a72274ca9f1b2a8b44a662ee0bf1b435909deb473d6f97bcd035bcdbc71/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb", size = 4636797, upload-time = "2026-05-04T22:59:24.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/e1/48cedb2fe63626e91ded1edad159e2a4fb8b6906c4425eb7749673077ce7/cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355", size = 4666800, upload-time = "2026-05-04T22:59:27.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssselect2"
|
name = "cssselect2"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -1063,6 +1122,33 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pdfminer-six"
|
||||||
|
version = "20251230"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "cryptography" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/46/9a/d79d8fa6d47a0338846bb558b39b9963b8eb2dfedec61867c138c1b17eeb/pdfminer_six-20251230.tar.gz", hash = "sha256:e8f68a14c57e00c2d7276d26519ea64be1b48f91db1cdc776faa80528ca06c1e", size = 8511285, upload-time = "2025-12-30T15:49:13.104Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/d7/b288ea32deb752a09aab73c75e1e7572ab2a2b56c3124a5d1eb24c62ceb3/pdfminer_six-20251230-py3-none-any.whl", hash = "sha256:9ff2e3466a7dfc6de6fd779478850b6b7c2d9e9405aa2a5869376a822771f485", size = 6591909, upload-time = "2025-12-30T15:49:10.76Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pdfplumber"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pdfminer-six" },
|
||||||
|
{ name = "pillow" },
|
||||||
|
{ name = "pypdfium2" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/38/37/9ca3519e92a8434eb93be570b131476cc0a4e840bb39c62ddb7813a39d53/pdfplumber-0.11.9.tar.gz", hash = "sha256:481224b678b2bbdbf376e2c39bf914144eef7c3d301b4a28eebf0f7f6109d6dc", size = 102768, upload-time = "2026-01-05T08:10:29.072Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/c8/cdbc975f5b634e249cfa6597e37c50f3078412474f21c015e508bfbfe3c3/pdfplumber-0.11.9-py3-none-any.whl", hash = "sha256:33ec5580959ba524e9100138746e090879504c42955df1b8a997604dd326c443", size = 60045, upload-time = "2026-01-05T08:10:27.512Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "12.2.0"
|
version = "12.2.0"
|
||||||
@@ -1330,6 +1416,35 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pypdfium2"
|
||||||
|
version = "5.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6d/3d/dc934d3b606c51c3ecc95b6731d84b7dd7ab8e513a50b0e98a4da6c8a719/pypdfium2-5.8.0.tar.gz", hash = "sha256:049397c647e50f83115ee951c49394dab9e9ba52ebdd5a11ab1109390eb3d34e", size = 271934, upload-time = "2026-05-04T17:39:43.794Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/8c/6b75b923cb81368fa3ea7c48a0616b839620a3aeff899885bd930449b89e/pypdfium2-5.8.0-py3-none-android_23_arm64_v8a.whl", hash = "sha256:f67b6c74b716d9ac725ad1af49ae786ad813ac20823d45606d59f1fc06caa8af", size = 3374554, upload-time = "2026-05-04T17:39:05.552Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/61/a885c7f36efba89ec98e3d1fe95c83b48c2d6dea321e9194ac6460e7a834/pypdfium2-5.8.0-py3-none-android_23_armeabi_v7a.whl", hash = "sha256:53e82bf3e6a2da170b1bda83f93b7eec57cb6efe3cacd05cba78823879a85203", size = 2831667, upload-time = "2026-05-04T17:39:08.028Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/1f/04b5627f6dba312d3e707e5b019c9f24d8b03b5aa366866a9e02ec00f8d4/pypdfium2-5.8.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:085e633dcc89b65ff4035a4787e98ce7ae636836eb39c83dd0db26113d9774bc", size = 3450815, upload-time = "2026-05-04T17:39:09.551Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/77/8e3a2aba2bc4aef5abe1b1306d05b00588dc0bf7f5c850d1adf6164c786b/pypdfium2-5.8.0-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:bc84b7c6efede88fcfb9467f81daf416f26b973a54fc1cf4d3410d622fda6d7a", size = 3634395, upload-time = "2026-05-04T17:39:11.225Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/93/11/6f2b1847d9fa457b3b7251afc2bba2706d104a0c6f01431dfae5d679a839/pypdfium2-5.8.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a63bf09b2e13ba8545c930d243f0650c664a1b51314daa3b5f38df6d1a17b4bc", size = 3617413, upload-time = "2026-05-04T17:39:13.139Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ed/fd/99ce639de5ca06d21743c740dd988cd209dda623bc763ae10b8a162022e1/pypdfium2-5.8.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:937881c1698456749ed203a58db1895baa5eb7178cdb837ef84867790638da28", size = 3347639, upload-time = "2026-05-04T17:39:15.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/47/82864cc6e26dd8969d5594c168635acb16458d35cf5fed65d6b2e32abb42/pypdfium2-5.8.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6be9dc2b84a8694ad7e626bab133244e8241014d5ed1930d865a9bdf90df1e24", size = 3746404, upload-time = "2026-05-04T17:39:17.094Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/58/e41e49bba951f61921bac7289e67fe02af5ac57192d0bbfb5f459dc3691d/pypdfium2-5.8.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f27bd82891ae302dd02d736b14809661f6d1220ee1e96dbed9b23e2811922a3", size = 4177893, upload-time = "2026-05-04T17:39:18.729Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/15/fa7031010d5cf6853dadb4864680a0bfb7782c5bb6a1a401e0c25c4fca87/pypdfium2-5.8.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26c1089cdbbdc7fe1248f6d17fe3f30214be4f287dd0196b31aaee18a1564240", size = 3665152, upload-time = "2026-05-04T17:39:20.207Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/6a/5a3520a8b0cfa8d7fdc3f03a07ad9d6146c28ffd519330706f64fd8939a8/pypdfium2-5.8.0-py3-none-manylinux_2_27_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1c038a9290864aaa4862dd32e591993d82551ca4d152b4e8ce6d43ba37dc04a8", size = 3095365, upload-time = "2026-05-04T17:39:22.054Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/d3/845bae4de3cfa36865959046156edb5bf9baea400ccdecdd84fdd911b0f5/pypdfium2-5.8.0-py3-none-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f104bc1a6d8bfc1ff088aa50db13b9729cfdb3722b44975c3c457e9a7b9c7318", size = 2961801, upload-time = "2026-05-04T17:39:23.817Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/76/cf54eabee4a172241dfcfe63533bd1e11e2162114a983453a5a40bfec114/pypdfium2-5.8.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:04ca7c57a553facf8d46c6ea8ba6fa557e698670cfa4a58e0e01fdae2f6be87d", size = 4133067, upload-time = "2026-05-04T17:39:25.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/77/66/dcf871d19187ca04ea184a99801a6e7e556d8347aa49540fee33cda6dfc5/pypdfium2-5.8.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ad42b9c22477b32dbedcbc8232833f385d92fd0cf92822547b02383cf9a476d7", size = 3749100, upload-time = "2026-05-04T17:39:27.203Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/67/0d456c79660959ca45ad307b4d67161d29f9ed4083ee1e8fe8c6925b7c82/pypdfium2-5.8.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:388e3119cf5ca0979b7d5f6d40b7fcd5ab49e17ed4e6de6af89ba116061acfda", size = 4339212, upload-time = "2026-05-04T17:39:29.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/89/e5b0e0f7936be341c91c0f45cd70d693878894ed62aed93a6ee32e9c43c4/pypdfium2-5.8.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:aa05bbfa485ce7916217aa78d856c9f9cd86b08b20846c650392a67975ee72e9", size = 4383943, upload-time = "2026-05-04T17:39:31.287Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/21/4502ed255f082f579cd3537c2971cf1a57778d43703a08bcd1a92253189f/pypdfium2-5.8.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:f0813a16bb39d5ebd173ea5484430bb67a89b4b181db0a636c73b64ad063c3ea", size = 3925680, upload-time = "2026-05-04T17:39:33.241Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/4f/2e59723e7a07779439bd885c1b4960079c9710603308888d29ac926ae69a/pypdfium2-5.8.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:a3c78f7d20dd821bec6c072efdb21a1370b9efe10fdeeb68c969e67608e25385", size = 4269560, upload-time = "2026-05-04T17:39:34.926Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/4e/7b6b1bde3788c8b880d4b8131d95d9d339cebafb3ad9102d82e234bb65be/pypdfium2-5.8.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:86d302e207c138c827b885a72784f7b306d840646ebeae07e8efdbc39321c629", size = 4182434, upload-time = "2026-05-04T17:39:36.624Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/7b/6ed4782e0d7a5278330598ce8c4b2df7255f4585a0b3d04520fa580d6507/pypdfium2-5.8.0-py3-none-win32.whl", hash = "sha256:3f25fd436920a907291462b41bdc0ab9f8235c3944b4c9c15398da595ffd1fed", size = 3636680, upload-time = "2026-05-04T17:39:38.49Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/55/da7223d4202b2461f4f889b0baf10dddec3db7f88e6fd8c52db4a516eecd/pypdfium2-5.8.0-py3-none-win_amd64.whl", hash = "sha256:55592af0bddd2d62bed18e0053c546c9b72041430c5115e54870f7f6163125b0", size = 3754962, upload-time = "2026-05-04T17:39:40.13Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/7a/f3dcefe6ee7389aad3ca1488c177e8fbf978206de21c7a99ccf487ea38ab/pypdfium2-5.8.0-py3-none-win_arm64.whl", hash = "sha256:3f17ed97ae8a5a1705301ca93af256a5b02f9009dee4e99c5e175831d46ebd7c", size = 3548362, upload-time = "2026-05-04T17:39:42.304Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyphen"
|
name = "pyphen"
|
||||||
version = "0.17.2"
|
version = "0.17.2"
|
||||||
@@ -1643,8 +1758,10 @@ server = [
|
|||||||
{ name = "asyncmy" },
|
{ name = "asyncmy" },
|
||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
{ name = "kaleido" },
|
{ name = "kaleido" },
|
||||||
|
{ name = "pdfplumber" },
|
||||||
{ name = "pillow" },
|
{ name = "pillow" },
|
||||||
{ name = "plotly" },
|
{ name = "plotly" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
@@ -1666,8 +1783,10 @@ requires-dist = [
|
|||||||
{ name = "flask-wtf", marker = "extra == 'client'", specifier = ">=1.2.0" },
|
{ name = "flask-wtf", marker = "extra == 'client'", specifier = ">=1.2.0" },
|
||||||
{ name = "gunicorn", marker = "extra == 'client'", specifier = ">=21.0.0" },
|
{ name = "gunicorn", marker = "extra == 'client'", specifier = ">=21.0.0" },
|
||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
|
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27.0" },
|
||||||
|
{ name = "httpx", marker = "extra == 'server'", specifier = ">=0.27.0" },
|
||||||
{ name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.0" },
|
{ name = "jinja2", marker = "extra == 'server'", specifier = ">=3.1.0" },
|
||||||
{ name = "kaleido", marker = "extra == 'server'", specifier = ">=0.2.0" },
|
{ name = "kaleido", marker = "extra == 'server'", specifier = ">=0.2.0" },
|
||||||
|
{ name = "pdfplumber", marker = "extra == 'server'", specifier = ">=0.10.0" },
|
||||||
{ name = "pillow", marker = "extra == 'server'", specifier = ">=10.0.0" },
|
{ name = "pillow", marker = "extra == 'server'", specifier = ">=10.0.0" },
|
||||||
{ name = "plotly", marker = "extra == 'server'", specifier = ">=5.0.0" },
|
{ name = "plotly", marker = "extra == 'server'", specifier = ">=5.0.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||||
|
|||||||
Reference in New Issue
Block a user