feat: FASE 4 - Editor Maker (Fabric.js) con annotazioni, task editor, preview e storico versioni

- recipe_list.html: lista ricette con filtri, paginazione, cards Alpine.js
- recipe_editor.html: form metadati, upload drag-and-drop, canvas Fabric.js per annotazioni
- annotation-editor.js: editor annotazioni Fabric.js (marker, frecce, rettangoli, zoom, pan)
- task_editor.html: editor task/subtask inline con drag-and-drop reorder e tolleranze
- recipe_preview.html: anteprima ricetta come MeasurementTec
- version_history.html: timeline versioni con conteggio misurazioni AJAX
- maker.py: 6 route pagina + 13 proxy AJAX, gestione sicura risposte lista API
- i18n: 170+ stringhe tradotte IT/EN per tutti i template Maker

Architect review: 3 CRITICO + 5 MEDIO + 3 NEW risolti, 2 BASSO differiti

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adriano
2026-02-07 13:15:24 +01:00
parent a386986c17
commit e1f4ee73d0
11 changed files with 5915 additions and 19 deletions
+354 -19
View File
@@ -1,45 +1,380 @@
"""Maker blueprint - recipe creation and editing."""
from flask import Blueprint, render_template, session, redirect, url_for
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from flask_babel import gettext as _
maker_bp = Blueprint("maker", __name__)
from blueprints.auth import login_required, role_required
from services.api_client import api_client
maker_bp = Blueprint("maker", __name__, url_prefix="/maker")
# ============================================================================
# PAGINE (GET)
# ============================================================================
@maker_bp.route("/recipes")
@login_required
@role_required("Maker")
def recipe_list():
"""List all recipes with filters."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("maker/recipe_list.html")
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 100, type=int)
search = request.args.get("search", "", type=str)
params = {"page": page, "per_page": per_page}
if search:
params["search"] = search
resp = api_client.get("/api/recipes", params=params)
if resp.get("error"):
flash(_("Errore nel caricamento delle ricette: %(error)s", error=resp.get("detail", "")), "error")
recipes = []
total = 0
pages = 0
else:
recipes = resp.get("items", [])
total = resp.get("total", 0)
pages = resp.get("pages", 1)
return render_template(
"maker/recipe_list.html",
recipes=recipes,
page=page,
per_page=per_page,
total=total,
pages=pages,
search=search
)
@maker_bp.route("/recipes/new")
@login_required
@role_required("Maker")
def recipe_new():
"""Create new recipe."""
return render_template("maker/recipe_editor.html", recipe=None)
@maker_bp.route("/recipes/<int:recipe_id>/edit")
def recipe_editor(recipe_id: int | None = None):
"""Recipe editor - create or edit."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("maker/recipe_editor.html", recipe_id=recipe_id)
@login_required
@role_required("Maker")
def recipe_edit(recipe_id: int):
"""Edit existing recipe."""
# Carica recipe con dettagli completi
resp = api_client.get(f"/api/recipes/{recipe_id}")
if resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
recipe = resp
# Carica anche le versioni
versions_resp = api_client.get(f"/api/recipes/{recipe_id}/versions")
if isinstance(versions_resp, list):
recipe["versions"] = versions_resp
else:
recipe["versions"] = []
return render_template("maker/recipe_editor.html", recipe=recipe)
@maker_bp.route("/recipes/<int:recipe_id>/tasks")
@login_required
@role_required("Maker")
def task_editor(recipe_id: int):
"""Task/subtask editor with tolerances."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("maker/task_editor.html", recipe_id=recipe_id)
# Carica recipe con task/subtask
resp = api_client.get(f"/api/recipes/{recipe_id}")
if resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
recipe = resp
# Carica tasks con subtasks
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, list):
recipe["tasks"] = tasks_resp
else:
recipe["tasks"] = []
return render_template("maker/task_editor.html", recipe=recipe)
@maker_bp.route("/recipes/<int:recipe_id>/preview")
@login_required
@role_required("Maker")
def recipe_preview(recipe_id: int):
"""Preview recipe as MeasurementTec would see it."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("maker/recipe_preview.html", recipe_id=recipe_id)
# Carica recipe completo
resp = api_client.get(f"/api/recipes/{recipe_id}")
if resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
recipe = resp
# Carica tasks con subtasks
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
if isinstance(tasks_resp, list):
recipe["tasks"] = tasks_resp
else:
recipe["tasks"] = []
return render_template("maker/recipe_preview.html", recipe=recipe)
@maker_bp.route("/recipes/<int:recipe_id>/versions")
@login_required
@role_required("Maker")
def version_history(recipe_id: int):
"""Version history with diff."""
if "user" not in session:
return redirect(url_for("auth.login"))
return render_template("maker/version_history.html", recipe_id=recipe_id)
# Carica recipe base
resp = api_client.get(f"/api/recipes/{recipe_id}")
if resp.get("error"):
flash(_("Errore nel caricamento della ricetta: %(error)s", error=resp.get("detail", "")), "error")
return redirect(url_for("maker.recipe_list"))
recipe = resp
# Carica versioni
versions_resp = api_client.get(f"/api/recipes/{recipe_id}/versions")
if isinstance(versions_resp, list):
versions = versions_resp
else:
versions = []
flash(_("Errore nel caricamento delle versioni: %(error)s",
error=versions_resp.get("detail", "") if isinstance(versions_resp, dict) else ""), "warning")
return render_template("maker/version_history.html", recipe=recipe, versions=versions)
# ============================================================================
# API PROXY AJAX (JSON)
# ============================================================================
@maker_bp.route("/api/recipes", methods=["POST"])
@login_required
@role_required("Maker")
def api_create_recipe():
"""Proxy: Create new recipe."""
data = request.get_json(silent=True) or {}
resp = api_client.post("/api/recipes", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["PUT"])
@login_required
@role_required("Maker")
def api_update_recipe(recipe_id: int):
"""Proxy: Update recipe (creates new version)."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/recipes/{recipe_id}", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/recipes/<int:recipe_id>", methods=["DELETE"])
@login_required
@role_required("Maker")
def api_delete_recipe(recipe_id: int):
"""Proxy: Delete recipe (soft delete)."""
resp = api_client.delete(f"/api/recipes/{recipe_id}")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# ============================================================================
# TASK API PROXY
# ============================================================================
@maker_bp.route("/api/recipes/<int:recipe_id>/tasks", methods=["POST"])
@login_required
@role_required("Maker")
def api_create_task(recipe_id: int):
"""Proxy: Create new task."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/recipes/{recipe_id}/tasks", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/tasks/<int:task_id>", methods=["PUT"])
@login_required
@role_required("Maker")
def api_update_task(task_id: int):
"""Proxy: Update task."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/tasks/{task_id}", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/tasks/<int:task_id>", methods=["DELETE"])
@login_required
@role_required("Maker")
def api_delete_task(task_id: int):
"""Proxy: Delete task."""
resp = api_client.delete(f"/api/tasks/{task_id}")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
# 204 No Content returns empty dict
return jsonify(resp), 204
@maker_bp.route("/api/tasks/reorder", methods=["PUT"])
@login_required
@role_required("Maker")
def api_reorder_tasks():
"""Proxy: Reorder tasks."""
data = request.get_json(silent=True) or {}
resp = api_client.put("/api/tasks/reorder", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
# ============================================================================
# SUBTASK API PROXY
# ============================================================================
@maker_bp.route("/api/tasks/<int:task_id>/subtasks", methods=["POST"])
@login_required
@role_required("Maker")
def api_create_subtask(task_id: int):
"""Proxy: Create new subtask."""
data = request.get_json(silent=True) or {}
resp = api_client.post(f"/api/tasks/{task_id}/subtasks", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/subtasks/<int:subtask_id>", methods=["PUT"])
@login_required
@role_required("Maker")
def api_update_subtask(subtask_id: int):
"""Proxy: Update subtask."""
data = request.get_json(silent=True) or {}
resp = api_client.put(f"/api/subtasks/{subtask_id}", data=data)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
@maker_bp.route("/api/subtasks/<int:subtask_id>", methods=["DELETE"])
@login_required
@role_required("Maker")
def api_delete_subtask(subtask_id: int):
"""Proxy: Delete subtask."""
resp = api_client.delete(f"/api/subtasks/{subtask_id}")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
# 204 No Content returns empty dict
return jsonify(resp), 204
# ============================================================================
# FILE API PROXY
# ============================================================================
@maker_bp.route("/api/upload", methods=["POST"])
@login_required
@role_required("Maker")
def api_upload_file():
"""Proxy: Upload file (multipart forward)."""
# Controlla se c'è un file
if "file" not in request.files:
return jsonify({"error": True, "detail": _("Nessun file caricato")}), 400
file = request.files["file"]
if file.filename == "":
return jsonify({"error": True, "detail": _("Nome file vuoto")}), 400
# Prepara multipart data
recipe_id = request.form.get("recipe_id")
version_id = request.form.get("version_id")
files = {"file": (file.filename, file.stream, file.content_type)}
data = {}
if recipe_id:
data["recipe_id"] = recipe_id
if version_id:
data["version_id"] = version_id
# Forward multipart request
resp = api_client.post("/api/files/upload", data=data, files=files)
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 201
@maker_bp.route("/api/files/<path:file_path>", methods=["DELETE"])
@login_required
@role_required("Maker")
def api_delete_file(file_path: str):
"""Proxy: Delete file."""
resp = api_client.delete(f"/api/files/{file_path}")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
# 204 No Content returns empty dict
return jsonify(resp), 204
# ============================================================================
# VERSION API PROXY
# ============================================================================
@maker_bp.route("/api/recipes/<int:recipe_id>/versions/<int:version_number>/measurement-count", methods=["GET"])
@login_required
@role_required("Maker")
def api_get_measurement_count(recipe_id: int, version_number: int):
"""Proxy: Get measurement count for a specific version."""
resp = api_client.get(f"/api/recipes/{recipe_id}/versions/{version_number}/measurement-count")
if resp.get("error"):
return jsonify(resp), resp.get("status_code", 500)
return jsonify(resp), 200
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python
"""Compile .po files to .mo files for Flask-Babel."""
from pathlib import Path
from babel.messages.mofile import write_mo
from babel.messages.pofile import read_po
def compile_po_file(po_path: Path) -> None:
"""Compile a single .po file to .mo."""
mo_path = po_path.with_suffix('.mo')
print(f"Compiling {po_path} -> {mo_path}")
with open(po_path, 'rb') as po_file:
catalog = read_po(po_file)
with open(mo_path, 'wb') as mo_file:
write_mo(mo_file, catalog)
print(f" {len(catalog)} messages compiled")
def main():
"""Find and compile all .po files in translations directory."""
translations_dir = Path(__file__).parent / 'translations'
if not translations_dir.exists():
print(f"ERROR: {translations_dir} does not exist")
return
po_files = list(translations_dir.rglob('*.po'))
if not po_files:
print(f"No .po files found in {translations_dir}")
return
print(f"Found {len(po_files)} .po file(s)")
for po_file in po_files:
compile_po_file(po_file)
print("\n✓ All translations compiled successfully")
if __name__ == '__main__':
main()
+872
View File
@@ -0,0 +1,872 @@
/**
* Annotation Editor - Fabric.js-based editor for image annotations
*
* Alpine.js component for the Maker role to place measurement markers,
* arrows, and area rectangles on technical drawings.
*
* Dependencies:
* - Fabric.js v5.3.1 (loaded via CDN before this file)
* - Alpine.js (loaded with defer)
*
* Annotation JSON Format (output):
* {
* "version": 1,
* "width": 800,
* "height": 480,
* "objects": [
* { "type": "marker", "left": 150, "top": 200, "markerNumber": 1, ... },
* { "type": "arrow", "left": 100, "top": 80, "x1": 100, "y1": 80, "x2": 300, "y2": 200, ... },
* { "type": "area", "left": 50, "top": 60, "width": 150, "height": 100, ... }
* ]
* }
*
* Usage:
* <div x-data="annotationEditor()"
* x-init="loadBackgroundImage('/path/to/image.jpg')">
* <canvas x-ref="fabricCanvas"></canvas>
* </div>
*/
function annotationEditor() {
'use strict';
// ---- Debounce utility ----
function debounce(fn, delay) {
var timer = null;
return function () {
var context = this;
var args = arguments;
if (timer) clearTimeout(timer);
timer = setTimeout(function () {
fn.apply(context, args);
}, delay);
};
}
return {
// ---- State ----
/** @type {fabric.Canvas|null} */
canvas: null,
/** @type {HTMLCanvasElement|null} */
canvasEl: null,
/** Current interaction mode: 'select' | 'marker' | 'arrow' | 'rect' | 'pan' */
activeMode: 'select',
/** Next auto-assigned marker number */
nextMarkerNumber: 1,
/** Current zoom level (1 = 100%) */
zoomLevel: 1,
/** Whether a background image has been loaded */
imageLoaded: false,
/** Whether the canvas has unsaved changes */
isDirty: false,
// ----------------------------------------------------------------
// Initialization
// ----------------------------------------------------------------
/**
* Alpine init hook.
* Creates the Fabric.js canvas instance and wires up all event listeners.
*/
init() {
this.canvasEl = this.$refs.fabricCanvas;
if (!this.canvasEl) {
console.error('AnnotationEditor: canvas ref "fabricCanvas" not found');
return;
}
this.canvas = new fabric.Canvas(this.canvasEl, {
selection: true,
preserveObjectStacking: true,
backgroundColor: '#f1f5f9',
});
this.setupEvents();
this.setupKeyboard();
this.resizeCanvas();
this._debouncedResize = debounce(this.resizeCanvas.bind(this), 200);
window.addEventListener('resize', this._debouncedResize);
// ---- Bridge: listen for commands from recipeEditor ----
var self = this;
this._onToolChange = function (e) {
if (e.detail && e.detail.tool) {
self.setMode(e.detail.tool);
}
};
window.addEventListener('anno-tool-change', this._onToolChange);
this._onDeleteSelected = function () {
self.deleteSelected();
};
window.addEventListener('anno-delete-selected', this._onDeleteSelected);
this._onZoom = function (e) {
if (e.detail && e.detail.delta > 0) {
self.zoomIn();
} else {
self.zoomOut();
}
};
window.addEventListener('anno-zoom', this._onZoom);
this._onZoomReset = function () {
self.resetZoom();
};
window.addEventListener('anno-zoom-reset', this._onZoomReset);
this._onImageLoaded = function (e) {
if (e.detail && e.detail.path) {
// Wait for x-show transition to reveal the canvas
setTimeout(function () {
self.resizeCanvas();
self.loadBackgroundImage('/api/files/' + e.detail.path);
if (e.detail.annotations) {
self.loadAnnotationsJson(e.detail.annotations);
}
}, 150);
}
};
window.addEventListener('image-loaded', this._onImageLoaded);
// ---- Initial load: check parent scope for existing file ----
var filePath = this.currentFilePath; // from parent recipeEditor scope
if (filePath) {
var annoJson = this.annotationsJson; // from parent recipeEditor scope
this.loadBackgroundImage('/api/files/' + filePath);
if (annoJson) {
this.loadAnnotationsJson(annoJson);
}
}
},
// ----------------------------------------------------------------
// Background image
// ----------------------------------------------------------------
/**
* Load an image as the canvas background.
* Scales the image to fit within the canvas while maintaining aspect ratio
* and never exceeding the original size (scale <= 1).
*
* @param {string} imageUrl - URL of the image to load
*/
loadBackgroundImage(imageUrl) {
if (!this.canvas) return;
fabric.Image.fromURL(
imageUrl,
function (img) {
if (!img || !img.width || !img.height) {
console.error('AnnotationEditor: failed to load background image');
return;
}
var scale = Math.min(
this.canvas.width / img.width,
this.canvas.height / img.height,
1 // never upscale
);
this.canvas.setBackgroundImage(
img,
this.canvas.renderAll.bind(this.canvas),
{
scaleX: scale,
scaleY: scale,
originX: 'left',
originY: 'top',
}
);
this.imageLoaded = true;
}.bind(this),
{ crossOrigin: 'anonymous' }
);
},
// ----------------------------------------------------------------
// Object creation - Markers
// ----------------------------------------------------------------
/**
* Add a numbered circle marker at the given position.
*
* @param {number} x - left position on canvas
* @param {number} y - top position on canvas
* @param {number} [number] - explicit marker number (auto-increments if omitted)
* @returns {fabric.Group} the created marker group
*/
addMarker(x, y, number) {
var markerNumber = number || this.nextMarkerNumber++;
var circle = new fabric.Circle({
radius: 16,
fill: '#2563EB',
originX: 'center',
originY: 'center',
stroke: '#ffffff',
strokeWidth: 2,
});
var text = new fabric.Text(markerNumber.toString(), {
fontSize: 14,
fill: '#ffffff',
fontFamily: 'Inter, sans-serif',
fontWeight: 'bold',
originX: 'center',
originY: 'center',
});
var marker = new fabric.Group([circle, text], {
left: x,
top: y,
selectable: true,
hasControls: false,
hasBorders: true,
lockScalingX: true,
lockScalingY: true,
// Custom properties
objectType: 'marker',
markerNumber: markerNumber,
});
this.canvas.add(marker);
this.canvas.setActiveObject(marker);
this.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
return marker;
},
// ----------------------------------------------------------------
// Object creation - Arrows
// ----------------------------------------------------------------
/**
* Add an arrow (line + triangular arrowhead) between two points.
*
* @param {number} x1 - start x
* @param {number} y1 - start y
* @param {number} x2 - end x
* @param {number} y2 - end y
* @returns {fabric.Group} the created arrow group
*/
addArrow(x1, y1, x2, y2) {
var line = new fabric.Line([x1, y1, x2, y2], {
stroke: '#DC2626',
strokeWidth: 2,
originX: 'center',
originY: 'center',
});
var angle = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI;
var arrowHead = new fabric.Triangle({
left: x2,
top: y2,
originX: 'center',
originY: 'center',
width: 12,
height: 12,
fill: '#DC2626',
angle: angle + 90,
});
var arrow = new fabric.Group([line, arrowHead], {
selectable: true,
objectType: 'arrow',
// Store original endpoints for serialization
arrowX1: x1,
arrowY1: y1,
arrowX2: x2,
arrowY2: y2,
});
this.canvas.add(arrow);
this.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
return arrow;
},
// ----------------------------------------------------------------
// Object creation - Rectangles
// ----------------------------------------------------------------
/**
* Add a dashed-outline area rectangle.
*
* @param {number} x - left position
* @param {number} y - top position
* @param {number} [width=150] - rectangle width
* @param {number} [height=100] - rectangle height
* @returns {fabric.Rect} the created rectangle
*/
addRect(x, y, width, height) {
var rect = new fabric.Rect({
left: x,
top: y,
width: width || 150,
height: height || 100,
fill: 'rgba(37, 99, 235, 0.1)',
stroke: '#2563EB',
strokeWidth: 2,
strokeDashArray: [5, 3],
selectable: true,
objectType: 'area',
});
this.canvas.add(rect);
this.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
return rect;
},
// ----------------------------------------------------------------
// Toolbar / mode switching
// ----------------------------------------------------------------
/**
* Set the current interaction mode.
*
* @param {'select'|'marker'|'arrow'|'rect'|'pan'} mode
*/
setMode(mode) {
this.activeMode = mode;
if (mode === 'select') {
this.canvas.selection = true;
this.canvas.defaultCursor = 'default';
this.canvas.forEachObject(function (o) {
o.selectable = true;
});
} else if (mode === 'pan') {
this.canvas.selection = false;
this.canvas.defaultCursor = 'grab';
this.canvas.forEachObject(function (o) {
o.selectable = false;
});
} else {
// marker, arrow, rect
this.canvas.selection = false;
this.canvas.defaultCursor = 'crosshair';
this.canvas.discardActiveObject();
}
this.canvas.renderAll();
},
// ----------------------------------------------------------------
// Mouse events
// ----------------------------------------------------------------
/**
* Wire up all Fabric.js mouse events for drawing, panning, selection,
* and zoom.
*/
setupEvents() {
var self = this;
var isDrawing = false;
var drawStartX = 0;
var drawStartY = 0;
var tempRect = null;
var tempLine = null;
var isPanning = false;
var lastPanX = 0;
var lastPanY = 0;
// ---- mouse:down ----
this.canvas.on('mouse:down', function (opt) {
var pointer = self.canvas.getPointer(opt.e);
if (self.activeMode === 'marker') {
self.addMarker(pointer.x, pointer.y);
self.setMode('select');
} else if (self.activeMode === 'arrow') {
isDrawing = true;
drawStartX = pointer.x;
drawStartY = pointer.y;
// Preview line
tempLine = new fabric.Line(
[drawStartX, drawStartY, drawStartX, drawStartY],
{
stroke: '#DC2626',
strokeWidth: 2,
strokeDashArray: [4, 4],
selectable: false,
evented: false,
objectType: '_temp',
}
);
self.canvas.add(tempLine);
} else if (self.activeMode === 'rect') {
isDrawing = true;
drawStartX = pointer.x;
drawStartY = pointer.y;
// Preview rect
tempRect = new fabric.Rect({
left: drawStartX,
top: drawStartY,
width: 0,
height: 0,
fill: 'rgba(37, 99, 235, 0.05)',
stroke: '#2563EB',
strokeWidth: 1,
strokeDashArray: [4, 4],
selectable: false,
evented: false,
objectType: '_temp',
});
self.canvas.add(tempRect);
} else if (self.activeMode === 'pan') {
isPanning = true;
lastPanX = opt.e.clientX;
lastPanY = opt.e.clientY;
self.canvas.defaultCursor = 'grabbing';
}
});
// ---- mouse:move ----
this.canvas.on('mouse:move', function (opt) {
if (isPanning) {
var vpt = self.canvas.viewportTransform;
vpt[4] += opt.e.clientX - lastPanX;
vpt[5] += opt.e.clientY - lastPanY;
lastPanX = opt.e.clientX;
lastPanY = opt.e.clientY;
self.canvas.requestRenderAll();
return;
}
if (!isDrawing) return;
var pointer = self.canvas.getPointer(opt.e);
if (self.activeMode === 'arrow' && tempLine) {
tempLine.set({ x2: pointer.x, y2: pointer.y });
self.canvas.requestRenderAll();
} else if (self.activeMode === 'rect' && tempRect) {
var x = Math.min(drawStartX, pointer.x);
var y = Math.min(drawStartY, pointer.y);
var w = Math.abs(pointer.x - drawStartX);
var h = Math.abs(pointer.y - drawStartY);
tempRect.set({ left: x, top: y, width: w, height: h });
self.canvas.requestRenderAll();
}
});
// ---- mouse:up ----
this.canvas.on('mouse:up', function (opt) {
// Clean up temp preview objects
if (tempLine) {
self.canvas.remove(tempLine);
tempLine = null;
}
if (tempRect) {
self.canvas.remove(tempRect);
tempRect = null;
}
if (isDrawing) {
var pointer = self.canvas.getPointer(opt.e);
if (self.activeMode === 'arrow') {
var dx = pointer.x - drawStartX;
var dy = pointer.y - drawStartY;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 10) {
self.addArrow(drawStartX, drawStartY, pointer.x, pointer.y);
}
} else if (self.activeMode === 'rect') {
var w = Math.abs(pointer.x - drawStartX);
var h = Math.abs(pointer.y - drawStartY);
var x = Math.min(drawStartX, pointer.x);
var y = Math.min(drawStartY, pointer.y);
if (w > 5 && h > 5) {
self.addRect(x, y, w, h);
}
}
isDrawing = false;
self.setMode('select');
}
if (isPanning) {
isPanning = false;
self.canvas.defaultCursor = 'grab';
}
});
// ---- Selection events ----
this.canvas.on('selection:created', function (e) {
window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: true } }));
var selected = e.selected;
if (selected && selected.length === 1 && selected[0].objectType === 'marker') {
window.dispatchEvent(new CustomEvent('marker-selected', {
detail: { markerNumber: selected[0].markerNumber }
}));
}
});
this.canvas.on('selection:updated', function (e) {
window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: true } }));
var selected = e.selected;
if (selected && selected.length === 1 && selected[0].objectType === 'marker') {
window.dispatchEvent(new CustomEvent('marker-selected', {
detail: { markerNumber: selected[0].markerNumber }
}));
}
});
this.canvas.on('selection:cleared', function () {
window.dispatchEvent(new CustomEvent('annotation-selected', { detail: { selected: false } }));
window.dispatchEvent(new CustomEvent('marker-selected', { detail: { markerNumber: null } }));
});
// ---- Object modification ----
this.canvas.on('object:modified', function () {
self.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
});
this.canvas.on('object:moved', function () {
self.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: self.getAnnotationsJson() } }));
});
// ---- Zoom with mouse wheel ----
this.canvas.on('mouse:wheel', function (opt) {
var delta = opt.e.deltaY;
var zoom = self.canvas.getZoom();
zoom *= Math.pow(0.999, delta);
zoom = Math.max(0.25, Math.min(5, zoom));
self.canvas.zoomToPoint(
{ x: opt.e.offsetX, y: opt.e.offsetY },
zoom
);
self.zoomLevel = zoom;
opt.e.preventDefault();
opt.e.stopPropagation();
});
},
// ----------------------------------------------------------------
// Keyboard shortcuts
// ----------------------------------------------------------------
/**
* Register global keyboard shortcuts.
* Ignored when focus is inside input / textarea elements.
*/
setupKeyboard() {
var self = this;
document.addEventListener('keydown', function (e) {
// Skip when typing in form fields
var tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
if (e.target.isContentEditable) return;
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
self.deleteSelected();
} else if (e.key === 'Escape') {
self.setMode('select');
self.canvas.discardActiveObject();
self.canvas.renderAll();
}
});
},
// ----------------------------------------------------------------
// Object manipulation
// ----------------------------------------------------------------
/**
* Delete the currently selected object(s) from the canvas.
*/
deleteSelected() {
var active = this.canvas.getActiveObject();
if (!active) return;
if (active.type === 'activeSelection') {
var self = this;
active.forEachObject(function (obj) {
self.canvas.remove(obj);
});
this.canvas.discardActiveObject();
} else {
this.canvas.remove(active);
}
this.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
this.canvas.renderAll();
},
/**
* Clear all annotation objects from the canvas (keeps background image).
*/
clearAll() {
this.canvas.getObjects().slice().forEach(
function (obj) {
this.canvas.remove(obj);
}.bind(this)
);
this.nextMarkerNumber = 1;
this.isDirty = true;
window.dispatchEvent(new CustomEvent('annotations-changed', { detail: { json: this.getAnnotationsJson() } }));
this.canvas.renderAll();
},
// ----------------------------------------------------------------
// Zoom controls
// ----------------------------------------------------------------
/**
* Zoom in by 20%.
*/
zoomIn() {
var zoom = Math.min(this.canvas.getZoom() * 1.2, 5);
this.canvas.setZoom(zoom);
this.zoomLevel = zoom;
},
/**
* Zoom out by 20%.
*/
zoomOut() {
var zoom = Math.max(this.canvas.getZoom() / 1.2, 0.25);
this.canvas.setZoom(zoom);
this.zoomLevel = zoom;
},
/**
* Reset zoom and pan to default (1:1, origin at top-left).
*/
resetZoom() {
this.canvas.setViewportTransform([1, 0, 0, 1, 0, 0]);
this.canvas.setZoom(1);
this.zoomLevel = 1;
},
// ----------------------------------------------------------------
// Canvas sizing
// ----------------------------------------------------------------
/**
* Resize the Fabric.js canvas to fill its parent container.
* Maintains a minimum 5:3 aspect ratio (height = width * 0.6).
*/
resizeCanvas() {
var container = this.canvasEl ? this.canvasEl.parentElement : null;
if (!container || !this.canvas) return;
var width = container.clientWidth;
var height = Math.max(400, Math.round(width * 0.6));
this.canvas.setWidth(width);
this.canvas.setHeight(height);
this.canvas.renderAll();
},
// ----------------------------------------------------------------
// Serialization - Export
// ----------------------------------------------------------------
/**
* Serialize all canvas annotation objects to a JSON string.
*
* @returns {string} JSON representation of all annotations
*/
getAnnotationsJson() {
var objects = this.canvas.getObjects().map(function (obj) {
var data = {
type: obj.objectType || obj.type,
left: Math.round(obj.left * 100) / 100,
top: Math.round(obj.top * 100) / 100,
width: Math.round((obj.width || 0) * 100) / 100,
height: Math.round((obj.height || 0) * 100) / 100,
scaleX: Math.round((obj.scaleX || 1) * 1000) / 1000,
scaleY: Math.round((obj.scaleY || 1) * 1000) / 1000,
angle: Math.round((obj.angle || 0) * 100) / 100,
markerNumber: obj.markerNumber || null,
};
// Arrow-specific: store original endpoints
if (obj.objectType === 'arrow') {
data.x1 = obj.arrowX1 != null ? Math.round(obj.arrowX1 * 100) / 100 : null;
data.y1 = obj.arrowY1 != null ? Math.round(obj.arrowY1 * 100) / 100 : null;
data.x2 = obj.arrowX2 != null ? Math.round(obj.arrowX2 * 100) / 100 : null;
data.y2 = obj.arrowY2 != null ? Math.round(obj.arrowY2 * 100) / 100 : null;
}
return data;
});
return JSON.stringify({
version: 1,
width: this.canvas.width,
height: this.canvas.height,
objects: objects,
});
},
// ----------------------------------------------------------------
// Serialization - Import
// ----------------------------------------------------------------
/**
* Load annotations from a JSON string and recreate all objects on the canvas.
* Clears any existing annotation objects first (keeps background image).
*
* @param {string|Object} jsonString - JSON string or parsed object
*/
loadAnnotationsJson(jsonString) {
if (!jsonString) return;
try {
var data =
typeof jsonString === 'string' ? JSON.parse(jsonString) : jsonString;
// Remove existing objects
this.canvas.getObjects().slice().forEach(
function (obj) {
this.canvas.remove(obj);
}.bind(this)
);
var objects = data.objects || [];
for (var i = 0; i < objects.length; i++) {
var obj = objects[i];
if (obj.type === 'marker') {
this.addMarker(obj.left, obj.top, obj.markerNumber);
} else if (obj.type === 'arrow') {
// Reconstruct arrow from stored endpoints or from position/size
var x1 = obj.x1 != null ? obj.x1 : obj.left;
var y1 = obj.y1 != null ? obj.y1 : obj.top;
var x2 = obj.x2 != null ? obj.x2 : obj.left + (obj.width || 100);
var y2 = obj.y2 != null ? obj.y2 : obj.top + (obj.height || 50);
this.addArrow(x1, y1, x2, y2);
} else if (obj.type === 'area') {
var w = (obj.width || 150) * (obj.scaleX || 1);
var h = (obj.height || 100) * (obj.scaleY || 1);
this.addRect(obj.left, obj.top, w, h);
}
}
// Recalculate nextMarkerNumber
var markerNumbers = objects
.filter(function (o) {
return o.type === 'marker' && o.markerNumber;
})
.map(function (o) {
return o.markerNumber;
});
this.nextMarkerNumber =
markerNumbers.length > 0 ? Math.max.apply(null, markerNumbers) + 1 : 1;
this.isDirty = false;
this.canvas.renderAll();
} catch (e) {
console.error('AnnotationEditor: failed to load annotations:', e);
}
},
// ----------------------------------------------------------------
// Marker query helpers
// ----------------------------------------------------------------
/**
* Get a sorted list of all markers currently on the canvas.
*
* @returns {Array<{number: number, left: number, top: number}>}
*/
getMarkers() {
return this.canvas
.getObjects()
.filter(function (obj) {
return obj.objectType === 'marker';
})
.map(function (obj) {
return {
number: obj.markerNumber,
left: Math.round(obj.left * 100) / 100,
top: Math.round(obj.top * 100) / 100,
};
})
.sort(function (a, b) {
return a.number - b.number;
});
},
/**
* Highlight a specific marker by its number.
* Selects it on the canvas so the user sees the Fabric selection border.
*
* @param {number} markerNumber
*/
selectMarkerByNumber(markerNumber) {
var target = null;
this.canvas.forEachObject(function (obj) {
if (obj.objectType === 'marker' && obj.markerNumber === markerNumber) {
target = obj;
}
});
if (target) {
this.canvas.setActiveObject(target);
this.canvas.renderAll();
}
},
// ----------------------------------------------------------------
// Cleanup
// ----------------------------------------------------------------
/**
* Destroy the Fabric.js canvas and remove event listeners.
* Call this when the Alpine component is removed from the DOM.
*/
destroy() {
if (this._debouncedResize) {
window.removeEventListener('resize', this._debouncedResize);
}
if (this._onToolChange) {
window.removeEventListener('anno-tool-change', this._onToolChange);
}
if (this._onDeleteSelected) {
window.removeEventListener('anno-delete-selected', this._onDeleteSelected);
}
if (this._onZoom) {
window.removeEventListener('anno-zoom', this._onZoom);
}
if (this._onZoomReset) {
window.removeEventListener('anno-zoom-reset', this._onZoomReset);
}
if (this._onImageLoaded) {
window.removeEventListener('image-loaded', this._onImageLoaded);
}
if (this.canvas) {
this.canvas.dispose();
this.canvas = null;
}
},
};
}
+826
View File
@@ -0,0 +1,826 @@
{% extends "base.html" %}
{% block title %}
{% if recipe %}
{{ recipe.code }} — {{ _('Modifica Ricetta') }}
{% else %}
{{ _('Nuova Ricetta') }}
{% endif %}
— TieMeasureFlow
{% endblock %}
{% block extra_head %}
<style>
/* Drag-and-drop overlay */
.drop-zone {
border: 2px dashed var(--border-color);
border-radius: 0.75rem;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.drop-zone.drag-over {
border-color: var(--color-primary);
background-color: rgba(37, 99, 235, 0.05);
}
.dark .drop-zone.drag-over {
background-color: rgba(59, 130, 246, 0.08);
}
/* Annotation toolbar button */
.anno-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
font-weight: 500;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background: var(--bg-card);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
gap: 0.25rem;
}
.anno-btn:hover {
background: var(--bg-secondary);
color: var(--text-primary);
border-color: var(--text-muted);
}
.anno-btn.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.anno-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Canvas container */
.canvas-container {
position: relative;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
overflow: hidden;
min-height: 400px;
}
.canvas-container canvas {
display: block;
}
</style>
{% endblock %}
{% block content %}
{# ——— Determine mode flags ——— #}
{% set is_new = (recipe is none) %}
{% set recipe_id = recipe.id if recipe else None %}
{% set current_version = recipe.current_version if recipe and recipe.current_version else None %}
{% set versions = recipe.versions if recipe and recipe.versions else [] %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl"
x-data="recipeEditor()"
x-cloak>
<!-- ============================================================
Breadcrumb
============================================================ -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<li>
<a href="{{ url_for('maker.recipe_list') }}"
class="hover:text-primary transition-colors inline-flex items-center gap-1">
<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 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>
{{ _('Ricette') }}
</a>
</li>
<li>
<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="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="font-medium text-[var(--text-primary)]">
{% if is_new %}
{{ _('Nuova Ricetta') }}
{% else %}
{{ recipe.code }}
{% endif %}
</li>
</ol>
</nav>
<!-- ============================================================
Header + Action Buttons
============================================================ -->
<div class="mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
<div>
<h1 class="text-3xl font-bold text-[var(--text-primary)] mb-1">
{% if is_new %}
{{ _('Nuova Ricetta') }}
{% else %}
<span class="font-mono text-primary">{{ recipe.code }}</span>
<span class="text-[var(--text-muted)] text-lg font-normal ml-2">
(v<span x-text="currentVersion"></span>)
</span>
{% endif %}
</h1>
{% if not is_new %}
<p class="text-sm text-[var(--text-secondary)]" x-text="name"></p>
{% else %}
<p class="text-sm text-[var(--text-secondary)]">
{{ _('Compila i dati della ricetta e carica il disegno tecnico') }}
</p>
{% endif %}
</div>
<!-- Top action buttons -->
<div class="flex items-center gap-2 shrink-0 flex-wrap">
<button @click="saveRecipe()"
:disabled="saving || !name.trim() || (isNew && !code.trim())"
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>
<span x-text="saving ? '{{ _('Salvataggio...') }}' : '{{ _('Salva') }}'"></span>
</button>
{% if not is_new %}
<a href="{{ url_for('maker.recipe_preview', recipe_id=recipe_id) }}"
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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ _('Anteprima') }}
</a>
<a href="{{ url_for('maker.task_editor', recipe_id=recipe_id) }}"
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 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>
{{ _('Task') }}
</a>
{% endif %}
<a href="{{ url_for('maker.recipe_list') }}"
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="M6 18L18 6M6 6l12 12"/>
</svg>
{{ _('Annulla') }}
</a>
</div>
</div>
</div>
<!-- ============================================================
Error Banner
============================================================ -->
<div x-show="errorMessage"
x-transition
class="mb-6 flex items-center gap-3 px-4 py-3 rounded-lg
bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-800
text-red-800 dark:text-red-200 text-sm">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span x-text="errorMessage"></span>
<button @click="errorMessage = ''" class="ml-auto shrink-0 hover:opacity-70">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- ============================================================
Success Banner
============================================================ -->
<div x-show="saved"
x-transition
x-init="$watch('saved', v => { if (v) setTimeout(() => saved = false, 4000) })"
class="mb-6 flex items-center gap-3 px-4 py-3 rounded-lg
bg-emerald-50 dark:bg-emerald-900/30 border border-emerald-200 dark:border-emerald-800
text-emerald-800 dark:text-emerald-200 text-sm">
<svg class="w-5 h-5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ _('Ricetta salvata con successo') }}</span>
</div>
<!-- ============================================================
1. Card: Metadati Ricetta
============================================================ -->
<div class="tmf-card mb-6">
<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="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>
{{ _('Metadati Ricetta') }}
</div>
<div class="tmf-card-body">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<!-- Codice -->
<div>
<label class="tmf-label" for="recipe-code">
{{ _('Codice') }} <span class="text-red-500">*</span>
</label>
<input id="recipe-code"
type="text"
x-model="code"
class="tmf-input font-mono tracking-wide"
:disabled="!isNew"
:class="{ 'opacity-60 cursor-not-allowed': !isNew }"
placeholder="{{ _('Es. COUPLING-256') }}"
required>
<p x-show="!isNew" class="text-xs text-[var(--text-muted)] mt-1">
{{ _('Il codice non puo essere modificato dopo la creazione') }}
</p>
</div>
<!-- Nome -->
<div>
<label class="tmf-label" for="recipe-name">
{{ _('Nome') }} <span class="text-red-500">*</span>
</label>
<input id="recipe-name"
type="text"
x-model="name"
class="tmf-input"
placeholder="{{ _('Es. Coupling Assembly 256') }}"
required>
</div>
<!-- Descrizione -->
<div class="sm:col-span-2">
<label class="tmf-label" for="recipe-description">
{{ _('Descrizione') }}
</label>
<textarea id="recipe-description"
x-model="description"
class="tmf-input"
rows="3"
placeholder="{{ _('Descrizione opzionale della ricetta...') }}"></textarea>
</div>
</div>
</div>
</div>
<!-- ============================================================
2. Card: Upload / Annotazioni (Canvas Fabric.js)
============================================================ -->
<div class="tmf-card mb-6">
<div class="tmf-card-header flex items-center justify-between">
<div class="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="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>
{{ _('Disegno Tecnico e Annotazioni') }}
</div>
<!-- File indicator -->
<template x-if="currentFilePath">
<span class="badge badge-pass text-xs">
<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>
{{ _('Immagine caricata') }}
</span>
</template>
</div>
<div class="tmf-card-body space-y-4">
<!-- Annotation Toolbar -->
<div x-show="currentFilePath"
x-transition
class="flex flex-wrap items-center gap-1.5 p-2 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<!-- Select Tool -->
<button type="button"
class="anno-btn"
:class="{ 'active': annoTool === 'select' }"
@click="setAnnoTool('select')"
title="{{ _('Seleziona') }}">
<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="M15 15l-2 5L9 9l11 4-5 2z"/>
</svg>
<span class="hidden sm:inline">{{ _('Seleziona') }}</span>
</button>
<!-- Marker Tool -->
<button type="button"
class="anno-btn"
:class="{ 'active': annoTool === 'marker' }"
@click="setAnnoTool('marker')"
title="{{ _('Marker numerato') }}">
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
<span class="hidden sm:inline">{{ _('Marker') }} #</span>
</button>
<!-- Arrow Tool -->
<button type="button"
class="anno-btn"
:class="{ 'active': annoTool === 'arrow' }"
@click="setAnnoTool('arrow')"
title="{{ _('Freccia') }}">
<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="M17 8l4 4m0 0l-4 4m4-4H3"/>
</svg>
<span class="hidden sm:inline">{{ _('Freccia') }}</span>
</button>
<!-- Rectangle Tool -->
<button type="button"
class="anno-btn"
:class="{ 'active': annoTool === 'rect' }"
@click="setAnnoTool('rect')"
title="{{ _('Rettangolo') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
</svg>
<span class="hidden sm:inline">{{ _('Rettangolo') }}</span>
</button>
<!-- Separator -->
<div class="w-px h-6 bg-[var(--border-color)] mx-1"></div>
<!-- Delete -->
<button type="button"
class="anno-btn text-red-500 hover:text-red-600"
@click="deleteAnnotation()"
:disabled="!annoSelected"
title="{{ _('Elimina selezionato') }}">
<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>
<span class="hidden sm:inline">{{ _('Elimina') }}</span>
</button>
<!-- Separator -->
<div class="w-px h-6 bg-[var(--border-color)] mx-1"></div>
<!-- Zoom Controls -->
<span class="text-xs text-[var(--text-muted)] hidden sm:inline mr-1">{{ _('Zoom') }}:</span>
<button type="button"
class="anno-btn"
@click="zoomCanvas(-0.1)"
title="{{ _('Zoom indietro') }}">
<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="M20 12H4"/>
</svg>
</button>
<button type="button"
class="anno-btn"
@click="zoomCanvas(0.1)"
title="{{ _('Zoom avanti') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
</button>
<button type="button"
class="anno-btn"
@click="resetZoom()"
title="{{ _('Reset zoom') }}">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</button>
<!-- Separator -->
<div class="w-px h-6 bg-[var(--border-color)] mx-1"></div>
<!-- Pan Mode -->
<button type="button"
class="anno-btn"
:class="{ 'active': annoTool === 'pan' }"
@click="setAnnoTool('pan')"
title="{{ _('Trascina') }}">
<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="M7 11.5V14m0-2.5v-6a1.5 1.5 0 113 0m-3 6a1.5 1.5 0 00-3 0v2a7.5 7.5 0 0015 0v-5a1.5 1.5 0 00-3 0m-6-2V11m0-5.5v-1a1.5 1.5 0 013 0v1m0 0V11m0-5.5a1.5 1.5 0 013 0v3m0 0V11"/>
</svg>
<span class="hidden sm:inline">{{ _('Pan') }}</span>
</button>
</div>
<!-- Canvas Area (shown when image loaded) -->
<div x-show="currentFilePath"
x-transition
x-data="annotationEditor()"
class="canvas-container"
id="annotationCanvasContainer">
<canvas id="annotationCanvas" x-ref="fabricCanvas"></canvas>
</div>
<!-- Drop Zone (always visible for uploading/replacing) -->
<div class="drop-zone p-6 text-center cursor-pointer relative"
:class="{ 'drag-over': dragOver }"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="handleDrop($event)"
@click="$refs.fileInput.click()">
<input type="file"
x-ref="fileInput"
@change="uploadFile($event.target.files[0]); $event.target.value = ''"
accept="image/*,application/pdf"
class="hidden">
<!-- Uploading spinner -->
<template x-if="uploadingFile">
<div class="flex flex-col items-center gap-3 py-4">
<svg class="w-8 h-8 animate-spin text-primary" 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 text-[var(--text-secondary)]">{{ _('Caricamento in corso...') }}</p>
</div>
</template>
<!-- Upload prompt -->
<template x-if="!uploadingFile">
<div class="flex flex-col items-center gap-3 py-4">
<div class="w-12 h-12 rounded-full bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
<svg class="w-6 h-6 text-primary" 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>
</div>
<div>
<p class="text-sm font-medium text-[var(--text-primary)]">
<template x-if="!currentFilePath">
<span>{{ _('Trascina qui il disegno tecnico oppure clicca per selezionare') }}</span>
</template>
<template x-if="currentFilePath">
<span>{{ _('Trascina qui per sostituire il disegno tecnico') }}</span>
</template>
</p>
<p class="text-xs text-[var(--text-muted)] mt-1">
{{ _('Formati supportati: PNG, JPG, PDF') }}
</p>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- ============================================================
3. Card: Versioning (solo in modifica)
============================================================ -->
{% if not is_new %}
<div class="tmf-card mb-6">
<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>
{{ _('Versioning') }}
</div>
<div class="tmf-card-body space-y-4">
<!-- Current version info -->
<div class="flex items-center gap-3 flex-wrap">
<span class="text-sm text-[var(--text-secondary)]">
{{ _('Versione corrente:') }}
</span>
<span class="badge badge-neutral font-mono font-semibold"
x-text="'v' + currentVersion">
</span>
<template x-if="versions.length > 1">
<a href="{{ url_for('maker.version_history', recipe_id=recipe_id) }}"
class="text-xs text-primary hover:underline">
{{ _('Vedi cronologia') }} (<span x-text="versions.length"></span> {{ _('versioni') }})
</a>
</template>
</div>
<!-- Auto-version notice -->
<div class="flex items-start gap-2 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<svg class="w-5 h-5 text-blue-500 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-xs text-blue-700 dark:text-blue-300">
{{ _('Se modifichi questa ricetta verra creata automaticamente la versione') }}
<strong x-text="'v' + (currentVersion + 1)"></strong>.
{{ _('Le misure esistenti resteranno associate alla versione corrente.') }}
</p>
</div>
<!-- Change notes -->
<div>
<label class="tmf-label" for="change-notes">
{{ _('Motivo della modifica') }}
</label>
<input id="change-notes"
type="text"
x-model="changeNotes"
class="tmf-input"
placeholder="{{ _('Es. Aggiornate tolleranze foro centrale...') }}">
<p class="text-xs text-[var(--text-muted)] mt-1">
{{ _('Opzionale. Verra registrato nella cronologia versioni.') }}
</p>
</div>
</div>
</div>
{% endif %}
<!-- ============================================================
4. Footer Action Buttons
============================================================ -->
<div class="flex flex-col sm:flex-row items-center gap-3 pt-4 pb-8">
<button @click="saveRecipe()"
:disabled="saving || !name.trim() || (isNew && !code.trim())"
class="btn btn-primary gap-2 w-full sm:w-auto justify-center">
<svg x-show="!saving" 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 13l4 4L19 7"/>
</svg>
<svg x-show="saving" class="w-5 h-5 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>
<span x-text="saving ? '{{ _('Salvataggio...') }}' : '{{ _('Salva Ricetta') }}'"></span>
</button>
<a href="{{ url_for('maker.recipe_list') }}"
class="btn btn-secondary gap-2 w-full sm:w-auto justify-center">
<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="M6 18L18 6M6 6l12 12"/>
</svg>
{{ _('Annulla') }}
</a>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdnjs.cloudflare.com/ajax/libs/fabric.js/5.3.1/fabric.min.js"></script>
<script src="{{ url_for('static', filename='js/annotation-editor.js') }}"></script>
<script>
function recipeEditor() {
return {
// ---- Mode ----
isNew: {{ 'true' if is_new else 'false' }},
recipeId: {{ recipe_id|tojson if recipe_id else 'null' }},
// ---- Form data ----
code: {{ (recipe.code if recipe else '')|tojson }},
name: {{ (recipe.name if recipe else '')|tojson }},
description: {{ (recipe.description if recipe and recipe.description else '')|tojson }},
changeNotes: '',
// ---- File upload ----
currentFilePath: {{ (current_version.tasks[0].file_path if current_version and current_version.tasks and current_version.tasks|length > 0 else None)|tojson }},
uploadingFile: false,
dragOver: false,
// ---- Annotation state (managed by annotation-editor.js) ----
annotationsJson: {{ (current_version.tasks[0].annotations_json if current_version and current_version.tasks and current_version.tasks|length > 0 and current_version.tasks[0].annotations_json else None)|tojson }},
annoTool: 'select',
annoSelected: false,
// ---- Save state ----
saving: false,
saved: false,
errorMessage: '',
// ---- Versioning ----
versions: {{ versions|tojson }},
currentVersion: {{ (current_version.version_number if current_version else 0)|tojson }},
// ============================================================
// Init
// ============================================================
init() {
// Listen for annotation selection events from annotation-editor.js
window.addEventListener('annotation-selected', (e) => {
this.annoSelected = e.detail.selected;
});
// Listen for annotation data changes
window.addEventListener('annotations-changed', (e) => {
this.annotationsJson = e.detail.json;
});
},
// ============================================================
// Save Recipe
// ============================================================
async saveRecipe() {
this.saving = true;
this.errorMessage = '';
this.saved = false;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
const payload = {
code: this.code.trim(),
name: this.name.trim(),
description: this.description.trim(),
};
// Include change notes if editing
if (!this.isNew && this.changeNotes.trim()) {
payload.change_notes = this.changeNotes.trim();
}
// Include annotations if available
if (this.annotationsJson) {
payload.annotations_json = this.annotationsJson;
}
// Include file path if uploaded
if (this.currentFilePath) {
payload.file_path = this.currentFilePath;
}
const url = this.isNew
? '/maker/api/recipes'
: '/maker/api/recipes/' + this.recipeId;
const method = this.isNew ? 'POST' : 'PUT';
try {
const resp = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken || ''
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || data.error || '{{ _("Errore nel salvataggio") }}';
this.saving = false;
return;
}
if (this.isNew) {
// Redirect to task editor after creating a new recipe
window.location.href = '/maker/recipes/' + data.id + '/tasks';
return;
}
// Update local state on successful edit
this.saved = true;
this.changeNotes = '';
// Update version info if server returned it
if (data.current_version) {
this.currentVersion = data.current_version.version_number;
}
// Refresh versions list if available
if (data.versions) {
this.versions = data.versions;
}
} catch (err) {
console.error('Save error:', err);
this.errorMessage = '{{ _("Errore di connessione al server") }}';
}
this.saving = false;
},
// ============================================================
// File Upload
// ============================================================
async uploadFile(file) {
if (!file) return;
// Validate file type
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'application/pdf'];
if (!allowedTypes.includes(file.type)) {
this.errorMessage = '{{ _("Formato file non supportato. Usa PNG, JPG o PDF.") }}';
return;
}
// Validate file size (max 20MB)
const maxSize = 20 * 1024 * 1024;
if (file.size > maxSize) {
this.errorMessage = '{{ _("File troppo grande. Dimensione massima: 20MB.") }}';
return;
}
this.uploadingFile = true;
this.errorMessage = '';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
const formData = new FormData();
formData.append('file', file);
if (this.recipeId) {
formData.append('recipe_id', this.recipeId);
}
try {
const resp = await fetch('/maker/api/upload', {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken || ''
},
body: formData
});
const data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || '{{ _("Errore durante il caricamento del file") }}';
} else {
this.currentFilePath = data.file_path;
// Notify annotation-editor to load the new image
window.dispatchEvent(new CustomEvent('image-loaded', {
detail: { path: data.file_path, annotations: null }
}));
}
} catch (err) {
console.error('Upload error:', err);
this.errorMessage = '{{ _("Errore di connessione durante il caricamento") }}';
}
this.uploadingFile = false;
this.dragOver = false;
},
// ============================================================
// Drag & Drop
// ============================================================
handleDrop(event) {
this.dragOver = false;
const files = event.dataTransfer?.files;
if (files && files.length > 0) {
this.uploadFile(files[0]);
}
},
// ============================================================
// Annotation Tools (delegates to annotation-editor.js)
// ============================================================
setAnnoTool(tool) {
this.annoTool = tool;
window.dispatchEvent(new CustomEvent('anno-tool-change', {
detail: { tool }
}));
},
deleteAnnotation() {
window.dispatchEvent(new CustomEvent('anno-delete-selected'));
},
zoomCanvas(delta) {
window.dispatchEvent(new CustomEvent('anno-zoom', {
detail: { delta }
}));
},
resetZoom() {
window.dispatchEvent(new CustomEvent('anno-zoom-reset'));
},
// ============================================================
// Navigation
// ============================================================
goToPreview() {
if (this.recipeId) {
window.location.href = '/maker/recipes/' + this.recipeId + '/preview';
}
},
goToTasks() {
if (this.recipeId) {
window.location.href = '/maker/recipes/' + this.recipeId + '/tasks';
}
}
};
}
</script>
{% endblock %}
+385
View File
@@ -0,0 +1,385 @@
{% extends "base.html" %}
{% block title %}{{ _('Gestione Ricette') }} — TieMeasureFlow{% endblock %}
{% block content %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-6xl"
x-data="{
recipes: {{ recipes|tojson }},
search: '{{ search or '' }}',
filter: 'all',
deleteModal: false,
deleteTarget: null,
get filteredRecipes() {
let filtered = this.recipes;
// Apply search filter
if (this.search.trim()) {
const term = this.search.toLowerCase().trim();
filtered = filtered.filter(r =>
r.name.toLowerCase().includes(term) ||
r.code.toLowerCase().includes(term) ||
(r.description && r.description.toLowerCase().includes(term))
);
}
// Apply status filter
if (this.filter === 'active') {
filtered = filtered.filter(r => r.active);
} else if (this.filter === 'inactive') {
filtered = filtered.filter(r => !r.active);
}
return filtered;
},
async deleteRecipe(id) {
const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.content;
try {
const resp = await fetch(`/maker/api/recipes/${id}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': csrfToken || '' }
});
if (resp.ok) {
this.recipes = this.recipes.filter(r => r.id !== id);
this.deleteModal = false;
this.deleteTarget = null;
} else {
const data = await resp.json();
alert(data.error || '{{ _('Errore durante eliminazione') }}');
}
} catch (err) {
console.error(err);
alert('{{ _('Errore di connessione') }}');
}
},
openDeleteModal(recipe) {
this.deleteTarget = recipe;
this.deleteModal = true;
}
}">
<!-- Breadcrumb -->
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<li>
<a href="{{ url_for('maker.recipe_list') }}"
class="hover:text-primary transition-colors inline-flex items-center gap-1">
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
</svg>
{{ _('Dashboard') }}
</a>
</li>
<li>
<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="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="font-medium text-[var(--text-primary)]">
{{ _('Ricette') }}
</li>
</ol>
</nav>
<!-- Header Section -->
<div class="mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
<div>
<h1 class="text-3xl font-bold text-[var(--text-primary)] mb-1">
{{ _('Gestione Ricette') }}
</h1>
<p class="text-sm text-[var(--text-secondary)]">
{{ _('Crea e gestisci le ricette di misura') }}
</p>
</div>
<a href="{{ url_for('maker.recipe_new') }}"
class="btn btn-primary gap-2 shrink-0 w-full sm:w-auto justify-center">
<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="M12 4v16m8-8H4"/>
</svg>
{{ _('Nuova Ricetta') }}
</a>
</div>
</div>
<!-- Filters -->
<div class="tmf-card mb-6">
<div class="p-4">
<div class="flex flex-col sm:flex-row gap-3">
<!-- Search Input -->
<div class="flex-1">
<div class="relative">
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<input type="text"
x-model="search"
class="tmf-input pl-10"
:placeholder="'{{ _('Cerca per nome, codice o descrizione...') }}'">
</div>
</div>
<!-- Status Filter -->
<div class="sm:w-48">
<select x-model="filter" class="tmf-input">
<option value="all">{{ _('Tutti') }}</option>
<option value="active">{{ _('Attive') }}</option>
<option value="inactive">{{ _('Disattivate') }}</option>
</select>
</div>
</div>
</div>
</div>
<!-- Results Count -->
<div class="mb-5">
<p class="text-sm text-[var(--text-secondary)]">
<span x-text="filteredRecipes.length"></span>
<span x-text="filteredRecipes.length === 1 ? '{{ _('ricetta trovata') }}' : '{{ _('ricette trovate') }}'"></span>
</p>
</div>
<!-- Recipe Cards -->
<div class="space-y-4" x-show="filteredRecipes.length > 0">
<template x-for="recipe in filteredRecipes" :key="recipe.id">
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
<div class="p-5 sm:p-6">
<!-- Card Header -->
<div class="flex flex-col sm:flex-row sm:items-start gap-4 mb-4">
<!-- Left: Recipe Info -->
<div class="flex-1 min-w-0">
<!-- Badges Row -->
<div class="flex items-center gap-2 mb-3 flex-wrap">
<!-- Code Badge -->
<span class="inline-flex items-center px-3 py-1 rounded-md text-sm font-semibold
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
border border-primary-200 dark:border-primary-800 font-mono tracking-wide"
x-text="recipe.code">
</span>
<!-- Version Badge -->
<template x-if="recipe.current_version">
<span class="badge badge-neutral font-mono"
x-text="'v' + recipe.current_version.version_number">
</span>
</template>
<!-- Status Badge -->
<span class="badge"
:class="recipe.active ? 'badge-pass' : 'badge-fail'"
x-text="recipe.active ? '{{ _('Attiva') }}' : '{{ _('Disattivata') }}'">
</span>
</div>
<!-- Recipe Name -->
<h3 class="text-xl font-bold text-[var(--text-primary)] mb-2"
x-text="recipe.name">
</h3>
<!-- Description -->
<template x-if="recipe.description">
<p class="text-sm text-[var(--text-secondary)] leading-relaxed line-clamp-2 mb-3"
x-text="recipe.description">
</p>
</template>
<!-- Meta Information -->
<div class="flex items-center gap-4 flex-wrap text-xs text-[var(--text-secondary)]">
<!-- Task Count -->
<template x-if="recipe.current_version && recipe.current_version.task_count">
<span class="inline-flex items-center gap-1.5">
<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="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
<span x-text="recipe.current_version.task_count"></span>
<span>{{ _('task') }}</span>
</span>
</template>
<!-- Updated Date -->
<template x-if="recipe.current_version && recipe.current_version.created_at">
<span class="inline-flex items-center gap-1.5">
<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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span>{{ _('Aggiornata') }}</span>
<span class="font-medium" x-text="new Date(recipe.current_version.created_at).toLocaleDateString('{{ current_language or 'it' }}')"></span>
</span>
</template>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex items-center gap-2 flex-wrap pt-4 border-t border-[var(--border-color)]">
<!-- Modifica -->
<a :href="'/maker/recipes/' + recipe.id + '/edit'"
class="btn btn-secondary gap-1.5 text-xs">
<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="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>
{{ _('Modifica') }}
</a>
<!-- Task -->
<a :href="'/maker/recipes/' + recipe.id + '/tasks'"
class="btn btn-secondary gap-1.5 text-xs">
<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 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>
{{ _('Task') }}
</a>
<!-- Anteprima -->
<a :href="'/maker/recipes/' + recipe.id + '/preview'"
class="btn btn-secondary gap-1.5 text-xs">
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ _('Anteprima') }}
</a>
<!-- Versioni -->
<a :href="'/maker/recipes/' + recipe.id + '/versions'"
class="btn btn-secondary gap-1.5 text-xs">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Versioni') }}
</a>
<!-- Spacer -->
<div class="flex-1"></div>
<!-- Elimina -->
<button @click="openDeleteModal(recipe)"
class="btn btn-danger gap-1.5 text-xs">
<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>
{{ _('Elimina') }}
</button>
</div>
</div>
</div>
</template>
</div>
<!-- Empty State -->
<div x-show="filteredRecipes.length === 0"
x-cloak
class="text-center py-16">
<div class="inline-flex items-center justify-center w-20 h-20 rounded-full
bg-[var(--bg-secondary)] mb-5">
<svg class="w-10 h-10 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" 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>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
<span x-show="search.trim() || filter !== 'all'">
{{ _('Nessuna ricetta trovata') }}
</span>
<span x-show="!search.trim() && filter === 'all'">
{{ _('Nessuna ricetta disponibile') }}
</span>
</h3>
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto mb-6">
<span x-show="search.trim() || filter !== 'all'">
{{ _('Prova a modificare i filtri di ricerca') }}
</span>
<span x-show="!search.trim() && filter === 'all'">
{{ _('Inizia creando la tua prima ricetta di misura') }}
</span>
</p>
<a href="{{ url_for('maker.recipe_new') }}"
x-show="!search.trim() && filter === 'all'"
class="btn btn-primary gap-2 inline-flex">
<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="M12 4v16m8-8H4"/>
</svg>
{{ _('Crea Prima Ricetta') }}
</a>
</div>
<!-- Delete Confirmation Modal -->
<div x-show="deleteModal"
x-cloak
@keydown.escape.window="deleteModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true">
<!-- Backdrop -->
<div x-show="deleteModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
@click="deleteModal = false"
class="fixed inset-0 bg-black/50 backdrop-blur-sm transition-opacity">
</div>
<!-- Modal Content -->
<div class="flex min-h-full items-center justify-center p-4">
<div x-show="deleteModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
x-transition:enter-end="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100 translate-y-0 sm:scale-100"
x-transition:leave-end="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
class="tmf-card max-w-lg w-full relative">
<div class="p-6">
<!-- Icon -->
<div class="mx-auto flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
<svg class="w-6 h-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<!-- Title -->
<h3 class="text-lg font-semibold text-[var(--text-primary)] text-center mb-2">
{{ _('Conferma Eliminazione') }}
</h3>
<!-- Message -->
<template x-if="deleteTarget">
<div>
<p class="text-sm text-[var(--text-secondary)] text-center mb-4">
{{ _('Sei sicuro di voler disattivare la ricetta') }}
<span class="font-semibold text-[var(--text-primary)]" x-text="deleteTarget.name"></span>?
</p>
<p class="text-xs text-[var(--text-muted)] text-center mb-6">
{{ _('Le misure esistenti non verranno eliminate') }}
</p>
</div>
</template>
<!-- Actions -->
<div class="flex flex-col sm:flex-row gap-3">
<button @click="deleteModal = false"
class="btn btn-secondary flex-1 justify-center">
{{ _('Annulla') }}
</button>
<button @click="deleteRecipe(deleteTarget.id)"
class="btn btn-danger flex-1 justify-center">
{{ _('Elimina') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+445
View File
@@ -0,0 +1,445 @@
{% extends "base.html" %}
{% block title %}{{ recipe.code }} — {{ _('Anteprima Ricetta') }} — TieMeasureFlow{% endblock %}
{% block extra_head %}
<style>
/* Annotation viewer container */
.annotation-container {
position: relative;
width: 100%;
min-height: 300px;
background: var(--bg-secondary);
border-radius: 0.5rem;
overflow: hidden;
}
.annotation-container canvas {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}
/* Tolerance bar gradient */
.tolerance-bar-bg {
background: linear-gradient(to right,
rgba(220, 38, 38, 0.15) 0%,
rgba(217, 119, 6, 0.15) 15%,
rgba(5, 150, 105, 0.15) 30%,
rgba(5, 150, 105, 0.15) 70%,
rgba(217, 119, 6, 0.15) 85%,
rgba(220, 38, 38, 0.15) 100%
);
}
</style>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl"
x-data="recipePreview()"
x-cloak>
{# ================================================================
BREADCRUMB
================================================================ #}
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<li>
<a href="{{ url_for('maker.recipe_list') }}"
class="hover:text-primary transition-colors inline-flex items-center gap-1">
<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 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>
{{ _('Ricette') }}
</a>
</li>
<li>
<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="M9 5l7 7-7 7"/>
</svg>
</li>
<li>
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
class="hover:text-primary transition-colors">
{{ recipe.code }}
</a>
</li>
<li>
<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="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="font-medium text-[var(--text-primary)]">
{{ _('Anteprima') }}
</li>
</ol>
</nav>
{# ================================================================
HEADER
================================================================ #}
<div class="mb-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
<div class="flex items-center gap-3 flex-wrap">
{# Preview badge #}
<span class="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider
bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300
border border-amber-300 dark:border-amber-700">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ _('Anteprima') }}
</span>
<div>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">
<span class="font-mono text-primary">{{ recipe.code }}</span>
<span class="text-[var(--text-muted)] text-lg font-normal ml-1">
&mdash; {{ recipe.name }}
</span>
</h1>
{% if recipe.current_version %}
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
{{ _('Versione') }} <span class="font-mono font-semibold">v{{ recipe.current_version.version_number }}</span>
</p>
{% endif %}
</div>
</div>
{# Action buttons #}
<div class="flex items-center gap-2 shrink-0 flex-wrap">
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
class="btn btn-secondary gap-1.5 text-sm">
<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>
{{ _('Torna a Ricetta') }}
</a>
<a href="{{ url_for('maker.task_editor', recipe_id=recipe.id) }}"
class="btn btn-secondary gap-1.5 text-sm">
<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="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>
{{ _('Modifica Task') }}
</a>
</div>
</div>
</div>
{# ================================================================
PREVIEW BANNER
================================================================ #}
<div class="mb-6 flex items-start gap-3 px-4 py-3 rounded-lg
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800">
<svg class="w-5 h-5 text-amber-500 shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div>
<p class="text-sm font-medium text-amber-800 dark:text-amber-200">
{{ _('Modalita Anteprima') }}
</p>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-0.5">
{{ _('Stai vedendo la ricetta come la vedra il Tecnico di Misura. Tutti i campi sono in sola lettura.') }}
</p>
</div>
</div>
{# ================================================================
RECIPE DESCRIPTION (if present)
================================================================ #}
{% if recipe.description %}
<div class="tmf-card mb-6">
<div class="tmf-card-body">
<p class="text-sm text-[var(--text-secondary)] leading-relaxed">{{ recipe.description }}</p>
</div>
</div>
{% endif %}
{# ================================================================
TASKS LOOP
================================================================ #}
{% if recipe.tasks %}
{% for task in recipe.tasks %}
<div class="tmf-card mb-6">
{# ---- Task Header ---- #}
<div class="tmf-card-header flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-lg
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
border border-primary-200 dark:border-primary-800
text-sm font-bold">
{{ loop.index }}
</span>
<div>
<h2 class="font-semibold text-[var(--text-primary)]">{{ task.title }}</h2>
{% if task.directive or task.description %}
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
{{ task.directive or task.description }}
</p>
{% endif %}
</div>
</div>
{% if task.subtasks %}
<span class="badge badge-neutral text-xs font-mono">
{{ task.subtasks|length }} {{ _('misure') }}
</span>
{% endif %}
</div>
<div class="tmf-card-body space-y-5">
{# ---- Technical Drawing / Annotations ---- #}
{% if task.file_path and task.file_type == 'image' %}
<div>
<div class="flex items-center gap-2 mb-2 text-xs font-medium 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="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>
{{ _('Disegno Tecnico') }}
</div>
<div class="annotation-container"
x-data="annotationViewer()"
x-init="
imageUrl = '/api/files/{{ task.file_path }}';
annotations = {{ task.annotations_json|default('null')|tojson }};
$nextTick(() => init());
">
<canvas x-ref="annotationCanvas"></canvas>
</div>
</div>
{% elif task.file_path and task.file_type == 'pdf' %}
<div class="flex items-center gap-3 p-4 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<svg class="w-10 h-10 text-red-400 shrink-0" fill="none" stroke="currentColor" stroke-width="1" 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>
<div class="flex-1">
<p class="text-sm font-medium text-[var(--text-primary)]">{{ _('Documento PDF allegato') }}</p>
<p class="text-xs text-[var(--text-muted)]">{{ task.file_path }}</p>
</div>
<a href="/api/files/{{ task.file_path }}"
target="_blank"
class="btn btn-secondary text-xs gap-1.5">
<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 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
{{ _('Apri PDF') }}
</a>
</div>
{% else %}
<div class="flex items-center justify-center p-6 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<div class="text-center">
<svg class="w-10 h-10 mx-auto text-[var(--text-muted)] mb-2" fill="none" stroke="currentColor" stroke-width="1" 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>
<p class="text-sm text-[var(--text-muted)]">{{ _('Nessuna immagine allegata') }}</p>
</div>
</div>
{% endif %}
{# ---- Subtask List ---- #}
{% if task.subtasks %}
<div>
<div class="flex items-center gap-2 mb-3 text-xs font-medium 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 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 2"/>
</svg>
{{ _('Punti di Misura') }}
</div>
<div class="space-y-3">
{% for subtask in task.subtasks %}
<div class="p-4 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
hover:border-primary/30 transition-colors">
{# Subtask header: marker badge + description #}
<div class="flex items-start gap-3 mb-3">
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full shrink-0
bg-primary text-white font-bold text-sm shadow-sm">
{{ subtask.marker_number }}
</span>
<div class="flex-1 min-w-0">
<p class="font-medium text-sm text-[var(--text-primary)]">{{ subtask.description }}</p>
<div class="flex items-center gap-2 mt-0.5 text-xs text-[var(--text-muted)]">
{% if subtask.measurement_type %}
<span>{{ subtask.measurement_type }}</span>
<span>&middot;</span>
{% endif %}
<span>{{ subtask.unit or 'mm' }}</span>
</div>
</div>
</div>
{# Tolerance parameters grid #}
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
{# Nominal #}
<div class="col-span-2 sm:col-span-1 flex items-center justify-between sm:flex-col sm:items-center
py-2 px-3 rounded-lg bg-primary-50 dark:bg-primary-900/20
border border-primary-100 dark:border-primary-800">
<span class="text-[10px] font-medium text-primary-600 dark:text-primary-400 uppercase tracking-wider">
{{ _('Nominale') }}
</span>
<span class="measure-value text-sm font-bold text-primary">
{% if subtask.nominal is not none %}{{ "%.3f"|format(subtask.nominal) }}{% else %}&mdash;{% endif %}
</span>
</div>
{# LTL #}
<div class="flex items-center justify-between sm:flex-col sm:items-center
py-2 px-3 rounded-lg bg-red-50/50 dark:bg-red-900/10">
<span class="text-[10px] font-medium text-measure-fail uppercase tracking-wider">LTL</span>
<span class="measure-value text-xs font-semibold text-measure-fail">
{% if subtask.ltl is not none %}{{ "%.3f"|format(subtask.ltl) }}{% else %}&mdash;{% endif %}
</span>
</div>
{# LWL #}
<div class="flex items-center justify-between sm:flex-col sm:items-center
py-2 px-3 rounded-lg bg-amber-50/50 dark:bg-amber-900/10">
<span class="text-[10px] font-medium text-measure-warning uppercase tracking-wider">LWL</span>
<span class="measure-value text-xs font-semibold text-measure-warning">
{% if subtask.lwl is not none %}{{ "%.3f"|format(subtask.lwl) }}{% else %}&mdash;{% endif %}
</span>
</div>
{# UWL #}
<div class="flex items-center justify-between sm:flex-col sm:items-center
py-2 px-3 rounded-lg bg-amber-50/50 dark:bg-amber-900/10">
<span class="text-[10px] font-medium text-measure-warning uppercase tracking-wider">UWL</span>
<span class="measure-value text-xs font-semibold text-measure-warning">
{% if subtask.uwl is not none %}{{ "%.3f"|format(subtask.uwl) }}{% else %}&mdash;{% endif %}
</span>
</div>
{# UTL #}
<div class="flex items-center justify-between sm:flex-col sm:items-center
py-2 px-3 rounded-lg bg-red-50/50 dark:bg-red-900/10">
<span class="text-[10px] font-medium text-measure-fail uppercase tracking-wider">UTL</span>
<span class="measure-value text-xs font-semibold text-measure-fail">
{% if subtask.utl is not none %}{{ "%.3f"|format(subtask.utl) }}{% else %}&mdash;{% endif %}
</span>
</div>
</div>
{# Visual tolerance bar #}
{% if subtask.ltl is not none and subtask.utl is not none %}
<div class="mt-2">
<div class="tolerance-bar-bg h-2 rounded-full relative overflow-hidden border border-[var(--border-color)]">
{# Nominal center line #}
<div class="absolute top-0 bottom-0 w-px bg-primary/40" style="left: 50%;"></div>
</div>
<div class="flex justify-between mt-0.5 text-[9px] font-mono text-[var(--text-muted)]">
<span>{{ "%.2f"|format(subtask.ltl) }}</span>
<span>{{ "%.2f"|format(subtask.nominal or 0) }}</span>
<span>{{ "%.2f"|format(subtask.utl) }}</span>
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="text-center py-4">
<p class="text-sm text-[var(--text-muted)]">{{ _('Nessun punto di misura definito per questo task') }}</p>
</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
{# ---- Empty state: no tasks ---- #}
<div class="tmf-card">
<div class="p-12 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
bg-[var(--bg-secondary)] mb-4">
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" 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 2"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
{{ _('Nessun task definito') }}
</h3>
<p class="text-sm text-[var(--text-secondary)] mb-4">
{{ _('Questa ricetta non contiene ancora task di misurazione.') }}
</p>
<a href="{{ url_for('maker.task_editor', recipe_id=recipe.id) }}"
class="btn btn-primary gap-2 inline-flex">
<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="M12 4v16m8-8H4"/>
</svg>
{{ _('Aggiungi Task') }}
</a>
</div>
</div>
{% endif %}
{# ================================================================
FOOTER NAVIGATION
================================================================ #}
<div class="flex flex-col sm:flex-row items-center justify-between gap-3 pt-6 pb-8">
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
class="btn btn-secondary gap-2 w-full sm:w-auto justify-center">
<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>
{{ _('Torna a Ricetta') }}
</a>
<div class="flex items-center gap-2 w-full sm:w-auto">
<a href="{{ url_for('maker.task_editor', recipe_id=recipe.id) }}"
class="btn btn-primary gap-2 flex-1 sm:flex-initial justify-center">
<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="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>
{{ _('Modifica Task') }}
</a>
<a href="{{ url_for('maker.version_history', recipe_id=recipe.id) }}"
class="btn btn-secondary gap-2 flex-1 sm:flex-initial justify-center">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
{{ _('Storico Versioni') }}
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}"></script>
<script>
/**
* Recipe Preview - Minimal Alpine.js component
* Read-only preview mode, no editing capabilities.
*/
function recipePreview() {
return {
recipe: {{ recipe|tojson }},
tasks: {{ recipe.tasks|tojson if recipe.tasks else '[]' }},
init() {
// Nothing to do - this is a read-only preview.
// annotation-viewer.js components are initialized inline via x-init.
}
};
}
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+409
View File
@@ -0,0 +1,409 @@
{% extends "base.html" %}
{% block title %}{{ recipe.code }} — {{ _('Storico Versioni') }} — TieMeasureFlow{% endblock %}
{% block extra_head %}
<style>
/* Timeline vertical connector */
.timeline-connector {
position: absolute;
left: 1.25rem;
top: 2.5rem;
bottom: 0;
width: 2px;
background: var(--border-color);
}
.timeline-connector::before {
content: '';
position: absolute;
top: -4px;
left: -3px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--border-color);
}
/* Timeline dot */
.timeline-dot {
position: absolute;
left: 0.5rem;
top: 0.875rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
font-weight: 700;
z-index: 1;
border: 2px solid var(--bg-primary);
box-shadow: 0 0 0 2px var(--border-color);
}
.timeline-dot.current {
background: var(--color-primary);
color: #fff;
box-shadow: 0 0 0 2px var(--color-primary), 0 0 8px rgba(37, 99, 235, 0.3);
}
.timeline-dot.past {
background: var(--bg-secondary);
color: var(--text-muted);
}
</style>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl"
x-data="versionHistory()"
x-init="loadMeasurementCounts()"
x-cloak>
{# ================================================================
BREADCRUMB
================================================================ #}
<nav class="mb-6" aria-label="Breadcrumb">
<ol class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<li>
<a href="{{ url_for('maker.recipe_list') }}"
class="hover:text-primary transition-colors inline-flex items-center gap-1">
<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 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>
{{ _('Ricette') }}
</a>
</li>
<li>
<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="M9 5l7 7-7 7"/>
</svg>
</li>
<li>
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
class="hover:text-primary transition-colors">
{{ recipe.code }}
</a>
</li>
<li>
<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="M9 5l7 7-7 7"/>
</svg>
</li>
<li class="font-medium text-[var(--text-primary)]">
{{ _('Storico Versioni') }}
</li>
</ol>
</nav>
{# ================================================================
HEADER
================================================================ #}
<div class="mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-3">
<div>
<h1 class="text-3xl font-bold text-[var(--text-primary)] mb-1">
<span class="font-mono text-primary">{{ recipe.code }}</span>
<span class="text-[var(--text-muted)] text-lg font-normal ml-2">
&mdash; {{ recipe.name }}
</span>
</h1>
<div class="flex items-center gap-3 mt-1.5">
<p class="text-sm text-[var(--text-secondary)]">
{{ _('Storico Versioni') }}
</p>
{% if recipe.current_version %}
<span class="badge badge-pass font-mono text-xs">
{{ _('Corrente:') }} v{{ recipe.current_version.version_number }}
</span>
{% endif %}
</div>
</div>
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
class="btn btn-secondary gap-1.5 shrink-0">
<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>
{{ _('Torna a Ricetta') }}
</a>
</div>
</div>
{# ================================================================
VERSION TIMELINE
================================================================ #}
<template x-if="versions.length > 0">
<div class="relative pl-12">
{# Vertical connector line #}
<div class="absolute left-5 top-6 bottom-6 w-0.5 bg-[var(--border-color)]"></div>
<template x-for="(version, idx) in sortedVersions" :key="version.id">
<div class="relative mb-6 last:mb-0">
{# Timeline dot #}
<div class="absolute -left-7 top-4 w-6 h-6 rounded-full flex items-center justify-center
text-[10px] font-bold z-10 border-2"
:class="version.version_number === currentVersionNumber
? 'bg-primary text-white border-primary shadow-md shadow-primary/30'
: 'bg-[var(--bg-card)] text-[var(--text-muted)] border-[var(--border-color)]'"
x-text="'v' + version.version_number">
</div>
{# Version card #}
<div class="tmf-card transition-all duration-200"
:class="version.version_number === currentVersionNumber ? 'border-primary/40 shadow-md' : ''">
<div class="p-5">
{# Card header row #}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-3">
<div class="flex items-center gap-2.5 flex-wrap">
{# Version badge #}
<span class="inline-flex items-center px-2.5 py-1 rounded-md text-sm font-bold font-mono"
:class="version.version_number === currentVersionNumber
? 'bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-800'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border-color)]'"
x-text="'v' + version.version_number">
</span>
{# Current badge #}
<template x-if="version.version_number === currentVersionNumber">
<span class="badge badge-pass text-xs">
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
{{ _('Corrente') }}
</span>
</template>
{# Measurement count badge #}
<template x-if="measurementCounts[version.id] !== undefined">
<span class="badge text-xs"
:class="measurementCounts[version.id] > 0 ? 'badge-pass' : 'badge-neutral'">
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" 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 x-text="measurementCounts[version.id]"></span>
{{ _('misurazioni') }}
</span>
</template>
<template x-if="measurementCounts[version.id] === undefined && loadingCounts">
<span class="badge badge-neutral text-xs">
<svg class="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
{{ _('Caricamento...') }}
</span>
</template>
</div>
{# View button #}
<a :href="'/maker/recipes/' + recipeId + '/edit?version=' + version.id"
class="btn btn-secondary text-xs gap-1.5 shrink-0">
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ _('Visualizza') }}
</a>
</div>
{# Version details #}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{# Created at #}
<div class="flex items-center gap-2 text-[var(--text-secondary)]">
<svg class="w-4 h-4 text-[var(--text-muted)] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
<span x-text="formatDate(version.created_at)"></span>
</div>
{# Created by #}
<template x-if="version.created_by_user">
<div class="flex items-center gap-2 text-[var(--text-secondary)]">
<svg class="w-4 h-4 text-[var(--text-muted)] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
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>
<span x-text="version.created_by_user.display_name || version.created_by_user.username"></span>
</div>
</template>
{# Task count (if available) #}
<template x-if="version.tasks && version.tasks.length > 0">
<div class="flex items-center gap-2 text-[var(--text-secondary)]">
<svg class="w-4 h-4 text-[var(--text-muted)] shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
</svg>
<span x-text="version.tasks.length + ' task'"></span>
</div>
</template>
</div>
{# Change notes #}
<template x-if="version.change_notes">
<div class="mt-3 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<div class="flex items-start gap-2">
<svg class="w-4 h-4 text-[var(--text-muted)] shrink-0 mt-0.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"/>
</svg>
<p class="text-sm text-[var(--text-secondary)] leading-relaxed" x-text="version.change_notes"></p>
</div>
</div>
</template>
</div>
</div>
</div>
</template>
</div>
</template>
{# ================================================================
EMPTY STATE
================================================================ #}
<template x-if="versions.length === 0">
<div class="tmf-card">
<div class="p-12 text-center">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
bg-[var(--bg-secondary)] mb-4">
<svg class="w-8 h-8 text-[var(--text-muted)]" fill="none" stroke="currentColor" stroke-width="1.5" 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)] mb-2">
{{ _('Nessuna versione trovata') }}
</h3>
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto">
{{ _('Questa ricetta non ha ancora versioni registrate. La prima versione verra creata automaticamente al salvataggio.') }}
</p>
</div>
</div>
</template>
{# ================================================================
FOOTER
================================================================ #}
<div class="flex items-center justify-between gap-3 pt-6 pb-8">
<a href="{{ url_for('maker.recipe_edit', recipe_id=recipe.id) }}"
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>
{{ _('Torna a Ricetta') }}
</a>
<a href="{{ url_for('maker.recipe_preview', recipe_id=recipe.id) }}"
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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
{{ _('Anteprima') }}
</a>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
/**
* Version History - Alpine.js component
* Loads and displays recipe version timeline with AJAX measurement counts.
*/
function versionHistory() {
return {
recipeId: {{ recipe.id|tojson }},
currentVersionNumber: {{ (recipe.current_version.version_number if recipe.current_version else 0)|tojson }},
versions: {{ versions|tojson }},
measurementCounts: {},
loadingCounts: true,
/**
* Sorted versions: most recent first.
*/
get sortedVersions() {
return [...this.versions].sort((a, b) => b.version_number - a.version_number);
},
/**
* Load measurement counts for all versions via AJAX.
* Calls the proxy API endpoint for each version.
*/
async loadMeasurementCounts() {
this.loadingCounts = true;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const promises = this.versions.map(async (version) => {
try {
const resp = await fetch(
'/maker/api/recipes/' + this.recipeId + '/versions/' + version.version_number + '/measurement-count',
{
method: 'GET',
headers: {
'X-CSRFToken': csrfToken
}
}
);
if (resp.ok) {
const data = await resp.json();
this.measurementCounts[version.id] = data.measurement_count || 0;
} else {
this.measurementCounts[version.id] = 0;
}
} catch (err) {
console.error('Error loading measurement count for version', version.version_number, err);
this.measurementCounts[version.id] = 0;
}
});
await Promise.all(promises);
this.loadingCounts = false;
},
/**
* Format ISO date string to a localized display format.
* Example output: "15 Gen 2026, 14:30"
* @param {string} isoString - ISO 8601 date string
* @returns {string} Formatted date
*/
formatDate(isoString) {
if (!isoString) return '—';
try {
const date = new Date(isoString);
if (isNaN(date.getTime())) return isoString;
const months = [
'Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu',
'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'
];
const day = date.getDate();
const month = months[date.getMonth()];
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return day + ' ' + month + ' ' + year + ', ' + hours + ':' + minutes;
} catch (e) {
return isoString;
}
}
};
}
</script>
{% endblock %}
@@ -502,3 +502,450 @@ msgstr "Browser does not support Web Serial API"
# Recipe Selection Additional
msgid "Errore di connessione"
msgstr "Connection Error"
# Maker - Recipe List
msgid "Gestione Ricette"
msgstr "Recipe Management"
msgid "Crea e gestisci le ricette di misura"
msgstr "Create and manage measurement recipes"
msgid "Nuova Ricetta"
msgstr "New Recipe"
msgid "Cerca per nome, codice o descrizione..."
msgstr "Search by name, code or description..."
msgid "Tutti"
msgstr "All"
msgid "Attive"
msgstr "Active"
msgid "Disattivate"
msgstr "Inactive"
msgid "ricetta trovata"
msgstr "recipe found"
msgid "Attiva"
msgstr "Active"
msgid "Disattivata"
msgstr "Inactive"
msgid "task"
msgstr "task"
msgid "Aggiornata"
msgstr "Updated"
msgid "Versioni"
msgstr "Versions"
msgid "Nessuna ricetta disponibile"
msgstr "No recipes available"
msgid "Prova a modificare i filtri di ricerca"
msgstr "Try modifying search filters"
msgid "Inizia creando la tua prima ricetta di misura"
msgstr "Start by creating your first measurement recipe"
msgid "Crea Prima Ricetta"
msgstr "Create First Recipe"
msgid "Conferma Eliminazione"
msgstr "Confirm Deletion"
msgid "Sei sicuro di voler disattivare la ricetta"
msgstr "Are you sure you want to deactivate the recipe"
msgid "Le misure esistenti non verranno eliminate"
msgstr "Existing measurements will not be deleted"
msgid "Errore durante eliminazione"
msgstr "Error during deletion"
msgid "Dashboard"
msgstr "Dashboard"
# Maker - Recipe Editor
msgid "Modifica Ricetta"
msgstr "Edit Recipe"
msgid "Compila i dati della ricetta e carica il disegno tecnico"
msgstr "Fill in recipe data and upload the technical drawing"
msgid "Ricetta salvata con successo"
msgstr "Recipe saved successfully"
msgid "Metadati Ricetta"
msgstr "Recipe Metadata"
msgid "Es. COUPLING-256"
msgstr "E.g. COUPLING-256"
msgid "Es. Coupling Assembly 256"
msgstr "E.g. Coupling Assembly 256"
msgid "Descrizione opzionale della ricetta..."
msgstr "Optional recipe description..."
msgid "Il codice non puo essere modificato dopo la creazione"
msgstr "Code cannot be changed after creation"
msgid "Disegno Tecnico e Annotazioni"
msgstr "Technical Drawing and Annotations"
msgid "Immagine caricata"
msgstr "Image uploaded"
msgid "Marker numerato"
msgstr "Numbered marker"
msgid "Marker"
msgstr "Marker"
msgid "Freccia"
msgstr "Arrow"
msgid "Rettangolo"
msgstr "Rectangle"
msgid "Elimina selezionato"
msgstr "Delete selected"
msgid "Zoom"
msgstr "Zoom"
msgid "Zoom indietro"
msgstr "Zoom out"
msgid "Zoom avanti"
msgstr "Zoom in"
msgid "Reset zoom"
msgstr "Reset zoom"
msgid "Trascina"
msgstr "Drag"
msgid "Pan"
msgstr "Pan"
msgid "Caricamento in corso..."
msgstr "Upload in progress..."
msgid "Trascina qui il disegno tecnico oppure clicca per selezionare"
msgstr "Drag the technical drawing here or click to select"
msgid "Trascina qui per sostituire il disegno tecnico"
msgstr "Drag here to replace the technical drawing"
msgid "Formati supportati: PNG, JPG, PDF"
msgstr "Supported formats: PNG, JPG, PDF"
msgid "Versioning"
msgstr "Versioning"
msgid "Versione corrente:"
msgstr "Current version:"
msgid "Vedi cronologia"
msgstr "View history"
msgid "versioni"
msgstr "versions"
msgid "Se modifichi questa ricetta verra creata automaticamente la versione"
msgstr "If you modify this recipe, version will be created automatically"
msgid "Le misure esistenti resteranno associate alla versione corrente."
msgstr "Existing measurements will remain associated with the current version."
msgid "Motivo della modifica"
msgstr "Reason for modification"
msgid "Es. Aggiornate tolleranze foro centrale..."
msgstr "E.g. Updated center hole tolerances..."
msgid "Opzionale. Verra registrato nella cronologia versioni."
msgstr "Optional. Will be recorded in version history."
msgid "Salva Ricetta"
msgstr "Save Recipe"
msgid "Formato file non supportato. Usa PNG, JPG o PDF."
msgstr "Unsupported file format. Use PNG, JPG or PDF."
msgid "File troppo grande. Dimensione massima: 20MB."
msgstr "File too large. Maximum size: 20MB."
msgid "Errore durante il caricamento del file"
msgstr "Error uploading file"
msgid "Errore di connessione durante il caricamento"
msgstr "Connection error during upload"
msgid "Nessun file caricato"
msgstr "No file uploaded"
msgid "Nome file vuoto"
msgstr "Empty file name"
# Maker - Task Editor
msgid "Editor Task"
msgstr "Task Editor"
msgid "Task e Misurazioni"
msgstr "Tasks and Measurements"
msgid "Torna a Ricetta"
msgstr "Back to Recipe"
msgid "Aggiungi Task"
msgstr "Add Task"
msgid "Nuovo Task"
msgstr "New Task"
msgid "Titolo"
msgstr "Title"
msgid "Direttiva"
msgstr "Directive"
msgid "Es. Controllo dimensionale flangia"
msgstr "E.g. Flange dimensional check"
msgid "Es. Seguire procedura ISO 2768"
msgstr "E.g. Follow ISO 2768 procedure"
msgid "Descrizione opzionale..."
msgstr "Optional description..."
msgid "Crea Task"
msgstr "Create Task"
msgid "Trascina per riordinare"
msgstr "Drag to reorder"
msgid "Modifica task"
msgstr "Edit task"
msgid "Espandi/Comprimi"
msgstr "Expand/Collapse"
msgid "Elimina task"
msgstr "Delete task"
msgid "UTL"
msgstr "UTL"
msgid "UWL"
msgstr "UWL"
msgid "LWL"
msgstr "LWL"
msgid "LTL"
msgstr "LTL"
msgid "Unita"
msgstr "Unit"
msgid "Tolleranze"
msgstr "Tolerances"
msgid "Azioni"
msgstr "Actions"
msgid "#"
msgstr "#"
msgid "Tipo"
msgstr "Type"
msgid "Lineare"
msgstr "Linear"
msgid "Diametro"
msgstr "Diameter"
msgid "Raggio"
msgstr "Radius"
msgid "Angolo"
msgstr "Angle"
msgid "Rugosita"
msgstr "Roughness"
msgid "Coppia"
msgstr "Torque"
msgid "Forza"
msgstr "Force"
msgid "Peso"
msgstr "Weight"
msgid "Altro"
msgstr "Other"
msgid "Nessuna misurazione definita"
msgstr "No measurements defined"
msgid "Aggiungi la prima misurazione per questo task"
msgstr "Add the first measurement for this task"
msgid "Nuova Misurazione"
msgstr "New Measurement"
msgid "Marker #"
msgstr "Marker #"
msgid "Es. Diametro foro principale"
msgstr "E.g. Main hole diameter"
msgid "Lim. Tol. Inf."
msgstr "Lower Tol. Lim."
msgid "Lim. Warn. Inf."
msgstr "Lower Warn. Lim."
msgid "Lim. Warn. Sup."
msgstr "Upper Warn. Lim."
msgid "Lim. Tol. Sup."
msgstr "Upper Tol. Lim."
msgid "Aggiungi Misurazione"
msgstr "Add Measurement"
msgid "Nessun task definito"
msgstr "No tasks defined"
msgid "Inizia aggiungendo il primo task di misurazione per questa ricetta"
msgstr "Start by adding the first measurement task for this recipe"
msgid "Aggiungi Primo Task"
msgstr "Add First Task"
msgid "Conferma Eliminazione Task"
msgstr "Confirm Task Deletion"
msgid "Sei sicuro di voler eliminare il task"
msgstr "Are you sure you want to delete the task"
msgid "Verranno eliminate anche"
msgstr "The following will also be deleted:"
msgid "misurazioni associate."
msgstr "associated measurements."
msgid "Elimina Task"
msgstr "Delete Task"
msgid "Conferma Eliminazione Misurazione"
msgstr "Confirm Measurement Deletion"
msgid "Sei sicuro di voler eliminare la misurazione"
msgstr "Are you sure you want to delete the measurement"
msgid "Elimina Misurazione"
msgstr "Delete Measurement"
msgid "Errore nella creazione del task"
msgstr "Error creating task"
msgid "Task creato con successo"
msgstr "Task created successfully"
msgid "Errore nel salvataggio del task"
msgstr "Error saving task"
msgid "Task aggiornato"
msgstr "Task updated"
msgid "Task eliminato"
msgstr "Task deleted"
msgid "Errore nell'eliminazione del task"
msgstr "Error deleting task"
msgid "Errore nel riordinamento"
msgstr "Error reordering"
msgid "Errore nella creazione della misurazione"
msgstr "Error creating measurement"
msgid "Misurazione aggiunta"
msgstr "Measurement added"
msgid "Misurazione aggiornata"
msgstr "Measurement updated"
msgid "Misurazione eliminata"
msgstr "Measurement deleted"
msgid "Errore nell'eliminazione della misurazione"
msgstr "Error deleting measurement"
msgid "Direttiva opzionale..."
msgstr "Optional directive..."
# Maker - Recipe Preview
msgid "Anteprima Ricetta"
msgstr "Recipe Preview"
msgid "Modalita Anteprima"
msgstr "Preview Mode"
msgid "Stai vedendo la ricetta come la vedra il Tecnico di Misura. Tutti i campi sono in sola lettura."
msgstr "You are viewing the recipe as the Measurement Technician would see it. All fields are read-only."
msgid "misure"
msgstr "measurements"
msgid "Documento PDF allegato"
msgstr "Attached PDF document"
msgid "Punti di Misura"
msgstr "Measurement Points"
msgid "Nessun punto di misura definito per questo task"
msgstr "No measurement points defined for this task"
msgid "Questa ricetta non contiene ancora task di misurazione."
msgstr "This recipe does not contain any measurement tasks yet."
msgid "Modifica Task"
msgstr "Edit Tasks"
# Maker - Version History
msgid "Storico Versioni"
msgstr "Version History"
msgid "Corrente:"
msgstr "Current:"
msgid "Corrente"
msgstr "Current"
msgid "Visualizza"
msgstr "View"
msgid "Nessuna versione trovata"
msgstr "No versions found"
msgid "Questa ricetta non ha ancora versioni registrate. La prima versione verra creata automaticamente al salvataggio."
msgstr "This recipe does not have any registered versions yet. The first version will be created automatically when saved."
# Maker - API Errors
msgid "Errore nel caricamento della ricetta: %(error)s"
msgstr "Error loading recipe: %(error)s"
msgid "Errore nel caricamento delle versioni: %(error)s"
msgstr "Error loading versions: %(error)s"
@@ -502,3 +502,450 @@ msgstr "Browser non supporta Web Serial API"
# Recipe Selection Additional
msgid "Errore di connessione"
msgstr "Errore di connessione"
# Maker - Recipe List
msgid "Gestione Ricette"
msgstr "Gestione Ricette"
msgid "Crea e gestisci le ricette di misura"
msgstr "Crea e gestisci le ricette di misura"
msgid "Nuova Ricetta"
msgstr "Nuova Ricetta"
msgid "Cerca per nome, codice o descrizione..."
msgstr "Cerca per nome, codice o descrizione..."
msgid "Tutti"
msgstr "Tutti"
msgid "Attive"
msgstr "Attive"
msgid "Disattivate"
msgstr "Disattivate"
msgid "ricetta trovata"
msgstr "ricetta trovata"
msgid "Attiva"
msgstr "Attiva"
msgid "Disattivata"
msgstr "Disattivata"
msgid "task"
msgstr "task"
msgid "Aggiornata"
msgstr "Aggiornata"
msgid "Versioni"
msgstr "Versioni"
msgid "Nessuna ricetta disponibile"
msgstr "Nessuna ricetta disponibile"
msgid "Prova a modificare i filtri di ricerca"
msgstr "Prova a modificare i filtri di ricerca"
msgid "Inizia creando la tua prima ricetta di misura"
msgstr "Inizia creando la tua prima ricetta di misura"
msgid "Crea Prima Ricetta"
msgstr "Crea Prima Ricetta"
msgid "Conferma Eliminazione"
msgstr "Conferma Eliminazione"
msgid "Sei sicuro di voler disattivare la ricetta"
msgstr "Sei sicuro di voler disattivare la ricetta"
msgid "Le misure esistenti non verranno eliminate"
msgstr "Le misure esistenti non verranno eliminate"
msgid "Errore durante eliminazione"
msgstr "Errore durante eliminazione"
msgid "Dashboard"
msgstr "Dashboard"
# Maker - Recipe Editor
msgid "Modifica Ricetta"
msgstr "Modifica Ricetta"
msgid "Compila i dati della ricetta e carica il disegno tecnico"
msgstr "Compila i dati della ricetta e carica il disegno tecnico"
msgid "Ricetta salvata con successo"
msgstr "Ricetta salvata con successo"
msgid "Metadati Ricetta"
msgstr "Metadati Ricetta"
msgid "Es. COUPLING-256"
msgstr "Es. COUPLING-256"
msgid "Es. Coupling Assembly 256"
msgstr "Es. Coupling Assembly 256"
msgid "Descrizione opzionale della ricetta..."
msgstr "Descrizione opzionale della ricetta..."
msgid "Il codice non puo essere modificato dopo la creazione"
msgstr "Il codice non puo essere modificato dopo la creazione"
msgid "Disegno Tecnico e Annotazioni"
msgstr "Disegno Tecnico e Annotazioni"
msgid "Immagine caricata"
msgstr "Immagine caricata"
msgid "Marker numerato"
msgstr "Marker numerato"
msgid "Marker"
msgstr "Marker"
msgid "Freccia"
msgstr "Freccia"
msgid "Rettangolo"
msgstr "Rettangolo"
msgid "Elimina selezionato"
msgstr "Elimina selezionato"
msgid "Zoom"
msgstr "Zoom"
msgid "Zoom indietro"
msgstr "Zoom indietro"
msgid "Zoom avanti"
msgstr "Zoom avanti"
msgid "Reset zoom"
msgstr "Reset zoom"
msgid "Trascina"
msgstr "Trascina"
msgid "Pan"
msgstr "Pan"
msgid "Caricamento in corso..."
msgstr "Caricamento in corso..."
msgid "Trascina qui il disegno tecnico oppure clicca per selezionare"
msgstr "Trascina qui il disegno tecnico oppure clicca per selezionare"
msgid "Trascina qui per sostituire il disegno tecnico"
msgstr "Trascina qui per sostituire il disegno tecnico"
msgid "Formati supportati: PNG, JPG, PDF"
msgstr "Formati supportati: PNG, JPG, PDF"
msgid "Versioning"
msgstr "Versioning"
msgid "Versione corrente:"
msgstr "Versione corrente:"
msgid "Vedi cronologia"
msgstr "Vedi cronologia"
msgid "versioni"
msgstr "versioni"
msgid "Se modifichi questa ricetta verra creata automaticamente la versione"
msgstr "Se modifichi questa ricetta verra creata automaticamente la versione"
msgid "Le misure esistenti resteranno associate alla versione corrente."
msgstr "Le misure esistenti resteranno associate alla versione corrente."
msgid "Motivo della modifica"
msgstr "Motivo della modifica"
msgid "Es. Aggiornate tolleranze foro centrale..."
msgstr "Es. Aggiornate tolleranze foro centrale..."
msgid "Opzionale. Verra registrato nella cronologia versioni."
msgstr "Opzionale. Verra registrato nella cronologia versioni."
msgid "Salva Ricetta"
msgstr "Salva Ricetta"
msgid "Formato file non supportato. Usa PNG, JPG o PDF."
msgstr "Formato file non supportato. Usa PNG, JPG o PDF."
msgid "File troppo grande. Dimensione massima: 20MB."
msgstr "File troppo grande. Dimensione massima: 20MB."
msgid "Errore durante il caricamento del file"
msgstr "Errore durante il caricamento del file"
msgid "Errore di connessione durante il caricamento"
msgstr "Errore di connessione durante il caricamento"
msgid "Nessun file caricato"
msgstr "Nessun file caricato"
msgid "Nome file vuoto"
msgstr "Nome file vuoto"
# Maker - Task Editor
msgid "Editor Task"
msgstr "Editor Task"
msgid "Task e Misurazioni"
msgstr "Task e Misurazioni"
msgid "Torna a Ricetta"
msgstr "Torna a Ricetta"
msgid "Aggiungi Task"
msgstr "Aggiungi Task"
msgid "Nuovo Task"
msgstr "Nuovo Task"
msgid "Titolo"
msgstr "Titolo"
msgid "Direttiva"
msgstr "Direttiva"
msgid "Es. Controllo dimensionale flangia"
msgstr "Es. Controllo dimensionale flangia"
msgid "Es. Seguire procedura ISO 2768"
msgstr "Es. Seguire procedura ISO 2768"
msgid "Descrizione opzionale..."
msgstr "Descrizione opzionale..."
msgid "Crea Task"
msgstr "Crea Task"
msgid "Trascina per riordinare"
msgstr "Trascina per riordinare"
msgid "Modifica task"
msgstr "Modifica task"
msgid "Espandi/Comprimi"
msgstr "Espandi/Comprimi"
msgid "Elimina task"
msgstr "Elimina task"
msgid "UTL"
msgstr "UTL"
msgid "UWL"
msgstr "UWL"
msgid "LWL"
msgstr "LWL"
msgid "LTL"
msgstr "LTL"
msgid "Unita"
msgstr "Unita"
msgid "Tolleranze"
msgstr "Tolleranze"
msgid "Azioni"
msgstr "Azioni"
msgid "#"
msgstr "#"
msgid "Tipo"
msgstr "Tipo"
msgid "Lineare"
msgstr "Lineare"
msgid "Diametro"
msgstr "Diametro"
msgid "Raggio"
msgstr "Raggio"
msgid "Angolo"
msgstr "Angolo"
msgid "Rugosita"
msgstr "Rugosita"
msgid "Coppia"
msgstr "Coppia"
msgid "Forza"
msgstr "Forza"
msgid "Peso"
msgstr "Peso"
msgid "Altro"
msgstr "Altro"
msgid "Nessuna misurazione definita"
msgstr "Nessuna misurazione definita"
msgid "Aggiungi la prima misurazione per questo task"
msgstr "Aggiungi la prima misurazione per questo task"
msgid "Nuova Misurazione"
msgstr "Nuova Misurazione"
msgid "Marker #"
msgstr "Marker #"
msgid "Es. Diametro foro principale"
msgstr "Es. Diametro foro principale"
msgid "Lim. Tol. Inf."
msgstr "Lim. Tol. Inf."
msgid "Lim. Warn. Inf."
msgstr "Lim. Warn. Inf."
msgid "Lim. Warn. Sup."
msgstr "Lim. Warn. Sup."
msgid "Lim. Tol. Sup."
msgstr "Lim. Tol. Sup."
msgid "Aggiungi Misurazione"
msgstr "Aggiungi Misurazione"
msgid "Nessun task definito"
msgstr "Nessun task definito"
msgid "Inizia aggiungendo il primo task di misurazione per questa ricetta"
msgstr "Inizia aggiungendo il primo task di misurazione per questa ricetta"
msgid "Aggiungi Primo Task"
msgstr "Aggiungi Primo Task"
msgid "Conferma Eliminazione Task"
msgstr "Conferma Eliminazione Task"
msgid "Sei sicuro di voler eliminare il task"
msgstr "Sei sicuro di voler eliminare il task"
msgid "Verranno eliminate anche"
msgstr "Verranno eliminate anche"
msgid "misurazioni associate."
msgstr "misurazioni associate."
msgid "Elimina Task"
msgstr "Elimina Task"
msgid "Conferma Eliminazione Misurazione"
msgstr "Conferma Eliminazione Misurazione"
msgid "Sei sicuro di voler eliminare la misurazione"
msgstr "Sei sicuro di voler eliminare la misurazione"
msgid "Elimina Misurazione"
msgstr "Elimina Misurazione"
msgid "Errore nella creazione del task"
msgstr "Errore nella creazione del task"
msgid "Task creato con successo"
msgstr "Task creato con successo"
msgid "Errore nel salvataggio del task"
msgstr "Errore nel salvataggio del task"
msgid "Task aggiornato"
msgstr "Task aggiornato"
msgid "Task eliminato"
msgstr "Task eliminato"
msgid "Errore nell'eliminazione del task"
msgstr "Errore nell'eliminazione del task"
msgid "Errore nel riordinamento"
msgstr "Errore nel riordinamento"
msgid "Errore nella creazione della misurazione"
msgstr "Errore nella creazione della misurazione"
msgid "Misurazione aggiunta"
msgstr "Misurazione aggiunta"
msgid "Misurazione aggiornata"
msgstr "Misurazione aggiornata"
msgid "Misurazione eliminata"
msgstr "Misurazione eliminata"
msgid "Errore nell'eliminazione della misurazione"
msgstr "Errore nell'eliminazione della misurazione"
msgid "Direttiva opzionale..."
msgstr "Direttiva opzionale..."
# Maker - Recipe Preview
msgid "Anteprima Ricetta"
msgstr "Anteprima Ricetta"
msgid "Modalita Anteprima"
msgstr "Modalita Anteprima"
msgid "Stai vedendo la ricetta come la vedra il Tecnico di Misura. Tutti i campi sono in sola lettura."
msgstr "Stai vedendo la ricetta come la vedra il Tecnico di Misura. Tutti i campi sono in sola lettura."
msgid "misure"
msgstr "misure"
msgid "Documento PDF allegato"
msgstr "Documento PDF allegato"
msgid "Punti di Misura"
msgstr "Punti di Misura"
msgid "Nessun punto di misura definito per questo task"
msgstr "Nessun punto di misura definito per questo task"
msgid "Questa ricetta non contiene ancora task di misurazione."
msgstr "Questa ricetta non contiene ancora task di misurazione."
msgid "Modifica Task"
msgstr "Modifica Task"
# Maker - Version History
msgid "Storico Versioni"
msgstr "Storico Versioni"
msgid "Corrente:"
msgstr "Corrente:"
msgid "Corrente"
msgstr "Corrente"
msgid "Visualizza"
msgstr "Visualizza"
msgid "Nessuna versione trovata"
msgstr "Nessuna versione trovata"
msgid "Questa ricetta non ha ancora versioni registrate. La prima versione verra creata automaticamente al salvataggio."
msgstr "Questa ricetta non ha ancora versioni registrate. La prima versione verra creata automaticamente al salvataggio."
# Maker - API Errors
msgid "Errore nel caricamento della ricetta: %(error)s"
msgstr "Errore nel caricamento della ricetta: %(error)s"
msgid "Errore nel caricamento delle versioni: %(error)s"
msgstr "Errore nel caricamento delle versioni: %(error)s"
+118
View File
@@ -0,0 +1,118 @@
#!/usr/bin/env python
"""Verify i18n setup for TieMeasureFlow."""
import json
from pathlib import Path
def check_file(path: Path, description: str) -> bool:
"""Check if a file exists and report."""
exists = path.exists()
status = "[OK]" if exists else "[FAIL]"
print(f"{status} {description}: {path}")
return exists
def check_json_valid(path: Path) -> bool:
"""Check if JSON file is valid."""
try:
with open(path, 'r', encoding='utf-8') as f:
json.load(f)
return True
except Exception as e:
print(f" ERROR: {e}")
return False
def main():
"""Verify i18n setup."""
print("=== TieMeasureFlow i18n Verification ===\n")
root = Path(__file__).parent
all_good = True
# Check Flask-Babel files
print("Flask-Babel (Server-side):")
files = [
(root / "translations/babel.cfg", "Babel config"),
(root / "translations/it/LC_MESSAGES/messages.po", "Italian .po"),
(root / "translations/it/LC_MESSAGES/messages.mo", "Italian .mo"),
(root / "translations/en/LC_MESSAGES/messages.po", "English .po"),
(root / "translations/en/LC_MESSAGES/messages.mo", "English .mo"),
]
for path, desc in files:
if not check_file(path, desc):
all_good = False
# Count messages in .po files
if (root / "translations/it/LC_MESSAGES/messages.po").exists():
with open(root / "translations/it/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f:
content = f.read()
msgid_count = content.count('msgid "') - 1 # Exclude header
print(f" Italian: {msgid_count} messages")
if (root / "translations/en/LC_MESSAGES/messages.po").exists():
with open(root / "translations/en/LC_MESSAGES/messages.po", 'r', encoding='utf-8') as f:
content = f.read()
msgid_count = content.count('msgid "') - 1
print(f" English: {msgid_count} messages")
# Check Alpine.js i18n files
print("\nAlpine.js i18n (Client-side):")
json_files = [
(root / "static/js/locales/it.json", "Italian locale"),
(root / "static/js/locales/en.json", "English locale"),
]
for path, desc in json_files:
if check_file(path, desc):
if not check_json_valid(path):
all_good = False
else:
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
keys = count_keys(data)
print(f" {keys} total keys")
# Check app.py integration
print("\nApp Integration:")
app_py = root / "app.py"
if check_file(app_py, "app.py"):
with open(app_py, 'r', encoding='utf-8') as f:
content = f.read()
checks = [
("from flask_babel import Babel", "Flask-Babel imported"),
("def get_locale()", "get_locale() function"),
("Babel(app, locale_selector=get_locale)", "Babel initialized"),
("def set_language(lang)", "set_language endpoint"),
]
for check_str, check_desc in checks:
found = check_str in content
status = "[OK]" if found else "[FAIL]"
print(f" {status} {check_desc}")
if not found:
all_good = False
# Summary
print("\n" + "="*40)
if all_good:
print("[OK] i18n setup verified successfully!")
print("\nNext steps:")
print("1. Use _() in Python code and templates")
print("2. Use $t() in Alpine.js components")
print("3. Test language switching: /set-language/en or /set-language/it")
else:
print("[FAIL] Some checks failed - review errors above")
return 1
return 0
def count_keys(obj, depth=0):
"""Recursively count keys in nested dict."""
if not isinstance(obj, dict):
return 0
count = len(obj)
for value in obj.values():
if isinstance(value, dict):
count += count_keys(value, depth + 1)
return count
if __name__ == '__main__':
exit(main())