e1f4ee73d0
- 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>
410 lines
17 KiB
HTML
410 lines
17 KiB
HTML
{% 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">
|
|
— {{ 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 %}
|