6fbff2fe76
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>
1852 lines
79 KiB
HTML
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 %}
|