Files
TieMeasureFlow/src/frontend/flask_app/templates/maker/task_editor.html
T
Adriano Dal Pastro 6fbff2fe76 feat(tasks): il tipo di un task si dichiara, non si deduce
Punto 2 del documento modifiche del 28/07, prima richiesta di Menoncin.

Il sistema distingueva un task di misura da uno documentale deducendolo: se aveva
quote era una misura, altrimenti una nota. Conseguenza: un task di misura a cui le
quote non erano ancora state inserite veniva trattato come nota, e il sistema si
comportava in modo diverso a seconda di quanto fosse completa la ricetta.

Nuovo campo task_type su recipe_tasks (migrazione 007) con nota, misura e disegno,
piu' xf_compare e camera_measure gia' nell'enum: allargare un enum MySQL piu' avanti
e' un ALTER su tabella viva, e non costa nulla prevederli adesso.

Il backfill riproduce la classificazione che era a schermo, cosi' nessuna ricetta
cambia comportamento all'aggiornamento: i task con quote diventano 'measure'; quelli
senza quote ma con un disegno allegato diventano 'drawing' e non 'note', perche' e'
gia' cio' che mostravano - chiamarli note sarebbe stato l'unico punto in cui questa
migrazione cambiava le carte in tavola.

Il tipo viene copiato esplicitamente nel copy-on-write del versioning: una nuova
versione che lo perdesse riclassificherebbe in silenzio tutti i task alla prima
modifica di una ricetta. Il task "Technical Drawing" creato d'ufficio quando si
carica un disegno su una ricetta senza task nasce come 'drawing'.

Lato operatore decide ora il tipo, non il conteggio delle quote: fermo linea, fine
produzione, avvio produzione e la barra di produzione seguono il tipo. Dove servono
davvero delle quote da mostrare - elenco marker, tastierino, fine ciclo misura -
resta anche il controllo che ce ne sia almeno una, e un task di misura ancora privo
di quote lo dichiara invece di somigliare a una nota. Nella lista task compare il
tipo, cosi' si vede prima di aprire.

La 007 e' stata eseguita su SQLite usa e getta con tre righe costruite apposta - una
con quote, una col solo disegno, una nota secca - e il backfill le classifica come
atteso. Il backfill girera' sui dati reali del cliente, provarlo a mano non bastava.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:15:18 +00:00

1852 lines
79 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ recipe.code }} — {{ _('Editor Task') }} — TieMeasureFlow{% endblock %}
{% block extra_head %}
<style>
/* ---- Drag & Drop visual feedback ---- */
.task-card.dragging {
opacity: 0.5;
transform: scale(0.98);
}
.task-card.drag-over-above {
border-top: 3px solid var(--color-primary);
}
.task-card.drag-over-below {
border-bottom: 3px solid var(--color-primary);
}
/* ---- Drag handle cursor ---- */
.drag-handle {
cursor: grab;
user-select: none;
touch-action: none;
}
.drag-handle:active {
cursor: grabbing;
}
/* ---- Tolerance bar mini-visualization ---- */
.tolerance-bar {
display: flex;
align-items: center;
height: 8px;
border-radius: 4px;
overflow: hidden;
min-width: 120px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
}
.tolerance-bar .seg-fail-low {
background: var(--color-fail);
opacity: 0.6;
}
.tolerance-bar .seg-warn-low {
background: var(--color-warning);
opacity: 0.6;
}
.tolerance-bar .seg-nominal {
background: var(--color-pass);
}
.tolerance-bar .seg-warn-high {
background: var(--color-warning);
opacity: 0.6;
}
.tolerance-bar .seg-fail-high {
background: var(--color-fail);
opacity: 0.6;
}
/* ---- Marker badge (coerente con annotation-editor.js) ---- */
.marker-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: #2563EB;
color: #FFFFFF;
font-size: 0.75rem;
font-weight: 700;
line-height: 1;
flex-shrink: 0;
}
/* ---- Subtask inline editing row ---- */
.subtask-edit-row input,
.subtask-edit-row select {
font-size: 0.8125rem;
padding: 0.3rem 0.5rem;
}
/* ---- Subtask table horizontal scroll on mobile ---- */
.subtask-table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.subtask-table-wrap::-webkit-scrollbar {
height: 4px;
}
</style>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 sm:px-6 lg:px-8 py-8 max-w-5xl"
x-data="taskEditor()"
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)]">
{{ _('Task') }}
</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">
<span class="font-mono text-primary">{{ recipe.code }}</span>
<span class="text-[var(--text-muted)] text-lg font-normal ml-2">
{{ _('Task e Misurazioni') }}
</span>
</h1>
<p class="text-sm text-[var(--text-secondary)]">
{{ recipe.name }}
{% if recipe.current_version %}
<span class="ml-2 badge badge-neutral font-mono text-xs">v{{ recipe.current_version.version_number }}</span>
{% endif %}
</p>
</div>
<!-- Top 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">
<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 a Ricetta') }}
</a>
<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>
</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="successMessage"
x-transition
x-init="$watch('successMessage', v => { if (v) setTimeout(() => successMessage = '', 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 x-text="successMessage"></span>
</div>
<!-- ============================================================
Add Task Button (top)
============================================================ -->
<div class="mb-4 flex items-center justify-between">
<p class="text-sm text-[var(--text-secondary)]">
<span x-text="tasks.length"></span>
<span x-text="tasks.length === 1 ? '{{ _('task') }}' : '{{ _('task') }}'"></span>
</p>
<div class="flex items-center gap-2">
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
x-show="!showAddTask"
class="btn btn-primary 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="M12 4v16m8-8H4"/>
</svg>
{{ _('Aggiungi Task') }}
</button>
<button @click="showAiImport = true"
x-show="!showAddTask"
class="btn btn-secondary gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
{{ _('Importa da PDF') }}
</button>
</div>
</div>
<!-- ============================================================
Add Task Form (inline)
============================================================ -->
<div x-show="showAddTask"
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"
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 4v16m8-8H4"/>
</svg>
{{ _('Nuovo Task') }}
</div>
<div class="tmf-card-body">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Titolo -->
<div class="sm:col-span-2">
<label class="tmf-label">{{ _('Titolo') }} <span class="text-red-500">*</span></label>
<input type="text"
x-ref="newTaskTitle"
x-model="newTask.title"
@keydown.enter.prevent="addTask()"
class="tmf-input"
placeholder="{{ _('Es. Controllo dimensionale flangia') }}">
</div>
<!-- Tipo: dichiarato qui, non dedotto dal contenuto -->
<div>
<label class="tmf-label">{{ _('Tipo') }} <span class="text-red-500">*</span></label>
<select x-model="newTask.task_type" class="tmf-input">
<option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option>
</select>
<p class="mt-1 text-xs text-[var(--text-secondary)]">
{{ _('Un task di misura resta tale anche prima di inserire le quote') }}
</p>
</div>
<!-- Direttiva -->
<div>
<label class="tmf-label">{{ _('Direttiva') }}</label>
<input type="text"
x-model="newTask.directive"
class="tmf-input"
placeholder="{{ _('Es. Seguire procedura ISO 2768') }}">
</div>
<!-- Descrizione -->
<div class="sm:col-span-2">
<label class="tmf-label">{{ _('Descrizione') }}</label>
<textarea x-model="newTask.description"
class="tmf-input text-sm"
rows="3"
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
</div>
</div>
</div>
<div class="tmf-card-footer flex items-center gap-2 justify-end">
<button @click="showAddTask = false; resetNewTask()"
class="btn btn-secondary">
{{ _('Annulla') }}
</button>
<button @click="addTask()"
:disabled="!newTask.title.trim() || 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>
{{ _('Crea Task') }}
</button>
</div>
</div>
<!-- ============================================================
Task List
============================================================ -->
<div class="space-y-4">
<template x-for="(task, taskIndex) in tasks" :key="task.id">
<div class="tmf-card task-card transition-all duration-200"
:class="{
'dragging': dragState.dragging === task.id,
'drag-over-above': dragState.over === task.id && dragState.position === 'above',
'drag-over-below': dragState.over === task.id && dragState.position === 'below'
}"
draggable="true"
@dragstart="handleDragStart($event, task)"
@dragend="handleDragEnd($event)"
@dragover.prevent="handleDragOver($event, task)"
@dragleave="handleDragLeave($event, task)"
@drop.prevent="handleDrop($event, task)">
<!-- ========== Task Card Header ========== -->
<div class="tmf-card-header flex items-center gap-3 cursor-pointer select-none"
@click="toggleExpand(task.id)">
<!-- Drag Handle -->
<span class="drag-handle text-[var(--text-muted)] hover:text-[var(--text-secondary)] px-1"
@click.stop
title="{{ _('Trascina per riordinare') }}">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<circle cx="9" cy="5" r="1.5"/>
<circle cx="15" cy="5" r="1.5"/>
<circle cx="9" cy="12" r="1.5"/>
<circle cx="15" cy="12" r="1.5"/>
<circle cx="9" cy="19" r="1.5"/>
<circle cx="15" cy="19" r="1.5"/>
</svg>
</span>
<!-- Order Number -->
<span class="text-xs font-mono text-[var(--text-muted)] w-6 text-center"
x-text="taskIndex + 1">
</span>
<!-- Task Title (view mode) -->
<template x-if="editingTask !== task.id">
<span class="flex-1 font-semibold text-[var(--text-primary)] truncate"
x-text="task.title">
</span>
</template>
<!-- Task Title (edit mode) -->
<template x-if="editingTask === task.id">
<input type="text"
x-model="editTaskData.title"
@click.stop
@keydown.enter.prevent="updateTask(task.id)"
@keydown.escape.prevent="cancelEditTask()"
class="tmf-input flex-1 text-sm font-semibold"
x-ref="editTaskTitleInput">
</template>
<!-- Badge: N misurazioni -->
<span class="badge badge-neutral text-xs shrink-0"
x-text="(task.subtasks ? task.subtasks.length : 0) + ' {{ _('misurazioni') }}'">
</span>
<!-- Action Buttons -->
<div class="flex items-center gap-1 shrink-0" @click.stop>
<!-- Edit Task -->
<template x-if="editingTask !== task.id">
<button @click="startEditTask(task)"
class="p-2 rounded-md text-[var(--text-muted)] hover:text-primary hover:bg-[var(--bg-secondary)] transition-colors"
title="{{ _('Modifica task') }}">
<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>
</template>
<!-- Save / Cancel (edit mode) -->
<template x-if="editingTask === task.id">
<div class="flex items-center gap-1">
<button @click="updateTask(task.id)"
:disabled="!editTaskData.title.trim() || saving"
class="p-2 rounded-md text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/30 transition-colors"
title="{{ _('Salva') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
</button>
<button @click="cancelEditTask()"
class="p-2 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
title="{{ _('Annulla') }}">
<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>
<!-- Expand/Collapse -->
<button @click.stop="toggleExpand(task.id)"
class="p-2 rounded-md text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)] transition-colors"
title="{{ _('Espandi/Comprimi') }}">
<svg class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-180': expandedTask === task.id }"
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>
<!-- Delete Task -->
<button @click="confirmDeleteTask(task)"
class="p-2 rounded-md text-[var(--text-muted)] hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="{{ _('Elimina task') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</div>
<!-- ========== Task Card Body (collapsible) ========== -->
<div x-show="expandedTask === task.id"
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">
<div class="tmf-card-body space-y-4">
<!-- ========== Task File Upload / Preview ========== -->
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
{{ _('Disegno Tecnico') }}
</p>
<!-- Preview if file exists -->
<template x-if="task.file_path">
<div class="space-y-2">
<div class="relative rounded-lg overflow-hidden bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<canvas :id="'preview-' + task.id"
class="block mx-auto"
style="max-height: 192px;">
</canvas>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2 flex-wrap">
<a :href="'/maker/recipes/' + recipeId + '/tasks/' + task.id + '/drawing'"
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="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 Disegno') }}
</a>
<label class="btn btn-secondary text-xs gap-1.5 cursor-pointer">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 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"
@change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''">
</label>
</div>
</div>
</template>
<!-- Upload zone if no file -->
<template x-if="!task.file_path">
<label class="flex flex-col items-center gap-2 p-4 rounded-lg border-2 border-dashed border-[var(--border-color)] hover:border-primary/50 cursor-pointer transition-colors">
<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="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
</svg>
<span class="text-xs text-[var(--text-secondary)]">{{ _('Carica immagine o PDF') }}</span>
<input type="file"
accept="image/*,application/pdf"
class="hidden"
@change="uploadTaskFile(task.id, $event.target.files[0], task); $event.target.value = ''">
</label>
</template>
</div>
<!-- Task Directive / Description (read-only display) -->
<template x-if="task.directive || task.description">
<div class="flex flex-col gap-2 p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<template x-if="task.directive">
<div class="flex items-start gap-2">
<span class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide shrink-0 mt-0.5">{{ _('Direttiva') }}:</span>
<span class="text-sm text-[var(--text-secondary)]" x-text="task.directive"></span>
</div>
</template>
<template x-if="task.description">
<div class="flex items-start gap-2">
<span class="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wide shrink-0 mt-0.5">{{ _('Descrizione') }}:</span>
<span class="text-sm text-[var(--text-secondary)] whitespace-pre-wrap" x-text="task.description"></span>
</div>
</template>
</div>
</template>
<!-- Edit Task Extra Fields (when in edit mode) -->
<template x-if="editingTask === task.id">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/10 border border-blue-200 dark:border-blue-800">
<div>
<label class="tmf-label">{{ _('Tipo') }}</label>
<select x-model="editTaskData.task_type" class="tmf-input text-sm">
<option value="note">{{ _('Nota') }}</option>
<option value="measure">{{ _('Misura') }}</option>
<option value="drawing">{{ _('Disegno') }}</option>
</select>
</div>
<div>
<label class="tmf-label">{{ _('Direttiva') }}</label>
<input type="text"
x-model="editTaskData.directive"
class="tmf-input text-sm"
placeholder="{{ _('Direttiva opzionale...') }}">
</div>
<div class="sm:col-span-2">
<label class="tmf-label">{{ _('Descrizione') }}</label>
<textarea x-model="editTaskData.description"
class="tmf-input text-sm"
rows="3"
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
</div>
</div>
</template>
<!-- ========== Subtask Table ========== -->
<div class="subtask-table-wrap">
<table class="tmf-table" x-show="task.subtasks && task.subtasks.length > 0">
<thead>
<tr>
<th class="w-16 text-center">{{ _('#') }}</th>
<th>{{ _('Descrizione') }}</th>
<th class="w-24">{{ _('Tipo') }}</th>
<th class="w-20 text-right">{{ _('Nominale') }}</th>
<th class="w-20 text-right">{{ _('UTL') }}</th>
<th class="w-20 text-right">{{ _('UWL') }}</th>
<th class="w-20 text-right">{{ _('LWL') }}</th>
<th class="w-20 text-right">{{ _('LTL') }}</th>
<th class="w-16 text-center">{{ _('Unita') }}</th>
<th class="w-32">{{ _('Tolleranze') }}</th>
<th class="w-20 text-center">{{ _('Azioni') }}</th>
</tr>
</thead>
<tbody>
<template x-for="subtask in task.subtasks" :key="subtask.id">
<tr class="hover:bg-[var(--bg-card-hover)]">
<td class="text-center">
<span class="marker-badge" x-text="subtask.marker_number"></span>
</td>
<td>
<span class="text-sm" x-text="subtask.description"></span>
</td>
<td>
<span class="text-xs badge badge-neutral" x-text="subtask.measurement_type || '-'"></span>
</td>
<td class="text-right">
<span class="measure-value text-sm" x-text="subtask.nominal != null ? subtask.nominal : '-'"></span>
</td>
<td class="text-right">
<span class="measure-value text-sm text-red-500" x-text="subtask.utl != null ? subtask.utl : '-'"></span>
</td>
<td class="text-right">
<span class="measure-value text-sm text-amber-500" x-text="subtask.uwl != null ? subtask.uwl : '-'"></span>
</td>
<td class="text-right">
<span class="measure-value text-sm text-amber-500" x-text="subtask.lwl != null ? subtask.lwl : '-'"></span>
</td>
<td class="text-right">
<span class="measure-value text-sm text-red-500" x-text="subtask.ltl != null ? subtask.ltl : '-'"></span>
</td>
<td class="text-center">
<span class="text-xs text-[var(--text-muted)]" x-text="subtask.unit || 'mm'"></span>
</td>
<td>
<template x-if="subtask.nominal != null && subtask.ltl != null && subtask.utl != null">
<div class="tolerance-bar" :title="toleranceTitle(subtask)">
<div class="seg-fail-low h-full" :style="toleranceSegStyle(subtask, 'fail-low')"></div>
<div class="seg-warn-low h-full" :style="toleranceSegStyle(subtask, 'warn-low')"></div>
<div class="seg-nominal h-full" :style="toleranceSegStyle(subtask, 'nominal')"></div>
<div class="seg-warn-high h-full" :style="toleranceSegStyle(subtask, 'warn-high')"></div>
<div class="seg-fail-high h-full" :style="toleranceSegStyle(subtask, 'fail-high')"></div>
</div>
</template>
<template x-if="subtask.nominal == null || subtask.ltl == null || subtask.utl == null">
<span class="text-xs text-[var(--text-muted)]">-</span>
</template>
</td>
<td class="text-center">
<div class="flex items-center justify-center gap-1">
<button @click.stop="startEditSubtask(subtask, task.id)"
class="p-1.5 rounded text-[var(--text-muted)] hover:text-primary hover:bg-[var(--bg-secondary)] transition-colors"
title="{{ _('Modifica') }}">
<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="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.stop="confirmDeleteSubtask(subtask, task.id)"
class="p-1.5 rounded text-[var(--text-muted)] hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="{{ _('Elimina') }}">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- ========== Empty Subtask State ========== -->
<div x-show="!task.subtasks || task.subtasks.length === 0"
class="text-center py-8">
<div class="inline-flex items-center justify-center w-14 h-14 rounded-full
bg-[var(--bg-secondary)] mb-3">
<svg class="w-7 h-7 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 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>
</div>
<p class="text-sm text-[var(--text-secondary)] mb-1">
{{ _('Nessuna misurazione definita') }}
</p>
<p class="text-xs text-[var(--text-muted)]">
{{ _('Aggiungi la prima misurazione per questo task') }}
</p>
</div>
<!-- ========== Add Subtask Inline Form ========== -->
<div x-show="addingSubtaskForTask === task.id"
x-transition
class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]">
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-3"
x-text="editingSubtaskId ? '{{ _('Modifica Misurazione') }}' : '{{ _('Nuova Misurazione') }}'">
</p>
<div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-6 gap-3">
<!-- Marker Number -->
<div>
<label class="tmf-label text-xs">{{ _('Marker #') }}</label>
<input type="number"
x-model.number="newSubtask.marker_number"
min="1"
class="tmf-input measure-value text-center">
</div>
<!-- Description -->
<div class="col-span-2 sm:col-span-3 lg:col-span-2">
<label class="tmf-label text-xs">{{ _('Descrizione') }} <span class="text-red-500">*</span></label>
<input type="text"
x-model="newSubtask.description"
@keydown.enter.prevent="editingSubtaskId ? updateSubtask(editingSubtaskId, task.id) : addSubtask(task.id)"
class="tmf-input"
placeholder="{{ _('Es. Diametro foro principale') }}">
</div>
<!-- Measurement Type -->
<div>
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
<select x-model="newSubtask.measurement_type"
class="tmf-input">
<option value="">-</option>
<option value="linear">{{ _('Lineare') }}</option>
<option value="diameter">{{ _('Diametro') }}</option>
<option value="radius">{{ _('Raggio') }}</option>
<option value="angle">{{ _('Angolo') }}</option>
<option value="roughness">{{ _('Rugosita') }}</option>
<option value="torque">{{ _('Coppia') }}</option>
<option value="force">{{ _('Forza') }}</option>
<option value="weight">{{ _('Peso') }}</option>
<option value="other">{{ _('Altro') }}</option>
</select>
</div>
<!-- Nominal -->
<div>
<label class="tmf-label text-xs">{{ _('Nominale') }}</label>
<input type="number" step="any"
x-model.number="newSubtask.nominal"
class="tmf-input measure-value text-right"
placeholder="0.000">
</div>
<!-- Unit -->
<div>
<label class="tmf-label text-xs">{{ _('Unita') }}</label>
<select x-model="newSubtask.unit"
class="tmf-input">
<option value="mm">mm</option>
<option value="cm">cm</option>
<option value="m">m</option>
<option value="μm">μm</option>
<option value="in">in</option>
<option value="°">°</option>
<option value="g">g</option>
<option value="kg">kg</option>
<option value="N">N</option>
<option value="Nm">Nm</option>
</select>
</div>
</div>
<!-- Tolerance Fields -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3">
<div>
<label class="tmf-label text-xs text-red-500">{{ _('LTL') }}</label>
<input type="number" step="any"
x-model.number="newSubtask.ltl"
class="tmf-input measure-value text-right"
placeholder="{{ _('Lim. Tol. Inf.') }}">
</div>
<div>
<label class="tmf-label text-xs text-amber-500">{{ _('LWL') }}</label>
<input type="number" step="any"
x-model.number="newSubtask.lwl"
class="tmf-input measure-value text-right"
placeholder="{{ _('Lim. Warn. Inf.') }}">
</div>
<div>
<label class="tmf-label text-xs text-amber-500">{{ _('UWL') }}</label>
<input type="number" step="any"
x-model.number="newSubtask.uwl"
class="tmf-input measure-value text-right"
placeholder="{{ _('Lim. Warn. Sup.') }}">
</div>
<div>
<label class="tmf-label text-xs text-red-500">{{ _('UTL') }}</label>
<input type="number" step="any"
x-model.number="newSubtask.utl"
class="tmf-input measure-value text-right"
placeholder="{{ _('Lim. Tol. Sup.') }}">
</div>
</div>
<!-- Form Actions -->
<div class="flex items-center gap-2 justify-end mt-4">
<button @click="cancelSubtaskForm(task.id)"
class="btn btn-secondary text-xs">
{{ _('Annulla') }}
</button>
<button @click="editingSubtaskId ? updateSubtask(editingSubtaskId, task.id) : addSubtask(task.id)"
:disabled="!newSubtask.description.trim() || saving"
class="btn btn-primary text-xs gap-1.5">
<svg x-show="!saving" 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>
<svg x-show="saving" class="w-3.5 h-3.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="editingSubtaskId ? '{{ _('Aggiorna Misurazione') }}' : '{{ _('Aggiungi Misurazione') }}'"></span>
</button>
</div>
</div>
</div>
<!-- ========== Task Card Footer ========== -->
<div class="tmf-card-footer flex items-center justify-end">
<button @click="openAddSubtask(task)"
x-show="addingSubtaskForTask !== task.id"
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="M12 4v16m8-8H4"/>
</svg>
{{ _('Aggiungi Misurazione') }}
</button>
</div>
</div>
</div>
</template>
</div>
<!-- ============================================================
Empty State (no tasks)
============================================================ -->
<div x-show="tasks.length === 0 && !showAddTask"
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 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>
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-2">
{{ _('Nessun task definito') }}
</h3>
<p class="text-sm text-[var(--text-secondary)] max-w-md mx-auto mb-6">
{{ _('Inizia aggiungendo il primo task di misurazione per questa ricetta') }}
</p>
<button @click="showAddTask = true; $nextTick(() => $refs.newTaskTitle && $refs.newTaskTitle.focus())"
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 Primo Task') }}
</button>
</div>
<!-- ============================================================
Delete Task Confirmation Modal
============================================================ -->
<div x-show="deleteTaskModal"
x-cloak
@keydown.escape.window="deleteTaskModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true">
<!-- Backdrop -->
<div x-show="deleteTaskModal"
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="deleteTaskModal = 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="deleteTaskModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 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: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>
<h3 class="text-lg font-semibold text-[var(--text-primary)] text-center mb-2">
{{ _('Conferma Eliminazione Task') }}
</h3>
<template x-if="deleteTargetTask">
<div>
<p class="text-sm text-[var(--text-secondary)] text-center mb-2">
{{ _('Sei sicuro di voler eliminare il task') }}
<span class="font-semibold text-[var(--text-primary)]" x-text="deleteTargetTask.title"></span>?
</p>
<p class="text-xs text-[var(--text-muted)] text-center mb-6"
x-show="deleteTargetTask.subtasks && deleteTargetTask.subtasks.length > 0">
{{ _('Verranno eliminate anche') }}
<span class="font-semibold" x-text="deleteTargetTask.subtasks.length"></span>
{{ _('misurazioni associate.') }}
</p>
</div>
</template>
<div class="flex flex-col sm:flex-row gap-3">
<button @click="deleteTaskModal = false"
class="btn btn-secondary flex-1 justify-center">
{{ _('Annulla') }}
</button>
<button @click="deleteTask(deleteTargetTask.id)"
class="btn btn-danger flex-1 justify-center">
{{ _('Elimina Task') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- ============================================================
Delete Subtask Confirmation Modal
============================================================ -->
<div x-show="deleteSubtaskModal"
x-cloak
@keydown.escape.window="deleteSubtaskModal = false"
class="fixed inset-0 z-50 overflow-y-auto"
role="dialog"
aria-modal="true">
<!-- Backdrop -->
<div x-show="deleteSubtaskModal"
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="deleteSubtaskModal = 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="deleteSubtaskModal"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 translate-y-4 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: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="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>
</div>
<h3 class="text-lg font-semibold text-[var(--text-primary)] text-center mb-2">
{{ _('Conferma Eliminazione Misurazione') }}
</h3>
<template x-if="deleteTargetSubtask">
<p class="text-sm text-[var(--text-secondary)] text-center mb-6">
{{ _('Sei sicuro di voler eliminare la misurazione') }}
<span class="font-semibold text-[var(--text-primary)]">
#<span x-text="deleteTargetSubtask.marker_number"></span>
</span>
<span x-text="deleteTargetSubtask.description"></span>?
</p>
</template>
<div class="flex flex-col sm:flex-row gap-3">
<button @click="deleteSubtaskModal = false"
class="btn btn-secondary flex-1 justify-center">
{{ _('Annulla') }}
</button>
<button @click="deleteSubtask(deleteTargetSubtask.id, deleteSubtaskParentId)"
class="btn btn-danger flex-1 justify-center">
{{ _('Elimina Misurazione') }}
</button>
</div>
</div>
</div>
</div>
</div>
{# ================================================================
AI IMPORT MODAL — Upload PDF + preview suggested tasks
================================================================ #}
<div x-show="showAiImport"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
x-cloak
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click.self="showAiImport = false">
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl max-w-2xl w-full mx-4 max-h-[85vh] flex flex-col border border-[var(--border-color)]">
{# Header #}
<div class="p-5 border-b border-[var(--border-color)] shrink-0">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary-50 dark:bg-primary-900/30 flex items-center justify-center">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div>
<h3 class="text-lg font-bold text-[var(--text-primary)]">{{ _('Importa da Scheda Tecnica') }}</h3>
<p class="text-xs text-[var(--text-secondary)]">{{ _('Carica un PDF e l\'AI estrarrà i task automaticamente') }}</p>
</div>
</div>
</div>
{# Body #}
<div class="p-5 overflow-y-auto flex-1">
{# Upload area (before analysis) #}
<div x-show="!aiSuggestions.length && !aiParsing">
<label class="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-[var(--border-color)] rounded-xl cursor-pointer
hover:border-primary hover:bg-primary-50/50 dark:hover:bg-primary-900/10 transition-colors">
<svg class="w-10 h-10 text-[var(--text-muted)] mb-2" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/>
</svg>
<span class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Clicca per caricare un PDF') }}</span>
<span class="text-xs text-[var(--text-muted)] mt-1">{{ _('Max 20MB') }}</span>
<input type="file" accept=".pdf,application/pdf" class="hidden" @change="uploadAndParse($event)">
</label>
</div>
{# Loading #}
<div x-show="aiParsing" class="flex flex-col items-center justify-center py-12">
<svg class="w-10 h-10 animate-spin text-primary mb-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
<p class="text-sm font-medium text-[var(--text-secondary)]">{{ _('Analisi in corso con AI...') }}</p>
<p class="text-xs text-[var(--text-muted)] mt-1">{{ _('Potrebbe richiedere fino a 30 secondi') }}</p>
</div>
{# Error #}
<div x-show="aiError" class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 mb-4">
<p class="text-sm text-red-700 dark:text-red-300" x-text="aiError"></p>
<button @click="aiError = ''; aiSuggestions = []" class="text-xs text-red-600 underline mt-1">{{ _('Riprova') }}</button>
</div>
{# Suggested tasks (editable preview) #}
<div x-show="aiSuggestions.length > 0" class="space-y-3">
<p class="text-sm font-medium text-[var(--text-secondary)] mb-2">
<span x-text="aiSuggestions.length"></span> {{ _('task suggeriti — modifica o rimuovi prima di confermare') }}
</p>
<template x-for="(suggestion, idx) in aiSuggestions" :key="idx">
<div class="tmf-card">
<div class="p-4 space-y-2">
<div class="flex items-center justify-between gap-2">
<span class="text-xs font-bold text-primary">Task <span x-text="idx + 1"></span></span>
<button @click="aiSuggestions.splice(idx, 1)" class="text-xs text-red-500 hover:text-red-700">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
<input type="text" x-model="suggestion.title" class="tmf-input text-sm font-semibold"
placeholder="{{ _('Titolo') }}">
<input type="text" x-model="suggestion.directive" class="tmf-input text-sm"
placeholder="{{ _('Direttiva') }}">
<textarea x-model="suggestion.description" class="tmf-input text-sm" rows="3"
placeholder="{{ _('Descrizione') }}"></textarea>
</div>
</div>
</template>
</div>
</div>
{# Footer #}
<div class="p-4 border-t border-[var(--border-color)] shrink-0 flex items-center justify-between gap-2">
<button @click="showAiImport = false; aiSuggestions = []; aiError = ''"
class="btn btn-secondary text-sm">
{{ _('Annulla') }}
</button>
<button x-show="aiSuggestions.length > 0"
@click="createTasksFromSuggestions()"
:disabled="aiCreating"
class="btn btn-primary text-sm gap-1.5">
<svg x-show="!aiCreating" class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg>
<svg x-show="aiCreating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
</svg>
{{ _('Crea') }} <span x-text="aiSuggestions.length"></span> {{ _('task') }}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
<script>if(typeof pdfjsLib!=='undefined')pdfjsLib.GlobalWorkerOptions.workerSrc='{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';</script>
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
<script>
// Force reload when page is restored from browser bfcache (back/forward navigation).
// This ensures fresh data is displayed after editing annotations in task_drawing.
window.addEventListener('pageshow', function (event) {
if (event.persisted) { window.location.reload(); }
});
</script>
<script>
function taskEditor() {
return {
// ---- Recipe context ----
recipeId: {{ recipe.id|tojson }},
// ---- Task list ----
tasks: {{ recipe.tasks|tojson }},
// ---- UI state ----
expandedTask: null,
showAddTask: false,
saving: false,
errorMessage: '',
successMessage: '',
// ---- Task CRUD state ----
editingTask: null,
editTaskData: { title: '', directive: '', description: '', task_type: 'note' },
newTask: { title: '', directive: '', description: '', task_type: 'note' },
// ---- Subtask CRUD state ----
editingSubtaskId: null,
addingSubtaskForTask: null,
newSubtask: {
marker_number: 1, description: '', measurement_type: '',
nominal: null, utl: null, uwl: null, lwl: null, ltl: null, unit: 'mm'
},
// ---- Delete modals ----
deleteTaskModal: false,
deleteTargetTask: null,
deleteSubtaskModal: false,
deleteTargetSubtask: null,
deleteSubtaskParentId: null,
// ---- Drag & Drop state ----
dragState: { dragging: null, over: null, position: null },
// ---- AI Import ----
showAiImport: false,
aiParsing: false,
aiCreating: false,
aiSuggestions: [],
aiError: '',
// ============================================================
// Init
// ============================================================
init() {
// Sort tasks by order_index on load
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
// Check if returning from task_drawing with updated annotations
try {
var updatedRaw = sessionStorage.getItem('taskDrawingUpdated');
if (updatedRaw) {
sessionStorage.removeItem('taskDrawingUpdated');
var updated = JSON.parse(updatedRaw);
if (updated.taskId) {
var task = this.tasks.find(function(t) { return t.id === updated.taskId; });
if (task) {
// Merge updated annotations into local state
if (updated.annotations_json) {
task.annotations_json = (typeof updated.annotations_json === 'string')
? JSON.parse(updated.annotations_json) : updated.annotations_json;
}
// Auto-expand the edited task and render preview
this.expandedTask = task.id;
this.renderTaskPreview(task);
return; // skip default expansion logic
}
}
}
} catch (_e) { /* sessionStorage parse error - ignore */ }
// Auto-expand the first task if there is only one
if (this.tasks.length === 1) {
this.expandedTask = this.tasks[0].id;
this.renderTaskPreview(this.tasks[0]);
}
},
// ============================================================
// CSRF Helper
// ============================================================
csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.content || '';
},
// ============================================================
// Render Task Preview (deferred)
// ============================================================
renderTaskPreview(task) {
if (!task || !task.file_path) return;
if (typeof renderAnnotationPreview !== 'function') {
// Fallback: show image via <img> tag if viewer script not loaded
this.$nextTick(() => {
const canvas = document.getElementById('preview-' + task.id);
if (canvas && canvas.parentElement) {
const img = document.createElement('img');
img.src = '/maker/api/files/' + task.file_path;
img.className = 'block mx-auto max-h-48 object-contain';
img.loading = 'lazy';
canvas.parentElement.replaceChild(img, canvas);
}
});
return;
}
this.$nextTick(() => {
setTimeout(() => {
const canvas = document.getElementById('preview-' + task.id);
if (canvas) {
renderAnnotationPreview(canvas, '/maker/api/files/' + task.file_path, task.annotations_json);
}
}, 50);
});
},
// ============================================================
// Toggle Expand
// ============================================================
toggleExpand(taskId) {
this.expandedTask = this.expandedTask === taskId ? null : taskId;
// Render preview when expanding
if (this.expandedTask === taskId) {
const task = this.tasks.find(t => t.id === taskId);
if (task) this.renderTaskPreview(task);
}
// Reset inline editors when collapsing
if (this.expandedTask !== taskId) {
this.editingSubtaskId = null;
this.addingSubtaskForTask = null;
}
},
// ============================================================
// TASK CRUD
// ============================================================
resetNewTask() {
this.newTask = { title: '', directive: '', description: '', task_type: 'note' };
},
// ============================================================
// AI Import
// ============================================================
async uploadAndParse(event) {
var file = event.target.files[0];
if (!file) return;
this.aiParsing = true;
this.aiError = '';
this.aiSuggestions = [];
try {
var formData = new FormData();
formData.append('file', file);
var resp = await fetch('/maker/api/recipes/' + this.recipeId + '/parse-technical-sheet', {
method: 'POST',
headers: { 'X-CSRFToken': this.csrfToken() },
body: formData
});
var data = await resp.json();
if (!resp.ok || data.error) {
this.aiError = data.detail || {{ _("Errore nell'analisi del PDF")|tojson }};
} else {
this.aiSuggestions = data.suggested_tasks || [];
if (!this.aiSuggestions.length) {
this.aiError = {{ _("Nessun task identificato nel PDF")|tojson }};
}
}
} catch (err) {
console.error('AI parse error:', err);
this.aiError = {{ _("Errore di connessione al server")|tojson }};
}
this.aiParsing = false;
event.target.value = '';
},
async createTasksFromSuggestions() {
this.aiCreating = true;
var created = 0;
for (var i = 0; i < this.aiSuggestions.length; i++) {
var s = this.aiSuggestions[i];
if (!s.title || !s.title.trim()) continue;
try {
var resp = await fetch('/maker/api/recipes/' + this.recipeId + '/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': this.csrfToken() },
body: JSON.stringify({
title: s.title.trim(),
directive: (s.directive || '').trim() || null,
description: (s.description || '').trim() || null
})
});
var data = await resp.json();
if (!data.error) {
if (!data.subtasks) data.subtasks = [];
this.tasks.push(data);
created++;
}
} catch (err) {
console.error('Create task error:', err);
}
}
this.aiCreating = false;
this.showAiImport = false;
this.aiSuggestions = [];
if (created > 0) {
this.successMessage = created + ' ' + {{ _("task creati dalla scheda tecnica")|tojson }};
}
},
async addTask() {
if (!this.newTask.title.trim()) return;
this.saving = true;
this.errorMessage = '';
try {
const resp = await fetch(`/maker/api/recipes/${this.recipeId}/tasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken()
},
body: JSON.stringify({
title: this.newTask.title.trim(),
task_type: this.newTask.task_type || 'note',
directive: this.newTask.directive.trim() || null,
description: this.newTask.description.trim() || null
})
});
const data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || {{ _("Errore nella creazione del task")|tojson }};
} else {
// Ensure subtasks array exists
if (!data.subtasks) data.subtasks = [];
this.tasks.push(data);
this.resetNewTask();
this.showAddTask = false;
this.expandedTask = data.id;
this.successMessage = {{ _("Task creato con successo")|tojson }};
}
} catch (err) {
console.error('addTask error:', err);
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
}
this.saving = false;
},
startEditTask(task) {
this.editingTask = task.id;
this.editTaskData = {
title: task.title,
task_type: task.task_type || 'note',
directive: task.directive || '',
description: task.description || ''
};
// Ensure expanded
this.expandedTask = task.id;
},
cancelEditTask() {
this.editingTask = null;
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note' };
},
async updateTask(taskId) {
if (!this.editTaskData.title.trim()) return;
this.saving = true;
this.errorMessage = '';
try {
const resp = await fetch(`/maker/api/tasks/${taskId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken()
},
body: JSON.stringify({
title: this.editTaskData.title.trim(),
task_type: this.editTaskData.task_type || 'note',
directive: this.editTaskData.directive.trim() || null,
description: this.editTaskData.description.trim() || null
})
});
const data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || {{ _("Errore nel salvataggio del task")|tojson }};
} else {
// Update local state
const idx = this.tasks.findIndex(t => t.id === taskId);
if (idx !== -1) {
// Preserve subtasks from local state since PUT may not return them
const existingSubtasks = this.tasks[idx].subtasks;
this.tasks[idx] = { ...this.tasks[idx], ...data };
if (!this.tasks[idx].subtasks) {
this.tasks[idx].subtasks = existingSubtasks || [];
}
}
this.cancelEditTask();
this.successMessage = {{ _("Task aggiornato")|tojson }};
}
} catch (err) {
console.error('updateTask error:', err);
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
}
this.saving = false;
},
confirmDeleteTask(task) {
this.deleteTargetTask = task;
this.deleteTaskModal = true;
},
async deleteTask(taskId) {
this.saving = true;
this.errorMessage = '';
try {
const resp = await fetch(`/maker/api/tasks/${taskId}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': this.csrfToken() }
});
if (resp.ok || resp.status === 204) {
this.tasks = this.tasks.filter(t => t.id !== taskId);
this.deleteTaskModal = false;
this.deleteTargetTask = null;
if (this.expandedTask === taskId) this.expandedTask = null;
this.successMessage = {{ _("Task eliminato")|tojson }};
} else {
const data = await resp.json();
this.errorMessage = data.detail || {{ _("Errore nell'eliminazione del task")|tojson }};
}
} catch (err) {
console.error('deleteTask error:', err);
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
}
this.saving = false;
},
// ============================================================
// DRAG & DROP REORDER
// ============================================================
handleDragStart(event, task) {
this.dragState.dragging = task.id;
event.dataTransfer.effectAllowed = 'move';
// Required for Firefox
event.dataTransfer.setData('text/plain', String(task.id));
},
handleDragEnd(event) {
this.dragState = { dragging: null, over: null, position: null };
},
handleDragOver(event, task) {
if (this.dragState.dragging === task.id) return;
event.dataTransfer.dropEffect = 'move';
// Determine above/below based on mouse position
const rect = event.currentTarget.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
this.dragState.over = task.id;
this.dragState.position = event.clientY < midY ? 'above' : 'below';
},
handleDragLeave(event, task) {
// Only clear if leaving the card entirely
if (!event.currentTarget.contains(event.relatedTarget)) {
if (this.dragState.over === task.id) {
this.dragState.over = null;
this.dragState.position = null;
}
}
},
async handleDrop(event, targetTask) {
const draggedId = this.dragState.dragging;
const targetId = targetTask.id;
if (draggedId === targetId) {
this.dragState = { dragging: null, over: null, position: null };
return;
}
// Reorder locally
const draggedIdx = this.tasks.findIndex(t => t.id === draggedId);
const targetIdx = this.tasks.findIndex(t => t.id === targetId);
if (draggedIdx === -1 || targetIdx === -1) return;
// Remove dragged task
const [dragged] = this.tasks.splice(draggedIdx, 1);
// Recalculate target index after removal
let insertIdx = this.tasks.findIndex(t => t.id === targetId);
if (this.dragState.position === 'below') insertIdx += 1;
// Insert at new position
this.tasks.splice(insertIdx, 0, dragged);
this.dragState = { dragging: null, over: null, position: null };
// Persist to server
await this.reorderTasks();
},
async reorderTasks() {
this.errorMessage = '';
const taskIds = this.tasks.map(t => t.id);
try {
const resp = await fetch('/maker/api/tasks/reorder', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken()
},
body: JSON.stringify({ task_ids: taskIds })
});
const data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || {{ _("Errore nel riordinamento")|tojson }};
}
} catch (err) {
console.error('reorderTasks error:', err);
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
}
},
// ============================================================
// SUBTASK CRUD
// ============================================================
nextMarkerNumber(task) {
if (!task.subtasks || task.subtasks.length === 0) return 1;
return Math.max(...task.subtasks.map(s => s.marker_number || 0)) + 1;
},
resetNewSubtask(taskId) {
const task = this.tasks.find(t => t.id === taskId);
this.newSubtask = {
marker_number: task ? this.nextMarkerNumber(task) : 1,
description: '',
measurement_type: '',
nominal: null,
utl: null,
uwl: null,
lwl: null,
ltl: null,
unit: 'mm'
};
},
openAddSubtask(task) {
this.editingSubtaskId = null;
this.addingSubtaskForTask = task.id;
this.resetNewSubtask(task.id);
},
async addSubtask(taskId) {
if (!this.newSubtask.description.trim()) return;
this.saving = true;
this.errorMessage = '';
const payload = {
marker_number: this.newSubtask.marker_number || 1,
description: this.newSubtask.description.trim(),
measurement_type: this.newSubtask.measurement_type || null,
nominal: this.newSubtask.nominal,
utl: this.newSubtask.utl,
uwl: this.newSubtask.uwl,
lwl: this.newSubtask.lwl,
ltl: this.newSubtask.ltl,
unit: this.newSubtask.unit || 'mm'
};
try {
const resp = await fetch(`/maker/api/tasks/${taskId}/subtasks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken()
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || {{ _("Errore nella creazione della misurazione")|tojson }};
} else {
const task = this.tasks.find(t => t.id === taskId);
if (task) {
if (!task.subtasks) task.subtasks = [];
task.subtasks.push(data);
}
this.resetNewSubtask(taskId);
this.successMessage = {{ _("Misurazione aggiunta")|tojson }};
}
} catch (err) {
console.error('addSubtask error:', err);
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
}
this.saving = false;
},
startEditSubtask(subtask, taskId) {
// Open the same form used for adding, but pre-filled with existing data
this.editingSubtaskId = subtask.id;
this.addingSubtaskForTask = taskId;
this.newSubtask = {
marker_number: subtask.marker_number,
description: subtask.description || '',
measurement_type: subtask.measurement_type || '',
nominal: subtask.nominal,
utl: subtask.utl,
uwl: subtask.uwl,
lwl: subtask.lwl,
ltl: subtask.ltl,
unit: subtask.unit || 'mm'
};
},
cancelSubtaskForm(taskId) {
this.editingSubtaskId = null;
this.addingSubtaskForTask = null;
this.resetNewSubtask(taskId);
},
async updateSubtask(subtaskId, taskId) {
if (!this.newSubtask.description.trim()) return;
this.saving = true;
this.errorMessage = '';
const payload = {
marker_number: this.newSubtask.marker_number || 1,
description: this.newSubtask.description.trim(),
measurement_type: this.newSubtask.measurement_type || null,
nominal: this.newSubtask.nominal,
utl: this.newSubtask.utl,
uwl: this.newSubtask.uwl,
lwl: this.newSubtask.lwl,
ltl: this.newSubtask.ltl,
unit: this.newSubtask.unit || 'mm'
};
try {
const resp = await fetch(`/maker/api/subtasks/${subtaskId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken()
},
body: JSON.stringify(payload)
});
const data = await resp.json();
if (data.error) {
this.errorMessage = data.detail || {{ _("Errore nel salvataggio della misurazione")|tojson }};
} else {
// Update local state
const task = this.tasks.find(t => t.id === taskId);
if (task && task.subtasks) {
const idx = task.subtasks.findIndex(s => s.id === subtaskId);
if (idx !== -1) {
task.subtasks[idx] = { ...task.subtasks[idx], ...data };
}
}
this.cancelSubtaskForm(taskId);
this.successMessage = {{ _("Misurazione aggiornata")|tojson }};
}
} catch (err) {
console.error('updateSubtask error:', err);
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
}
this.saving = false;
},
confirmDeleteSubtask(subtask, taskId) {
this.deleteTargetSubtask = subtask;
this.deleteSubtaskParentId = taskId;
this.deleteSubtaskModal = true;
},
async deleteSubtask(subtaskId, taskId) {
this.saving = true;
this.errorMessage = '';
try {
const resp = await fetch(`/maker/api/subtasks/${subtaskId}`, {
method: 'DELETE',
headers: { 'X-CSRFToken': this.csrfToken() }
});
if (resp.ok || resp.status === 204) {
const task = this.tasks.find(t => t.id === taskId);
if (task && task.subtasks) {
task.subtasks = task.subtasks.filter(s => s.id !== subtaskId);
}
this.deleteSubtaskModal = false;
this.deleteTargetSubtask = null;
this.deleteSubtaskParentId = null;
this.successMessage = {{ _("Misurazione eliminata")|tojson }};
} else {
const data = await resp.json();
this.errorMessage = data.detail || {{ _("Errore nell'eliminazione della misurazione")|tojson }};
}
} catch (err) {
console.error('deleteSubtask error:', err);
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
}
this.saving = false;
},
// ============================================================
// TASK FILE UPLOAD
// ============================================================
async uploadTaskFile(taskId, file, task) {
if (!file) return;
const allowedTypes = ['image/png', 'image/jpeg', 'image/jpg', 'image/webp', 'application/pdf'];
if (!allowedTypes.includes(file.type)) {
this.errorMessage = {{ _("Formato file non supportato. Usa PNG, JPG o PDF.")|tojson }};
return;
}
if (file.size > 20 * 1024 * 1024) {
this.errorMessage = {{ _("File troppo grande. Dimensione massima: 20MB.")|tojson }};
return;
}
this.saving = true;
this.errorMessage = '';
try {
// 1. Upload file
const formData = new FormData();
formData.append('file', file);
formData.append('recipe_id', this.recipeId);
const uploadResp = await fetch('/maker/api/upload', {
method: 'POST',
headers: { 'X-CSRFToken': this.csrfToken() },
body: formData
});
const uploadData = await uploadResp.json();
if (uploadData.error) {
this.errorMessage = uploadData.detail || {{ _("Errore durante il caricamento del file")|tojson }};
this.saving = false;
return;
}
// Determine file type
const fileType = file.type === 'application/pdf' ? 'pdf' : 'image';
// 2. Update task with file_path
const updateResp = await fetch(`/maker/api/tasks/${taskId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.csrfToken()
},
body: JSON.stringify({
file_path: uploadData.file_path,
file_type: fileType
})
});
const updateData = await updateResp.json();
if (updateData.error) {
this.errorMessage = updateData.detail || {{ _("Errore nel salvataggio del task")|tojson }};
this.saving = false;
return;
}
// 3. Update local state
task.file_path = uploadData.file_path;
task.file_type = fileType;
this.successMessage = {{ _("File caricato con successo")|tojson }};
// Re-render preview canvas with new image
this.renderTaskPreview(task);
} catch (err) {
console.error('uploadTaskFile error:', err);
this.errorMessage = {{ _("Errore di connessione al server")|tojson }};
}
this.saving = false;
},
// ============================================================
// TOLERANCE BAR VISUALIZATION
// ============================================================
toleranceTitle(s) {
return `LTL: ${s.ltl} | LWL: ${s.lwl ?? '-'} | Nom: ${s.nominal} | UWL: ${s.uwl ?? '-'} | UTL: ${s.utl}`;
},
toleranceSegStyle(s, segment) {
// Compute proportional widths for tolerance bar segments.
// Full range: LTL to UTL. Nominal sits in the middle.
const ltl = s.ltl ?? s.nominal;
const utl = s.utl ?? s.nominal;
const lwl = s.lwl ?? ltl;
const uwl = s.uwl ?? utl;
const nom = s.nominal;
const range = utl - ltl;
if (range <= 0) return 'width: 20%';
const pct = (v) => ((v - ltl) / range) * 100;
switch (segment) {
case 'fail-low':
return `width: ${pct(lwl)}%`;
case 'warn-low':
return `width: ${pct(nom) - pct(lwl)}%`;
case 'nominal':
// Small central band around nominal
return `width: ${Math.max(pct(uwl) - pct(nom), 0)}%`;
case 'warn-high':
// From UWL towards nominal already covered; this is UWL to UTL gap
// Re-compute: fail-low = 0..lwl, warn-low = lwl..nom, nominal = nom..uwl, warn-high = uwl..utl, fail-high = utl..end
// Since range = utl-ltl and pct(utl)=100, we use:
return `width: ${100 - pct(uwl)}%`;
case 'fail-high':
// Already at utl which is 100%, so no extra fail-high in simple model.
// Instead, restructure: LTL..LWL | LWL..NOM | NOM..UWL | UWL..UTL
return 'width: 0%';
default:
return 'width: 0%';
}
}
};
}
</script>
{% endblock %}