chore(v2): restructure monorepo to src/ layout with uv
Aligns the repo with the python-project-spec-design.md template chosen for V2.0.0. Big move, no logic changes. The 3 pre-existing test failures (test_recipes::test_update_recipe, test_recipes:: test_recipe_versioning, test_tasks::test_reorder_tasks, plus the client test_save_measurement_proxy) survive unchanged. Layout changes - server/ -> src/backend/ - server/middleware/ -> src/backend/api/middleware/ - server/routers/ -> src/backend/api/routers/ - server/models/ -> src/backend/models/orm/ - server/schemas/ -> src/backend/models/api/ - server/uploads/ -> uploads/ (project root, mounted volume) - server/tests/ -> src/backend/tests/ - client/ -> src/frontend/flask_app/ (Flask kept; React deroga is documented in CLAUDE.md, justified by tablet UX, USB caliper/barcode workflow and Fabric.js integration) Tooling - pyproject.toml: monorepo with [project] core deps and optional-dependencies server / client / dev. Replaces both server/requirements.txt and client/requirements.txt. - uv.lock + .python-version (3.11) committed for reproducible builds. - Dockerfile (root, backend) and Dockerfile.frontend rewritten to use uv sync --frozen --no-dev --extra server|client; legacy Dockerfiles preserved as Dockerfile.legacy for reference but excluded from build context via .dockerignore. - docker-compose.dev.yml + docker-compose.yml: build context now ".", dockerfile pointing to the root files. Code adjustments forced by the move - Every "from config|database|models|schemas|services|routers|middleware import ..." rewritten to its src.backend.* equivalent (50+ files including indented inline imports inside test bodies). - src/backend/migrations/env.py: insert project root into sys.path so alembic can resolve src.backend.* imports regardless of cwd. - src/backend/config.py: env_file ../../.env (was ../.env), upload_path resolves project root via parents[2]. - src/backend/tests/conftest.py + tests: import ... from src.backend.* instead of bare names; old per-directory pytest.ini files removed in favor of root pyproject.toml [tool.pytest.ini_options]. - .gitignore: uploads/ at root, src/frontend/flask_app/static/css/ tailwind.css path; .dockerignore tightened. - CLAUDE.md: rewrote sections "Layout del repository", "Comandi di Sviluppo", "Database & Migrations", "Test", "i18n", and all path references throughout the architecture sections. Verified - uv lock resolves 77 packages; uv sync --extra server --extra client --extra dev installs cleanly. - uv run pytest: 171 passed, 4 pre-existing failures. - uv run alembic -c src/backend/migrations/alembic.ini check loads config and metadata (errors only on the absent local MySQL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Gestione Stazioni') }} - TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>
|
||||
window.__stations = {{ stations|tojson }};
|
||||
window.__allRecipes = {{ all_recipes|tojson }};
|
||||
</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
||||
x-data="stationManagement(window.__stations, window.__allRecipes)">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Stazioni') }}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci stazioni di misurazione e relative ricette assegnate') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
|
||||
<div class="relative w-full sm:w-80">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" 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>
|
||||
<input type="text" x-model="search"
|
||||
placeholder="{{ _('Cerca stazione...') }}"
|
||||
class="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] placeholder-[var(--text-secondary)]
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
|
||||
</div>
|
||||
|
||||
<button @click="openCreateModal()"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
|
||||
hover:bg-primary-700 transition-colors shadow-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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{{ _('Nuova Stazione') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stations Table -->
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-sm overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Codice') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Nome') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Postazione') }}</th>
|
||||
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
|
||||
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-color)]">
|
||||
<template x-for="station in filteredStations" :key="station.id">
|
||||
<tr class="hover:bg-[var(--bg-secondary)] transition-colors cursor-pointer"
|
||||
@click="openEditModal(station)">
|
||||
<td class="px-4 py-3">
|
||||
<span class="font-mono text-sm font-semibold text-[var(--text-primary)]" x-text="station.code"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-[var(--text-primary)]" x-text="station.name"></td>
|
||||
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] hidden md:table-cell" x-text="station.location || '-'"></td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
|
||||
:class="station.active
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'"
|
||||
x-text="station.active ? '{{ _('Attiva') }}' : '{{ _('Disattivata') }}'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right" @click.stop>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button @click="openAssignmentsModal(station)"
|
||||
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"
|
||||
:title="'{{ _('Gestisci ricette') }}'">
|
||||
<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>
|
||||
</button>
|
||||
<button @click="openEditModal(station)"
|
||||
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors"
|
||||
:title="'{{ _('Modifica') }}'">
|
||||
<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>
|
||||
</button>
|
||||
<button @click="confirmDelete(station)"
|
||||
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
:title="'{{ _('Elimina') }}'">
|
||||
<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 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<template x-if="filteredStations.length === 0">
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="w-12 h-12 mx-auto text-[var(--text-secondary)] opacity-40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
||||
</svg>
|
||||
<p class="mt-3 text-sm text-[var(--text-secondary)]">{{ _('Nessuna stazione trovata') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs text-[var(--text-secondary)]">
|
||||
<span x-text="filteredStations.length"></span> {{ _('stazioni') }}
|
||||
</div>
|
||||
|
||||
<!-- Modal: Create / Edit Station -->
|
||||
<div x-show="showModal" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape.window="closeModal()">
|
||||
<div class="absolute inset-0 bg-black/50" @click="closeModal()"></div>
|
||||
<div x-show="showModal"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]"
|
||||
x-text="isEditing ? '{{ _('Modifica Stazione') }}' : '{{ _('Nuova Stazione') }}'"></h2>
|
||||
<button @click="closeModal()" class="p-1 rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Codice') }} <span class="text-red-500">*</span></label>
|
||||
<input type="text" x-model="form.code"
|
||||
:disabled="isEditing"
|
||||
:class="isEditing ? 'opacity-50 cursor-not-allowed font-mono' : 'font-mono uppercase'"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="ST-001"
|
||||
maxlength="100">
|
||||
<template x-if="isEditing">
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Il codice non può essere modificato') }}</p>
|
||||
</template>
|
||||
<template x-if="!isEditing">
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Identificativo univoco usato dal client tramite STATION_CODE') }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nome') }} <span class="text-red-500">*</span></label>
|
||||
<input type="text" x-model="form.name"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="{{ _('Nome descrittivo della stazione') }}"
|
||||
maxlength="255">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Postazione') }}</label>
|
||||
<input type="text" x-model="form.location"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="{{ _('Es. Reparto A - Linea 2') }}"
|
||||
maxlength="255">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Note') }}</label>
|
||||
<textarea x-model="form.notes" rows="3"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="{{ _('Note opzionali') }}"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.active"
|
||||
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{{ _('Attiva') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template x-if="errorMsg">
|
||||
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--border-color)]">
|
||||
<button @click="closeModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg
|
||||
hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
{{ _('Annulla') }}
|
||||
</button>
|
||||
<button @click="saveStation()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
|
||||
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
|
||||
<span x-show="!saving" x-text="isEditing ? '{{ _('Salva Modifiche') }}' : '{{ _('Crea Stazione') }}'"></span>
|
||||
<span x-show="saving" x-cloak>{{ _('Salvataggio...') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal: Recipe Assignments -->
|
||||
<div x-show="showAssignments" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape.window="closeAssignmentsModal()">
|
||||
<div class="absolute inset-0 bg-black/50" @click="closeAssignmentsModal()"></div>
|
||||
<div x-show="showAssignments"
|
||||
x-transition
|
||||
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{{ _('Ricette Assegnate') }}</h2>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-0.5" x-text="assignmentStation ? assignmentStation.code + ' — ' + assignmentStation.name : ''"></p>
|
||||
</div>
|
||||
<button @click="closeAssignmentsModal()" class="p-1 rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<!-- Add recipe -->
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Aggiungi ricetta') }}</label>
|
||||
<select x-model="recipeToAdd"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
|
||||
<option value="">{{ _('-- Seleziona ricetta --') }}</option>
|
||||
<template x-for="r in unassignedRecipes" :key="r.id">
|
||||
<option :value="r.id" x-text="r.code + ' — ' + r.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="assignRecipe()"
|
||||
:disabled="!recipeToAdd || saving"
|
||||
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
|
||||
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
|
||||
{{ _('Assegna') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Assigned list -->
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-[var(--text-primary)] mb-2">
|
||||
{{ _('Ricette correntemente assegnate') }} (<span x-text="assignedRecipes.length"></span>)
|
||||
</h3>
|
||||
<div class="border border-[var(--border-color)] rounded-lg divide-y divide-[var(--border-color)] max-h-96 overflow-y-auto">
|
||||
<template x-for="r in assignedRecipes" :key="r.id">
|
||||
<div class="flex items-center justify-between px-3 py-2 hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<div>
|
||||
<span class="font-mono text-xs font-semibold text-[var(--text-primary)]" x-text="r.code"></span>
|
||||
<span class="ml-2 text-sm text-[var(--text-secondary)]" x-text="r.name"></span>
|
||||
</div>
|
||||
<button @click="unassignRecipe(r.id)"
|
||||
:disabled="saving"
|
||||
class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50"
|
||||
:title="'{{ _('Rimuovi') }}'">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="assignedRecipes.length === 0">
|
||||
<div class="px-3 py-6 text-center text-sm text-[var(--text-secondary)]">
|
||||
{{ _('Nessuna ricetta assegnata') }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="errorMsg">
|
||||
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--border-color)]">
|
||||
<button @click="closeAssignmentsModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg
|
||||
hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
{{ _('Chiudi') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Delete Modal -->
|
||||
<div x-show="showDeleteConfirm" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-black/50" @click="showDeleteConfirm = false"></div>
|
||||
<div x-show="showDeleteConfirm"
|
||||
x-transition
|
||||
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">{{ _('Conferma Eliminazione') }}</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
{{ _('Sei sicuro di voler eliminare la stazione') }}
|
||||
<strong x-text="deleteTarget?.code"></strong>?
|
||||
<br><span class="text-xs text-red-600 dark:text-red-400">{{ _('Verranno rimosse anche tutte le assegnazioni di ricette.') }}</span>
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showDeleteConfirm = false"
|
||||
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
{{ _('Annulla') }}
|
||||
</button>
|
||||
<button @click="performDelete()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium text-white rounded-lg shadow-sm bg-red-600 hover:bg-red-700 disabled:opacity-50 transition-colors">
|
||||
{{ _('Elimina') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function stationManagement(initialStations, initialRecipes) {
|
||||
return {
|
||||
stations: initialStations || [],
|
||||
allRecipes: initialRecipes || [],
|
||||
search: '',
|
||||
showModal: false,
|
||||
showAssignments: false,
|
||||
showDeleteConfirm: false,
|
||||
isEditing: false,
|
||||
editingId: null,
|
||||
saving: false,
|
||||
errorMsg: '',
|
||||
deleteTarget: null,
|
||||
assignmentStation: null,
|
||||
assignedRecipes: [],
|
||||
recipeToAdd: '',
|
||||
form: {
|
||||
code: '',
|
||||
name: '',
|
||||
location: '',
|
||||
notes: '',
|
||||
active: true,
|
||||
},
|
||||
|
||||
get csrfToken() {
|
||||
const meta = document.querySelector('meta[name=csrf-token]');
|
||||
return meta ? meta.content : '';
|
||||
},
|
||||
|
||||
get filteredStations() {
|
||||
if (!this.search.trim()) return this.stations;
|
||||
const q = this.search.toLowerCase();
|
||||
return this.stations.filter(s =>
|
||||
s.code.toLowerCase().includes(q) ||
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
(s.location && s.location.toLowerCase().includes(q))
|
||||
);
|
||||
},
|
||||
|
||||
get unassignedRecipes() {
|
||||
const assignedIds = new Set(this.assignedRecipes.map(r => r.id));
|
||||
return this.allRecipes.filter(r => !assignedIds.has(r.id) && r.active !== false);
|
||||
},
|
||||
|
||||
openCreateModal() {
|
||||
this.isEditing = false;
|
||||
this.editingId = null;
|
||||
this.errorMsg = '';
|
||||
this.form = { code: '', name: '', location: '', notes: '', active: true };
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
openEditModal(station) {
|
||||
this.isEditing = true;
|
||||
this.editingId = station.id;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
code: station.code,
|
||||
name: station.name,
|
||||
location: station.location || '',
|
||||
notes: station.notes || '',
|
||||
active: station.active,
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.errorMsg = '';
|
||||
},
|
||||
|
||||
async saveStation() {
|
||||
this.saving = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
if (this.isEditing) {
|
||||
const data = {
|
||||
name: this.form.name,
|
||||
location: this.form.location || null,
|
||||
notes: this.form.notes || null,
|
||||
active: this.form.active,
|
||||
};
|
||||
const resp = await fetch(`/admin/api/stations/${this.editingId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || '{{ _("Errore nel salvataggio") }}';
|
||||
return;
|
||||
}
|
||||
const idx = this.stations.findIndex(s => s.id === this.editingId);
|
||||
if (idx >= 0) this.stations[idx] = result;
|
||||
} else {
|
||||
if (!this.form.code || !this.form.name) {
|
||||
this.errorMsg = '{{ _("Codice e nome sono obbligatori") }}';
|
||||
return;
|
||||
}
|
||||
const data = {
|
||||
code: this.form.code.trim(),
|
||||
name: this.form.name,
|
||||
location: this.form.location || null,
|
||||
notes: this.form.notes || null,
|
||||
active: this.form.active,
|
||||
};
|
||||
const resp = await fetch('/admin/api/stations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || '{{ _("Errore nella creazione") }}';
|
||||
return;
|
||||
}
|
||||
this.stations.push(result);
|
||||
}
|
||||
this.closeModal();
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
confirmDelete(station) {
|
||||
this.deleteTarget = station;
|
||||
this.showDeleteConfirm = true;
|
||||
},
|
||||
|
||||
async performDelete() {
|
||||
if (!this.deleteTarget) return;
|
||||
this.saving = true;
|
||||
try {
|
||||
const resp = await fetch(`/admin/api/stations/${this.deleteTarget.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRFToken': this.csrfToken },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const result = await resp.json().catch(() => ({}));
|
||||
alert(result.detail || '{{ _("Errore nell\'eliminazione") }}');
|
||||
return;
|
||||
}
|
||||
this.stations = this.stations.filter(s => s.id !== this.deleteTarget.id);
|
||||
this.showDeleteConfirm = false;
|
||||
this.deleteTarget = null;
|
||||
} catch (e) {
|
||||
alert('{{ _("Errore di connessione al server") }}');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async openAssignmentsModal(station) {
|
||||
this.assignmentStation = station;
|
||||
this.assignedRecipes = [];
|
||||
this.recipeToAdd = '';
|
||||
this.errorMsg = '';
|
||||
this.showAssignments = true;
|
||||
try {
|
||||
const resp = await fetch(`/admin/api/stations/${station.id}/recipes`);
|
||||
const data = await resp.json();
|
||||
if (resp.ok && Array.isArray(data)) {
|
||||
this.assignedRecipes = data;
|
||||
} else {
|
||||
this.errorMsg = (data && data.detail) || '{{ _("Errore nel caricamento delle ricette") }}';
|
||||
}
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
}
|
||||
},
|
||||
|
||||
closeAssignmentsModal() {
|
||||
this.showAssignments = false;
|
||||
this.assignmentStation = null;
|
||||
this.assignedRecipes = [];
|
||||
this.errorMsg = '';
|
||||
},
|
||||
|
||||
async assignRecipe() {
|
||||
if (!this.recipeToAdd || !this.assignmentStation) return;
|
||||
this.saving = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ recipe_id: parseInt(this.recipeToAdd, 10) }),
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || '{{ _("Errore nell\'assegnazione") }}';
|
||||
return;
|
||||
}
|
||||
const recipe = this.allRecipes.find(r => r.id === parseInt(this.recipeToAdd, 10));
|
||||
if (recipe) this.assignedRecipes.push({ id: recipe.id, code: recipe.code, name: recipe.name, active: recipe.active });
|
||||
this.recipeToAdd = '';
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
async unassignRecipe(recipeId) {
|
||||
if (!this.assignmentStation) return;
|
||||
this.saving = true;
|
||||
this.errorMsg = '';
|
||||
try {
|
||||
const resp = await fetch(`/admin/api/stations/${this.assignmentStation.id}/recipes/${recipeId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRFToken': this.csrfToken },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const result = await resp.json().catch(() => ({}));
|
||||
this.errorMsg = result.detail || '{{ _("Errore nella rimozione") }}';
|
||||
return;
|
||||
}
|
||||
this.assignedRecipes = this.assignedRecipes.filter(r => r.id !== recipeId);
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,535 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Gestione Utenti') }} - TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>window.__users = {{ users|tojson }};</script>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"
|
||||
x-data="userManagement(window.__users)">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _('Gestione Utenti') }}</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">{{ _('Crea, modifica e gestisci gli utenti del sistema') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 mb-6">
|
||||
<!-- Search -->
|
||||
<div class="relative w-full sm:w-80">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)]" 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>
|
||||
<input type="text" x-model="search"
|
||||
placeholder="{{ _('Cerca utente...') }}"
|
||||
class="w-full pl-10 pr-4 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] placeholder-[var(--text-secondary)]
|
||||
focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
|
||||
</div>
|
||||
|
||||
<!-- New User Button -->
|
||||
<button @click="openCreateModal()"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
|
||||
hover:bg-primary-700 transition-colors shadow-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="M12 4v16m8-8H4"/>
|
||||
</svg>
|
||||
{{ _('Nuovo Utente') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Users Table -->
|
||||
<div class="bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-sm overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Username') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden sm:table-cell">{{ _('Nome Visualizzato') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider hidden md:table-cell">{{ _('Email') }}</th>
|
||||
<th class="text-left px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Ruoli') }}</th>
|
||||
<th class="text-center px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Stato') }}</th>
|
||||
<th class="text-right px-4 py-3 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Azioni') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[var(--border-color)]">
|
||||
<template x-for="user in filteredUsers" :key="user.id">
|
||||
<tr class="hover:bg-[var(--bg-secondary)] transition-colors cursor-pointer"
|
||||
@click="openEditModal(user)">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center text-primary font-semibold text-xs"
|
||||
x-text="user.display_name ? user.display_name[0].toUpperCase() : user.username[0].toUpperCase()"></div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]" x-text="user.username"></span>
|
||||
<template x-if="user.is_admin">
|
||||
<span class="ml-1.5 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">Admin</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] hidden sm:table-cell" x-text="user.display_name"></td>
|
||||
<td class="px-4 py-3 text-sm text-[var(--text-secondary)] hidden md:table-cell" x-text="user.email || '-'"></td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="role in user.roles" :key="role">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
|
||||
:class="{
|
||||
'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300': role === 'Maker',
|
||||
'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300': role === 'MeasurementTec',
|
||||
'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300': role === 'Metrologist'
|
||||
}"
|
||||
x-text="role"></span>
|
||||
</template>
|
||||
<template x-if="!user.roles || user.roles.length === 0">
|
||||
<span class="text-xs text-[var(--text-secondary)]">-</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium"
|
||||
:class="user.active
|
||||
? 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'"
|
||||
x-text="user.active ? '{{ _('Attivo') }}' : '{{ _('Disattivato') }}'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right" @click.stop>
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button @click="openEditModal(user)" class="p-1.5 rounded-lg text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors" :title="'{{ _('Modifica') }}'">
|
||||
<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>
|
||||
</button>
|
||||
<button @click="toggleActive(user)" class="p-1.5 rounded-lg transition-colors"
|
||||
:class="user.active
|
||||
? 'text-[var(--text-secondary)] hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20'
|
||||
: 'text-[var(--text-secondary)] hover:text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20'"
|
||||
:title="user.active ? '{{ _('Disattiva') }}' : '{{ _('Riattiva') }}'">
|
||||
<svg x-show="user.active" 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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
|
||||
</svg>
|
||||
<svg x-show="!user.active" x-cloak 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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<template x-if="filteredUsers.length === 0">
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="w-12 h-12 mx-auto text-[var(--text-secondary)] opacity-40" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
</svg>
|
||||
<p class="mt-3 text-sm text-[var(--text-secondary)]">{{ _('Nessun utente trovato') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Count -->
|
||||
<div class="mt-3 text-xs text-[var(--text-secondary)]">
|
||||
<span x-text="filteredUsers.length"></span> {{ _('utenti') }}
|
||||
</div>
|
||||
|
||||
<!-- Modal: Create / Edit User -->
|
||||
<div x-show="showModal" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape.window="closeModal()">
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/50" @click="closeModal()"></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div x-show="showModal"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)]">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]"
|
||||
x-text="isEditing ? '{{ _('Modifica Utente') }}' : '{{ _('Nuovo Utente') }}'"></h2>
|
||||
<button @click="closeModal()" class="p-1 rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
<!-- Username -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Username') }}</label>
|
||||
<input type="text" x-model="form.username"
|
||||
:disabled="isEditing"
|
||||
:class="isEditing ? 'opacity-50 cursor-not-allowed' : ''"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="{{ _('Username') }}">
|
||||
<template x-if="isEditing">
|
||||
<p class="mt-1 text-xs text-[var(--text-secondary)]">{{ _('Il nome utente non può essere modificato') }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Display Name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Nome Visualizzato') }}</label>
|
||||
<input type="text" x-model="form.display_name"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="{{ _('Nome Visualizzato') }}">
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Email') }}</label>
|
||||
<input type="email" x-model="form.email"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
placeholder="email@example.com">
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
{{ _('Password') }}
|
||||
<template x-if="isEditing">
|
||||
<span class="font-normal text-[var(--text-secondary)]"> ({{ _('lascia vuoto per non modificare') }})</span>
|
||||
</template>
|
||||
</label>
|
||||
<input type="password" x-model="form.password"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors"
|
||||
:placeholder="isEditing ? '{{ _('Nuova password (opzionale)') }}' : '{{ _('Password') }}'"
|
||||
minlength="6">
|
||||
</div>
|
||||
|
||||
<!-- Roles -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-2">{{ _('Ruoli') }}</label>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.roles" value="Maker"
|
||||
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||
<span class="text-sm text-[var(--text-primary)]">Maker</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.roles" value="MeasurementTec"
|
||||
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||
<span class="text-sm text-[var(--text-primary)]">MeasurementTec</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.roles" value="Metrologist"
|
||||
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||
<span class="text-sm text-[var(--text-primary)]">Metrologist</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin + Preferences Row -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<!-- Is Admin -->
|
||||
<div>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" x-model="form.is_admin"
|
||||
class="rounded border-[var(--border-color)] text-primary focus:ring-primary/30">
|
||||
<span class="text-sm font-medium text-[var(--text-primary)]">{{ _('Amministratore') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Language -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Lingua') }}</label>
|
||||
<select x-model="form.language_pref"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
|
||||
<option value="it">{{ _('Italiano') }}</option>
|
||||
<option value="en">{{ _('English') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Theme -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-[var(--text-primary)] mb-1">{{ _('Tema') }}</label>
|
||||
<select x-model="form.theme_pref"
|
||||
class="w-full px-3 py-2 rounded-lg border border-[var(--border-color)] bg-[var(--bg-card)]
|
||||
text-sm text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary transition-colors">
|
||||
<option value="light">{{ _('Chiaro') }}</option>
|
||||
<option value="dark">{{ _('Scuro') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<template x-if="errorMsg">
|
||||
<div class="px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800">
|
||||
<p class="text-sm text-red-700 dark:text-red-300" x-text="errorMsg"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--border-color)]">
|
||||
<button @click="closeModal()"
|
||||
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg
|
||||
hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
{{ _('Annulla') }}
|
||||
</button>
|
||||
<button @click="saveUser()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 bg-primary text-white text-sm font-medium rounded-lg
|
||||
hover:bg-primary-700 transition-colors shadow-sm disabled:opacity-50">
|
||||
<span x-show="!saving" x-text="isEditing ? '{{ _('Salva Modifiche') }}' : '{{ _('Crea Utente') }}'"></span>
|
||||
<span x-show="saving" x-cloak>{{ _('Salvataggio...') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirm Toggle Modal -->
|
||||
<div x-show="showToggleConfirm" x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-black/50" @click="showToggleConfirm = false"></div>
|
||||
<div x-show="showToggleConfirm"
|
||||
x-transition
|
||||
class="relative bg-[var(--bg-card)] rounded-xl border border-[var(--border-color)] shadow-xl w-full max-w-sm p-6">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2"
|
||||
x-text="toggleUser?.active ? '{{ _('Conferma Disattivazione') }}' : '{{ _('Conferma Riattivazione') }}'"></h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
<span x-text="toggleUser?.active
|
||||
? '{{ _('Sei sicuro di voler disattivare l\'utente') }}'
|
||||
: '{{ _('Sei sicuro di voler riattivare l\'utente') }}'"></span>
|
||||
<strong x-text="toggleUser?.username"></strong>?
|
||||
</p>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="showToggleConfirm = false"
|
||||
class="px-4 py-2 text-sm font-medium text-[var(--text-secondary)] rounded-lg hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
{{ _('Annulla') }}
|
||||
</button>
|
||||
<button @click="confirmToggle()"
|
||||
:disabled="saving"
|
||||
class="px-4 py-2 text-sm font-medium text-white rounded-lg shadow-sm disabled:opacity-50 transition-colors"
|
||||
:class="toggleUser?.active ? 'bg-red-600 hover:bg-red-700' : 'bg-emerald-600 hover:bg-emerald-700'">
|
||||
<span x-text="toggleUser?.active ? '{{ _('Disattiva') }}' : '{{ _('Riattiva') }}'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function userManagement(initialUsers) {
|
||||
return {
|
||||
users: initialUsers || [],
|
||||
search: '',
|
||||
showModal: false,
|
||||
showToggleConfirm: false,
|
||||
isEditing: false,
|
||||
editingUserId: null,
|
||||
toggleUser: null,
|
||||
saving: false,
|
||||
errorMsg: '',
|
||||
form: {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
},
|
||||
|
||||
get csrfToken() {
|
||||
const meta = document.querySelector('meta[name=csrf-token]');
|
||||
return meta ? meta.content : '';
|
||||
},
|
||||
|
||||
get filteredUsers() {
|
||||
if (!this.search.trim()) return this.users;
|
||||
const q = this.search.toLowerCase();
|
||||
return this.users.filter(u =>
|
||||
u.username.toLowerCase().includes(q) ||
|
||||
(u.display_name && u.display_name.toLowerCase().includes(q)) ||
|
||||
(u.email && u.email.toLowerCase().includes(q)) ||
|
||||
(u.roles && u.roles.some(r => r.toLowerCase().includes(q)))
|
||||
);
|
||||
},
|
||||
|
||||
openCreateModal() {
|
||||
this.isEditing = false;
|
||||
this.editingUserId = null;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: '',
|
||||
display_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
roles: [],
|
||||
is_admin: false,
|
||||
language_pref: 'it',
|
||||
theme_pref: 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
openEditModal(user) {
|
||||
this.isEditing = true;
|
||||
this.editingUserId = user.id;
|
||||
this.errorMsg = '';
|
||||
this.form = {
|
||||
username: user.username,
|
||||
display_name: user.display_name || '',
|
||||
email: user.email || '',
|
||||
password: '',
|
||||
roles: [...(user.roles || [])],
|
||||
is_admin: user.is_admin || false,
|
||||
language_pref: user.language_pref || 'it',
|
||||
theme_pref: user.theme_pref || 'light'
|
||||
};
|
||||
this.showModal = true;
|
||||
},
|
||||
|
||||
closeModal() {
|
||||
this.showModal = false;
|
||||
this.errorMsg = '';
|
||||
},
|
||||
|
||||
async saveUser() {
|
||||
this.saving = true;
|
||||
this.errorMsg = '';
|
||||
|
||||
try {
|
||||
if (this.isEditing) {
|
||||
// Update user
|
||||
const data = {
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch(`/admin/api/users/${this.editingUserId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nel salvataggio';
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.editingUserId);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
// Change password if provided
|
||||
if (this.form.password) {
|
||||
const pwResp = await fetch(`/admin/api/users/${this.editingUserId}/password`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ password: this.form.password })
|
||||
});
|
||||
if (!pwResp.ok) {
|
||||
const pwResult = await pwResp.json();
|
||||
this.errorMsg = pwResult.detail || 'Errore nel cambio password';
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create user
|
||||
if (!this.form.username || !this.form.display_name || !this.form.password) {
|
||||
this.errorMsg = '{{ _("Username, nome visualizzato e password sono obbligatori") }}';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
username: this.form.username,
|
||||
password: this.form.password,
|
||||
display_name: this.form.display_name,
|
||||
email: this.form.email || null,
|
||||
roles: this.form.roles,
|
||||
is_admin: this.form.is_admin,
|
||||
language_pref: this.form.language_pref,
|
||||
theme_pref: this.form.theme_pref
|
||||
};
|
||||
|
||||
const resp = await fetch('/admin/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.errorMsg = result.detail || 'Errore nella creazione';
|
||||
return;
|
||||
}
|
||||
|
||||
this.users.push(result);
|
||||
}
|
||||
|
||||
this.closeModal();
|
||||
} catch (e) {
|
||||
this.errorMsg = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
toggleActive(user) {
|
||||
this.toggleUser = user;
|
||||
this.showToggleConfirm = true;
|
||||
},
|
||||
|
||||
async confirmToggle() {
|
||||
if (!this.toggleUser) return;
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const newActive = !this.toggleUser.active;
|
||||
const resp = await fetch(`/admin/api/users/${this.toggleUser.id}/toggle-active`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken },
|
||||
body: JSON.stringify({ active: newActive })
|
||||
});
|
||||
|
||||
const result = await resp.json();
|
||||
if (!resp.ok) {
|
||||
alert(result.detail || 'Errore');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update local array
|
||||
const idx = this.users.findIndex(u => u.id === this.toggleUser.id);
|
||||
if (idx >= 0) this.users[idx] = result;
|
||||
|
||||
this.showToggleConfirm = false;
|
||||
this.toggleUser = null;
|
||||
} catch (e) {
|
||||
alert('{{ _("Errore di connessione al server") }}');
|
||||
} finally {
|
||||
this.saving = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Login — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- Card -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-xl p-8">
|
||||
<!-- Logo -->
|
||||
<div class="text-center mb-8">
|
||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
||||
alt="TieMeasureFlow Logo"
|
||||
class="mx-auto h-16 w-auto mb-4"
|
||||
onerror="this.style.display='none'">
|
||||
<h2 class="text-3xl font-bold text-slate-900 dark:text-white mb-2">
|
||||
TieMeasureFlow
|
||||
</h2>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
{{ _('Accedi al sistema') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Login Form -->
|
||||
<form method="POST" action="{{ url_for('auth.login') }}" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<!-- Username -->
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ _('Username') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
required
|
||||
autofocus
|
||||
placeholder="{{ _('Username') }}"
|
||||
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ _('Password') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
placeholder="{{ _('Password') }}"
|
||||
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
|
||||
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{{ _('Accedi') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Help Text -->
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ _('Hai dimenticato la password?') }}
|
||||
<br>
|
||||
{{ _('Contatta l\'amministratore') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="text-center text-xs text-slate-500 dark:text-slate-400">
|
||||
<p>TieMeasureFlow © 2025 - {{ _('Sistema di misurazione industriale') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,203 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Profilo') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-4xl">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-3">
|
||||
<svg class="h-8 w-8 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<h1 class="text-3xl font-bold text-slate-900 dark:text-white">
|
||||
{{ _('Profilo Utente') }}
|
||||
</h1>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
{{ _('Gestisci le tue informazioni e preferenze') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Profile Card -->
|
||||
<div class="bg-white dark:bg-slate-800 shadow-lg rounded-xl overflow-hidden">
|
||||
<form method="POST" action="{{ url_for('auth.profile') }}" class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<!-- Account Information Section -->
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-6">
|
||||
{{ _('Informazioni Account') }}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Username (readonly) -->
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ _('Username') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text"
|
||||
id="username"
|
||||
value="{{ user.username if user else '' }}"
|
||||
disabled
|
||||
class="block w-full pl-10 pr-3 py-2.5 border border-slate-200 dark:border-slate-700 rounded-lg bg-slate-50 dark:bg-slate-900 text-slate-500 dark:text-slate-400 cursor-not-allowed">
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">
|
||||
{{ _('Il nome utente non può essere modificato') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Display Name -->
|
||||
<div>
|
||||
<label for="display_name" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ _('Nome Visualizzato') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input type="text"
|
||||
id="display_name"
|
||||
name="display_name"
|
||||
value="{{ user.display_name if user else '' }}"
|
||||
placeholder="{{ _('Nome da visualizzare nell\'interfaccia') }}"
|
||||
class="block w-full pl-10 pr-3 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-slate-500 transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roles (readonly badges) -->
|
||||
{% if user and user.roles %}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ _('Ruoli') }}
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for role in user.roles %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs font-medium
|
||||
{% if role == 'Maker' %}
|
||||
bg-indigo-100 dark:bg-indigo-900/30 text-indigo-800 dark:text-indigo-200 border border-indigo-200 dark:border-indigo-800
|
||||
{% elif role == 'MeasurementTec' %}
|
||||
bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-800
|
||||
{% elif role == 'Metrologist' %}
|
||||
bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-200 border border-purple-200 dark:border-purple-800
|
||||
{% else %}
|
||||
bg-slate-100 dark:bg-slate-700 text-slate-800 dark:text-slate-200 border border-slate-200 dark:border-slate-600
|
||||
{% endif %}">
|
||||
<svg class="h-3 w-3 mr-1.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ _(role) }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preferences Section -->
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-6">
|
||||
{{ _('Preferenze') }}
|
||||
</h2>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Language Preference -->
|
||||
<div>
|
||||
<label for="language_pref" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ _('Lingua Preferita') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
</div>
|
||||
<select id="language_pref"
|
||||
name="language_pref"
|
||||
class="block w-full pl-10 pr-10 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white transition-colors">
|
||||
<option value="it" {% if user and user.language_pref == 'it' %}selected{% endif %}>{{ _('Italiano') }}</option>
|
||||
<option value="en" {% if user and user.language_pref == 'en' %}selected{% endif %}>{{ _('English') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Preference -->
|
||||
<div>
|
||||
<label for="theme_pref" class="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
{{ _('Tema Preferito') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<svg class="h-5 w-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
</div>
|
||||
<select id="theme_pref"
|
||||
name="theme_pref"
|
||||
class="block w-full pl-10 pr-10 py-2.5 border border-slate-300 dark:border-slate-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400 bg-white dark:bg-slate-700 text-slate-900 dark:text-white transition-colors">
|
||||
<option value="light" {% if user and user.theme_pref == 'light' %}selected{% endif %}>{{ _('Chiaro') }}</option>
|
||||
<option value="dark" {% if user and user.theme_pref == 'dark' %}selected{% endif %}>{{ _('Scuro') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="px-6 py-4 sm:px-8 bg-slate-50 dark:bg-slate-900/50 flex items-center justify-between">
|
||||
<a href="/"
|
||||
class="inline-flex items-center px-4 py-2 border border-slate-300 dark:border-slate-600 rounded-lg shadow-sm text-sm font-medium text-slate-700 dark:text-slate-300 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
|
||||
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
{{ _('Indietro') }}
|
||||
</a>
|
||||
|
||||
<button type="submit"
|
||||
class="inline-flex items-center px-6 py-2 border border-transparent rounded-lg shadow-sm text-sm font-semibold text-white bg-primary-600 hover:bg-primary-700 dark:bg-primary-500 dark:hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-all duration-200">
|
||||
<svg class="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{{ _('Salva Modifiche') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Account Actions -->
|
||||
<div class="mt-8 bg-white dark:bg-slate-800 shadow-lg rounded-xl overflow-hidden">
|
||||
<div class="p-6 sm:p-8">
|
||||
<h2 class="text-lg font-semibold text-slate-900 dark:text-white mb-4">
|
||||
{{ _('Azioni Account') }}
|
||||
</h2>
|
||||
<div class="flex items-start space-x-4">
|
||||
<div class="flex-shrink-0">
|
||||
<svg class="h-6 w-6 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-medium text-slate-900 dark:text-white">{{ _('Esci dal sistema') }}</h3>
|
||||
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{{ _('Termina la sessione corrente e torna alla schermata di login') }}
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<a href="{{ url_for('auth.logout') }}"
|
||||
class="inline-flex items-center px-4 py-2 border border-red-300 dark:border-red-700 rounded-lg shadow-sm text-sm font-medium text-red-700 dark:text-red-300 bg-white dark:bg-slate-800 hover:bg-red-50 dark:hover:bg-red-900/20 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200">
|
||||
{{ _('Logout') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ current_language|default('it') }}"
|
||||
:class="{ 'dark': $store.theme.dark }"
|
||||
class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description" content="TieMeasureFlow - Sistema di gestione misurazioni industriali">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<title>{% block title %}TieMeasureFlow{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='img/tmflow-icon.svg') }}">
|
||||
|
||||
<!-- Google Fonts: Inter + JetBrains Mono -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- TailwindCSS (compiled) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/tailwind.css') }}">
|
||||
|
||||
<!-- Theme CSS Variables -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/themes.css') }}">
|
||||
|
||||
<!-- Alpine.js Theme Init (before Alpine loads) -->
|
||||
<script src="{{ url_for('static', filename='js/alpine-init.js') }}"></script>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
|
||||
<body class="h-full bg-[var(--bg-primary)] text-[var(--text-primary)] font-sans antialiased transition-colors duration-300">
|
||||
|
||||
<!-- Alpine.js App Wrapper -->
|
||||
<div x-data class="min-h-full flex flex-col">
|
||||
|
||||
<!-- Navbar -->
|
||||
{% include "components/navbar.html" %}
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="fixed top-16 right-4 z-50 flex flex-col gap-2 max-w-md w-full"
|
||||
x-data
|
||||
x-init="setTimeout(() => { $el.remove() }, 8000)">
|
||||
{% for category, message in messages %}
|
||||
<div x-data="{ show: true }"
|
||||
x-show="show"
|
||||
x-transition:enter="transition ease-out duration-300"
|
||||
x-transition:enter-start="opacity-0 translate-x-4"
|
||||
x-transition:enter-end="opacity-100 translate-x-0"
|
||||
x-transition:leave="transition ease-in duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-x-0"
|
||||
x-transition:leave-end="opacity-0 translate-x-4"
|
||||
class="flex items-center gap-3 px-4 py-3 rounded-lg shadow-lg border
|
||||
{% if category == 'success' %}
|
||||
bg-emerald-50 dark:bg-emerald-900/30 border-emerald-200 dark:border-emerald-800 text-emerald-800 dark:text-emerald-200
|
||||
{% elif category == 'error' %}
|
||||
bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200
|
||||
{% elif category == 'warning' %}
|
||||
bg-amber-50 dark:bg-amber-900/30 border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-200
|
||||
{% else %}
|
||||
bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800 text-blue-800 dark:text-blue-200
|
||||
{% endif %}">
|
||||
|
||||
<!-- Icon -->
|
||||
{% if category == 'success' %}
|
||||
<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>
|
||||
{% elif category == 'error' %}
|
||||
<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>
|
||||
{% elif category == 'warning' %}
|
||||
<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 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
|
||||
<span class="text-sm font-medium flex-1">{{ message }}</span>
|
||||
|
||||
<!-- Dismiss -->
|
||||
<button @click="show = false" class="shrink-0 hover:opacity-70 transition-opacity touch-target">
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="py-4 text-center border-t border-[var(--border-color)] transition-colors duration-300">
|
||||
<p class="text-xs text-steel dark:text-steel-light tracking-wide">
|
||||
Powered by <span class="font-semibold">TieMeasureFlow</span> — Tielogic
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
<!-- Alpine.js CDN (defer) - must load AFTER extra_js so component functions are defined -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,159 @@
|
||||
<!--
|
||||
Barcode Scanner Modal Component
|
||||
Camera-based barcode/QR code scanner
|
||||
Include in pages where barcode scanning is needed
|
||||
-->
|
||||
<div x-data="barcodeScanner()"
|
||||
@barcode-use.window="stopScan(); clear()"
|
||||
x-init="$watch('scanning', val => console.log('Scanning:', val))"
|
||||
x-cloak>
|
||||
|
||||
<!-- Trigger button (styled slot - customize per use case) -->
|
||||
<slot name="trigger">
|
||||
<button @click="startScan()"
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 px-4 py-2.5 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg text-sm font-medium text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors duration-200 shadow-sm hover:shadow-md">
|
||||
|
||||
<!-- Barcode icon -->
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"/>
|
||||
</svg>
|
||||
<span>{{ _('Scansiona Barcode') }}</span>
|
||||
</button>
|
||||
</slot>
|
||||
|
||||
<!-- Modal overlay -->
|
||||
<div x-show="scanning || result"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
|
||||
style="display: none;">
|
||||
|
||||
<div @click.outside="scanning && !result ? null : (stopScan(), clear())"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl max-w-md w-full overflow-hidden">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center px-6 py-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">
|
||||
{{ _('Scansiona Barcode') }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<button @click="stopScan(); clear()"
|
||||
type="button"
|
||||
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition-colors p-1 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||
<svg class="w-5 h-5" 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>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6">
|
||||
|
||||
<!-- Camera preview container -->
|
||||
<div x-show="scanning && !result"
|
||||
class="relative rounded-xl overflow-hidden bg-slate-900 aspect-square mb-4">
|
||||
|
||||
<!-- Scanner element -->
|
||||
<div id="barcode-reader" class="w-full h-full"></div>
|
||||
|
||||
<!-- Scanning indicator -->
|
||||
<div class="absolute inset-0 pointer-events-none">
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="w-64 h-64 border-2 border-white/50 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Instruction overlay -->
|
||||
<div class="absolute bottom-0 inset-x-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||
<p class="text-white text-sm text-center font-medium">
|
||||
{{ _('Inquadra il codice a barre nel riquadro') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success result -->
|
||||
<div x-show="result"
|
||||
x-transition
|
||||
class="text-center py-8">
|
||||
|
||||
<!-- Success icon -->
|
||||
<div class="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400 mb-3">
|
||||
{{ _('Codice rilevato') }}
|
||||
</p>
|
||||
|
||||
<div class="bg-slate-50 dark:bg-slate-900/50 rounded-lg p-4 mb-4">
|
||||
<p class="font-mono text-xl font-bold text-primary break-all" x-text="result"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div x-show="error"
|
||||
x-transition
|
||||
class="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<p class="text-sm text-red-800 dark:text-red-200" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div class="flex gap-3">
|
||||
<button x-show="result"
|
||||
@click="$dispatch('barcode-use', { code: result }); stopScan(); clear()"
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2.5 bg-primary hover:bg-primary-dark text-white font-medium rounded-lg transition-colors duration-200 shadow-sm hover:shadow-md">
|
||||
{{ _('Usa Codice') }}
|
||||
</button>
|
||||
|
||||
<button x-show="scanning && !result"
|
||||
@click="stopScan()"
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2.5 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 font-medium rounded-lg transition-colors duration-200">
|
||||
{{ _('Ferma') }}
|
||||
</button>
|
||||
|
||||
<button x-show="!scanning || result"
|
||||
@click="stopScan(); clear()"
|
||||
type="button"
|
||||
class="flex-1 px-4 py-2.5 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-300 font-medium rounded-lg transition-colors duration-200">
|
||||
{{ _('Chiudi') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
window.BARCODE_I18N = {
|
||||
scanner_lib_not_loaded: "{{ _('Libreria scanner non caricata') }}",
|
||||
no_camera_available: "{{ _('Nessuna fotocamera disponibile') }}",
|
||||
camera_access_error: "{{ _('Impossibile accedere alla fotocamera') }}"
|
||||
};
|
||||
</script>
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
<!--
|
||||
Caliper Status Indicator - Passive HID Monitor
|
||||
Shows USB caliper reading feedback without Web Serial.
|
||||
Flashes green when a caliper reading is detected via burst timing.
|
||||
-->
|
||||
<div x-data="caliperStatus()"
|
||||
class="inline-block">
|
||||
|
||||
<!-- Passive indicator (no connect/disconnect button needed) -->
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all duration-300"
|
||||
:class="active
|
||||
? 'bg-green-50 dark:bg-green-900/20 border-green-300 dark:border-green-700 shadow-md shadow-green-100 dark:shadow-green-900/30'
|
||||
: 'bg-[var(--bg-card)] border-[var(--border-color)]'"
|
||||
:title="readingCount > 0
|
||||
? '{{ _('Letture calibro') }}: ' + readingCount
|
||||
: '{{ _('Calibro USB') }}'">
|
||||
|
||||
<!-- Status dot -->
|
||||
<span class="relative flex h-2.5 w-2.5">
|
||||
<!-- Ping animation when active -->
|
||||
<span x-show="active"
|
||||
x-transition
|
||||
class="absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75 animate-ping"></span>
|
||||
|
||||
<!-- Static dot -->
|
||||
<span class="relative inline-flex rounded-full h-2.5 w-2.5 transition-colors duration-300"
|
||||
:class="active
|
||||
? 'bg-green-500'
|
||||
: readingCount > 0
|
||||
? 'bg-green-400'
|
||||
: 'bg-slate-400 dark:bg-slate-500'
|
||||
"></span>
|
||||
</span>
|
||||
|
||||
<!-- Caliper icon -->
|
||||
<svg class="w-4 h-4 transition-colors duration-300"
|
||||
:class="active ? 'text-green-600 dark:text-green-400' : 'text-slate-500 dark:text-slate-400'"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
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>
|
||||
|
||||
<!-- Label -->
|
||||
<span class="text-xs font-medium transition-colors duration-300"
|
||||
:class="active ? 'text-green-700 dark:text-green-300' : 'text-slate-600 dark:text-slate-400'"
|
||||
x-text="active ? '{{ _('Lettura calibro') }}' : '{{ _('Calibro USB') }}'"></span>
|
||||
|
||||
<!-- Last reading value (shows when active or when there's a recent reading) -->
|
||||
<span x-show="lastReading !== null"
|
||||
x-transition
|
||||
class="font-mono text-xs font-semibold px-1.5 py-0.5 rounded transition-colors duration-300"
|
||||
:class="active
|
||||
? 'text-green-700 dark:text-green-200 bg-green-100 dark:bg-green-800/40'
|
||||
: 'text-primary bg-blue-50 dark:bg-blue-900/30'"
|
||||
x-text="lastReading?.toFixed(3) + ' mm'"></span>
|
||||
|
||||
<!-- Reading counter badge -->
|
||||
<span x-show="readingCount > 0 && !active"
|
||||
x-transition
|
||||
class="text-[10px] font-mono text-slate-400 dark:text-slate-500"
|
||||
x-text="'(' + readingCount + ')'"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,76 @@
|
||||
{# Measurement Feedback Component
|
||||
|
||||
Mostra feedback visivo in tempo reale della misurazione corrente.
|
||||
|
||||
Variabili Alpine.js richieste nel parent component:
|
||||
- currentValue: float | null - valore corrente misurato
|
||||
- nominal: float - valore nominale
|
||||
- utl: float - Upper Tolerance Limit
|
||||
- uwl: float - Upper Warning Limit
|
||||
- lwl: float - Lower Warning Limit
|
||||
- ltl: float - Lower Tolerance Limit
|
||||
- unit: string - unità di misura (es. "mm")
|
||||
- passFailStatus: computed - 'pass' | 'warning' | 'fail'
|
||||
- deviation: computed - scostamento dal nominale
|
||||
- progressWidth: computed - larghezza barra percentuale
|
||||
#}
|
||||
|
||||
<div class="measurement-feedback" x-show="currentValue !== null">
|
||||
<!-- Status bar colorata -->
|
||||
<div class="h-3 rounded-full overflow-hidden bg-slate-200 dark:bg-slate-600">
|
||||
<div class="h-full rounded-full transition-all duration-300"
|
||||
:class="{
|
||||
'bg-measure-pass': passFailStatus === 'pass',
|
||||
'bg-measure-warning': passFailStatus === 'warning',
|
||||
'bg-measure-fail': passFailStatus === 'fail'
|
||||
}"
|
||||
:style="'width: ' + progressWidth + '%'">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status text + deviation -->
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Icona stato - CONFORME -->
|
||||
<template x-if="passFailStatus === 'pass'">
|
||||
<span class="flex items-center gap-1 text-measure-pass font-semibold text-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{{ _('CONFORME') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Icona stato - ATTENZIONE -->
|
||||
<template x-if="passFailStatus === 'warning'">
|
||||
<span class="flex items-center gap-1 text-measure-warning font-semibold text-sm">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{{ _('ATTENZIONE') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Icona stato - NON CONFORME -->
|
||||
<template x-if="passFailStatus === 'fail'">
|
||||
<span class="flex items-center gap-1 text-measure-fail font-semibold text-sm">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
{{ _('NON CONFORME') }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Deviation con segno -->
|
||||
<span class="font-mono text-sm font-medium"
|
||||
:class="{
|
||||
'text-measure-pass': passFailStatus === 'pass',
|
||||
'text-measure-warning': passFailStatus === 'warning',
|
||||
'text-measure-fail': passFailStatus === 'fail'
|
||||
}"
|
||||
x-text="deviation !== null ?
|
||||
(deviation >= 0 ? '+' : '') + deviation.toFixed(3) + ' ' + unit : ''">
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,307 @@
|
||||
<!-- TieMeasureFlow Navbar -->
|
||||
<nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300"
|
||||
x-data="{ mobileOpen: false, userDropdown: false }">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-14">
|
||||
|
||||
<!-- Left: Logo -->
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="{{ url_for('index') }}" class="flex items-center gap-2.5 group">
|
||||
{% if company_logo %}
|
||||
<img src="{{ url_for('static', filename='img/' ~ company_logo) }}" alt="Logo" class="h-8 w-auto"
|
||||
onerror="this.src='{{ url_for('static', filename='img/tmflow-logo.svg') }}'">
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='img/tmflow-logo.svg') }}"
|
||||
alt="TieMeasureFlow"
|
||||
class="h-8 w-auto">
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Center: Navigation Links (Desktop) -->
|
||||
{% if current_user %}
|
||||
<div class="hidden md:flex items-center gap-1">
|
||||
|
||||
{# MeasurementTec: Misure #}
|
||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('measure.') %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Clipboard Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
<span>{{ _('Misure') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Maker: Ricette #}
|
||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
||||
<a href="{{ url_for('maker.recipe_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('maker.') %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Edit/Pencil Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
<span>{{ _('Ricette') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Metrologist: Statistiche #}
|
||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||
<a href="{{ url_for('statistics.dashboard') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint and request.endpoint.startswith('statistics.') %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Chart Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
<span>{{ _('Statistiche') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Admin: Utenti #}
|
||||
{% if current_user.get('is_admin') %}
|
||||
<a href="{{ url_for('admin.user_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint == 'admin.user_list' %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Users Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
</svg>
|
||||
<span>{{ _('Utenti') }}</span>
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.station_list') }}"
|
||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors duration-200
|
||||
{% if request.endpoint == 'admin.station_list' %}
|
||||
text-primary bg-primary-50 dark:bg-primary-900/20
|
||||
{% endif %}">
|
||||
<!-- Station / Workstation Icon -->
|
||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
||||
</svg>
|
||||
<span>{{ _('Stazioni') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Right: Controls -->
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
<!-- Language Toggle -->
|
||||
<div class="flex items-center border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
<a href="{{ url_for('set_language', lang='it') }}"
|
||||
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
|
||||
{% if current_language == 'it' %}
|
||||
bg-primary text-white
|
||||
{% else %}
|
||||
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
||||
{% endif %}">
|
||||
IT
|
||||
</a>
|
||||
<a href="{{ url_for('set_language', lang='en') }}"
|
||||
class="px-2 py-1.5 text-xs font-semibold transition-colors duration-200
|
||||
{% if current_language == 'en' %}
|
||||
bg-primary text-white
|
||||
{% else %}
|
||||
text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]
|
||||
{% endif %}">
|
||||
EN
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<button @click="$store.theme.toggle()"
|
||||
class="p-2 rounded-lg text-[var(--text-secondary)] hover:text-primary
|
||||
hover:bg-[var(--bg-secondary)] transition-colors duration-200"
|
||||
:title="$store.theme.dark ? 'Tema chiaro' : 'Tema scuro'">
|
||||
<!-- Sun (shown in dark mode) -->
|
||||
<svg x-show="$store.theme.dark" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/>
|
||||
</svg>
|
||||
<!-- Moon (shown in light mode) -->
|
||||
<svg x-show="!$store.theme.dark" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User Menu (when logged in) -->
|
||||
{% if current_user %}
|
||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
|
||||
<button @click="open = !open"
|
||||
class="flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-lg
|
||||
hover:bg-[var(--bg-secondary)] transition-colors duration-200">
|
||||
<!-- Avatar -->
|
||||
<div class="w-7 h-7 rounded-full bg-primary/10 border border-primary/20
|
||||
flex items-center justify-center text-primary font-semibold text-xs">
|
||||
{{ current_user.get('display_name', current_user.get('username', '?'))[0]|upper }}
|
||||
</div>
|
||||
<span class="hidden sm:block text-sm font-medium text-[var(--text-primary)] max-w-[120px] truncate">
|
||||
{{ current_user.get('display_name', current_user.get('username', '')) }}
|
||||
</span>
|
||||
<svg class="w-3.5 h-3.5 text-[var(--text-secondary)] transition-transform duration-200"
|
||||
:class="{ 'rotate-180': open }"
|
||||
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<div x-show="open"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 scale-95 -translate-y-1"
|
||||
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-100"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
class="absolute right-0 mt-1 w-52 rounded-lg bg-[var(--bg-card)] border border-[var(--border-color)]
|
||||
shadow-lg py-1 z-50">
|
||||
<!-- User Info Header -->
|
||||
<div class="px-4 py-2.5 border-b border-[var(--border-color)]">
|
||||
<p class="text-sm font-semibold text-[var(--text-primary)] truncate">
|
||||
{{ current_user.get('display_name', current_user.get('username', '')) }}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
|
||||
{{ current_user.get('roles', [])|join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('auth.profile') }}"
|
||||
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-secondary)]
|
||||
hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
|
||||
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
{{ _('Profilo') }}
|
||||
</a>
|
||||
|
||||
<div class="border-t border-[var(--border-color)] my-1"></div>
|
||||
|
||||
<a href="{{ url_for('auth.logout') }}"
|
||||
class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-measure-fail
|
||||
hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
{{ _('Logout') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Mobile Hamburger (when logged in) -->
|
||||
{% if current_user %}
|
||||
<button @click="mobileOpen = !mobileOpen"
|
||||
class="md:hidden p-2 rounded-lg text-[var(--text-secondary)]
|
||||
hover:bg-[var(--bg-secondary)] transition-colors duration-200">
|
||||
<svg x-show="!mobileOpen" class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
<svg x-show="mobileOpen" x-cloak class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
{% if current_user %}
|
||||
<div x-show="mobileOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
x-cloak
|
||||
class="md:hidden border-t border-[var(--border-color)] bg-[var(--bg-card)]">
|
||||
<div class="px-4 py-3 space-y-1">
|
||||
|
||||
{% if current_user.get('roles') and 'MeasurementTec' in current_user.roles %}
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
{{ _('Misure') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.get('roles') and 'Maker' in current_user.roles %}
|
||||
<a href="{{ url_for('maker.recipe_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||
</svg>
|
||||
{{ _('Ricette') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.get('roles') and 'Metrologist' in current_user.roles %}
|
||||
<a href="{{ url_for('statistics.dashboard') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
{{ _('Statistiche') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{# Admin: Utenti + Stazioni #}
|
||||
{% if current_user.get('is_admin') %}
|
||||
<a href="{{ url_for('admin.user_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/>
|
||||
</svg>
|
||||
{{ _('Utenti') }}
|
||||
</a>
|
||||
|
||||
<a href="{{ url_for('admin.station_list') }}"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
||||
transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25"/>
|
||||
</svg>
|
||||
{{ _('Stazioni') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</nav>
|
||||
@@ -0,0 +1,32 @@
|
||||
{# Next Measurement Component
|
||||
|
||||
Indicatore della prossima misura da effettuare.
|
||||
|
||||
Variabili Alpine.js richieste nel parent component:
|
||||
- nextSubtask: object | null - prossimo subtask da misurare
|
||||
- marker_number: int
|
||||
- description: string
|
||||
- nominal: float
|
||||
- unit: string
|
||||
#}
|
||||
|
||||
<div x-show="nextSubtask" class="mt-4 p-3 rounded-lg bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700">
|
||||
<div class="flex items-center gap-2 text-sm text-steel dark:text-steel-light">
|
||||
<!-- Arrow right icon -->
|
||||
<svg class="w-4 h-4 text-primary flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"/>
|
||||
</svg>
|
||||
<span class="font-medium">{{ _('Prossima misura') }}:</span>
|
||||
<span class="font-semibold text-[var(--text-primary)]">
|
||||
#<span x-text="nextSubtask?.marker_number"></span>
|
||||
<span x-text="nextSubtask?.description" class="ml-1"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Nominal preview -->
|
||||
<div class="mt-1 ml-6 text-xs text-steel dark:text-steel-light">
|
||||
<span>Nom: </span>
|
||||
<span class="font-mono font-medium" x-text="nextSubtask?.nominal?.toFixed(3)"></span>
|
||||
<span class="ml-1" x-text="nextSubtask?.unit || 'mm'"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,121 @@
|
||||
<!-- Numpad Component - Touch-friendly numeric keypad for measurement input -->
|
||||
<div
|
||||
x-data="numpad()"
|
||||
@keydown.window="handleKeydown($event)"
|
||||
class="numpad-container w-full max-w-sm mx-auto"
|
||||
>
|
||||
<!-- Value Display -->
|
||||
<div class="value-display mb-3 p-4 bg-white dark:bg-slate-700 rounded-lg border-2 border-slate-200 dark:border-slate-600 shadow-sm">
|
||||
<div class="flex items-baseline justify-center">
|
||||
<span
|
||||
x-text="displayValue || '0'"
|
||||
class="font-mono text-3xl font-bold text-slate-900 dark:text-white"
|
||||
></span>
|
||||
<span
|
||||
x-text="unit"
|
||||
class="ml-2 text-sm font-medium text-steel dark:text-steel-light"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Keypad Grid 4x4 -->
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<!-- Row 1: 7 8 9 ⌫ -->
|
||||
<button
|
||||
@click="addDigit('7')"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>7</button>
|
||||
|
||||
<button
|
||||
@click="addDigit('8')"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>8</button>
|
||||
|
||||
<button
|
||||
@click="addDigit('9')"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>9</button>
|
||||
|
||||
<button
|
||||
@click="backspace()"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>⌫</button>
|
||||
|
||||
<!-- Row 2: 4 5 6 C -->
|
||||
<button
|
||||
@click="addDigit('4')"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>4</button>
|
||||
|
||||
<button
|
||||
@click="addDigit('5')"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>5</button>
|
||||
|
||||
<button
|
||||
@click="addDigit('6')"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>6</button>
|
||||
|
||||
<button
|
||||
@click="clearAll()"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-measure-fail hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>C</button>
|
||||
|
||||
<!-- Row 3: 1 2 3 +/− -->
|
||||
<button
|
||||
@click="addDigit('1')"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>1</button>
|
||||
|
||||
<button
|
||||
@click="addDigit('2')"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>2</button>
|
||||
|
||||
<button
|
||||
@click="addDigit('3')"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>3</button>
|
||||
|
||||
<button
|
||||
@click="toggleSign()"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>+/−</button>
|
||||
|
||||
<!-- Row 4: 0 . (spacer) ✓ -->
|
||||
<button
|
||||
@click="addDigit('0')"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-slate-900 dark:text-white hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>0</button>
|
||||
|
||||
<button
|
||||
@click="addDecimal()"
|
||||
type="button"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-slate-200 dark:border-slate-600 bg-slate-100 dark:bg-slate-600 text-steel dark:text-steel-light hover:bg-slate-50 dark:hover:bg-slate-600 active:scale-95 active:bg-slate-100 dark:active:bg-slate-500 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>.</button>
|
||||
|
||||
<!-- Spacer -->
|
||||
<div></div>
|
||||
|
||||
<button
|
||||
@click="confirm()"
|
||||
type="button"
|
||||
:disabled="!hasValue"
|
||||
class="min-h-[56px] min-w-[56px] font-mono text-xl font-semibold rounded-xl border border-primary bg-primary text-white hover:bg-primary-dark active:scale-95 transition-all duration-150 select-none cursor-pointer flex items-center justify-center touch-manipulation focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-40 disabled:cursor-not-allowed disabled:active:scale-100 disabled:hover:bg-primary"
|
||||
>✓</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Stazione non configurata') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<div class="mt-20 p-8 bg-[var(--bg-card)] rounded-xl shadow-lg text-center border border-red-200 dark:border-red-800">
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full
|
||||
bg-red-50 dark:bg-red-900/30 mb-6">
|
||||
<svg class="w-8 h-8 text-measure-fail" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-measure-fail mb-4">
|
||||
{{ _('Stazione non configurata') }}
|
||||
</h1>
|
||||
|
||||
<p class="mb-4 text-[var(--text-primary)]">
|
||||
{{ _('Questo client non ha impostato la variabile di ambiente STATION_CODE.') }}
|
||||
</p>
|
||||
|
||||
<p class="mb-6 text-sm text-[var(--text-secondary)]">
|
||||
{{ _('Contattare il responsabile IT: il file .env del container deve contenere STATION_CODE con il codice della stazione assegnata.') }}
|
||||
</p>
|
||||
|
||||
{% if error %}
|
||||
<pre class="text-xs text-left text-[var(--text-secondary)] bg-[var(--bg-secondary)]
|
||||
p-3 rounded-lg border border-[var(--border-color)] overflow-auto">{{ error }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,632 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}
|
||||
{% if recipe %}
|
||||
{{ recipe.code }} — {{ _('Modifica Ricetta') }}
|
||||
{% else %}
|
||||
{{ _('Nuova Ricetta') }}
|
||||
{% endif %}
|
||||
— TieMeasureFlow
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
.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);
|
||||
}
|
||||
</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') }}
|
||||
</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: Immagine Anteprima (thumbnail ricetta)
|
||||
============================================================ -->
|
||||
<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>
|
||||
{{ _('Immagine Anteprima') }}
|
||||
</div>
|
||||
<span x-show="currentFilePath" 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>
|
||||
</div>
|
||||
|
||||
<div class="tmf-card-body space-y-4">
|
||||
|
||||
<!-- Current image preview -->
|
||||
<template x-if="currentFilePath">
|
||||
<div class="space-y-3">
|
||||
<div class="relative rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)]">
|
||||
<img x-bind:src="'/maker/api/files/' + currentFilePath"
|
||||
class="block mx-auto max-h-64 object-contain p-2"
|
||||
:class="{ 'opacity-40': uploadingFile }"
|
||||
loading="lazy">
|
||||
<!-- Upload overlay on image -->
|
||||
<div x-show="uploadingFile"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-white/60 dark:bg-black/50">
|
||||
<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>
|
||||
<span class="text-sm font-medium text-[var(--text-primary)] mt-2">{{ _('Caricamento in corso...') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Action buttons -->
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<label class="btn btn-secondary text-sm gap-1.5 cursor-pointer"
|
||||
:class="{ 'opacity-50 pointer-events-none': uploadingFile }">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
|
||||
</svg>
|
||||
{{ _('Sostituisci') }}
|
||||
<input type="file"
|
||||
accept="image/*,application/pdf"
|
||||
class="hidden"
|
||||
:disabled="uploadingFile"
|
||||
@change="uploadFile($event.target.files[0]); $event.target.value = ''">
|
||||
</label>
|
||||
<button @click.stop="currentFilePath = ''"
|
||||
type="button"
|
||||
:disabled="uploadingFile"
|
||||
class="btn btn-secondary text-sm gap-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20">
|
||||
<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>
|
||||
{{ _('Rimuovi') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Upload area (only when no image) -->
|
||||
<template x-if="!currentFilePath">
|
||||
<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 -->
|
||||
<div x-show="uploadingFile" 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>
|
||||
<span class="text-sm text-[var(--text-secondary)]">{{ _('Caricamento in corso...') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Upload prompt -->
|
||||
<div x-show="!uploadingFile" 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 class="text-center">
|
||||
<span class="inline-block px-4 py-2 rounded-lg bg-primary text-white text-sm font-semibold mb-2">
|
||||
{{ _('Carica Immagine') }}
|
||||
</span>
|
||||
<p class="text-xs text-[var(--text-muted)]">
|
||||
{{ _('Trascina qui oppure clicca. Formati: PNG, JPG, WebP, PDF (max 50MB)') }}
|
||||
</p>
|
||||
<p class="text-xs text-[var(--text-muted)] mt-1">
|
||||
{{ _('Usata come thumbnail nella lista ricette') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
</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 %}
|
||||
|
||||
<!-- ============================================================
|
||||
3. 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>
|
||||
function recipeEditor() {
|
||||
return {
|
||||
// ---- Mode ----
|
||||
isNew: {{ 'true' if recipe is none else 'false' }},
|
||||
recipeId: {{ recipe.id|tojson if recipe 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: '',
|
||||
|
||||
// ---- Save state ----
|
||||
saving: false,
|
||||
saved: false,
|
||||
errorMessage: '',
|
||||
|
||||
// ---- Versioning ----
|
||||
versions: {{ (recipe.versions if recipe and recipe.versions else [])|tojson }},
|
||||
currentVersion: {{ (recipe.current_version.version_number if recipe and recipe.current_version else 0)|tojson }},
|
||||
|
||||
// ---- File upload (preview image) ----
|
||||
currentFilePath: {{ (recipe.image_path if recipe and recipe.image_path else '')|tojson }},
|
||||
uploadingFile: false,
|
||||
dragOver: false,
|
||||
|
||||
// ============================================================
|
||||
// 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 image_path for preview thumbnail
|
||||
if (this.currentFilePath) {
|
||||
payload.image_path = this.currentFilePath;
|
||||
}
|
||||
|
||||
// Include change notes if editing
|
||||
if (!this.isNew && this.changeNotes.trim()) {
|
||||
payload.change_notes = this.changeNotes.trim();
|
||||
}
|
||||
|
||||
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) {
|
||||
var detail = data.detail || data.error || {{ _("Errore nel salvataggio")|tojson }};
|
||||
this.errorMessage = (typeof detail === 'string') ? detail : JSON.stringify(detail);
|
||||
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")|tojson }};
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// 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';
|
||||
}
|
||||
},
|
||||
|
||||
// ============================================================
|
||||
// File Upload (thumbnail)
|
||||
// ============================================================
|
||||
handleDrop(event) {
|
||||
this.dragOver = false;
|
||||
const files = event.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
this.uploadFile(files[0]);
|
||||
}
|
||||
},
|
||||
|
||||
async uploadFile(file) {
|
||||
if (!file) return;
|
||||
|
||||
// Validate file type
|
||||
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
|
||||
if (!allowed.includes(file.type)) {
|
||||
this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG, GIF, WebP o PDF.")|tojson }};
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate size (50MB)
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
this.errorMessage = {{ _("File troppo grande. Massimo 50MB.")|tojson }};
|
||||
return;
|
||||
}
|
||||
|
||||
this.uploadingFile = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (this.recipeId) {
|
||||
formData.append('recipe_id', this.recipeId);
|
||||
}
|
||||
|
||||
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 nel caricamento del file")|tojson }};
|
||||
this.uploadingFile = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentFilePath = data.file_path;
|
||||
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err);
|
||||
this.errorMessage = {{ _("Errore di connessione durante l'upload")|tojson }};
|
||||
}
|
||||
|
||||
this.uploadingFile = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,396 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Gestione Ricette') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>window.__recipeListData = {{ recipes|tojson }};</script>
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-6xl"
|
||||
x-data="{
|
||||
recipes: window.__recipeListData,
|
||||
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">
|
||||
<!-- Thumbnail -->
|
||||
<template x-if="recipe.image_path">
|
||||
<div class="w-20 h-20 rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)] shrink-0">
|
||||
<img :src="'/maker/api/files/' + recipe.image_path"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
onerror="this.parentElement.style.display='none'">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 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 %}
|
||||
@@ -0,0 +1,457 @@
|
||||
{% 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 %}
|
||||
<script>
|
||||
window.__taskAnnotations = {};
|
||||
{% for task in recipe.tasks %}
|
||||
{% if task.annotations_json %}
|
||||
window.__taskAnnotations[{{ task.id }}] = {{ task.annotations_json|tojson }};
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</script>
|
||||
<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">
|
||||
— {{ 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 = '/maker/api/files/{{ task.file_path }}';
|
||||
annotations = window.__taskAnnotations[{{ task.id }}] || null;
|
||||
$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="/maker/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>·</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 %}—{% 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 %}—{% 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 %}—{% 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 %}—{% 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 %}—{% 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="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></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 %}
|
||||
@@ -0,0 +1,537 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Disegno Task') }} #{{ (task.order_index or 0) + 1 }} — {{ recipe.code }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* 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 — use custom class name to avoid conflict with
|
||||
Fabric.js internal .canvas-container wrapper div */
|
||||
.anno-canvas-wrap {
|
||||
position: relative;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
min-height: 400px;
|
||||
}
|
||||
.anno-canvas-wrap canvas {
|
||||
display: block;
|
||||
}
|
||||
/* Fabric.js internal wrapper also needs display:block on canvases */
|
||||
.canvas-container canvas {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>
|
||||
window.__taskData = {{ task|tojson }};
|
||||
window.__i18n_taskDrawing = {
|
||||
saving: {{ _("Salvataggio...")|tojson }},
|
||||
saveAnnotations: {{ _("Salva Annotazioni")|tojson }},
|
||||
saveError: {{ _("Errore nel salvataggio delle annotazioni")|tojson }},
|
||||
saveSuccess: {{ _("Annotazioni salvate con successo")|tojson }},
|
||||
connectionError: {{ _("Errore di connessione al server")|tojson }}
|
||||
};
|
||||
</script>
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl"
|
||||
x-data="taskDrawing()"
|
||||
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>
|
||||
<a href="{{ url_for('maker.task_editor', recipe_id=recipe.id) }}"
|
||||
class="hover:text-primary transition-colors">
|
||||
{{ _('Task') }}
|
||||
</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)]">
|
||||
{{ _('Disegno Task') }} #{{ (task.order_index or 0) + 1 }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- ============================================================
|
||||
Header
|
||||
============================================================ -->
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)] mb-1">
|
||||
{{ _('Annotazioni Disegno') }}
|
||||
<span class="text-[var(--text-muted)] text-lg font-normal ml-2">
|
||||
Task #{{ (task.order_index or 0) + 1 }}
|
||||
</span>
|
||||
</h1>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{{ task.title or '' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0 flex-wrap">
|
||||
<button @click="saveAnnotations()"
|
||||
:disabled="saving"
|
||||
class="btn btn-primary gap-1.5">
|
||||
<svg x-show="!saving" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
<svg x-show="saving" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
<span x-text="saving ? window.__i18n_taskDrawing.saving : window.__i18n_taskDrawing.saveAnnotations"></span>
|
||||
</button>
|
||||
|
||||
<a :href="'{{ url_for('maker.task_editor', recipe_id=recipe.id) }}' + '?_=' + Date.now()"
|
||||
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="M11 17l-5-5m0 0l5-5m-5 5h12"/>
|
||||
</svg>
|
||||
{{ _('Torna ai Task') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
Error/Success Banners
|
||||
============================================================ -->
|
||||
<div x-show="errorMessage"
|
||||
x-transition
|
||||
class="mb-4 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>
|
||||
|
||||
<div x-show="successMessage"
|
||||
x-transition
|
||||
x-init="$watch('successMessage', v => { if (v) setTimeout(() => successMessage = '', 4000) })"
|
||||
class="mb-4 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 x-text="successMessage"></span>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
Annotation Toolbar
|
||||
============================================================ -->
|
||||
<div class="tmf-card mb-4">
|
||||
<div class="p-3 space-y-2">
|
||||
<!-- Row 1: Drawing Tools -->
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<!-- 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 selected -->
|
||||
<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>
|
||||
{{ _('Elimina') }}
|
||||
</button>
|
||||
|
||||
<!-- Clear All -->
|
||||
<button type="button"
|
||||
class="anno-btn text-red-500 hover:text-red-600"
|
||||
@click="if (confirm('{{ _('Eliminare tutte le annotazioni?') }}')) clearAllAnnotations()"
|
||||
title="{{ _('Cancella tutto') }}">
|
||||
<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 13h6m2 9H7a2 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"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
{{ _('Cancella tutto') }}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Color, Stroke Width, Line Style -->
|
||||
<div class="flex flex-wrap items-center gap-1.5 pt-1 border-t border-[var(--border-color)]">
|
||||
<!-- Color picker -->
|
||||
<span class="text-xs text-[var(--text-muted)] mr-1">{{ _('Colore') }}:</span>
|
||||
<template x-for="c in colorOptions" :key="c.value">
|
||||
<button type="button"
|
||||
class="w-6 h-6 rounded-full border-2 transition-all"
|
||||
:style="'background-color: ' + c.value"
|
||||
:class="propsDisabled ? 'border-[var(--border-color)] opacity-30 cursor-not-allowed' : (annoColor === c.value ? 'border-[var(--text-primary)] scale-110 ring-2 ring-offset-1 ring-[var(--color-primary)]' : 'border-[var(--border-color)] hover:scale-105')"
|
||||
:title="c.label"
|
||||
:disabled="propsDisabled"
|
||||
@click="setAnnoColor(c.value)">
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="w-px h-6 bg-[var(--border-color)] mx-1.5"></div>
|
||||
|
||||
<!-- Stroke width -->
|
||||
<span class="text-xs text-[var(--text-muted)] mr-1">{{ _('Spessore') }}:</span>
|
||||
<template x-for="sw in strokeOptions" :key="sw">
|
||||
<button type="button"
|
||||
class="anno-btn"
|
||||
:class="{ 'active': annoStrokeWidth === sw }"
|
||||
:disabled="propsDisabled"
|
||||
@click="setAnnoStrokeWidth(sw)"
|
||||
:title="sw + 'px'">
|
||||
<div class="flex items-center justify-center w-4 h-4">
|
||||
<div class="rounded-full bg-current" :style="'width: ' + (sw + 2) + 'px; height: ' + (sw + 2) + 'px'"></div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="w-px h-6 bg-[var(--border-color)] mx-1.5"></div>
|
||||
|
||||
<!-- Line style -->
|
||||
<span class="text-xs text-[var(--text-muted)] mr-1">{{ _('Linea') }}:</span>
|
||||
<template x-for="ls in lineDashOptions" :key="ls.icon">
|
||||
<button type="button"
|
||||
class="anno-btn"
|
||||
:class="{ 'active': JSON.stringify(annoLineDash) === JSON.stringify(ls.value) }"
|
||||
:disabled="propsDisabled"
|
||||
@click="setAnnoLineDash(ls.value)"
|
||||
:title="ls.label">
|
||||
<div class="flex items-center justify-center w-5 h-4">
|
||||
<template x-if="ls.icon === 'solid'">
|
||||
<div class="w-full h-0.5 bg-current"></div>
|
||||
</template>
|
||||
<template x-if="ls.icon === 'dashed'">
|
||||
<div class="w-full h-0.5 flex gap-0.5">
|
||||
<div class="flex-1 bg-current"></div>
|
||||
<div class="flex-1 bg-current"></div>
|
||||
<div class="flex-1 bg-current"></div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="ls.icon === 'dotted'">
|
||||
<div class="w-full h-0.5 flex gap-1 justify-center">
|
||||
<div class="w-1 h-1 rounded-full bg-current"></div>
|
||||
<div class="w-1 h-1 rounded-full bg-current"></div>
|
||||
<div class="w-1 h-1 rounded-full bg-current"></div>
|
||||
<div class="w-1 h-1 rounded-full bg-current"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================
|
||||
Canvas Area
|
||||
============================================================ -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-body p-0">
|
||||
<div x-data="annotationEditor()"
|
||||
class="anno-canvas-wrap"
|
||||
id="annotationCanvasContainer">
|
||||
<canvas id="annotationCanvas" x-ref="fabricCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</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="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-editor.js') }}?v=25"></script>
|
||||
<script>
|
||||
function taskDrawing() {
|
||||
const taskData = window.__taskData || {};
|
||||
|
||||
return {
|
||||
taskId: taskData.id || null,
|
||||
recipeId: {{ recipe.id|tojson }},
|
||||
|
||||
// Annotation state
|
||||
annoTool: 'select',
|
||||
annoSelected: false,
|
||||
annoSelectedType: null,
|
||||
annotationsJson: taskData.annotations_json || null,
|
||||
|
||||
// Color and stroke width
|
||||
annoColor: '#2563EB',
|
||||
annoStrokeWidth: 2,
|
||||
colorOptions: [
|
||||
{ value: '#2563EB', label: {{ _('Blu')|tojson }} },
|
||||
{ value: '#DC2626', label: {{ _('Rosso')|tojson }} },
|
||||
{ value: '#16A34A', label: {{ _('Verde')|tojson }} },
|
||||
{ value: '#F59E0B', label: {{ _('Arancio')|tojson }} },
|
||||
{ value: '#8B5CF6', label: {{ _('Viola')|tojson }} },
|
||||
{ value: '#000000', label: {{ _('Nero')|tojson }} },
|
||||
],
|
||||
strokeOptions: [1, 2, 3, 4],
|
||||
annoLineDash: [],
|
||||
lineDashOptions: [
|
||||
{ value: [], label: {{ _('Continua')|tojson }}, icon: 'solid' },
|
||||
{ value: [8, 4], label: {{ _('Tratteggiata')|tojson }}, icon: 'dashed' },
|
||||
{ value: [3, 3], label: {{ _('Punteggiata')|tojson }}, icon: 'dotted' },
|
||||
],
|
||||
|
||||
// Save state
|
||||
saving: false,
|
||||
errorMessage: '',
|
||||
successMessage: '',
|
||||
|
||||
get propsDisabled() {
|
||||
var isDrawingTool = this.annoTool === 'marker' || this.annoTool === 'arrow' || this.annoTool === 'rect';
|
||||
return !isDrawingTool && !this.annoSelected;
|
||||
},
|
||||
|
||||
init() {
|
||||
// Listen for annotation selection events from annotation-editor.js
|
||||
window.addEventListener('annotation-selected', (e) => {
|
||||
this.annoSelected = e.detail.selected;
|
||||
this.annoSelectedType = e.detail.objectType || null;
|
||||
});
|
||||
|
||||
// Listen for annotation data changes
|
||||
window.addEventListener('annotations-changed', (e) => {
|
||||
this.annotationsJson = e.detail.json;
|
||||
});
|
||||
|
||||
// Listen for mode changes from annotation editor (auto-select after placing)
|
||||
window.addEventListener('anno-mode-changed', (e) => {
|
||||
if (e.detail && e.detail.mode) {
|
||||
this.annoTool = e.detail.mode;
|
||||
}
|
||||
});
|
||||
|
||||
// Load the image into the annotation editor
|
||||
if (taskData.file_path) {
|
||||
this.$nextTick(() => {
|
||||
window.dispatchEvent(new CustomEvent('image-loaded', {
|
||||
detail: {
|
||||
path: taskData.file_path,
|
||||
annotations: taskData.annotations_json || null
|
||||
}
|
||||
}));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Save annotations to task ----
|
||||
async saveAnnotations() {
|
||||
this.saving = true;
|
||||
this.errorMessage = '';
|
||||
|
||||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
|
||||
const payload = {};
|
||||
if (this.annotationsJson) {
|
||||
payload.annotations_json = (typeof this.annotationsJson === 'string')
|
||||
? JSON.parse(this.annotationsJson)
|
||||
: this.annotationsJson;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/maker/api/tasks/${this.taskId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
if (data.error) {
|
||||
this.errorMessage = data.detail || window.__i18n_taskDrawing.saveError;
|
||||
} else {
|
||||
this.successMessage = window.__i18n_taskDrawing.saveSuccess;
|
||||
// Store updated annotations in sessionStorage so task_editor
|
||||
// can pick them up and refresh the preview immediately.
|
||||
try {
|
||||
sessionStorage.setItem('taskDrawingUpdated', JSON.stringify({
|
||||
taskId: this.taskId,
|
||||
annotations_json: this.annotationsJson
|
||||
}));
|
||||
} catch (_e) { /* sessionStorage may be unavailable */ }
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('saveAnnotations error:', err);
|
||||
this.errorMessage = window.__i18n_taskDrawing.connectionError;
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
// ---- Annotation Tools ----
|
||||
setAnnoTool(tool) {
|
||||
this.annoTool = tool;
|
||||
window.dispatchEvent(new CustomEvent('anno-tool-change', {
|
||||
detail: { tool }
|
||||
}));
|
||||
},
|
||||
|
||||
deleteAnnotation() {
|
||||
window.dispatchEvent(new CustomEvent('anno-delete-selected'));
|
||||
},
|
||||
|
||||
clearAllAnnotations() {
|
||||
window.dispatchEvent(new CustomEvent('anno-clear-all'));
|
||||
},
|
||||
|
||||
setAnnoColor(color) {
|
||||
this.annoColor = color;
|
||||
window.dispatchEvent(new CustomEvent('anno-color-change', {
|
||||
detail: { color }
|
||||
}));
|
||||
},
|
||||
|
||||
setAnnoStrokeWidth(width) {
|
||||
this.annoStrokeWidth = width;
|
||||
window.dispatchEvent(new CustomEvent('anno-stroke-width-change', {
|
||||
detail: { width }
|
||||
}));
|
||||
},
|
||||
|
||||
setAnnoLineDash(dash) {
|
||||
this.annoLineDash = dash;
|
||||
window.dispatchEvent(new CustomEvent('anno-line-dash-change', {
|
||||
detail: { dash }
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
— {{ 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 %}
|
||||
@@ -0,0 +1,335 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _('Seleziona Ricetta') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>window.__selectRecipeData = {{ recipes|tojson }};</script>
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-7xl"
|
||||
x-data="{
|
||||
recipes: window.__selectRecipeData,
|
||||
search: '{{ auto_recipe_code }}',
|
||||
lot_number: '{{ auto_lot }}',
|
||||
serial_number: '{{ auto_serial }}',
|
||||
barcodeModal: false,
|
||||
barcodeInput: '',
|
||||
barcodeLoading: false,
|
||||
barcodeError: '',
|
||||
get filteredRecipes() {
|
||||
if (!this.search) return this.recipes;
|
||||
const q = this.search.toLowerCase();
|
||||
return this.recipes.filter(r =>
|
||||
(r.name || '').toLowerCase().includes(q) ||
|
||||
(r.code || '').toLowerCase().includes(q) ||
|
||||
(r.description || '').toLowerCase().includes(q)
|
||||
);
|
||||
},
|
||||
buildTaskUrl(recipeId) {
|
||||
let url = '/measure/tasks/' + recipeId + '?';
|
||||
const params = [];
|
||||
if (this.lot_number) params.push('lot_number=' + encodeURIComponent(this.lot_number));
|
||||
if (this.serial_number) params.push('serial_number=' + encodeURIComponent(this.serial_number));
|
||||
return url + params.join('&');
|
||||
},
|
||||
async lookupBarcode() {
|
||||
if (!this.barcodeInput.trim()) return;
|
||||
this.barcodeLoading = true;
|
||||
this.barcodeError = '';
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content;
|
||||
const resp = await fetch('/measure/lookup-barcode', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken || ''
|
||||
},
|
||||
body: JSON.stringify({ code: this.barcodeInput.trim() })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) {
|
||||
this.barcodeError = data.detail || '{{ _("Ricetta non trovata") }}';
|
||||
} else {
|
||||
this.barcodeModal = false;
|
||||
this.barcodeInput = '';
|
||||
window.location.href = this.buildTaskUrl(data.id);
|
||||
}
|
||||
} catch (e) {
|
||||
this.barcodeError = '{{ _("Errore di connessione") }}';
|
||||
} finally {
|
||||
this.barcodeLoading = false;
|
||||
}
|
||||
}
|
||||
}">
|
||||
|
||||
<!-- Page Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="p-2 bg-primary-50 dark:bg-primary-900/30 rounded-lg">
|
||||
<svg class="h-7 w-7 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl sm:text-3xl font-bold text-[var(--text-primary)]">
|
||||
{{ _('Seleziona Ricetta') }}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-[var(--text-secondary)]">
|
||||
{{ _('Scegli la ricetta di misura da eseguire') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-steel-500 dark:text-steel-400">
|
||||
{{ _('Stazione') }}: <span class="font-mono font-bold">{{ station_code }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barcode Button -->
|
||||
<button @click="barcodeModal = true"
|
||||
class="btn btn-secondary gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4h2v16H3V4zm4 0h1v16H7V4zm3 0h2v16h-2V4zm4 0h3v16h-3V4zm5 0h2v16h-2V4z"/>
|
||||
</svg>
|
||||
{{ _('Scansiona Barcode') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search + Traceability Bar -->
|
||||
<div class="tmf-card mb-6">
|
||||
<div class="p-4 sm:p-5">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="sm:col-span-2">
|
||||
<label class="tmf-label">
|
||||
<svg class="w-3.5 h-3.5 inline-block mr-1 -mt-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
|
||||
</svg>
|
||||
{{ _('Cerca ricetta') }}
|
||||
</label>
|
||||
<input type="text"
|
||||
x-model="search"
|
||||
placeholder="{{ _('Nome, codice o descrizione...') }}"
|
||||
class="tmf-input">
|
||||
</div>
|
||||
|
||||
<!-- Lot Number -->
|
||||
<div>
|
||||
<label class="tmf-label">{{ _('Numero Lotto') }}</label>
|
||||
<input type="text"
|
||||
x-model="lot_number"
|
||||
placeholder="{{ _('Opzionale') }}"
|
||||
class="tmf-input">
|
||||
</div>
|
||||
|
||||
<!-- Serial Number -->
|
||||
<div>
|
||||
<label class="tmf-label">{{ _('Numero Seriale') }}</label>
|
||||
<input type="text"
|
||||
x-model="serial_number"
|
||||
placeholder="{{ _('Opzionale') }}"
|
||||
class="tmf-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Count -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-sm text-[var(--text-secondary)]">
|
||||
<span x-text="filteredRecipes.length"></span> {{ _('ricette trovate') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Recipe Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
|
||||
<template x-for="recipe in filteredRecipes" :key="recipe.id">
|
||||
<div class="tmf-card flex flex-col hover:border-primary/30 transition-all duration-200 group">
|
||||
<!-- Card Header -->
|
||||
<div class="p-5 flex-1">
|
||||
<!-- Code Badge + Version -->
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-md text-xs 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>
|
||||
<span class="badge badge-neutral text-xs"
|
||||
x-show="recipe.current_version || recipe.version"
|
||||
x-text="'v' + (recipe.current_version ? recipe.current_version.version_number : (recipe.version || ''))">
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Recipe Name -->
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2 leading-snug"
|
||||
x-text="recipe.name">
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-sm text-[var(--text-secondary)] line-clamp-2 leading-relaxed"
|
||||
x-text="recipe.description || '{{ _('Nessuna descrizione disponibile') }}'">
|
||||
</p>
|
||||
|
||||
<!-- Meta Info -->
|
||||
<div class="mt-4 flex items-center gap-3 text-xs text-[var(--text-muted)]">
|
||||
<template x-if="recipe.task_count != null">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 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>
|
||||
<span x-text="recipe.task_count + ' task'"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="recipe.updated_at">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span x-text="new Date(recipe.updated_at).toLocaleDateString('it-IT')"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Footer -->
|
||||
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
|
||||
rounded-b-xl">
|
||||
<a :href="buildTaskUrl(recipe.id)"
|
||||
class="btn btn-primary w-full justify-center text-sm font-semibold
|
||||
group-hover:shadow-md transition-shadow duration-200">
|
||||
<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>
|
||||
{{ _('Seleziona') }}
|
||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</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-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-1">
|
||||
{{ _('Nessuna ricetta trovata') }}
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto">
|
||||
<span x-show="search">
|
||||
{{ _('Nessun risultato per') }} "<span x-text="search" class="font-medium"></span>".
|
||||
{{ _('Prova con un termine diverso.') }}
|
||||
</span>
|
||||
<span x-show="!search">
|
||||
{{ _('Non ci sono ricette disponibili al momento.') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Barcode Modal -->
|
||||
<div x-show="barcodeModal" x-cloak
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
@keydown.escape.window="barcodeModal = false">
|
||||
<!-- Backdrop -->
|
||||
<div class="absolute inset-0 bg-black/50" @click="barcodeModal = false"></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="relative w-full max-w-md bg-[var(--bg-card)] rounded-xl shadow-2xl
|
||||
border border-[var(--border-color)]"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 scale-95"
|
||||
x-transition:enter-end="opacity-100 scale-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 scale-100"
|
||||
x-transition:leave-end="opacity-0 scale-95"
|
||||
@click.stop>
|
||||
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between p-5 border-b border-[var(--border-color)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-primary-50 dark:bg-primary-900/30 rounded-lg">
|
||||
<svg class="w-5 h-5 text-primary-600 dark:text-primary-400" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 4h2v16H3V4zm4 0h1v16H7V4zm3 0h2v16h-2V4zm4 0h3v16h-3V4zm5 0h2v16h-2V4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{{ _('Scansiona Barcode') }}
|
||||
</h3>
|
||||
</div>
|
||||
<button @click="barcodeModal = false"
|
||||
class="p-1.5 rounded-lg text-[var(--text-muted)] hover:text-[var(--text-primary)]
|
||||
hover:bg-[var(--bg-secondary)] transition-colors">
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-5">
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-4">
|
||||
{{ _('Inserisci o scansiona il codice della ricetta per selezionarla automaticamente.') }}
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="tmf-label">{{ _('Codice Ricetta') }}</label>
|
||||
<input type="text"
|
||||
x-model="barcodeInput"
|
||||
@keydown.enter.prevent="lookupBarcode()"
|
||||
x-ref="barcodeField"
|
||||
x-init="$watch('barcodeModal', val => { if (val) setTimeout(() => $refs.barcodeField.focus(), 100) })"
|
||||
placeholder="{{ _('Es. REC-001') }}"
|
||||
class="tmf-input font-mono text-lg tracking-wider"
|
||||
:disabled="barcodeLoading">
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="barcodeError" x-cloak
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-lg bg-red-50 dark:bg-red-900/20
|
||||
border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 text-sm">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span x-text="barcodeError"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex items-center justify-end gap-3 p-5 border-t border-[var(--border-color)]
|
||||
bg-[var(--bg-secondary)] rounded-b-xl">
|
||||
<button @click="barcodeModal = false; barcodeInput = ''; barcodeError = ''"
|
||||
class="btn btn-secondary">
|
||||
{{ _('Annulla') }}
|
||||
</button>
|
||||
<button @click="lookupBarcode()"
|
||||
:disabled="!barcodeInput.trim() || barcodeLoading"
|
||||
class="btn btn-primary">
|
||||
<svg x-show="barcodeLoading" 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"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
<span x-show="!barcodeLoading">
|
||||
<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>
|
||||
</span>
|
||||
{{ _('Cerca') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,312 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _('Riepilogo') }} - {{ recipe.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<!-- 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('measure.select_recipe') }}"
|
||||
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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
{{ _('Misure') }}
|
||||
</a>
|
||||
</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('measure.task_list', recipe_id=recipe.id) }}"
|
||||
class="hover:text-primary transition-colors">
|
||||
{{ recipe.name }}
|
||||
</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)]">
|
||||
{{ _('Riepilogo') }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Recipe Info Card -->
|
||||
<div class="tmf-card mb-6">
|
||||
<div class="tmf-card-header">
|
||||
<h1 class="text-2xl font-bold" style="color: var(--text-primary);">{{ _('Misurazioni Complete') }}</h1>
|
||||
</div>
|
||||
<div class="tmf-card-body">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Codice') }}</p>
|
||||
<p class="font-mono font-medium" style="color: var(--text-primary);">{{ recipe.code }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Nome') }}</p>
|
||||
<p class="font-medium" style="color: var(--text-primary);">{{ recipe.name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Versione') }}</p>
|
||||
<p class="font-mono font-medium" style="color: var(--text-primary);">{{ recipe.current_version.version_number if recipe.current_version else recipe.version }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Lotto') }}</p>
|
||||
<p class="font-mono font-medium" style="color: var(--text-primary);">{{ lot_number or '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if serial_number %}
|
||||
<div class="mt-3 pt-3" style="border-top: 1px solid var(--border-color);">
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Seriale') }}</p>
|
||||
<p class="font-mono font-medium" style="color: var(--text-primary);">{{ serial_number }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
{% set total_count = measurements|length %}
|
||||
{% set pass_count = measurements|selectattr('pass_fail', 'equalto', 'pass')|list|length %}
|
||||
{% set warning_count = measurements|selectattr('pass_fail', 'equalto', 'warning')|list|length %}
|
||||
{% set fail_count = measurements|selectattr('pass_fail', 'equalto', 'fail')|list|length %}
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<!-- Total -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Totale') }}</p>
|
||||
<p class="text-3xl font-bold" style="color: var(--text-primary);">{{ total_count }}</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg" style="background-color: var(--bg-secondary);">
|
||||
<svg class="h-8 w-8" style="color: var(--text-primary);" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pass -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Conformi') }}</p>
|
||||
<p class="text-3xl font-bold text-measure-pass dark:text-green-400">{{ pass_count }}</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<svg class="h-8 w-8 text-measure-pass dark:text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Attenzione') }}</p>
|
||||
<p class="text-3xl font-bold text-measure-warning dark:text-amber-400">{{ warning_count }}</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-amber-100 dark:bg-amber-900/30">
|
||||
<svg class="h-8 w-8 text-measure-warning dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fail -->
|
||||
<div class="tmf-card">
|
||||
<div class="tmf-card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-muted);">{{ _('Non Conformi') }}</p>
|
||||
<p class="text-3xl font-bold text-measure-fail dark:text-red-400">{{ fail_count }}</p>
|
||||
</div>
|
||||
<div class="p-3 rounded-lg bg-red-100 dark:bg-red-900/30">
|
||||
<svg class="h-8 w-8 text-measure-fail dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Measurements Table -->
|
||||
<div class="tmf-card mb-6">
|
||||
<div class="tmf-card-header flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary);">{{ _('Dettaglio Misurazioni') }}</h2>
|
||||
<button
|
||||
onclick="(function(){ var e = csvExport(); e.exportMeasurements(window.measurementData.measurements, 'misure_' + window.measurementData.recipeCode + '.csv'); })()"
|
||||
class="btn btn-secondary flex items-center gap-2">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||
<span>{{ _('Esporta CSV') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="tmf-card-body overflow-x-auto">
|
||||
<table class="tmf-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-center">{{ _('Data/Ora') }}</th>
|
||||
<th>{{ _('Task') }}</th>
|
||||
<th>{{ _('Descrizione') }}</th>
|
||||
<th class="text-right">{{ _('Nominale') }}</th>
|
||||
<th class="text-right">{{ _('Valore') }}</th>
|
||||
<th class="text-right">{{ _('Deviazione') }}</th>
|
||||
<th class="text-center">{{ _('Esito') }}</th>
|
||||
<th class="text-center">{{ _('Metodo') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in measurements|sort(attribute='task_info.order_index,subtask.marker_number') %}
|
||||
<tr>
|
||||
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
|
||||
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
|
||||
</td>
|
||||
<td style="color: var(--text-secondary);" class="text-sm">
|
||||
{{ m.task_info.title if m.task_info else '-' }}
|
||||
</td>
|
||||
<td style="color: var(--text-primary);">
|
||||
{{ m.subtask.description }}
|
||||
</td>
|
||||
<td class="text-right measure-value">
|
||||
{{ "%.3f"|format(m.subtask.nominal) }} {{ m.subtask.unit }}
|
||||
</td>
|
||||
<td class="text-right measure-value font-semibold" style="color: var(--text-primary);">
|
||||
{{ "%.3f"|format(m.value) }} {{ m.subtask.unit }}
|
||||
</td>
|
||||
<td class="text-right measure-value {% if m.deviation > 0 %}text-measure-fail dark:text-red-400{% elif m.deviation < 0 %}text-measure-pass dark:text-green-400{% else %}{% endif %}">
|
||||
{{ "%+.3f"|format(m.deviation) }}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if m.pass_fail == 'pass' %}
|
||||
<span class="badge badge-pass">{{ _('Pass') }}</span>
|
||||
{% elif m.pass_fail == 'warning' %}
|
||||
<span class="badge badge-warning">{{ _('Warning') }}</span>
|
||||
{% elif m.pass_fail == 'fail' %}
|
||||
<span class="badge badge-fail">{{ _('Fail') }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-neutral">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{% if m.input_method == 'manual' %}
|
||||
<div class="inline-flex items-center gap-1" style="color: var(--text-secondary);">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||
</svg>
|
||||
<span class="text-sm">{{ _('Manuale') }}</span>
|
||||
</div>
|
||||
{% elif m.input_method == 'usb_caliper' %}
|
||||
<div class="inline-flex items-center gap-1" style="color: var(--text-secondary);">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
|
||||
</svg>
|
||||
<span class="text-sm">{{ _('Calibro') }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span class="text-sm" style="color: var(--text-muted);">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Footer -->
|
||||
<div class="flex flex-col sm:flex-row gap-3 justify-between items-center">
|
||||
<div class="flex flex-col sm:flex-row gap-3 w-full sm:w-auto">
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="btn btn-secondary w-full sm:w-auto flex items-center justify-center gap-2">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12"/>
|
||||
</svg>
|
||||
<span>{{ _('Seleziona altra ricetta') }}</span>
|
||||
</a>
|
||||
<a href="{{ url_for('measure.task_list', recipe_id=recipe.id) }}"
|
||||
class="btn btn-secondary w-full sm:w-auto flex items-center justify-center gap-2">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
||||
</svg>
|
||||
<span>{{ _('Ripeti misurazioni') }}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% if current_user.get('roles') and 'Metrologist' in current_user.get('roles', []) %}
|
||||
<a href="{{ url_for('statistics.dashboard') }}"
|
||||
class="btn btn-primary w-full sm:w-auto flex items-center justify-center gap-2">
|
||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
<span>{{ _('Statistiche') }}</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{{ url_for('static', filename='js/csv-export.js') }}"></script>
|
||||
<script>
|
||||
window.CSV_I18N = {
|
||||
subtask_id: "{{ _('Subtask ID') }}",
|
||||
subtask_name: "{{ _('Nome Sottotask') }}",
|
||||
measured_value: "{{ _('Valore Misurato') }}",
|
||||
unit: "{{ _('Unita') }}",
|
||||
nominal_value: "{{ _('Valore Nominale') }}",
|
||||
tolerance_plus: "{{ _('Tolleranza +') }}",
|
||||
tolerance_minus: "{{ _('Tolleranza -') }}",
|
||||
deviation: "{{ _('Scarto') }}",
|
||||
result: "{{ _('Esito') }}",
|
||||
lot_number: "{{ _('Numero Lotto') }}",
|
||||
serial_number: "{{ _('Numero Seriale') }}",
|
||||
input_method: "{{ _('Metodo Input') }}",
|
||||
measurement_date: "{{ _('Data Misurazione') }}",
|
||||
operator: "{{ _('Operatore') }}",
|
||||
task_summary_title: "{{ _('RIEPILOGO ESECUZIONE TASK') }}",
|
||||
task_id: "{{ _('Task ID') }}",
|
||||
task_name: "{{ _('Nome Task') }}",
|
||||
recipe: "{{ _('Ricetta') }}",
|
||||
start_date: "{{ _('Data Inizio') }}",
|
||||
end_date: "{{ _('Data Fine') }}",
|
||||
status: "{{ _('Stato') }}",
|
||||
lot: "{{ _('Lotto') }}",
|
||||
serial: "{{ _('Seriale') }}",
|
||||
statistics: "{{ _('STATISTICHE') }}",
|
||||
total_measurements: "{{ _('Totale Misure') }}",
|
||||
passed: "{{ _('Passate') }}",
|
||||
failed: "{{ _('Fallite') }}",
|
||||
pass_rate: "{{ _('Percentuale Successo') }}",
|
||||
measurement_details: "{{ _('DETTAGLIO MISURE') }}"
|
||||
};
|
||||
</script>
|
||||
<script>
|
||||
// Pass data to csv-export.js
|
||||
window.measurementData = {
|
||||
recipeName: {{ recipe.name|tojson }},
|
||||
recipeCode: {{ recipe.code|tojson }},
|
||||
version: {{ (recipe.current_version.version_number if recipe.current_version else recipe.version)|tojson }},
|
||||
lotNumber: {{ (lot_number or '')|tojson }},
|
||||
serialNumber: {{ (serial_number or '')|tojson }},
|
||||
measurements: {{ measurements|tojson }}
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,708 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* Annotation viewer container */
|
||||
.annotation-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
.annotation-container canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Tolerance bar gradient background */
|
||||
.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%
|
||||
);
|
||||
}
|
||||
|
||||
/* Pulse animation for active marker */
|
||||
@keyframes markerPulse {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); }
|
||||
50% { box-shadow: 0 0 0 6px rgba(37, 99, 235, 0); }
|
||||
}
|
||||
.marker-active-pulse {
|
||||
animation: markerPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Left sidebar scrollbar */
|
||||
.sidebar-markers::-webkit-scrollbar { width: 3px; }
|
||||
.sidebar-markers::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
|
||||
|
||||
/* Right panel scrollbar */
|
||||
.right-panel::-webkit-scrollbar { width: 4px; }
|
||||
.right-panel::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<script>
|
||||
window.__taskData = {{ task|tojson }};
|
||||
window.__taskSubtasks = {{ task.subtasks|tojson if task.subtasks else '[]' }};
|
||||
window.__taskAnnotations = {{ task.annotations_json|default('null')|tojson }};
|
||||
window.__allTaskIds = {{ all_task_ids|tojson }};
|
||||
</script>
|
||||
|
||||
<div class="h-screen flex flex-col overflow-hidden"
|
||||
x-data="taskExecute()"
|
||||
x-init="init()"
|
||||
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
||||
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)">
|
||||
|
||||
{# ================================================================
|
||||
HEADER — Compact task info bar
|
||||
================================================================ #}
|
||||
<div class="shrink-0 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm z-20">
|
||||
<div class="px-3 py-2 flex items-center gap-3">
|
||||
|
||||
{# Back button #}
|
||||
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
||||
class="shrink-0 p-1.5 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
{# Task badge + title #}
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
|
||||
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
|
||||
border border-primary-200 dark:border-primary-800">
|
||||
Task {{ (task.order_index or 0) + 1 }}
|
||||
</span>
|
||||
<h1 class="text-sm font-bold text-[var(--text-primary)] truncate">
|
||||
{{ task.title or _('Task di misurazione') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{# Lot + Serial badges #}
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
{% if lot_number %}
|
||||
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
|
||||
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||
text-amber-800 dark:text-amber-200">
|
||||
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
<span class="font-mono font-semibold">{{ lot_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if serial_number %}
|
||||
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
|
||||
bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800
|
||||
text-indigo-800 dark:text-indigo-200">
|
||||
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
<span class="font-mono font-semibold">{{ serial_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Caliper status #}
|
||||
{% include "components/caliper_status.html" %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
MAIN — 3-column layout
|
||||
================================================================ #}
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
LEFT SIDEBAR — Marker list (vertical)
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
|
||||
<template x-for="(st, idx) in subtasks" :key="st.id">
|
||||
<button @click="goToSubtask(idx)"
|
||||
class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
|
||||
:class="idx === currentIndex
|
||||
? 'bg-primary-50 dark:bg-primary-900/20 border-l-[3px] border-l-primary'
|
||||
: 'hover:bg-[var(--bg-secondary)] border-l-[3px] border-l-transparent'">
|
||||
|
||||
{# Marker circle #}
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-all"
|
||||
:class="idx === currentIndex
|
||||
? 'bg-primary text-white shadow-md marker-active-pulse'
|
||||
: getMeasurementStatus(st.id) === 'pass'
|
||||
? 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40'
|
||||
: getMeasurementStatus(st.id) === 'fail'
|
||||
? 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40'
|
||||
: getMeasurementStatus(st.id) === 'warning'
|
||||
? 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40'
|
||||
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]'"
|
||||
x-text="st.marker_number"></span>
|
||||
|
||||
{# Status icon below circle #}
|
||||
<template x-if="getMeasurementStatus(st.id) === 'pass'">
|
||||
<svg class="w-3 h-3 mt-0.5 text-measure-pass" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="getMeasurementStatus(st.id) === 'fail'">
|
||||
<svg class="w-3 h-3 mt-0.5 text-measure-fail" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="getMeasurementStatus(st.id) === 'warning'">
|
||||
<svg class="w-3 h-3 mt-0.5 text-measure-warning" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
||||
</svg>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
CENTER — Image area
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="flex-1 flex flex-col overflow-hidden bg-[var(--bg-secondary)]">
|
||||
|
||||
{# Main image area #}
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
|
||||
{# Option A: Subtask has its own image → plain <img> #}
|
||||
<div x-show="showSubtaskImage" class="absolute inset-0 flex items-center justify-center p-2">
|
||||
<img :src="'/measure/api/files/' + currentSubtaskImage"
|
||||
alt="{{ _('Immagine dettaglio misura') }}"
|
||||
class="max-w-full max-h-full object-contain rounded-lg shadow-sm">
|
||||
</div>
|
||||
|
||||
{# Option B: Task image with annotations → annotation-viewer (kept in DOM via x-show) #}
|
||||
<div x-show="showTaskImage" class="absolute inset-0">
|
||||
{% if task.file_path %}
|
||||
<div class="annotation-container"
|
||||
x-data="annotationViewer()"
|
||||
x-init="
|
||||
imageUrl = '/measure/api/files/{{ task.file_path }}';
|
||||
annotations = window.__taskAnnotations;
|
||||
$nextTick(() => init());
|
||||
"
|
||||
x-effect="setActiveMarker(currentSubtask?.marker_number || 0)">
|
||||
<canvas x-ref="annotationCanvas"
|
||||
@click="handleClick($event)"
|
||||
class="cursor-pointer"></canvas>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Option C: No image → placeholder #}
|
||||
<div x-show="!showSubtaskImage && !showTaskImage" class="absolute inset-0 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<svg class="w-16 h-16 mx-auto text-[var(--text-muted)] mb-3 opacity-30" 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 font-medium text-[var(--text-muted)]">{{ _('Nessuna immagine allegata') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ──────────────────────────────────────────────
|
||||
RIGHT PANEL — Info + tolerances + numpad
|
||||
────────────────────────────────────────────── #}
|
||||
<div class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
|
||||
|
||||
{# ---- Subtask header ---- #}
|
||||
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full
|
||||
bg-primary text-white font-bold text-sm shadow marker-active-pulse"
|
||||
x-text="currentSubtask?.marker_number"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="font-semibold text-[var(--text-primary)] text-sm leading-tight truncate"
|
||||
x-text="currentSubtask?.description || '{{ _('Misurazione') }}'"></h2>
|
||||
<span class="text-[11px] text-[var(--text-muted)]"
|
||||
x-text="(currentSubtask?.measurement_type || '{{ _('Misura') }}') + ' (' + (currentSubtask?.unit || 'mm') + ')'"></span>
|
||||
</div>
|
||||
<span class="shrink-0 text-[11px] font-mono text-[var(--text-muted)] bg-[var(--bg-secondary)] px-1.5 py-0.5 rounded">
|
||||
<span x-text="currentIndex + 1"></span>/<span x-text="totalSubtasks"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Tolerance parameters (compact grid) ---- #}
|
||||
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
||||
{# Nominal row #}
|
||||
<div class="flex items-center justify-between py-1 px-2 rounded bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-800 mb-1.5">
|
||||
<span class="text-[10px] font-medium text-primary-700 dark:text-primary-300 uppercase tracking-wider">{{ _('Nominale') }}</span>
|
||||
<span class="font-mono font-bold text-primary text-sm"
|
||||
x-text="currentSubtask?.nominal?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
|
||||
{# 2x2 tolerance grid #}
|
||||
<div class="grid grid-cols-2 gap-1 text-[11px]">
|
||||
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
|
||||
<span class="text-measure-fail font-medium">UTL</span>
|
||||
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.utl?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
|
||||
<span class="text-measure-fail font-medium">LTL</span>
|
||||
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.ltl?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
|
||||
<span class="text-measure-warning font-medium">UWL</span>
|
||||
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.uwl?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
|
||||
<span class="text-measure-warning font-medium">LWL</span>
|
||||
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.lwl?.toFixed(3) || '—'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Tolerance bar (compact) ---- #}
|
||||
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
||||
<div class="tolerance-bar-bg h-5 rounded-full relative overflow-hidden border border-[var(--border-color)]">
|
||||
{# Zone labels #}
|
||||
<div class="absolute inset-0 flex items-center justify-between px-1.5 text-[8px] font-mono text-[var(--text-muted)]">
|
||||
<span x-text="currentSubtask?.ltl?.toFixed(2)"></span>
|
||||
<span x-text="currentSubtask?.nominal?.toFixed(2)"></span>
|
||||
<span x-text="currentSubtask?.utl?.toFixed(2)"></span>
|
||||
</div>
|
||||
{# Center line #}
|
||||
<div class="absolute top-0 bottom-0 w-px bg-[var(--text-muted)]/30" style="left: 50%;"></div>
|
||||
{# Value needle #}
|
||||
<div x-show="currentValue !== null"
|
||||
x-transition
|
||||
class="absolute top-0 bottom-0 w-1 rounded-full transition-all duration-300"
|
||||
:class="{
|
||||
'bg-measure-pass': passFailStatus === 'pass',
|
||||
'bg-measure-warning': passFailStatus === 'warning',
|
||||
'bg-measure-fail': passFailStatus === 'fail'
|
||||
}"
|
||||
:style="'left: calc(' + progressWidth + '% - 2px)'">
|
||||
<div class="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-800 shadow"
|
||||
:class="{
|
||||
'bg-measure-pass': passFailStatus === 'pass',
|
||||
'bg-measure-warning': passFailStatus === 'warning',
|
||||
'bg-measure-fail': passFailStatus === 'fail'
|
||||
}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Already measured indicator ---- #}
|
||||
<div x-show="isMeasured(currentSubtask?.id)"
|
||||
class="mx-3 mt-2 p-2 rounded-lg bg-measure-pass/10 border border-measure-pass/20">
|
||||
<div class="flex items-center gap-1.5 text-xs text-measure-pass font-medium">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" 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>
|
||||
<span>{{ _('Registrata') }}:</span>
|
||||
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
|
||||
<span x-text="currentSubtask?.unit || 'mm'"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---- Measurement feedback ---- #}
|
||||
<div class="px-3 pt-2" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
|
||||
get utl() { return currentSubtask?.utl || 0; },
|
||||
get uwl() { return currentSubtask?.uwl || 0; },
|
||||
get lwl() { return currentSubtask?.lwl || 0; },
|
||||
get ltl() { return currentSubtask?.ltl || 0; },
|
||||
get unit() { return currentSubtask?.unit || 'mm'; } }">
|
||||
{% include "components/measurement_feedback.html" %}
|
||||
</div>
|
||||
|
||||
{# ---- Numpad ---- #}
|
||||
<div class="px-3 py-2 relative flex-1">
|
||||
{# Saving overlay #}
|
||||
<div x-show="saving"
|
||||
x-transition
|
||||
class="absolute inset-0 z-10 bg-white/70 dark:bg-slate-900/70 flex items-center justify-center backdrop-blur-sm rounded">
|
||||
<div class="flex items-center gap-2 text-primary font-medium text-sm">
|
||||
<svg class="animate-spin w-4 h-4" 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-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
{{ _('Salvataggio...') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include "components/numpad.html" %}
|
||||
</div>
|
||||
|
||||
{# ---- Next measurement indicator ---- #}
|
||||
<div class="px-3 pb-2">
|
||||
{% include "components/next_measurement.html" %}
|
||||
</div>
|
||||
|
||||
{# ---- Error message ---- #}
|
||||
<div x-show="errorMessage"
|
||||
x-transition
|
||||
x-cloak
|
||||
class="mx-3 mb-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-xs text-measure-fail">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
<span x-text="errorMessage"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
FOOTER — Progress bar + navigation
|
||||
================================================================ #}
|
||||
<div class="shrink-0 bg-[var(--bg-card)] border-t border-[var(--border-color)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)] z-20">
|
||||
<div class="px-3 py-2 flex items-center gap-3">
|
||||
|
||||
{# Left: Back #}
|
||||
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
||||
class="btn btn-secondary text-xs shrink-0 gap-1 py-1.5 px-2.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 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">{{ _('Task') }}</span>
|
||||
</a>
|
||||
|
||||
{# Center: Progress bar #}
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-[var(--text-secondary)] shrink-0 font-mono">
|
||||
<span x-text="completedCount"></span>/<span x-text="totalSubtasks"></span>
|
||||
</span>
|
||||
<div class="flex-1 h-2 rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] overflow-hidden">
|
||||
<div class="h-full rounded-full transition-all duration-500 ease-out"
|
||||
:class="isComplete ? 'bg-measure-pass' : 'bg-primary'"
|
||||
:style="'width: ' + progressPercent + '%'"></div>
|
||||
</div>
|
||||
<span class="text-xs font-bold shrink-0"
|
||||
:class="isComplete ? 'text-measure-pass' : 'text-primary'"
|
||||
x-text="Math.round(progressPercent) + '%'"></span>
|
||||
</div>
|
||||
|
||||
{# Right: Summary button #}
|
||||
<button x-show="isComplete"
|
||||
x-transition
|
||||
@click="goToSummary()"
|
||||
class="btn btn-primary text-xs shrink-0 gap-1 py-1.5 px-2.5 shadow-md">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Riepilogo') }}
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ================================================================
|
||||
COMPLETION OVERLAY
|
||||
================================================================ #}
|
||||
<div x-show="showCompletionOverlay"
|
||||
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"
|
||||
x-cloak
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center"
|
||||
x-transition:enter="transition ease-out duration-300 delay-100"
|
||||
x-transition:enter-start="opacity-0 scale-90"
|
||||
x-transition:enter-end="opacity-100 scale-100">
|
||||
|
||||
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-measure-pass/10 mb-4">
|
||||
<svg class="w-8 h-8 text-measure-pass" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">{{ _('Misurazioni Complete') }}</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-2">
|
||||
{{ _('Tutte le') }} <span class="font-bold font-mono" x-text="totalSubtasks"></span> {{ _('misurazioni sono state registrate.') }}
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center gap-4 mb-5">
|
||||
<div class="text-center">
|
||||
<div class="text-xl font-bold font-mono text-measure-pass" x-text="passCount"></div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Conformi') }}</div>
|
||||
</div>
|
||||
<div class="text-center" x-show="warningCount > 0">
|
||||
<div class="text-xl font-bold font-mono text-measure-warning" x-text="warningCount"></div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Attenzione') }}</div>
|
||||
</div>
|
||||
<div class="text-center" x-show="failCount > 0">
|
||||
<div class="text-xl font-bold font-mono text-measure-fail" x-text="failCount"></div>
|
||||
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Non Conf.') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button @click="goToSummary()"
|
||||
class="btn btn-primary w-full justify-center gap-2">
|
||||
{{ _('Vai al Riepilogo') }}
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||
<script>
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
||||
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Task Execute - Main Alpine.js component
|
||||
* Manages measurement workflow state, save logic, and navigation.
|
||||
* Layout: 3-column (marker sidebar | image center | info+numpad right)
|
||||
*/
|
||||
function taskExecute() {
|
||||
return {
|
||||
// ---- Data from server ----
|
||||
task: window.__taskData,
|
||||
subtasks: window.__taskSubtasks,
|
||||
lotNumber: '{{ lot_number or '' }}',
|
||||
serialNumber: '{{ serial_number or '' }}',
|
||||
// ---- Measurement state ----
|
||||
currentIndex: 0,
|
||||
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
|
||||
saving: false,
|
||||
errorMessage: '',
|
||||
showCompletionOverlay: false,
|
||||
|
||||
// ---- Value from numpad / caliper ----
|
||||
currentValue: null,
|
||||
|
||||
// ---- Image switching logic ----
|
||||
get currentSubtaskImage() {
|
||||
return this.currentSubtask?.image_path || null;
|
||||
},
|
||||
get showSubtaskImage() {
|
||||
return !!this.currentSubtaskImage;
|
||||
},
|
||||
get showTaskImage() {
|
||||
return !this.currentSubtaskImage && !!this.task.file_path;
|
||||
},
|
||||
|
||||
// ---- Computed properties ----
|
||||
get currentSubtask() {
|
||||
return this.subtasks[this.currentIndex] || null;
|
||||
},
|
||||
|
||||
get nextSubtask() {
|
||||
return this.subtasks[this.currentIndex + 1] || null;
|
||||
},
|
||||
|
||||
get totalSubtasks() {
|
||||
return this.subtasks.length;
|
||||
},
|
||||
|
||||
get completedCount() {
|
||||
return this.measurements.length;
|
||||
},
|
||||
|
||||
get isComplete() {
|
||||
return this.completedCount >= this.totalSubtasks;
|
||||
},
|
||||
|
||||
get progressPercent() {
|
||||
return this.totalSubtasks > 0
|
||||
? (this.completedCount / this.totalSubtasks * 100)
|
||||
: 0;
|
||||
},
|
||||
|
||||
// ---- Pass/fail logic ----
|
||||
get passFailStatus() {
|
||||
if (this.currentValue === null || !this.currentSubtask) return null;
|
||||
const v = this.currentValue;
|
||||
const s = this.currentSubtask;
|
||||
if (s.utl != null && v > s.utl) return 'fail';
|
||||
if (s.ltl != null && v < s.ltl) return 'fail';
|
||||
if (s.uwl != null && v > s.uwl) return 'warning';
|
||||
if (s.lwl != null && v < s.lwl) return 'warning';
|
||||
return 'pass';
|
||||
},
|
||||
|
||||
get deviation() {
|
||||
if (this.currentValue === null || !this.currentSubtask) return null;
|
||||
return this.currentValue - (this.currentSubtask.nominal || 0);
|
||||
},
|
||||
|
||||
get progressWidth() {
|
||||
if (!this.currentSubtask || this.currentValue === null) return 50;
|
||||
const s = this.currentSubtask;
|
||||
if (s.utl == null || s.ltl == null) return 50;
|
||||
const range = s.utl - s.ltl;
|
||||
if (range <= 0) return 50;
|
||||
const pos = (this.currentValue - s.ltl) / range * 100;
|
||||
return Math.max(0, Math.min(100, pos));
|
||||
},
|
||||
|
||||
// ---- Summary counts ----
|
||||
get passCount() {
|
||||
return this.measurements.filter(m => m.pass_fail === 'pass').length;
|
||||
},
|
||||
get warningCount() {
|
||||
return this.measurements.filter(m => m.pass_fail === 'warning').length;
|
||||
},
|
||||
get failCount() {
|
||||
return this.measurements.filter(m => m.pass_fail === 'fail').length;
|
||||
},
|
||||
|
||||
// ---- Init ----
|
||||
init() {
|
||||
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
||||
},
|
||||
|
||||
// ---- Check if a subtask has been measured ----
|
||||
isMeasured(subtaskId) {
|
||||
return this.measurements.some(m => m.subtask_id === subtaskId);
|
||||
},
|
||||
|
||||
getMeasuredValue(subtaskId) {
|
||||
const m = this.measurements.find(m => m.subtask_id === subtaskId);
|
||||
return m ? m.value : null;
|
||||
},
|
||||
|
||||
getMeasurementStatus(subtaskId) {
|
||||
const m = this.measurements.find(m => m.subtask_id === subtaskId);
|
||||
return m ? m.pass_fail : null;
|
||||
},
|
||||
|
||||
// ---- Handle numpad confirm ----
|
||||
async handleMeasurement(value, inputMethod) {
|
||||
if (!this.currentSubtask || this.saving) return;
|
||||
|
||||
this.currentValue = value;
|
||||
this.errorMessage = '';
|
||||
|
||||
const pf = this.passFailStatus;
|
||||
const dev = this.deviation;
|
||||
|
||||
this.saving = true;
|
||||
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
||||
|
||||
const response = await fetch('{{ url_for("measure.save_measurement") }}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subtask_id: this.currentSubtask.id,
|
||||
version_id: this.task.version_id,
|
||||
value: value,
|
||||
lot_number: this.lotNumber,
|
||||
serial_number: this.serialNumber,
|
||||
input_method: inputMethod || 'manual',
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || result.error) {
|
||||
this.errorMessage = result.detail || '{{ _("Errore nel salvataggio della misurazione") }}';
|
||||
this.saving = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Record measurement locally
|
||||
const existingIdx = this.measurements.findIndex(m => m.subtask_id === this.currentSubtask.id);
|
||||
const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: pf, deviation: dev };
|
||||
if (existingIdx !== -1) {
|
||||
this.measurements.splice(existingIdx, 1, mEntry);
|
||||
} else {
|
||||
this.measurements.push(mEntry);
|
||||
}
|
||||
|
||||
this.saving = false;
|
||||
|
||||
// Pause to show result feedback
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
|
||||
// Check if all done
|
||||
if (this.completedCount >= this.totalSubtasks) {
|
||||
const taskIds = window.__allTaskIds || [];
|
||||
const currentIdx = taskIds.indexOf(this.task.id);
|
||||
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
|
||||
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
|
||||
} else {
|
||||
this.showCompletionOverlay = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.advanceToNext();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Save measurement error:', err);
|
||||
this.errorMessage = '{{ _("Errore di rete. Riprovare.") }}';
|
||||
this.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Advance to next unmeasured subtask ----
|
||||
advanceToNext() {
|
||||
this.currentValue = null;
|
||||
|
||||
for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) {
|
||||
if (!this.isMeasured(this.subtasks[i].id)) {
|
||||
this.currentIndex = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < this.currentIndex; i++) {
|
||||
if (!this.isMeasured(this.subtasks[i].id)) {
|
||||
this.currentIndex = i;
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Navigation ----
|
||||
goToSubtask(index) {
|
||||
if (index >= 0 && index < this.totalSubtasks) {
|
||||
this.currentIndex = index;
|
||||
this.currentValue = null;
|
||||
this.errorMessage = '';
|
||||
}
|
||||
},
|
||||
|
||||
goToSubtaskByMarker(markerNumber) {
|
||||
const idx = this.subtasks.findIndex(s => s.marker_number === markerNumber);
|
||||
if (idx !== -1) {
|
||||
this.goToSubtask(idx);
|
||||
}
|
||||
},
|
||||
|
||||
// ---- Go to summary ----
|
||||
goToSummary() {
|
||||
const recipeId = this.task.recipe_id || 0;
|
||||
window.location.href = '{{ url_for("measure.task_complete", recipe_id=0) }}'.replace('/0', '/' + recipeId) +
|
||||
'?version_id=' + encodeURIComponent(this.task.version_id || '');
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,247 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ recipe.name }} — {{ _('Task') }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl">
|
||||
|
||||
<!-- 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('measure.select_recipe') }}"
|
||||
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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
|
||||
</svg>
|
||||
{{ _('Misure') }}
|
||||
</a>
|
||||
</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)]">
|
||||
{{ recipe.name }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<!-- Recipe Info Card -->
|
||||
<div class="tmf-card mb-8">
|
||||
<div class="p-5 sm:p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
|
||||
<!-- Left: Recipe Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3 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">
|
||||
{{ recipe.code }}
|
||||
</span>
|
||||
|
||||
<!-- Version Badge -->
|
||||
{% if recipe.current_version or recipe.version %}
|
||||
<span class="badge badge-neutral">
|
||||
v{{ recipe.current_version.version_number if recipe.current_version else recipe.version }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)] mb-2">
|
||||
{{ recipe.name }}
|
||||
</h1>
|
||||
|
||||
{% if recipe.description %}
|
||||
<p class="text-sm text-[var(--text-secondary)] leading-relaxed max-w-2xl">
|
||||
{{ recipe.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right: Traceability Badges -->
|
||||
<div class="flex flex-col gap-2 sm:items-end shrink-0">
|
||||
{% if lot_number %}
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
||||
text-amber-800 dark:text-amber-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||||
</svg>
|
||||
<span class="font-medium">{{ _('Lotto') }}:</span>
|
||||
<span class="font-mono font-semibold">{{ lot_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if serial_number %}
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm
|
||||
bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800
|
||||
text-indigo-800 dark:text-indigo-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
<span class="font-medium">{{ _('Seriale') }}:</span>
|
||||
<span class="font-mono font-semibold">{{ serial_number }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Task Count Header -->
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-[var(--text-secondary)]" 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>
|
||||
{{ _('Task da eseguire') }}
|
||||
</h2>
|
||||
<span class="badge badge-neutral">
|
||||
{{ tasks|length }} task
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Task Cards -->
|
||||
{% if tasks %}
|
||||
<div class="space-y-4">
|
||||
{% for task in tasks %}
|
||||
<div class="tmf-card hover:border-primary/30 transition-all duration-200 group">
|
||||
<div class="p-5 sm:p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
|
||||
<!-- Task Number Circle -->
|
||||
<div class="flex items-center justify-center w-12 h-12 rounded-full shrink-0
|
||||
bg-primary-50 dark:bg-primary-900/30 border-2 border-primary-200 dark:border-primary-700
|
||||
text-primary-700 dark:text-primary-300 font-bold text-lg">
|
||||
{{ loop.index }}
|
||||
</div>
|
||||
|
||||
<!-- Task Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Task Header -->
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium text-[var(--text-muted)] uppercase tracking-wider">
|
||||
Task {{ loop.index }} {{ _('di') }} {{ tasks|length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Task Title -->
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||
{{ task.title or task.name or (_('Task') ~ ' ' ~ loop.index) }}
|
||||
</h3>
|
||||
|
||||
<!-- Directive -->
|
||||
{% if task.directive or task.description %}
|
||||
<p class="text-sm text-[var(--text-secondary)] leading-relaxed mb-3 line-clamp-2">
|
||||
{{ task.directive or task.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Meta Row -->
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<!-- Subtask Count -->
|
||||
{% if task.subtask_count is defined or task.subtasks %}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<span class="font-medium">
|
||||
{% if task.subtask_count is defined %}
|
||||
{{ task.subtask_count }}
|
||||
{% elif task.subtasks %}
|
||||
{{ task.subtasks|length }}
|
||||
{% endif %}
|
||||
{{ _('misurazioni') }}
|
||||
</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
<!-- File Attachment -->
|
||||
{% if task.file_path %}
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-[var(--text-secondary)]">
|
||||
{% if task.file_path.endswith('.pdf') %}
|
||||
<svg class="w-4 h-4 text-red-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 text-green-500" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<span class="font-medium">{{ _('Allegato') }}</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Button -->
|
||||
<div class="shrink-0 sm:ml-4">
|
||||
<a href="{{ url_for('measure.task_execute', task_id=task.id) }}"
|
||||
class="btn btn-primary gap-2 w-full sm:w-auto justify-center
|
||||
group-hover:shadow-md transition-shadow duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
{{ _('Inizia Misure') }}
|
||||
<svg class="w-4 h-4 transition-transform group-hover:translate-x-0.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-16">
|
||||
<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="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-1">
|
||||
{{ _('Nessun task disponibile') }}
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto">
|
||||
{{ _('Questa ricetta non ha ancora task definiti.') }}
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
<div class="mt-8 flex items-center justify-between">
|
||||
<a href="{{ url_for('measure.select_recipe') }}"
|
||||
class="btn btn-secondary gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
{{ _('Seleziona altra ricetta') }}
|
||||
</a>
|
||||
|
||||
{% if tasks %}
|
||||
<div class="text-sm text-[var(--text-secondary)]">
|
||||
{{ tasks|length }} task ·
|
||||
{% set total_subs = namespace(count=0) %}
|
||||
{% for t in tasks %}
|
||||
{% if t.subtask_count is defined %}
|
||||
{% set total_subs.count = total_subs.count + t.subtask_count %}
|
||||
{% elif t.subtasks %}
|
||||
{% set total_subs.count = total_subs.count + t.subtasks|length %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if total_subs.count > 0 %}
|
||||
{{ total_subs.count }} {{ _('misurazioni totali') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,465 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _("Statistiche SPC") }} — TieMeasureFlow{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Plotly.js CDN -->
|
||||
<script src="https://cdn.plot.ly/plotly-2.32.0.min.js" charset="utf-8"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-6"
|
||||
x-data="spcDashboard()"
|
||||
x-init="init()">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{{ _("Statistiche SPC") }}</h1>
|
||||
<p class="text-sm text-steel mt-1">{{ _("Analisi statistica di processo per le misurazioni") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Filtri -->
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-4 mb-6 shadow-sm">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
|
||||
<!-- Ricetta -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Ricetta") }}</label>
|
||||
<select x-model="selectedRecipeId"
|
||||
@change="onRecipeChange()"
|
||||
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
<option value="">{{ _("Seleziona ricetta...") }}</option>
|
||||
{% for r in recipes %}
|
||||
<option value="{{ r.id }}">{{ r.code }} — {{ r.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Subtask -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Punto di misura") }}</label>
|
||||
<select x-model="selectedSubtaskId"
|
||||
:disabled="subtasks.length === 0"
|
||||
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary disabled:opacity-50">
|
||||
<option value="">{{ _("Tutti i punti") }}</option>
|
||||
<template x-for="st in subtasks" :key="st.id">
|
||||
<option :value="st.id" x-text="'#' + st.marker_number + ' — ' + st.description"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Date range -->
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Dal") }}</label>
|
||||
<input type="date" x-model="dateFrom"
|
||||
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-steel mb-1">{{ _("Al") }}</label>
|
||||
<input type="date" x-model="dateTo"
|
||||
class="w-full rounded-lg border border-[var(--border-color)] bg-[var(--bg-primary)] text-[var(--text-primary)] px-3 py-2 text-sm focus:ring-2 focus:ring-primary focus:border-primary">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Apply button + Download -->
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<!-- Download buttons (visible only when data is loaded) -->
|
||||
<div class="flex gap-2" x-show="hasData" x-cloak>
|
||||
<button @click="downloadReport('spc')"
|
||||
:disabled="!selectedSubtaskId || downloadingReport"
|
||||
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
|
||||
<template x-if="downloadingReport === 'spc'">
|
||||
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="downloadingReport !== 'spc'">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
|
||||
{{ _("Report SPC") }}
|
||||
</button>
|
||||
<button @click="downloadReport('measurements')"
|
||||
:disabled="downloadingReport"
|
||||
class="px-4 py-2 bg-[var(--bg-primary)] hover:bg-[var(--bg-secondary)] text-[var(--text-primary)] border border-[var(--border-color)] rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
|
||||
<template x-if="downloadingReport === 'measurements'">
|
||||
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
</template>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" x-show="downloadingReport !== 'measurements'">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
|
||||
{{ _("Report Misurazioni") }}
|
||||
</button>
|
||||
</div>
|
||||
<div x-show="!hasData"></div>
|
||||
|
||||
<button @click="loadData()"
|
||||
:disabled="!selectedRecipeId || loading"
|
||||
class="px-5 py-2 bg-primary hover:bg-primary-dark text-white rounded-lg text-sm font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2">
|
||||
<template x-if="loading">
|
||||
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
</template>
|
||||
{{ _("Applica filtri") }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Inline report error (shown below buttons row) -->
|
||||
<div x-show="reportError" x-cloak
|
||||
class="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1"
|
||||
x-text="reportError"
|
||||
x-transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nessuna ricetta selezionata -->
|
||||
<template x-if="!selectedRecipeId && !loading">
|
||||
<div class="text-center py-16 text-steel">
|
||||
<svg class="mx-auto h-16 w-16 text-steel-light mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
|
||||
</svg>
|
||||
<p class="text-lg font-medium">{{ _("Seleziona una ricetta per iniziare") }}</p>
|
||||
<p class="text-sm mt-1">{{ _("Scegli una ricetta dal filtro e premi Applica filtri") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Contenuto dashboard -->
|
||||
<template x-if="hasData">
|
||||
<div>
|
||||
<!-- Row 1: Riepilogo + Capability -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
|
||||
<!-- Riepilogo Pass/Fail -->
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm">
|
||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Riepilogo") }}</h2>
|
||||
<template x-if="summary">
|
||||
<div>
|
||||
<div class="text-3xl font-bold font-mono text-[var(--text-primary)] mb-3" x-text="summary.total + ' misure'"></div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<!-- Pass -->
|
||||
<div class="text-center p-3 rounded-lg bg-emerald-50 dark:bg-emerald-900/20">
|
||||
<div class="text-2xl font-bold font-mono text-emerald-600" x-text="summary.pass_rate + '%'"></div>
|
||||
<div class="text-xs text-emerald-700 dark:text-emerald-400 mt-1">{{ _("Pass") }}</div>
|
||||
<div class="text-xs text-steel font-mono" x-text="summary.pass_count"></div>
|
||||
</div>
|
||||
<!-- Warning -->
|
||||
<div class="text-center p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20">
|
||||
<div class="text-2xl font-bold font-mono text-amber-600" x-text="summary.warning_rate + '%'"></div>
|
||||
<div class="text-xs text-amber-700 dark:text-amber-400 mt-1">{{ _("Warning") }}</div>
|
||||
<div class="text-xs text-steel font-mono" x-text="summary.warning_count"></div>
|
||||
</div>
|
||||
<!-- Fail -->
|
||||
<div class="text-center p-3 rounded-lg bg-red-50 dark:bg-red-900/20">
|
||||
<div class="text-2xl font-bold font-mono text-red-600" x-text="summary.fail_rate + '%'"></div>
|
||||
<div class="text-xs text-red-700 dark:text-red-400 mt-1">{{ _("Fail") }}</div>
|
||||
<div class="text-xs text-steel font-mono" x-text="summary.fail_count"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Capability -->
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm">
|
||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Indici di Capability") }}</h2>
|
||||
<template x-if="capability && capability.cp != null">
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-steel">Cp</div>
|
||||
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.cp)" x-text="capability.cp"></div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-steel">Cpk</div>
|
||||
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.cpk)" x-text="capability.cpk"></div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-steel">Pp</div>
|
||||
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.pp)" x-text="capability.pp"></div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="text-xs text-steel">Ppk</div>
|
||||
<div class="text-xl font-bold font-mono" :class="capabilityColor(capability.ppk)" x-text="capability.ppk"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Gauge -->
|
||||
<div id="cpk-gauge"></div>
|
||||
<!-- Stats -->
|
||||
<div class="mt-3 grid grid-cols-3 gap-2 text-center text-xs text-steel">
|
||||
<div>
|
||||
<span class="font-medium">n:</span>
|
||||
<span class="font-mono" x-text="capability.n"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">x̄:</span>
|
||||
<span class="font-mono" x-text="capability.mean"></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">σ:</span>
|
||||
<span class="font-mono" x-text="capability.std_dev"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!capability || capability.cp == null">
|
||||
<p class="text-center text-steel py-8">{{ _("Seleziona un punto di misura per calcolare gli indici") }}</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Carta di Controllo -->
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Carta di Controllo") }}</h2>
|
||||
<div id="control-chart" style="min-height: 350px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Istogramma -->
|
||||
<div class="bg-[var(--bg-secondary)] rounded-xl border border-[var(--border-color)] p-5 shadow-sm mb-6">
|
||||
<h2 class="text-sm font-semibold text-steel uppercase tracking-wider mb-4">{{ _("Istogramma") }}</h2>
|
||||
<div id="histogram-chart" style="min-height: 350px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error message -->
|
||||
<template x-if="errorMessage">
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-red-700 dark:text-red-300 text-sm">
|
||||
<span x-text="errorMessage"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// i18n bridge for spc-charts.js
|
||||
window.SPC_I18N = {
|
||||
noData: "{{ _('Nessun dato disponibile') }}",
|
||||
inControl: "{{ _('In controllo') }}",
|
||||
outOfControl: "{{ _('Fuori controllo') }}",
|
||||
controlChart: "{{ _('Carta di Controllo') }}",
|
||||
measureNum: "{{ _('Misura #') }}",
|
||||
value: "{{ _('Valore') }}",
|
||||
frequency: "{{ _('Frequenza') }}",
|
||||
normalCurve: "{{ _('Curva normale') }}",
|
||||
histogram: "{{ _('Istogramma') }}",
|
||||
indicesNotAvailable: "{{ _('Indici non disponibili') }}",
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/spc-charts.js') }}"></script>
|
||||
<script>
|
||||
function spcDashboard() {
|
||||
return {
|
||||
// Filter state
|
||||
selectedRecipeId: '',
|
||||
selectedSubtaskId: '',
|
||||
dateFrom: '',
|
||||
dateTo: '',
|
||||
subtasks: [],
|
||||
|
||||
// Data state
|
||||
summary: null,
|
||||
capability: null,
|
||||
controlChart: null,
|
||||
histogram: null,
|
||||
|
||||
// UI state
|
||||
loading: false,
|
||||
hasData: false,
|
||||
errorMessage: '',
|
||||
reportError: '',
|
||||
downloadingReport: false,
|
||||
|
||||
init() {
|
||||
// Nothing to pre-load
|
||||
},
|
||||
|
||||
async onRecipeChange() {
|
||||
this.selectedSubtaskId = '';
|
||||
this.subtasks = [];
|
||||
this.hasData = false;
|
||||
this.summary = null;
|
||||
this.capability = null;
|
||||
this.controlChart = null;
|
||||
this.histogram = null;
|
||||
|
||||
if (!this.selectedRecipeId) return;
|
||||
|
||||
// Load subtasks for dropdown
|
||||
try {
|
||||
const resp = await fetch('/statistics/api/subtasks?recipe_id=' + this.selectedRecipeId);
|
||||
const data = await resp.json();
|
||||
if (Array.isArray(data)) {
|
||||
this.subtasks = data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading subtasks:', e);
|
||||
}
|
||||
},
|
||||
|
||||
async loadData() {
|
||||
if (!this.selectedRecipeId) return;
|
||||
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
this.hasData = false;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('recipe_id', this.selectedRecipeId);
|
||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
|
||||
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
|
||||
|
||||
try {
|
||||
// Parallel fetch of all endpoints
|
||||
const [summaryResp, capabilityResp, controlResp, histogramResp] = await Promise.allSettled([
|
||||
fetch('/statistics/api/summary?' + params.toString()).then(r => r.json()),
|
||||
this.selectedSubtaskId
|
||||
? fetch('/statistics/api/capability?' + params.toString()).then(r => r.json())
|
||||
: Promise.resolve(null),
|
||||
this.selectedSubtaskId
|
||||
? fetch('/statistics/api/control-chart?' + params.toString()).then(r => r.json())
|
||||
: Promise.resolve(null),
|
||||
this.selectedSubtaskId
|
||||
? fetch('/statistics/api/histogram?' + params.toString()).then(r => r.json())
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// Summary (always available)
|
||||
if (summaryResp.status === 'fulfilled' && !summaryResp.value?.error) {
|
||||
this.summary = summaryResp.value;
|
||||
} else {
|
||||
this.errorMessage = (summaryResp.value?.detail) || '{{ _("Errore nel caricamento dei dati") }}';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Capability
|
||||
if (capabilityResp.status === 'fulfilled' && capabilityResp.value && !capabilityResp.value?.error) {
|
||||
this.capability = capabilityResp.value;
|
||||
} else {
|
||||
this.capability = null;
|
||||
}
|
||||
|
||||
// Control chart
|
||||
if (controlResp.status === 'fulfilled' && controlResp.value && !controlResp.value?.error) {
|
||||
this.controlChart = controlResp.value;
|
||||
} else {
|
||||
this.controlChart = null;
|
||||
}
|
||||
|
||||
// Histogram
|
||||
if (histogramResp.status === 'fulfilled' && histogramResp.value && !histogramResp.value?.error) {
|
||||
this.histogram = histogramResp.value;
|
||||
} else {
|
||||
this.histogram = null;
|
||||
}
|
||||
|
||||
this.hasData = true;
|
||||
|
||||
// Render charts after DOM update
|
||||
this.$nextTick(() => {
|
||||
this.renderCharts();
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error loading SPC data:', e);
|
||||
this.errorMessage = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
renderCharts() {
|
||||
// Control chart
|
||||
if (this.controlChart && this.selectedSubtaskId) {
|
||||
renderControlChart('control-chart', this.controlChart);
|
||||
} else {
|
||||
const el = document.getElementById('control-chart');
|
||||
if (el) el.innerHTML = '<p class="text-center text-steel py-12">{{ _("Seleziona un punto di misura per la carta di controllo") }}</p>';
|
||||
}
|
||||
|
||||
// Histogram
|
||||
if (this.histogram && this.selectedSubtaskId) {
|
||||
const tol = this.capability ? {
|
||||
utl: this.capability.utl,
|
||||
ltl: this.capability.ltl,
|
||||
nominal: this.capability.nominal,
|
||||
} : null;
|
||||
renderHistogram('histogram-chart', this.histogram, tol);
|
||||
} else {
|
||||
const el = document.getElementById('histogram-chart');
|
||||
if (el) el.innerHTML = '<p class="text-center text-steel py-12">' + {{ _("Seleziona un punto di misura per l'istogramma")|tojson }} + '</p>';
|
||||
}
|
||||
|
||||
// Capability gauge
|
||||
if (this.capability && this.capability.cpk != null) {
|
||||
renderCapabilityGauge('cpk-gauge', this.capability);
|
||||
}
|
||||
},
|
||||
|
||||
capabilityColor(value) {
|
||||
if (value == null) return 'text-steel';
|
||||
if (value >= 1.33) return 'text-emerald-600';
|
||||
if (value >= 1.0) return 'text-amber-600';
|
||||
return 'text-red-600';
|
||||
},
|
||||
|
||||
async downloadReport(type) {
|
||||
this.reportError = '';
|
||||
this.downloadingReport = type;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('recipe_id', this.selectedRecipeId);
|
||||
if (this.selectedSubtaskId) params.set('subtask_id', this.selectedSubtaskId);
|
||||
if (this.dateFrom) params.set('date_from', this.dateFrom + 'T00:00:00');
|
||||
if (this.dateTo) params.set('date_to', this.dateTo + 'T23:59:59');
|
||||
|
||||
const url = '/statistics/api/report-' + type + '?' + params.toString();
|
||||
const resp = await fetch(url);
|
||||
|
||||
if (!resp.ok) {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
const msg = errData.detail || '{{ _("Errore nella generazione del report") }}';
|
||||
console.error('Report download failed (' + resp.status + '):', msg, errData);
|
||||
this.reportError = msg;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check Content-Type: server may return JSON error with 200 status
|
||||
const contentType = resp.headers.get('Content-Type') || '';
|
||||
if (!contentType.includes('application/pdf')) {
|
||||
const errData = await resp.json().catch(() => ({}));
|
||||
const msg = errData.detail || '{{ _("Errore nella generazione del report") }}';
|
||||
console.error('Report returned non-PDF content-type "' + contentType + '":', msg, errData);
|
||||
this.reportError = msg;
|
||||
return;
|
||||
}
|
||||
|
||||
// Download the PDF
|
||||
const blob = await resp.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = type === 'spc'
|
||||
? 'spc_report_' + this.selectedRecipeId + '.pdf'
|
||||
: 'measurements_report_' + this.selectedRecipeId + '.pdf';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
} catch (e) {
|
||||
console.error('Error downloading report:', e);
|
||||
this.reportError = '{{ _("Errore di connessione al server") }}';
|
||||
} finally {
|
||||
this.downloadingReport = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user