Files
TieMeasureFlow/client/templates/maker/version_history.html
T
Adriano e1f4ee73d0 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>
2026-02-07 13:15:24 +01:00

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">
&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 %}