25f8b4d609
Rotte /maker/api/camera/servizi e /maker/api/camera/sorgenti (url ammesso solo se e' un servizio delle stazioni), sezione «Acquisizione» nell'editor task (servizio, sorgente, analisi impulsiva/in linea) salvata come acquisition_json. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jbf7qKuxsnAVjxthRy7dnB
2463 lines
114 KiB
HTML
2463 lines
114 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 %}
|
|
{# #53 #55: parte del form task comune a creazione e modifica; `d` e il nome dello stato Alpine (newTask / editTaskData) #}
|
|
{% macro task_flags_editor(d) %}
|
|
<div class="sm:col-span-2 space-y-3">
|
|
<!-- #53: il task entra nel ciclo di misura (timer e cicalino) -->
|
|
<label class="flex items-start gap-2 text-sm cursor-pointer">
|
|
<input type="checkbox" class="mt-0.5"
|
|
x-model="{{ d }}.in_loop"
|
|
@change="{{ d }}.in_loop_touched = true">
|
|
<span>{{ _('Nel ciclo di misura (timer e cicalino)') }}</span>
|
|
</label>
|
|
<!-- #55: opzioni valide solo per il tipo Nota -->
|
|
<template x-if="{{ d }}.task_type === 'note'">
|
|
<div class="space-y-3">
|
|
<label class="flex items-start gap-2 text-sm cursor-pointer">
|
|
<input type="checkbox" class="mt-0.5" x-model="{{ d }}.at_close">
|
|
<span>{{ _('Compila a fine produzione') }}</span>
|
|
</label>
|
|
<div class="space-y-2">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<label class="tmf-label mb-0">{{ _('Campi da compilare') }}</label>
|
|
<button type="button" class="btn btn-secondary text-xs gap-1"
|
|
:disabled="{{ d }}.fields.length >= MAX_NOTE_FIELDS"
|
|
@click="addNoteField({{ d }})">
|
|
+ {{ _('Aggiungi campo') }}
|
|
</button>
|
|
</div>
|
|
<template x-for="(f, fi) in {{ d }}.fields" :key="f._uid">
|
|
<div class="grid grid-cols-1 sm:grid-cols-12 gap-2 p-2 rounded border border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
|
<div class="sm:col-span-4">
|
|
<input type="text" class="tmf-input text-sm" maxlength="100"
|
|
placeholder="{{ _('Etichetta') }}"
|
|
x-model="f.label"
|
|
@input="deriveNoteFieldKey({{ d }}, f)">
|
|
<span class="block mt-0.5 text-[10px] font-mono text-[var(--text-muted)]" x-text="f.key"></span>
|
|
</div>
|
|
<div class="sm:col-span-2">
|
|
<select class="tmf-input text-sm" x-model="f.type">
|
|
<option value="text">{{ _('Testo') }}</option>
|
|
<option value="number">{{ _('Numero') }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="sm:col-span-3">
|
|
<input type="text" class="tmf-input text-sm"
|
|
placeholder="{{ _('Valore precompilato') }}"
|
|
x-model="f.default">
|
|
</div>
|
|
<label class="sm:col-span-1 flex items-center gap-1 text-xs cursor-pointer">
|
|
<input type="checkbox" x-model="f.attachment">
|
|
<span>{{ _('Allegato') }}</span>
|
|
</label>
|
|
<div class="sm:col-span-2 flex items-center justify-end gap-1">
|
|
<button type="button" class="p-1 rounded hover:bg-[var(--bg-card-hover)]"
|
|
:disabled="fi === 0" @click="moveNoteField({{ d }}, fi, -1)"
|
|
title="{{ _('Sposta su') }}">↑</button>
|
|
<button type="button" class="p-1 rounded hover:bg-[var(--bg-card-hover)]"
|
|
:disabled="fi === {{ d }}.fields.length - 1" @click="moveNoteField({{ d }}, fi, 1)"
|
|
title="{{ _('Sposta giu') }}">↓</button>
|
|
<button type="button" class="p-1 rounded text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
|
|
@click="removeNoteField({{ d }}, fi)"
|
|
title="{{ _('Rimuovi campo') }}">×</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
{% endmacro %}
|
|
<div class="tmf-page"
|
|
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')|tojson_attr }} : {{ _('task')|tojson_attr }}"></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"
|
|
@change="onTaskTypeChange(newTask, $event.target.value)">
|
|
<option value="note">{{ _('Nota') }}</option>
|
|
<option value="measure">{{ _('Misura') }}</option>
|
|
<option value="drawing">{{ _('Disegno') }}</option>
|
|
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
|
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
|
<!-- #54: task di controllo con esito OK/NOK, senza quote -->
|
|
<option value="check">{{ _('Controllo OK/NOK') }}</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">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
|
{% with target = 'newTaskDescription' %}
|
|
{% include "components/rich_text_toolbar.html" %}
|
|
{% endwith %}
|
|
</div>
|
|
<textarea x-model="newTask.description"
|
|
x-ref="newTaskDescription"
|
|
class="tmf-input text-sm"
|
|
rows="3"
|
|
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
|
</div>
|
|
{# #53 #55: ciclo di misura, compilazione a fine produzione e campi della nota #}
|
|
{{ task_flags_editor('newTask') }}
|
|
</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>
|
|
|
|
<!-- #53: segno sui task nel ciclo di misura -->
|
|
<span x-show="task.in_loop" class="shrink-0 text-primary"
|
|
title="{{ _('Nel ciclo di misura (timer e cicalino)') }}">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round"
|
|
d="M4 4v5h5M20 20v-5h-5M5.5 15A8 8 0 0018.4 17.4M18.5 9A8 8 0 005.6 6.6"/>
|
|
</svg>
|
|
</span>
|
|
<!-- #55: nota compilata a fine produzione -->
|
|
<span x-show="task.at_close" class="badge badge-neutral text-xs shrink-0">{{ _('A fine produzione') }}</span>
|
|
|
|
<!-- Badge: N misurazioni (non per i task di controllo, senza quote) -->
|
|
<span class="badge badge-neutral text-xs shrink-0"
|
|
x-show="task.task_type !== 'check'"
|
|
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">
|
|
|
|
<!-- ========== Acquisizione (#97): camera e modo di analisi, solo camera_measure / dxf_compare ========== -->
|
|
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
|
<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">
|
|
{{ _('Acquisizione') }}
|
|
</p>
|
|
<!-- Lettura: camera e modo salvati nel task -->
|
|
<template x-if="editingTask !== task.id">
|
|
<div class="text-xs space-y-1">
|
|
<template x-if="task.acquisition_json && task.acquisition_json.camera_url">
|
|
<div class="space-y-1">
|
|
<p><span class="text-[var(--text-secondary)]">{{ _('Servizio di acquisizione') }}:</span>
|
|
<span class="font-mono" x-text="task.acquisition_json.camera_url"></span></p>
|
|
<p><span class="text-[var(--text-secondary)]">{{ _('Sorgente') }}:</span>
|
|
<span class="font-mono" x-text="task.acquisition_json.camera_source"></span></p>
|
|
<p><span class="text-[var(--text-secondary)]">{{ _('Modo di analisi') }}:</span>
|
|
<span x-text="task.acquisition_json.modo === 'in_linea' ? {{ _('Analisi in linea')|tojson_attr }} : {{ _('Analisi impulsiva')|tojson_attr }}"></span></p>
|
|
</div>
|
|
</template>
|
|
<p x-show="!(task.acquisition_json && task.acquisition_json.camera_url)" class="text-[var(--text-muted)]">
|
|
{{ _("Camera della stazione dell'operatore") }}
|
|
</p>
|
|
</div>
|
|
</template>
|
|
<!-- Modifica: servizio, sorgente, modo (salvati con «Salva» del task) -->
|
|
<template x-if="editingTask === task.id">
|
|
<div class="space-y-3"
|
|
x-data="taskAcquisition(() => editTaskData.acquisition, {{ {'genericError': _('Errore durante il caricamento delle sorgenti')}|tojson_attr }})">
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('Servizio di acquisizione') }}</label>
|
|
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_url" @change="onServizio()">
|
|
<option value="">{{ _("Camera della stazione dell'operatore") }}</option>
|
|
<template x-for="sv in serviziConCorrente()" :key="sv.camera_url">
|
|
<option :value="sv.camera_url"
|
|
x-text="sv.camera_url + (sv.stazioni.length ? ' (' + sv.stazioni.join(', ') + ')' : '')"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<div x-show="editTaskData.acquisition.camera_url">
|
|
<label class="tmf-label text-xs">{{ _('Sorgente') }}</label>
|
|
<select class="tmf-input text-xs" x-model="editTaskData.acquisition.camera_source" :disabled="loading">
|
|
<option value="">{{ _('Scegli la sorgente') }}</option>
|
|
<template x-for="src in sourcesConCorrente()" :key="src.name">
|
|
<option :value="src.name" x-text="src.label || src.name"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
|
<div x-show="editTaskData.acquisition.camera_url" class="flex flex-wrap items-center gap-4 text-xs">
|
|
<label class="flex items-center gap-2">
|
|
<input type="radio" :name="'acq-modo-' + task.id" value="impulsiva" x-model="editTaskData.acquisition.modo">
|
|
{{ _('Analisi impulsiva') }}
|
|
</label>
|
|
<label class="flex items-center gap-2">
|
|
<input type="radio" :name="'acq-modo-' + task.id" value="in_linea" x-model="editTaskData.acquisition.modo">
|
|
{{ _('Analisi in linea') }}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ========== Ricetta visione (#46): solo camera_measure / dxf_compare ========== -->
|
|
<template x-if="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
|
<div class="p-3 rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)]"
|
|
x-data="visionRecipe(task.id, () => csrfToken(), {{ {'genericError': _('Errore durante il caricamento della ricetta')}|tojson_attr }})"
|
|
@dxf-recipe-saved="init()">{# #83: dopo «Salva ricetta» da DXF si rilegge la ricetta #}
|
|
<p class="text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wide mb-2">
|
|
{{ _('Ricetta visione') }}
|
|
</p>
|
|
<!-- Stato corrente: cartella, file, uscite -->
|
|
<template x-if="recipeDir">
|
|
<div class="text-xs space-y-1 mb-3">
|
|
<p><span class="text-[var(--text-secondary)]">{{ _('Cartella') }}:</span>
|
|
<span class="font-mono" x-text="recipeDir"></span></p>
|
|
<p x-show="files.length"><span class="text-[var(--text-secondary)]">{{ _('File') }}:</span>
|
|
<span class="font-mono" x-text="files.join(', ')"></span></p>
|
|
<p x-show="outputs.length"><span class="text-[var(--text-secondary)]">{{ _('Uscite numeriche') }}:</span></p>
|
|
<ul class="list-disc pl-5" x-show="outputs.length">
|
|
<template x-for="o in outputs" :key="o.key">
|
|
<li><span class="font-mono" x-text="o.key"></span>
|
|
<span class="text-[var(--text-muted)]" x-text="(o.quantity || o.unit) ? '(' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : ''"></span></li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
<p x-show="!recipeDir && !loading" class="text-xs text-[var(--text-muted)] mb-3">
|
|
{{ _('Nessuna ricetta caricata') }}
|
|
</p>
|
|
<!-- Caricamento: ricetta .json + allegati -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('Ricetta (.json)') }}</label>
|
|
<input type="file" accept=".json,application/json" class="tmf-input text-xs"
|
|
@change="onRecipe($event)">
|
|
</div>
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('File allegati (DXF, modelli, calibrazione)') }}</label>
|
|
<input type="file" multiple class="tmf-input text-xs" @change="onAttachments($event)">
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-end mt-3">
|
|
<button type="button" class="btn btn-primary text-xs" @click="upload()"
|
|
:disabled="!recipeFile || busy">{{ _('Carica ricetta') }}</button>
|
|
</div>
|
|
<!-- Errori 422 in chiaro -->
|
|
<ul x-show="errors.length" class="mt-3 text-xs text-red-500 list-disc pl-5">
|
|
<template x-for="(e, i) in errors" :key="i"><li x-text="e"></li></template>
|
|
</ul>
|
|
<!-- ========== Crea ricetta da DXF (#83): solo dxf_compare ========== -->
|
|
<template x-if="task.task_type === 'dxf_compare'">
|
|
<div class="mt-4 pt-3 border-t border-[var(--border-color)]"
|
|
x-data="dxfRecipeWizard(task.id, () => csrfToken(), {{ {'requestFailed': _('Richiesta non riuscita'), 'saved': _('Ricetta salvata')}|tojson_attr }})">
|
|
<button type="button" class="btn btn-secondary text-xs" @click="toggle()">
|
|
{{ _('Crea ricetta da DXF') }}
|
|
</button>
|
|
<div x-show="open" x-cloak class="mt-3 space-y-3">
|
|
<!-- Stazione -->
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('Stazione') }}</label>
|
|
<select class="tmf-input text-xs" x-model="stationId" @change="onStation()">
|
|
<option value="">{{ _('Scegli la stazione') }}</option>
|
|
<template x-for="st in stations" :key="st.id">
|
|
<option :value="String(st.id)" x-text="st.code + ' - ' + st.name"></option>
|
|
</template>
|
|
</select>
|
|
<p x-show="!stations.length" class="mt-1 text-xs text-[var(--text-muted)]">{{ _('Nessuna stazione con camera configurata') }}</p>
|
|
<p x-show="stationId && calibrated" class="mt-1 text-xs text-emerald-600">
|
|
{{ _('Calibrazione') }}: <span x-text="calLabel"></span>
|
|
</p>
|
|
<p x-show="stationId && calState && !calibrated" class="mt-1 text-xs text-amber-600">
|
|
<span x-text="calState === 'uncalibrated' ? {{ _('Stazione non calibrata: calibrala prima di creare la ricetta.')|tojson_attr }} : {{ _('Acquisitore non raggiungibile: impossibile leggere la calibrazione.')|tojson_attr }}"></span>
|
|
<a :href="calUrl" target="_blank" rel="noopener" x-show="calUrl"
|
|
class="font-medium text-primary hover:underline">{{ _('Calibra') }}</a>
|
|
<span class="text-[var(--text-muted)]">({{ _('oppure vedi la pagina Stazioni') }})</span>
|
|
</p>
|
|
</div>
|
|
<!-- Immagine: camera o file -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('Immagine del pezzo') }}</label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<button type="button" class="btn btn-secondary text-xs" @click="grab()"
|
|
:disabled="!stationId || grabbing">{{ _('Acquisisci dalla camera') }}</button>
|
|
<label class="btn btn-secondary text-xs cursor-pointer">
|
|
{{ _('Carica immagine') }}
|
|
<input type="file" accept="image/*" class="hidden" @change="onImage($event)">
|
|
</label>
|
|
</div>
|
|
<p class="mt-1 text-xs text-[var(--text-muted)]" x-text="imageName"></p>
|
|
</div>
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('Disegno (.dxf)') }}</label>
|
|
<input type="file" accept=".dxf" class="tmf-input text-xs" @change="onDxf($event)">
|
|
</div>
|
|
</div>
|
|
<!-- Pochi parametri -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('Banda ± mm') }}</label>
|
|
<input type="number" step="0.01" min="0.001" class="tmf-input text-xs" x-model.number="bandMm">
|
|
</div>
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('Soglia punti in banda %%') }}</label>
|
|
<input type="number" step="1" min="1" max="100" class="tmf-input text-xs" x-model.number="minPointsPct">
|
|
</div>
|
|
</div>
|
|
<label class="flex items-center gap-2 text-xs">
|
|
<input type="checkbox" x-model="dark">
|
|
{{ _('Pezzo scuro su fondo chiaro (controluce)') }}
|
|
</label>
|
|
<!-- Azioni -->
|
|
<div class="flex justify-end gap-2">
|
|
<button type="button" class="btn btn-secondary text-xs" @click="run(false)"
|
|
:disabled="!canRun">{{ _('Prova') }}</button>
|
|
<button type="button" class="btn btn-primary text-xs" @click="run(true)"
|
|
:disabled="!canRun || !result">{{ _('Salva ricetta') }}</button>
|
|
</div>
|
|
<p x-show="error" class="text-xs text-red-500" x-text="error"></p>
|
|
<p x-show="notice" class="text-xs text-emerald-600" x-text="notice"></p>
|
|
<!-- Anteprima con overlay e quattro valori -->
|
|
<div x-show="imageSrc" class="relative">
|
|
<img x-ref="img" :src="imageSrc" alt="" class="block w-full rounded border border-[var(--border-color)]"
|
|
@load="drawOverlay()">
|
|
<canvas x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none"></canvas>
|
|
</div>
|
|
<table x-show="result" class="tmf-table text-xs">
|
|
<tbody>
|
|
<template x-for="v in values" :key="v.key">
|
|
<tr><td class="font-mono" x-text="v.key"></td><td class="text-right" x-text="fmt(v.value)"></td></tr>
|
|
</template>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ========== 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>
|
|
{# Shown the way the operator will see it - the point of the
|
|
formatting is that the maker can check it here. #}
|
|
<span class="text-sm text-[var(--text-secondary)]"
|
|
x-html="richText(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"
|
|
@change="onTaskTypeChange(editTaskData, $event.target.value)">
|
|
<option value="note">{{ _('Nota') }}</option>
|
|
<option value="measure">{{ _('Misura') }}</option>
|
|
<option value="drawing">{{ _('Disegno') }}</option>
|
|
<option value="camera_measure">{{ _('Misura camera') }}</option>
|
|
<option value="dxf_compare">{{ _('Confronto profilo') }}</option>
|
|
<option value="check">{{ _('Controllo OK/NOK') }}</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">
|
|
<div class="flex items-center justify-between gap-2">
|
|
<label class="tmf-label">{{ _('Descrizione') }}</label>
|
|
{% with target = 'editTaskDescription' %}
|
|
{% include "components/rich_text_toolbar.html" %}
|
|
{% endwith %}
|
|
</div>
|
|
<textarea x-model="editTaskData.description"
|
|
x-ref="editTaskDescription"
|
|
class="tmf-input text-sm"
|
|
rows="3"
|
|
placeholder="{{ _('Descrizione opzionale...') }}"></textarea>
|
|
</div>
|
|
{# #53 #55: ciclo di misura, compilazione a fine produzione e campi della nota #}
|
|
{{ task_flags_editor('editTaskData') }}
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ========== Subtask Table ========== -->
|
|
<div class="subtask-table-wrap">
|
|
<table class="tmf-table" x-show="task.task_type !== 'check' && 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.task_type !== 'check' && (!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')|tojson_attr }} : {{ _('Nuova Misurazione')|tojson_attr }}">
|
|
</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>
|
|
<!-- Uscita visione (#46): solo task camera_measure / dxf_compare -->
|
|
<div class="col-span-2 sm:col-span-3 lg:col-span-2"
|
|
x-show="task.task_type === 'camera_measure' || task.task_type === 'dxf_compare'">
|
|
<label class="tmf-label text-xs">{{ _('Uscita visione') }}</label>
|
|
<select x-model="newSubtask.vision_output" class="tmf-input">
|
|
<option value="">{{ _('Nessuna') }}</option>
|
|
<template x-for="o in (visionOutputs[task.id] || [])" :key="o.key">
|
|
<option :value="o.key" :selected="o.key === newSubtask.vision_output"
|
|
x-text="o.key + ((o.quantity || o.unit) ? ' (' + [o.quantity, o.unit].filter(Boolean).join(', ') + ')' : '')"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<!-- Measurement Type -->
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('Tipo') }}</label>
|
|
<!-- #47: lista da Impostazioni (list_measurement_types) o voci di default; un valore salvato fuori lista resta selezionabile -->
|
|
<select x-model="newSubtask.measurement_type"
|
|
class="tmf-input">
|
|
<option value="">-</option>
|
|
<template x-for="o in measurementTypeOptions()" :key="o.value">
|
|
<option :value="o.value" :selected="o.value === newSubtask.measurement_type"
|
|
x-text="o.label"></option>
|
|
</template>
|
|
</select>
|
|
</div>
|
|
<!-- Nominal -->
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('Nominale') }}</label>
|
|
<input type="number" step="any"
|
|
x-model.number="newSubtask.nominal"
|
|
@input="onNominalInput($event)"
|
|
class="tmf-input measure-value text-right"
|
|
placeholder="0.000">
|
|
</div>
|
|
<!-- Unit -->
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('Unita') }}</label>
|
|
<!-- #47: lista da Impostazioni (list_units) o voci di default; un valore salvato fuori lista resta selezionabile -->
|
|
<select x-model="newSubtask.unit"
|
|
class="tmf-input">
|
|
<template x-for="o in unitOptions()" :key="o.value">
|
|
<option :value="o.value" :selected="o.value === newSubtask.unit"
|
|
x-text="o.label"></option>
|
|
</template>
|
|
</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"
|
|
@input="tolAuto = false"
|
|
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"
|
|
@input="tolAuto = false"
|
|
class="tmf-input measure-value text-right"
|
|
placeholder="{{ _('Lim. Tol. Sup.') }}">
|
|
</div>
|
|
</div>
|
|
<!-- #48: promemoria delle tolleranze generali (Impostazioni) -->
|
|
<p class="text-xs text-[var(--text-muted)] mt-2"
|
|
x-show="generalTolText()" x-text="generalTolText()"></p>
|
|
|
|
<!-- 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')|tojson_attr }} : {{ _('Aggiungi Misurazione')|tojson_attr }}"></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 && task.task_type !== 'check'"
|
|
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 %}
|
|
{# Bold and line breaks in descriptions: the same rules the server renders with. #}
|
|
<script src="{{ url_for('static', filename='js/rich-text.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/vision_recipe.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/station_calibration.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/dxf_recipe_wizard.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/task_acquisition.js') }}"></script>{# #97 #}
|
|
<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 src="{{ url_for('static', filename='js/image-crop.js') }}"></script>
|
|
<script>
|
|
// #56: stringhe e percorsi per il ritaglio prima dell'upload
|
|
ImageCrop.configure({
|
|
pdfSrc: '{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}',
|
|
workerSrc: '{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}',
|
|
labels: {
|
|
title: {{ _('Ritaglia immagine')|tojson }},
|
|
page: {{ _('Pagina')|tojson }},
|
|
hint: {{ _('Trascina il rettangolo o i suoi bordi per scegliere la zona da tenere.')|tojson }},
|
|
pdfNote: {{ _('Un PDF ritagliato viene salvato come immagine PNG.')|tojson }},
|
|
confirm: {{ _('Conferma ritaglio')|tojson }},
|
|
original: {{ _('Usa originale')|tojson }},
|
|
cancel: {{ _('Annulla')|tojson }},
|
|
loading: {{ _('Caricamento...')|tojson }},
|
|
error: {{ _('Impossibile aprire il file per il ritaglio.')|tojson }}
|
|
}
|
|
});
|
|
// #56: apre la modale di ritaglio; null = annullato, altrimenti il file da caricare
|
|
async function tmfCropBeforeUpload(file) {
|
|
const res = await ImageCrop.open(file);
|
|
if (res === null || res === file) return res;
|
|
const base = (file.name || 'file').replace(/\.[^.]+$/, '');
|
|
return new File([res], base + '-ritaglio.png', { type: 'image/png' });
|
|
}
|
|
</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', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }, // #97
|
|
newTask: { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [] },
|
|
MAX_NOTE_FIELDS: 20, // #55: limite campi per nota (stesso del backend)
|
|
_fieldUid: 0,
|
|
|
|
// ---- 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',
|
|
vision_output: ''
|
|
},
|
|
// Uscite numeriche della ricetta visione per task (#46), riempite da vision_recipe.js
|
|
visionOutputs: {},
|
|
|
|
// ---- Liste e tolleranze generali dalle Impostazioni (#47, #48); null = non configurato ----
|
|
configuredTypes: {{ list_measurement_types|default(none)|tojson }},
|
|
configuredUnits: {{ list_units|default(none)|tojson }},
|
|
generalTolPlus: {{ general_tolerance_plus|default(none)|tojson }},
|
|
generalTolMinus: {{ general_tolerance_minus|default(none)|tojson }},
|
|
// #48: true = utl/ltl si riempiono da soli dal nominale (solo quota nuova, finche' non li tocca l'utente)
|
|
tolAuto: false,
|
|
// Voci scritte a mano usate quando la lista non e' configurata (#47)
|
|
defaultTypeOptions: [
|
|
{value: 'linear', label: {{ _('Lineare')|tojson }}},
|
|
{value: 'diameter', label: {{ _('Diametro')|tojson }}},
|
|
{value: 'radius', label: {{ _('Raggio')|tojson }}},
|
|
{value: 'angle', label: {{ _('Angolo')|tojson }}},
|
|
{value: 'roughness', label: {{ _('Rugosita')|tojson }}},
|
|
{value: 'torque', label: {{ _('Coppia')|tojson }}},
|
|
{value: 'force', label: {{ _('Forza')|tojson }}},
|
|
{value: 'weight', label: {{ _('Peso')|tojson }}},
|
|
{value: 'other', label: {{ _('Altro')|tojson }}}
|
|
],
|
|
defaultUnits: ['mm', 'cm', 'm', '\u03bcm', 'in', '\u00b0', 'g', 'kg', 'N', 'Nm'],
|
|
|
|
// ---- 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() {
|
|
// Uscite della ricetta visione: evento dal componente visionRecipe (#46)
|
|
window.addEventListener('vision-outputs', (ev) => {
|
|
this.visionOutputs = { ...this.visionOutputs, [ev.detail.taskId]: ev.detail.outputs || [] };
|
|
});
|
|
// Sort tasks by order_index on load
|
|
this.tasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
|
|
|
// Show the notice left by _reloadForNewVersion() before its reload (gitea#4).
|
|
try {
|
|
var versionNotice = sessionStorage.getItem('taskEditorVersionNotice');
|
|
if (versionNotice) {
|
|
sessionStorage.removeItem('taskEditorVersionNotice');
|
|
this.successMessage = versionNotice;
|
|
}
|
|
} catch (_e) { /* sessionStorage unavailable - notice just does not show */ }
|
|
|
|
// 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 || '';
|
|
},
|
|
|
|
// ============================================================
|
|
// Version conflict (gitea#4)
|
|
//
|
|
// Copy-on-write on a version with measurements creates NEW ids for its
|
|
// task/subtasks. The first save after that happens silently (200/201,
|
|
// new ids in the response); a save that still targets an id from before
|
|
// gets 409 "Cannot modify a non-current version". Both cases are fixed
|
|
// the same way: reload, so the editor starts fresh from the current
|
|
// version instead of patching a local state that copy-on-write already
|
|
// moved out from under it.
|
|
// ============================================================
|
|
_reloadForNewVersion() {
|
|
try {
|
|
sessionStorage.setItem(
|
|
'taskEditorVersionNotice',
|
|
{{ _("La ricetta ha una nuova versione: pagina ricaricata")|tojson }}
|
|
);
|
|
} catch (_e) { /* sessionStorage unavailable - reload still happens */ }
|
|
window.location.reload();
|
|
},
|
|
|
|
// ============================================================
|
|
// 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', in_loop: false, in_loop_touched: false, at_close: false, fields: [] };
|
|
},
|
|
|
|
// #97: acquisition_json del task; null = camera della stazione (voce vuota o tipo senza camera)
|
|
_acquisitionPayload(obj) {
|
|
const a = obj.acquisition || {};
|
|
const usesCamera = obj.task_type === 'camera_measure' || obj.task_type === 'dxf_compare';
|
|
if (!usesCamera || !a.camera_url) return { ok: true, value: null };
|
|
if (!a.camera_source) {
|
|
this.errorMessage = {{ _("Scegli la sorgente della camera")|tojson }};
|
|
return { ok: false };
|
|
}
|
|
return { ok: true, value: { camera_url: a.camera_url, camera_source: a.camera_source, modo: a.modo === 'in_linea' ? 'in_linea' : 'impulsiva' } };
|
|
},
|
|
|
|
// ---- #53 #55: ciclo di misura e campi della nota ----
|
|
|
|
// #53: default di "nel ciclo" per tipo (stessa regola del backend)
|
|
loopDefault(type) {
|
|
return ['measure', 'camera_measure', 'dxf_compare', 'check'].includes(type);
|
|
},
|
|
|
|
// Cambio tipo: il default del ciclo segue il tipo finche l'utente non lo tocca;
|
|
// at_close e campi esistono solo per la nota.
|
|
onTaskTypeChange(obj, type) {
|
|
obj.task_type = type;
|
|
if (!obj.in_loop_touched) obj.in_loop = this.loopDefault(type);
|
|
if (type !== 'note') { obj.at_close = false; obj.fields = []; }
|
|
},
|
|
|
|
// #55: key dall'etichetta (minuscole, [a-z0-9_], max 40), unica nella lista
|
|
_slugKey(label) {
|
|
const base = (label || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
|
|
.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40);
|
|
return base || 'campo';
|
|
},
|
|
|
|
deriveNoteFieldKey(obj, f) {
|
|
if (f.locked) return; // la key non cambia piu dopo il primo salvataggio
|
|
const used = new Set(obj.fields.filter(x => x !== f).map(x => x.key));
|
|
const base = this._slugKey(f.label);
|
|
let key = base, n = 2;
|
|
while (used.has(key)) {
|
|
const suffix = '_' + n++;
|
|
key = base.slice(0, 40 - suffix.length) + suffix;
|
|
}
|
|
f.key = key;
|
|
},
|
|
|
|
addNoteField(obj) {
|
|
if (obj.fields.length >= this.MAX_NOTE_FIELDS) return;
|
|
const f = { _uid: ++this._fieldUid, key: '', label: '', type: 'text', default: '', attachment: false, locked: false };
|
|
obj.fields.push(f);
|
|
this.deriveNoteFieldKey(obj, f);
|
|
},
|
|
|
|
removeNoteField(obj, i) {
|
|
obj.fields.splice(i, 1);
|
|
},
|
|
|
|
moveNoteField(obj, i, dir) {
|
|
const j = i + dir;
|
|
if (j < 0 || j >= obj.fields.length) return;
|
|
const [f] = obj.fields.splice(i, 1);
|
|
obj.fields.splice(j, 0, f);
|
|
},
|
|
|
|
// Campi salvati -> stato di modifica (key bloccate)
|
|
_fieldsToState(fields) {
|
|
return (fields || []).map(f => ({
|
|
_uid: ++this._fieldUid, key: f.key, label: f.label || '', type: f.type || 'text',
|
|
default: f.default == null ? '' : f.default, attachment: !!f.attachment, locked: true
|
|
}));
|
|
},
|
|
|
|
// Stato -> corpo API (fields null se non e una nota o e vuota); ok=false con errorMessage se manca un'etichetta.
|
|
_fieldsPayload(obj) {
|
|
if (obj.task_type !== 'note') return { ok: true, fields: null };
|
|
const out = [];
|
|
for (const f of obj.fields) {
|
|
if (!f.label.trim()) {
|
|
this.errorMessage = {{ _("Ogni campo deve avere un'etichetta")|tojson }};
|
|
return { ok: false };
|
|
}
|
|
out.push({
|
|
key: f.key, label: f.label.trim(), type: f.type || 'text',
|
|
default: (f.default || '').trim() || null, attachment: !!f.attachment
|
|
});
|
|
}
|
|
return { ok: true, fields: out.length ? out : null };
|
|
},
|
|
|
|
// ============================================================
|
|
// 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.errorMessage = '';
|
|
const fp = this._fieldsPayload(this.newTask); // #55
|
|
if (!fp.ok) return;
|
|
this.saving = true;
|
|
|
|
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,
|
|
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
|
|
in_loop: !!this.newTask.in_loop,
|
|
at_close: this.newTask.task_type === 'note' && !!this.newTask.at_close,
|
|
fields: fp.fields
|
|
})
|
|
});
|
|
|
|
if (resp.status === 409) { this._reloadForNewVersion(); return; }
|
|
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 || '',
|
|
// #53 #55: valore salvato (task vecchi senza il campo: default del tipo)
|
|
in_loop: task.in_loop != null ? !!task.in_loop : this.loopDefault(task.task_type || 'note'),
|
|
in_loop_touched: false,
|
|
at_close: !!task.at_close,
|
|
fields: this._fieldsToState(task.fields),
|
|
// #97: camera e modo di analisi del task (acquisition_json), vuoti = camera della stazione
|
|
acquisition: {
|
|
camera_url: (task.acquisition_json && task.acquisition_json.camera_url) || '',
|
|
camera_source: (task.acquisition_json && task.acquisition_json.camera_source) || '',
|
|
modo: (task.acquisition_json && task.acquisition_json.modo) || 'impulsiva'
|
|
}
|
|
};
|
|
// Ensure expanded
|
|
this.expandedTask = task.id;
|
|
},
|
|
|
|
cancelEditTask() {
|
|
this.editingTask = null;
|
|
this.editTaskData = { title: '', directive: '', description: '', task_type: 'note', in_loop: false, in_loop_touched: false, at_close: false, fields: [], acquisition: { camera_url: '', camera_source: '', modo: 'impulsiva' } }; // #97
|
|
},
|
|
|
|
async updateTask(taskId) {
|
|
if (!this.editTaskData.title.trim()) return;
|
|
this.errorMessage = '';
|
|
const fp = this._fieldsPayload(this.editTaskData); // #55
|
|
if (!fp.ok) return;
|
|
const acq = this._acquisitionPayload(this.editTaskData); // #97
|
|
if (!acq.ok) return;
|
|
this.saving = true;
|
|
|
|
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,
|
|
// #53 #55: ciclo di misura, compilazione a fine produzione, campi della nota
|
|
in_loop: !!this.editTaskData.in_loop,
|
|
at_close: this.editTaskData.task_type === 'note' && !!this.editTaskData.at_close,
|
|
fields: fp.fields,
|
|
acquisition_json: acq.value // #97
|
|
})
|
|
});
|
|
|
|
if (resp.status === 409) { this._reloadForNewVersion(); return; }
|
|
const data = await resp.json();
|
|
|
|
if (data.error) {
|
|
this.errorMessage = data.detail || {{ _("Errore nel salvataggio del task")|tojson }};
|
|
} else if (data.id !== taskId) {
|
|
// Copy-on-write happened silently: this task, and every other one
|
|
// in the recipe, now has a different id than what is in local state.
|
|
this._reloadForNewVersion();
|
|
return;
|
|
} 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.status === 409) { this._reloadForNewVersion(); return; }
|
|
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 })
|
|
});
|
|
|
|
if (resp.status === 409) { this._reloadForNewVersion(); return; }
|
|
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;
|
|
},
|
|
|
|
// #47: opzioni della select Tipo; il valore corrente fuori lista viene aggiunto per non perderlo
|
|
measurementTypeOptions() {
|
|
const known = v => (this.defaultTypeOptions.find(d => d.value === v) || {label: v}).label;
|
|
const opts = this.configuredTypes
|
|
? this.configuredTypes.map(v => ({value: v, label: known(v)})) // un tipo di default noto mantiene l'etichetta tradotta
|
|
: this.defaultTypeOptions.slice();
|
|
const cur = this.newSubtask.measurement_type;
|
|
if (cur && !opts.some(o => o.value === cur)) opts.push({value: cur, label: known(cur)});
|
|
return opts;
|
|
},
|
|
|
|
// #47: opzioni della select Unita, stessa logica del Tipo
|
|
unitOptions() {
|
|
const opts = (this.configuredUnits || this.defaultUnits).map(v => ({value: v, label: v}));
|
|
const cur = this.newSubtask.unit;
|
|
if (cur && !opts.some(o => o.value === cur)) opts.push({value: cur, label: cur});
|
|
return opts;
|
|
},
|
|
|
|
// #47: unita di partenza di una quota nuova: la prima della lista configurata, altrimenti mm
|
|
defaultUnit() {
|
|
return this.configuredUnits ? this.configuredUnits[0] : 'mm';
|
|
},
|
|
|
|
// #48: testo "Tolleranze generali: +x / -y" sotto i campi; vuoto se non configurate
|
|
generalTolText() {
|
|
const parts = [];
|
|
if (this.generalTolPlus !== null) parts.push('+' + this.generalTolPlus);
|
|
if (this.generalTolMinus !== null) parts.push('\u2212' + this.generalTolMinus);
|
|
return parts.length ? {{ _('Tolleranze generali')|tojson }} + ': ' + parts.join(' / ') : '';
|
|
},
|
|
|
|
// #48: su quota nuova, scrivendo il nominale riempie utl = nominale + plus e ltl = nominale - minus
|
|
onNominalInput(ev) {
|
|
if (!this.tolAuto || this.editingSubtaskId) return;
|
|
const raw = String(ev.target.value).trim();
|
|
const nominal = parseFloat(raw);
|
|
if (raw === '' || !isFinite(nominal)) {
|
|
// nominale svuotato: i limiti riempiti da noi tornano vuoti
|
|
this.newSubtask.utl = null;
|
|
this.newSubtask.ltl = null;
|
|
return;
|
|
}
|
|
// decimali: quelli del nominale o delle tolleranze generali, al massimo 4
|
|
const dec = s => (String(s).split('.')[1] || '').length;
|
|
const decimals = Math.min(4, Math.max(dec(raw), dec(this.generalTolPlus), dec(this.generalTolMinus)));
|
|
const round = v => Number(v.toFixed(decimals));
|
|
if (this.generalTolPlus !== null) this.newSubtask.utl = round(nominal + this.generalTolPlus);
|
|
if (this.generalTolMinus !== null) this.newSubtask.ltl = round(nominal - this.generalTolMinus);
|
|
},
|
|
|
|
resetNewSubtask(taskId) {
|
|
const task = this.tasks.find(t => t.id === taskId);
|
|
// #48: automatismo attivo solo se c'e' almeno una tolleranza generale
|
|
this.tolAuto = this.generalTolPlus !== null || this.generalTolMinus !== null;
|
|
this.newSubtask = {
|
|
marker_number: task ? this.nextMarkerNumber(task) : 1,
|
|
description: '',
|
|
measurement_type: '',
|
|
nominal: null,
|
|
utl: null,
|
|
uwl: null,
|
|
lwl: null,
|
|
ltl: null,
|
|
unit: this.defaultUnit(), // #47
|
|
vision_output: ''
|
|
};
|
|
},
|
|
|
|
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',
|
|
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
|
|
};
|
|
|
|
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)
|
|
});
|
|
|
|
if (resp.status === 409) { this._reloadForNewVersion(); return; }
|
|
const data = await resp.json();
|
|
|
|
if (data.error) {
|
|
this.errorMessage = data.detail || {{ _("Errore nella creazione della misurazione")|tojson }};
|
|
} else if (data.task_id !== taskId) {
|
|
// Copy-on-write happened silently: this task, and every other one
|
|
// in the recipe, now has a different id than what is in local state.
|
|
this._reloadForNewVersion();
|
|
return;
|
|
} 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.tolAuto = false; // #48: una quota esistente non viene mai ricalcolata
|
|
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',
|
|
vision_output: subtask.vision_output || ''
|
|
};
|
|
},
|
|
|
|
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',
|
|
vision_output: this.newSubtask.vision_output || null // chiave uscita ricetta visione (#46)
|
|
};
|
|
|
|
try {
|
|
const resp = await fetch(`/maker/api/subtasks/${subtaskId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': this.csrfToken()
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (resp.status === 409) { this._reloadForNewVersion(); return; }
|
|
const data = await resp.json();
|
|
|
|
if (data.error) {
|
|
this.errorMessage = data.detail || {{ _("Errore nel salvataggio della misurazione")|tojson }};
|
|
} else if (data.task_id !== taskId) {
|
|
// Copy-on-write happened silently: this task, and every other one
|
|
// in the recipe, now has a different id than what is in local state.
|
|
this._reloadForNewVersion();
|
|
return;
|
|
} 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.status === 409) { this._reloadForNewVersion(); return; }
|
|
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;
|
|
}
|
|
|
|
// #56: scelta di ritaglio (immagine/PDF) prima dell'upload; senza ritaglio il file resta invariato
|
|
file = await tmfCropBeforeUpload(file);
|
|
if (!file) 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
|
|
})
|
|
});
|
|
|
|
if (updateResp.status === 409) { this._reloadForNewVersion(); return; }
|
|
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 %}
|