58baed19e2
Refs #118 #119 #120 #121 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2396 lines
115 KiB
HTML
2396 lines
115 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}{{ task.title or 'Task' }} — {{ _('Misure') }} — TieMeasureFlow{% endblock %}
|
|
|
|
{# Full-screen layout: no body scroll, no footer, main fills available space #}
|
|
{% block body_class %}h-screen overflow-hidden{% endblock %}
|
|
{% block wrapper_class %}h-full{% endblock %}
|
|
{% block main_class %}flex-1 min-h-0{% endblock %}
|
|
{% block footer %}{% endblock %}
|
|
|
|
{% block extra_head %}
|
|
<style>
|
|
/* Annotation viewer container */
|
|
.annotation-container {
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: var(--bg-secondary);
|
|
overflow: hidden;
|
|
}
|
|
.annotation-container canvas {
|
|
display: block;
|
|
max-width: 100%;
|
|
height: auto;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
/* Tolerance bar gradient background */
|
|
.tolerance-bar-bg {
|
|
background: linear-gradient(to right,
|
|
rgba(220, 38, 38, 0.15) 0%,
|
|
rgba(217, 119, 6, 0.15) 15%,
|
|
rgba(5, 150, 105, 0.15) 30%,
|
|
rgba(5, 150, 105, 0.15) 70%,
|
|
rgba(217, 119, 6, 0.15) 85%,
|
|
rgba(220, 38, 38, 0.15) 100%
|
|
);
|
|
}
|
|
|
|
/* Pulse animation for active marker */
|
|
@keyframes markerPulse {
|
|
0%, 100% { box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4); }
|
|
50% { box-shadow: 0 0 0 6px rgba(37, 99, 235, 0); }
|
|
}
|
|
.marker-active-pulse {
|
|
animation: markerPulse 2s ease-in-out infinite;
|
|
}
|
|
|
|
/* Left sidebar scrollbar */
|
|
.sidebar-markers::-webkit-scrollbar { width: 3px; }
|
|
.sidebar-markers::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
|
|
|
|
/* Right panel scrollbar */
|
|
.right-panel::-webkit-scrollbar { width: 4px; }
|
|
.right-panel::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<script>
|
|
window.__taskData = {{ task|tojson }};
|
|
window.__taskSubtasks = {{ task.subtasks|tojson if task.subtasks else '[]' }};
|
|
window.__taskAnnotations = {{ task.annotations_json|default('null')|tojson }};
|
|
window.__allTaskIds = {{ all_task_ids|tojson }};
|
|
</script>
|
|
|
|
<div class="h-full flex flex-col overflow-hidden"
|
|
x-data="taskExecute()"
|
|
x-init="init()"
|
|
@numpad-confirm.window="handleMeasurement($event.detail.value, $event.detail.inputMethod)"
|
|
@numpad-rejected.window="onNumpadRejected($event.detail)"
|
|
@numpad-error.window="onNumpadError($event.detail)"
|
|
@marker-click.window="goToSubtaskByMarker($event.detail.marker_number)"
|
|
@vision-recorded.window="onVisionRecorded($event.detail)">
|
|
|
|
{# ================================================================
|
|
HEADER — Compact task info bar
|
|
================================================================ #}
|
|
<div class="shrink-0 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm z-20">
|
|
<div class="px-3 py-2 flex items-center gap-3">
|
|
|
|
{# Back button #}
|
|
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
|
class="shrink-0 p-1.5 rounded-lg hover:bg-[var(--bg-secondary)] transition-colors text-[var(--text-muted)] hover:text-[var(--text-primary)]">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
|
</svg>
|
|
</a>
|
|
|
|
{# Task badge + title #}
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<span class="shrink-0 inline-flex items-center px-2 py-0.5 rounded text-xs font-bold
|
|
bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300
|
|
border border-primary-200 dark:border-primary-800">
|
|
Task {{ (task.order_index or 0) + 1 }}
|
|
</span>
|
|
<h1 class="text-sm font-bold text-[var(--text-primary)] truncate">
|
|
{{ task.title or _('Task di misurazione') }}
|
|
</h1>
|
|
</div>
|
|
|
|
{#
|
|
Lista task + Completato.
|
|
|
|
The list is the second level now: the operator arrives inside the sequence
|
|
and comes here when they want the whole of it. What used to sit beside it
|
|
was «Riepilogo», which jumped out of the sequence to a recap - the wrong
|
|
thing to offer someone halfway through a task. The way forward is
|
|
«Completato», and on a measurement task it stays shut until the cycle is
|
|
closed, which is the same rule the footer enforces.
|
|
#}
|
|
<div class="shrink-0 flex items-center gap-1.5">
|
|
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
|
class="btn btn-secondary text-xs py-1 px-2.5 gap-1">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
|
</svg>
|
|
{{ _('Lista task') }}
|
|
</a>
|
|
{# #60: un solo «Completato», quello grande in fondo (touch); qui resta solo la lista. #}
|
|
</div>
|
|
|
|
{# Fermo linea + Fine produzione: task di misura o del ciclo (#53: anche un check nel loop) #}
|
|
<template x-if="(isMeasureTask || isMeasurementTaskOfRun) && productionRun">
|
|
{# #61: Fermo linea / Fine produzione solo con una produzione aperta #}
|
|
<div class="shrink-0 flex items-center gap-1.5">
|
|
{# While the line is stopped this becomes the way to restart it, so the
|
|
capoturno never has to hunt for a second control. #}
|
|
<button x-show="!isPaused"
|
|
@click="openSupervisorModal('fermo_linea')"
|
|
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-amber-500 text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 hover:bg-amber-100 dark:hover:bg-amber-900/40">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
{{ _('Fermo linea') }}
|
|
</button>
|
|
<button x-show="isPaused" x-cloak
|
|
@click="openSupervisorModal('ripresa')"
|
|
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-emerald-500 text-emerald-700 dark:text-emerald-300 bg-emerald-50 dark:bg-emerald-900/20 hover:bg-emerald-100 dark:hover:bg-emerald-900/40">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"/>
|
|
</svg>
|
|
{{ _('Riprendi') }}
|
|
</button>
|
|
<button @click="openSupervisorModal('fine_produzione')"
|
|
class="btn text-xs py-1 px-2.5 gap-1 border-2 border-red-500 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40">
|
|
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
|
|
</svg>
|
|
{{ _('Fine Produzione') }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
{# Lot + Serial badges #}
|
|
<div class="shrink-0 flex items-center gap-2">
|
|
{% if lot_number %}
|
|
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
|
|
bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800
|
|
text-amber-800 dark:text-amber-200">
|
|
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
|
</svg>
|
|
<span class="font-mono font-semibold">{{ lot_number }}</span>
|
|
</div>
|
|
{% endif %}
|
|
{% if serial_number %}
|
|
<div class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs
|
|
bg-indigo-50 dark:bg-indigo-900/20 border border-indigo-200 dark:border-indigo-800
|
|
text-indigo-800 dark:text-indigo-200">
|
|
<svg class="w-3 h-3 shrink-0" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
|
</svg>
|
|
<span class="font-mono font-semibold">{{ serial_number }}</span>
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# Caliper status #}
|
|
{% include "components/caliper_status.html" %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
MAIN — 3-column layout
|
|
================================================================ #}
|
|
{# #46: camera_measure / dxf_compare get the acquisition panel below instead of the manual layout #}
|
|
<div class="flex-1 flex overflow-hidden" x-show="!isVisionTask">
|
|
|
|
{# ──────────────────────────────────────────────
|
|
LEFT SIDEBAR — Marker list (vertical, hidden when no subtasks)
|
|
────────────────────────────────────────────── #}
|
|
<div x-show="isMeasureTask && subtasks.length > 0"
|
|
class="shrink-0 w-14 md:w-16 bg-[var(--bg-card)] border-r border-[var(--border-color)] flex flex-col sidebar-markers overflow-y-auto">
|
|
<template x-for="(st, idx) in subtasks" :key="st.id">
|
|
<button @click="goToSubtask(idx)"
|
|
class="relative flex flex-col items-center justify-center py-2.5 px-1 border-b border-[var(--border-color)] transition-all duration-200"
|
|
:class="idx === currentIndex
|
|
? 'bg-primary-50 dark:bg-primary-900/20 border-l-[3px] border-l-primary'
|
|
: 'hover:bg-[var(--bg-secondary)] border-l-[3px] border-l-transparent'">
|
|
|
|
{# Marker circle #}
|
|
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-bold transition-all"
|
|
:class="idx === currentIndex
|
|
? 'bg-primary text-white shadow-md marker-active-pulse'
|
|
: getMeasurementStatus(st.id) === 'pass'
|
|
? 'bg-measure-pass/20 text-measure-pass border border-measure-pass/40'
|
|
: getMeasurementStatus(st.id) === 'fail'
|
|
? 'bg-red-100 dark:bg-red-900/30 text-measure-fail border border-measure-fail/40'
|
|
: getMeasurementStatus(st.id) === 'warning'
|
|
? 'bg-amber-100 dark:bg-amber-900/30 text-measure-warning border border-measure-warning/40'
|
|
: 'bg-[var(--bg-secondary)] text-[var(--text-muted)] border border-[var(--border-color)]'"
|
|
x-text="st.marker_number"></span>
|
|
|
|
{# Status icon below circle #}
|
|
<template x-if="getMeasurementStatus(st.id) === 'pass'">
|
|
<svg class="w-3 h-3 mt-0.5 text-measure-pass" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
</template>
|
|
<template x-if="getMeasurementStatus(st.id) === 'fail'">
|
|
<svg class="w-3 h-3 mt-0.5 text-measure-fail" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</template>
|
|
<template x-if="getMeasurementStatus(st.id) === 'warning'">
|
|
<svg class="w-3 h-3 mt-0.5 text-measure-warning" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
|
</svg>
|
|
</template>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
|
|
{# ──────────────────────────────────────────────
|
|
CENTER — Image area
|
|
────────────────────────────────────────────── #}
|
|
<div class="flex-1 flex flex-col overflow-hidden bg-[var(--bg-secondary)]">
|
|
|
|
{# What the task says to do. It was written for the operator and until now
|
|
only the maker ever saw it; the line breaks and the bold of the technical
|
|
sheet survive here as they do in the editor. #}
|
|
{% if task.directive or task.description %}
|
|
<div class="shrink-0 max-h-36 overflow-y-auto px-4 py-2.5
|
|
bg-[var(--bg-card)] border-b border-[var(--border-color)]">
|
|
{% if task.directive %}
|
|
<p class="text-sm font-semibold text-[var(--text-primary)] leading-snug">
|
|
{{ task.directive|rich_text }}
|
|
</p>
|
|
{% endif %}
|
|
{% if task.description %}
|
|
<div class="text-sm text-[var(--text-secondary)] leading-relaxed
|
|
{% if task.directive %}mt-1{% endif %}">
|
|
{{ task.description|rich_text }}
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
|
|
{# #55: the inputs of the fields of a «note» task. One block for the task on screen
|
|
and for the panel at the end of production, so the rules are written once. #}
|
|
{% macro result_fields() %}
|
|
<template x-for="fld in f.fields" :key="fld.key">
|
|
<div class="mb-3">
|
|
<label class="block text-sm font-medium text-[var(--text-secondary)] mb-1" x-text="fld.label"></label>
|
|
<input :type="fld.type === 'number' ? 'number' : 'text'"
|
|
:step="fld.type === 'number' ? 'any' : null"
|
|
:inputmode="fld.type === 'number' ? 'decimal' : null"
|
|
x-model="f.values[fld.key]"
|
|
class="input w-full text-base py-2">
|
|
<template x-if="fld.attachment">
|
|
<div class="mt-1.5">
|
|
<label class="btn btn-secondary text-xs gap-1.5 cursor-pointer">
|
|
{{ _('Allega immagine o PDF') }}
|
|
<input type="file" accept="image/*,application/pdf" class="hidden"
|
|
@change="pickFile(f, fld.key, $event)">
|
|
</label>
|
|
<span class="ml-2 text-xs text-[var(--text-muted)]" x-text="f.fileNames[fld.key]"></span>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
{% endmacro %}
|
|
|
|
{# #54 #55: result panel of the task on screen - OK / NOK for a «check», the
|
|
fields and «Completato» for a «note» with fields. #}
|
|
<div x-show="hasResultPanel" x-cloak
|
|
x-data="taskResultForm()"
|
|
x-init="ctx = () => ({ runId: productionRun ? productionRun.id : null, done: () => advanceAfterResult() });
|
|
initForTask(task);
|
|
$watch('productionRun && productionRun.id', () => loadLast())"
|
|
@task-result-reset.window="justRecorded = false; initForTask(task)"
|
|
class="shrink-0 max-h-[65%] overflow-y-auto px-4 py-3 bg-[var(--bg-card)] border-b border-[var(--border-color)]">
|
|
<template x-for="f in forms" :key="f.task_id">
|
|
<div class="max-w-2xl mx-auto">
|
|
<div x-show="f.result" class="mb-3 px-3 py-2 rounded-lg text-sm border border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
|
<span class="text-[var(--text-secondary)]">{{ _('Ultimo esito') }}:</span>
|
|
<span class="font-bold"
|
|
:class="f.result && f.result.outcome === 'nok' ? 'text-measure-fail' : 'text-measure-pass'"
|
|
x-text="resultLabel(f.result)"></span>
|
|
<span class="text-[var(--text-muted)]" x-text="f.result && f.result.note ? '— ' + f.result.note : ''"></span>
|
|
</div>
|
|
|
|
<template x-if="f.type === 'check'">
|
|
<div>
|
|
<textarea x-model="f.note" rows="2"
|
|
placeholder="{{ _('Nota (obbligatoria per un NOK)') }}"
|
|
class="input w-full text-base mb-3"></textarea>
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<button @click="submit('ok')" :disabled="working"
|
|
class="btn min-h-[72px] text-2xl font-bold justify-center shadow-md bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
|
{{ _('OK') }}
|
|
</button>
|
|
<button @click="submit('nok')" :disabled="working"
|
|
class="btn min-h-[72px] text-2xl font-bold justify-center shadow-md bg-red-600 text-white hover:bg-red-700 disabled:opacity-50">
|
|
{{ _('NOK') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template x-if="f.type === 'note'">
|
|
<div>
|
|
{{ result_fields() }}
|
|
<button @click="submit('done')" :disabled="working"
|
|
class="btn text-lg font-bold w-full min-h-[64px] justify-center gap-2 shadow-md bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
|
<span>{{ _('Completato') }}</span>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<p x-show="f.error" class="mt-2 text-sm text-measure-fail" x-text="f.error"></p>
|
|
{# The result is in, but going on failed (cycle refused, network): another try, without recording twice. #}
|
|
<button x-show="justRecorded" @click="proceed()"
|
|
class="btn btn-secondary mt-3 w-full justify-center">
|
|
{{ _('Prosegui') }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
{# Main image area #}
|
|
<div class="flex-1 overflow-hidden relative">
|
|
|
|
{# Option A: Subtask has its own image → plain <img> #}
|
|
<div x-show="showSubtaskImage" class="absolute inset-0 flex items-center justify-center p-2">
|
|
<img :src="'/measure/api/files/' + currentSubtaskImage"
|
|
alt="{{ _('Immagine dettaglio misura') }}"
|
|
class="max-w-full max-h-full object-contain rounded-lg shadow-sm">
|
|
</div>
|
|
|
|
{# Option B: Task image with annotations → annotation-viewer (kept in DOM via x-show) #}
|
|
<div x-show="showTaskImage" class="absolute inset-0">
|
|
{% if task.file_path %}
|
|
<div class="annotation-container"
|
|
x-data="annotationViewer()"
|
|
x-init="
|
|
imageUrl = '/measure/api/files/{{ task.file_path }}';
|
|
annotations = window.__taskAnnotations;
|
|
$nextTick(() => init());
|
|
"
|
|
x-effect="setActiveMarker(currentSubtask?.marker_number || 0)">
|
|
<canvas x-ref="annotationCanvas"
|
|
@click="handleClick($event)"
|
|
class="cursor-pointer"></canvas>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
|
|
{# Option C: No image → placeholder #}
|
|
<div x-show="!showSubtaskImage && !showTaskImage" class="absolute inset-0 flex items-center justify-center">
|
|
<div class="text-center">
|
|
<svg class="w-16 h-16 mx-auto text-[var(--text-muted)] mb-3 opacity-30" fill="none" stroke="currentColor" stroke-width="1" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
</svg>
|
|
<p class="text-sm font-medium text-[var(--text-muted)]">{{ _('Nessuna immagine allegata') }}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ──────────────────────────────────────────────
|
|
RIGHT PANEL — Info + tolerances + numpad (hidden when no subtasks)
|
|
────────────────────────────────────────────── #}
|
|
<div x-show="isMeasureTask && subtasks.length > 0"
|
|
class="shrink-0 w-72 lg:w-80 bg-[var(--bg-card)] border-l border-[var(--border-color)] flex flex-col right-panel overflow-y-auto">
|
|
|
|
{# ---- Subtask header ---- #}
|
|
<div class="p-3 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
|
<div class="flex items-center gap-2">
|
|
<span class="inline-flex items-center justify-center w-8 h-8 rounded-full
|
|
bg-primary text-white font-bold text-sm shadow marker-active-pulse"
|
|
x-text="currentSubtask?.marker_number"></span>
|
|
<div class="min-w-0 flex-1">
|
|
<h2 class="font-semibold text-[var(--text-primary)] text-sm leading-tight truncate"
|
|
x-text="currentSubtask?.description || '{{ _('Misurazione') }}'"></h2>
|
|
<span class="text-[11px] text-[var(--text-muted)]"
|
|
x-text="(currentSubtask?.measurement_type || '{{ _('Misura') }}') + ' (' + (currentSubtask?.unit || 'mm') + ')'"></span>
|
|
</div>
|
|
<span class="shrink-0 text-[11px] font-mono text-[var(--text-muted)] bg-[var(--bg-secondary)] px-1.5 py-0.5 rounded">
|
|
<span x-text="currentIndex + 1"></span>/<span x-text="totalSubtasks"></span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{# ---- Tolerance parameters (compact grid) ---- #}
|
|
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
|
{# Nominal row #}
|
|
<div class="flex items-center justify-between py-1 px-2 rounded bg-primary-50 dark:bg-primary-900/20 border border-primary-100 dark:border-primary-800 mb-1.5">
|
|
<span class="text-[10px] font-medium text-primary-700 dark:text-primary-300 uppercase tracking-wider">{{ _('Nominale') }}</span>
|
|
<span class="font-mono font-bold text-primary text-sm"
|
|
x-text="currentSubtask?.nominal?.toFixed(3) || '—'"></span>
|
|
</div>
|
|
|
|
{# 2x2 tolerance grid #}
|
|
<div class="grid grid-cols-2 gap-1 text-[11px]">
|
|
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
|
|
<span class="text-measure-fail font-medium">UTL</span>
|
|
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.utl?.toFixed(3) || '—'"></span>
|
|
</div>
|
|
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-red-50/50 dark:bg-red-900/10">
|
|
<span class="text-measure-fail font-medium">LTL</span>
|
|
<span class="font-mono font-semibold text-measure-fail" x-text="currentSubtask?.ltl?.toFixed(3) || '—'"></span>
|
|
</div>
|
|
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
|
|
<span class="text-measure-warning font-medium">UWL</span>
|
|
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.uwl?.toFixed(3) || '—'"></span>
|
|
</div>
|
|
<div class="flex items-center justify-between py-0.5 px-2 rounded bg-amber-50/50 dark:bg-amber-900/10">
|
|
<span class="text-measure-warning font-medium">LWL</span>
|
|
<span class="font-mono font-semibold text-measure-warning" x-text="currentSubtask?.lwl?.toFixed(3) || '—'"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ---- Tolerance bar (compact) ---- #}
|
|
<div class="px-3 py-2 border-b border-[var(--border-color)]" x-show="currentSubtask">
|
|
<div class="tolerance-bar-bg h-5 rounded-full relative overflow-hidden border border-[var(--border-color)]">
|
|
{# Zone labels #}
|
|
<div class="absolute inset-0 flex items-center justify-between px-1.5 text-[8px] font-mono text-[var(--text-muted)]">
|
|
<span x-text="currentSubtask?.ltl?.toFixed(2)"></span>
|
|
<span x-text="currentSubtask?.nominal?.toFixed(2)"></span>
|
|
<span x-text="currentSubtask?.utl?.toFixed(2)"></span>
|
|
</div>
|
|
{# Center line #}
|
|
<div class="absolute top-0 bottom-0 w-px bg-[var(--text-muted)]/30" style="left: 50%;"></div>
|
|
{# Value needle #}
|
|
<div x-show="currentValue !== null"
|
|
x-transition
|
|
class="absolute top-0 bottom-0 w-1 rounded-full transition-all duration-300"
|
|
:class="{
|
|
'bg-measure-pass': passFailStatus === 'pass',
|
|
'bg-measure-warning': passFailStatus === 'warning',
|
|
'bg-measure-fail': passFailStatus === 'fail'
|
|
}"
|
|
:style="'left: calc(' + progressWidth + '% - 2px)'">
|
|
<div class="absolute -top-0.5 left-1/2 -translate-x-1/2 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-slate-800 shadow"
|
|
:class="{
|
|
'bg-measure-pass': passFailStatus === 'pass',
|
|
'bg-measure-warning': passFailStatus === 'warning',
|
|
'bg-measure-fail': passFailStatus === 'fail'
|
|
}"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ---- Already measured indicator ---- #}
|
|
<div x-show="isMeasured(currentSubtask?.id)"
|
|
class="mx-3 mt-2 p-2 rounded-lg bg-measure-pass/10 border border-measure-pass/20">
|
|
<div class="flex items-center gap-1.5 text-xs text-measure-pass font-medium">
|
|
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
<span>{{ _('Registrata') }}:</span>
|
|
<span class="font-mono font-bold" x-text="getMeasuredValue(currentSubtask?.id)?.toFixed(3) || '—'"></span>
|
|
<span x-text="currentSubtask?.unit || 'mm'"></span>
|
|
</div>
|
|
</div>
|
|
|
|
{# ---- Measurement feedback ---- #}
|
|
<div class="px-3 pt-2" x-show="currentSubtask" x-data="{ get nominal() { return currentSubtask?.nominal || 0; },
|
|
get utl() { return currentSubtask?.utl || 0; },
|
|
get uwl() { return currentSubtask?.uwl || 0; },
|
|
get lwl() { return currentSubtask?.lwl || 0; },
|
|
get ltl() { return currentSubtask?.ltl || 0; },
|
|
get unit() { return currentSubtask?.unit || 'mm'; } }">
|
|
{% include "components/measurement_feedback.html" %}
|
|
</div>
|
|
|
|
{# ---- Numpad (only when a subtask is active) ---- #}
|
|
<div class="px-3 py-2 relative flex-1" x-show="currentSubtask">
|
|
{# Saving overlay #}
|
|
<div x-show="saving"
|
|
x-transition
|
|
class="absolute inset-0 z-10 bg-white/70 dark:bg-slate-900/70 flex items-center justify-center backdrop-blur-sm rounded">
|
|
<div class="flex items-center gap-2 text-primary font-medium text-sm">
|
|
<svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
</svg>
|
|
{{ _('Salvataggio...') }}
|
|
</div>
|
|
</div>
|
|
|
|
{% include "components/numpad.html" %}
|
|
</div>
|
|
|
|
{# ---- Next measurement indicator ---- #}
|
|
<div class="px-3 pb-2" x-show="currentSubtask">
|
|
{% include "components/next_measurement.html" %}
|
|
</div>
|
|
|
|
|
|
{# ---- Error message ---- #}
|
|
<div x-show="errorMessage"
|
|
x-transition
|
|
x-cloak
|
|
class="mx-3 mb-2 p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-xs text-measure-fail">
|
|
<div class="flex items-center gap-1.5">
|
|
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<span x-text="errorMessage"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{# ================================================================
|
|
VISION PANEL (#46) - acquire an image, run the recipe server-side, show the
|
|
quotes and the overlay. acquire(blob) is the entry point the agent camera
|
|
(#3) will reuse.
|
|
================================================================ #}
|
|
<div x-show="isVisionTask" x-cloak
|
|
x-data="visionExecute()"
|
|
x-init="ctx = () => ({ lot: lotNumber, serial: serialNumber, runId: productionRun ? productionRun.id : null })"
|
|
class="flex-1 min-h-0 overflow-hidden p-3 flex flex-col lg:flex-row gap-3">
|
|
{# #96: il pannello non scorre mai (overflow-hidden, min-h-0): l'immagine sta in fit nello spazio che resta #}
|
|
|
|
<div class="flex-1 min-w-0 min-h-0 flex flex-col gap-2">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
{% if not camera_enabled %}
|
|
{# #119: l'input file solo senza camera. On a tablet this opens the camera; on a PC it picks a file. «Riprova» reuses the same input. #}
|
|
<label class="btn btn-primary text-sm gap-1.5 cursor-pointer" :class="busy ? 'opacity-50 pointer-events-none' : ''">
|
|
<span x-text="result ? '{{ _('Riprova') }}' : '{{ _('Acquisisci immagine') }}'"></span>
|
|
<input type="file" accept="image/*" capture="environment" class="hidden"
|
|
@change="onPick($event)" :disabled="busy">
|
|
</label>
|
|
{% else %}
|
|
{# #97: camera del task (o della stazione). Impulsiva: «Acquisisci» rifa grab+check; in linea: «Pausa»/«Riprendi» sul ciclo #}
|
|
{% if camera_mode == 'in_linea' %}
|
|
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="togglePause()"
|
|
:class="registering ? 'opacity-50 pointer-events-none' : ''"
|
|
:disabled="registering">
|
|
<span x-text="(paused || saved) ? '{{ _('Riprendi') }}' : '{{ _('Pausa') }}'"></span>
|
|
</button>
|
|
{% else %}
|
|
{# #72: frame dalla camera, stessa strada di acquire(); #97: ora grab + analisi senza salvare #}
|
|
<button type="button" class="btn btn-secondary text-sm gap-1.5" @click="grab()"
|
|
:class="(busy || grabbing) ? 'opacity-50 pointer-events-none' : ''"
|
|
:disabled="busy || grabbing">
|
|
<span>{{ _('Acquisisci da camera') }}</span> {# #119 #}
|
|
</button>
|
|
{% endif %}
|
|
{# #97: salva l'ULTIMO frame mostrato (execute); fino ad allora e' solo un'anteprima #}
|
|
<button type="button" class="btn btn-primary text-sm gap-1.5" @click="record()"
|
|
:class="canRecord ? '' : 'opacity-50 pointer-events-none'"
|
|
:disabled="!canRecord">
|
|
<span>{{ _('Registra') }}</span>
|
|
</button>
|
|
<span x-show="preview && !saved" x-cloak
|
|
class="inline-block px-2 py-0.5 rounded text-xs font-bold bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700">{{ _('Anteprima, non registrata') }}</span>
|
|
<span x-show="grabbing" class="text-sm text-[var(--text-muted)]">{{ _('Acquisizione in corso...') }}</span>
|
|
{% endif %}
|
|
<span x-show="busy" class="text-sm text-[var(--text-muted)]">{{ _('Elaborazione in corso...') }}</span>
|
|
</div>
|
|
|
|
<div x-show="error" class="p-2 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-sm text-measure-fail" x-text="error"></div>
|
|
|
|
{# #96: riquadro flex-1 min-h-0; #115: dentro, barra zoom in overlay e viewport scorrevole con il wrapper (naturale x zoom) che contiene img e svg; #121: l'svg ha il viewBox delle dimensioni naturali dell'immagine, quindi l'overlay resta allineato #}
|
|
<div x-show="shownImage" class="relative flex-1 min-h-0 border border-[var(--border-color)] rounded-lg overflow-hidden bg-black/5">
|
|
{# #115: x-show sul contenitore della barra, mai sul viewport (con :style stringa Alpine lo pesterebbe) #}
|
|
<div x-show="z.w" class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded px-1 py-0.5"
|
|
style="background: rgba(0,0,0,.55); color: #fff;">
|
|
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(0.8)" title="{{ _('Riduci') }}">−</button>
|
|
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zoomBy(1.25)" title="{{ _('Ingrandisci') }}">+</button>
|
|
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="zFit()">{{ _('Adatta') }}</button>
|
|
{# #119: salva il frame grezzo PNG (senza disegni); la barra compare solo con un'immagine (x-show="z.w") #}
|
|
<button type="button" class="px-1.5 text-xs rounded hover:bg-white/20" @click="saveImage()" title="{{ _('Salva immagine') }}">{{ _('Salva') }}</button>
|
|
<span class="text-xs px-1" x-text="zPct()"></span>
|
|
</div>
|
|
{# #115: viewport = tutto il riquadro; solo :style stringa (uno style statico verrebbe cancellato da Alpine) #}
|
|
<div x-ref="vp" class="absolute inset-0" @wheel="zWheel($event)"
|
|
@pointerdown="zPanDown($event)" @pointermove="zPanMove($event)"
|
|
@pointerup="zPanEnd()" @pointercancel="zPanEnd()"
|
|
:style="'overflow:auto;touch-action:none;user-select:none;display:flex;cursor:' + zCursor()">
|
|
<div class="relative" :style="'margin:auto;flex:none;' + zSize()">
|
|
<img x-ref="img" :src="shownImage" @load="onImgLoad()" alt="" draggable="false" class="absolute inset-0 w-full h-full object-contain">
|
|
{# #121: overlay SVG al posto del canvas: viewBox = dimensioni naturali (le imposta drawOverlay), tratto costante a ogni zoom #}
|
|
<svg x-ref="overlay" class="absolute inset-0 w-full h-full pointer-events-none" preserveAspectRatio="none"></svg>
|
|
{# #116: etichette quota in HTML sopra l'svg (posizione in %, dimensione costante a ogni zoom); solo :style stringa #}
|
|
<template x-for="l in overlayLabels" :key="l.key">
|
|
<div class="absolute pointer-events-none whitespace-nowrap text-xs font-bold px-1.5 py-0.5 rounded shadow"
|
|
:class="l.cls" :style="l.style" x-text="l.text"></div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div x-show="!shownImage" class="text-sm text-[var(--text-muted)]">
|
|
{{ _('Nessuna immagine acquisita: premi «Acquisisci immagine».') }}
|
|
</div>
|
|
</div>
|
|
|
|
{# #96: colonna esito con scorrimento proprio (solo con molte quote); sotto lg max 40% dell'altezza #}
|
|
<div class="lg:w-80 shrink-0 min-h-0 overflow-y-auto max-h-[40%] lg:max-h-none flex flex-col gap-2" x-show="result">
|
|
<div class="text-sm font-bold text-[var(--text-primary)]">
|
|
{{ _('Esito') }}: <span x-text="(result && result.outcome) || '-'"></span>
|
|
</div>
|
|
|
|
<table class="w-full text-sm" x-show="rows.length">
|
|
{# #117: due righe per quota (esito, poi nominale e tolleranze come nella schermata dei calibri); un solo tbody per quota perche' x-for vuole un'unica radice #}
|
|
<template x-for="r in rows" :key="r.label">
|
|
<tbody>
|
|
<tr>
|
|
<td class="py-1 pr-2 text-[var(--text-secondary)]" x-text="r.label"></td>
|
|
{# #118: quota non misurata (value null) = «NaN», esito fail #}
|
|
<td class="py-1 pr-2 font-mono text-right" x-text="fmtValue(r.value, r.unit)"></td>
|
|
<td class="py-1"><span class="inline-block px-2 py-0.5 rounded text-xs font-bold" :class="statusClass(r.status)" x-text="r.status"></span></td>
|
|
</tr>
|
|
<tr class="border-b border-[var(--border-color)]">
|
|
<td colspan="3" class="pb-1.5 text-[11px] font-mono">
|
|
<span class="text-primary font-semibold">{{ _('Nom') }} <span x-text="fmtTol(r.nominal)"></span></span>
|
|
<span class="text-measure-fail ml-2">LTL <span x-text="fmtTol(r.ltl)"></span></span>
|
|
<span class="text-measure-fail ml-1">UTL <span x-text="fmtTol(r.utl)"></span></span>
|
|
<template x-if="r.lwl != null || r.uwl != null">
|
|
<span>
|
|
<span class="text-measure-warning ml-2">LWL <span x-text="fmtTol(r.lwl)"></span></span>
|
|
<span class="text-measure-warning ml-1">UWL <span x-text="fmtTol(r.uwl)"></span></span>
|
|
</span>
|
|
</template>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</template>
|
|
</table>
|
|
|
|
<div x-show="result && result.failures && result.failures.length"
|
|
class="p-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-300 dark:border-amber-700 text-xs">
|
|
<div class="font-bold mb-1">{{ _('Strumenti falliti') }}</div>
|
|
<template x-for="f in ((result && result.failures) || [])" :key="f.tool_id">
|
|
<div><span class="font-mono" x-text="f.tool_name || f.tool_id"></span>: <span x-text="f.error"></span></div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
MEASUREMENT TIMER BANNER
|
|
================================================================ #}
|
|
{# Past zero the counter turns round: it stops saying how long is left and starts
|
|
saying how late the line is, in red. A delay has to be read, not deduced. #}
|
|
<div x-show="timerActive"
|
|
x-transition
|
|
x-cloak
|
|
class="shrink-0 border-t px-4 py-2"
|
|
:class="isOverdue
|
|
? 'bg-red-50 dark:bg-red-900/30 border-red-400 dark:border-red-700'
|
|
: 'bg-amber-50 dark:bg-amber-900/20 border-amber-300 dark:border-amber-700'">
|
|
<div class="flex items-center justify-center gap-3">
|
|
<svg class="w-5 h-5 animate-pulse"
|
|
:class="isOverdue ? 'text-red-600' : 'text-amber-600'"
|
|
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<span x-show="!isOverdue" class="text-sm font-medium text-amber-800 dark:text-amber-200">
|
|
{{ _('Prossima misurazione tra') }}
|
|
</span>
|
|
<span x-show="isOverdue" class="text-sm font-bold text-red-800 dark:text-red-200">
|
|
{{ _('Misurazione in ritardo di') }}
|
|
</span>
|
|
<span class="text-lg font-bold font-mono px-2 py-0.5 rounded"
|
|
:class="isOverdue
|
|
? 'text-red-900 dark:text-red-100 bg-red-100 dark:bg-red-900/40'
|
|
: 'text-amber-900 dark:text-amber-100 bg-amber-100 dark:bg-amber-900/40'"
|
|
x-text="timerDisplay"></span>
|
|
<span class="text-xs"
|
|
:class="isOverdue ? 'text-red-600 dark:text-red-300' : 'text-amber-600 dark:text-amber-400'">
|
|
({{ _('Ciclo') }} #<span x-text="cycleCount"></span>)
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
MISURA SENZA QUOTE — declared a measurement task but none configured yet.
|
|
Under the old rule this screen quietly looked like a note; now the task keeps
|
|
its identity and says what is missing instead.
|
|
================================================================ #}
|
|
<div x-show="isMeasureTaskWithoutQuotes"
|
|
x-transition
|
|
x-cloak
|
|
class="shrink-0 bg-amber-50 dark:bg-amber-900/20 border-t border-amber-300 dark:border-amber-700 px-4 py-2">
|
|
<div class="flex items-center justify-center gap-2">
|
|
<svg class="w-4 h-4 text-amber-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
|
</svg>
|
|
<span class="text-sm text-amber-800 dark:text-amber-200">
|
|
{{ _('Task di misura senza quote configurate: la ricetta è incompleta') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
QUOTA FUORI TOLLERANZA — nothing moves from here without the capoturno or a
|
|
new reading of this same quote. Kept on screen after the modal is dismissed,
|
|
because the block outlives the modal: the server refuses the next value.
|
|
================================================================ #}
|
|
<div x-show="blockedByTolerance"
|
|
x-transition
|
|
x-cloak
|
|
class="shrink-0 bg-red-50 dark:bg-red-900/30 border-t-2 border-red-500 px-4 py-2">
|
|
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
|
<svg class="w-5 h-5 text-red-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
|
</svg>
|
|
<span class="text-sm font-semibold text-red-900 dark:text-red-100">
|
|
{{ _('Quota fuori tolleranza') }}
|
|
</span>
|
|
<span class="text-xs text-red-800 dark:text-red-200">
|
|
{{ _('serve il capoturno, oppure una nuova misura della stessa quota') }}
|
|
</span>
|
|
<button @click="openSupervisorModal('out_of_tolerance')"
|
|
class="btn text-xs py-1 px-2.5 bg-red-600 hover:bg-red-700 text-white">
|
|
{{ _('Autorizza') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
LINEA FERMA — the countdown is frozen, not merely hidden. Stated plainly
|
|
because a stopped line that looks like a running one is how a measurement
|
|
interval gets silently missed.
|
|
================================================================ #}
|
|
<div x-show="isPaused"
|
|
x-transition
|
|
x-cloak
|
|
class="shrink-0 bg-amber-100 dark:bg-amber-900/40 border-t-2 border-amber-500 px-4 py-2">
|
|
<div class="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
|
|
<svg class="w-5 h-5 text-amber-700 dark:text-amber-300 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<span class="text-sm font-semibold text-amber-900 dark:text-amber-100">
|
|
{{ _('Linea ferma') }}
|
|
</span>
|
|
{# A line stopped past its interval is frozen at a delay, not at a wait. Both
|
|
read as mm:ss, so the wording is what has to change. #}
|
|
<span class="text-xs text-amber-800 dark:text-amber-200">
|
|
<span x-show="timerRemaining > 0">{{ _('Il conto alla rovescia è congelato a') }}</span>
|
|
<span x-show="timerRemaining <= 0">{{ _('Misurazione in ritardo di') }}</span>
|
|
<span class="font-mono font-bold" x-text="timerDisplay"></span>
|
|
— {{ _('serve il capoturno per riprendere') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
PRODUCTION ERROR — the server refused to record the production state.
|
|
Shown because the fallback keeps the operator working locally, and a timer
|
|
that is not backed by the server must never look like one that is.
|
|
================================================================ #}
|
|
<div x-show="productionError"
|
|
x-transition
|
|
x-cloak
|
|
class="shrink-0 bg-red-50 dark:bg-red-900/20 border-t border-red-300 dark:border-red-700 px-4 py-2">
|
|
<div class="flex items-center justify-center gap-2">
|
|
<svg class="w-4 h-4 text-red-600 shrink-0" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"/>
|
|
</svg>
|
|
<span class="text-sm text-red-800 dark:text-red-200">
|
|
{{ _('Produzione non registrata sul server') }}:
|
|
<span x-text="productionError"></span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
AVVIO PRODUZIONE — visible after first cycle, before production started
|
|
================================================================ #}
|
|
<div x-show="cycleConfirmed && cycleCount === 1 && !productionStarted && isMeasureTask"
|
|
x-transition
|
|
x-cloak
|
|
class="shrink-0 border-t border-[var(--border-color)] bg-[var(--bg-card)] px-4 py-3">
|
|
<div class="flex items-center justify-center">
|
|
<button @click="startProduction()"
|
|
class="btn gap-2 px-6 py-2.5 text-sm font-bold
|
|
bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg hover:shadow-xl
|
|
rounded-xl transition-all duration-200">
|
|
<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="M5 3l14 9-14 9V3z"/>
|
|
</svg>
|
|
{{ _('Avvio Produzione') }}
|
|
</button>
|
|
</div>
|
|
<p class="text-xs text-center text-[var(--text-muted)] mt-1.5">
|
|
{{ _('Invia segnale al gestionale per avviare il timer della linea') }}
|
|
</p>
|
|
</div>
|
|
<div x-show="productionStarted && isMeasureTask"
|
|
x-transition
|
|
x-cloak
|
|
class="shrink-0 border-t border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-900/20 px-4 py-1.5">
|
|
<div class="flex items-center justify-center gap-2 text-xs text-emerald-700 dark:text-emerald-300">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
<span class="font-medium">{{ _('Produzione avviata') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
FOOTER — Progress bar + navigation
|
|
================================================================ #}
|
|
<div class="shrink-0 bg-[var(--bg-card)] border-t border-[var(--border-color)] shadow-[0_-2px_10px_rgba(0,0,0,0.05)] z-20">
|
|
<div class="px-3 py-2 flex items-center gap-3">
|
|
|
|
{# Left: Back #}
|
|
<a href="{{ url_for('measure.task_list', recipe_id=task.recipe_id or 0) }}"
|
|
class="btn btn-secondary text-xs shrink-0 gap-1 py-1.5 px-2.5">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
|
</svg>
|
|
<span class="hidden sm:inline">{{ _('Task') }}</span>
|
|
</a>
|
|
|
|
{# Center: Progress bar #}
|
|
<div class="flex-1 flex items-center gap-2">
|
|
<span class="text-xs font-medium text-[var(--text-secondary)] shrink-0 font-mono">
|
|
<span x-text="completedCount"></span>/<span x-text="totalSubtasks"></span>
|
|
</span>
|
|
<div class="flex-1 h-2 rounded-full bg-[var(--bg-secondary)] border border-[var(--border-color)] overflow-hidden">
|
|
<div class="h-full rounded-full transition-all duration-500 ease-out"
|
|
:class="isComplete ? 'bg-measure-pass' : 'bg-primary'"
|
|
:style="'width: ' + progressPercent + '%'"></div>
|
|
</div>
|
|
<span class="text-xs font-bold shrink-0"
|
|
:class="isComplete ? 'text-measure-pass' : 'text-primary'"
|
|
x-text="Math.round(progressPercent) + '%'"></span>
|
|
</div>
|
|
|
|
{# Right: Fine ciclo misura (measurement tasks) / Task successivo (after cycle) #}
|
|
<template x-if="isMeasureTask && subtasks.length > 0">
|
|
<div class="flex items-center gap-1.5">
|
|
{#
|
|
Shown from the start, and greyed until every quote has a value. It used
|
|
to appear only once the task was finished, which left the operator with
|
|
no sign that the button existed or what would bring it out - a control
|
|
that is missing teaches nothing, one that is disabled says what is left.
|
|
#}
|
|
<button x-show="!cycleConfirmed"
|
|
x-transition
|
|
@click="confirmCycle()"
|
|
:disabled="!isComplete"
|
|
:title="isComplete ? '' : missingQuotesLabel"
|
|
:class="isComplete
|
|
? 'bg-primary text-white hover:bg-primary-700 shadow-md'
|
|
: 'bg-steel-200 dark:bg-steel-700 text-steel-500 dark:text-steel-400 cursor-not-allowed'"
|
|
class="btn text-xs shrink-0 gap-1 py-1.5 px-3">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
<span x-text="isComplete
|
|
? {{ _('Fine ciclo misura')|tojson_attr }}
|
|
: missingQuotesLabel"></span>
|
|
</button>
|
|
{# Girare il pezzo e rimisurare, senza chiudere il ciclo: a second reading
|
|
of the same part is not a new one, and must not restart the interval. #}
|
|
<button x-show="isComplete && productionStarted"
|
|
x-transition
|
|
@click="remeasure()"
|
|
class="btn btn-secondary text-xs shrink-0 gap-1 py-1.5 px-3">
|
|
<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="M16.023 9.348h4.992V4.356M3.977 14.652H8.97v4.992m10.058-9.348a8.25 8.25 0 00-14.02-3.34L3.977 9.348m0 5.304a8.25 8.25 0 0014.02 3.34l1.03-1.03"/>
|
|
</svg>
|
|
{{ _('Rimisura') }}
|
|
</button>
|
|
{# The cycle is closed; this only walks on. «Completato» is the header's
|
|
word for finishing a task, and two buttons wearing it would be one too
|
|
many. #}
|
|
<button x-show="cycleConfirmed"
|
|
x-transition
|
|
@click="goToNextTask()"
|
|
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md
|
|
bg-measure-pass text-white hover:opacity-90">
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
{{ _('Task successivo') }}
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
{# #54 #55: a check / note-with-fields task has its own buttons in the panel #}
|
|
<template x-if="(!isMeasureTask || subtasks.length === 0) && !hasResultPanel">
|
|
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
|
|
<button @click="goToNextTask()"
|
|
:disabled="!canLeaveTask"
|
|
class="btn text-lg font-bold flex-1 w-full min-h-[64px] justify-center gap-2 shadow-md
|
|
bg-measure-pass text-white hover:opacity-90">
|
|
<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="M5 13l4 4L19 7"/>
|
|
</svg>
|
|
{{ _('Completato') }}
|
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/>
|
|
</svg>
|
|
</button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
È ORA DI MISURARE — the interval expired on a task that is not the
|
|
measurement. The operator is told why the screen is about to change, then
|
|
taken there; there is no way to dismiss it, because putting the measurement
|
|
off is exactly what the interval exists to prevent.
|
|
================================================================ #}
|
|
<div x-show="announcedRedirect"
|
|
x-transition
|
|
x-cloak
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center border-2 border-red-500">
|
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-red-100 dark:bg-red-900/30 mb-4">
|
|
<svg class="w-8 h-8 text-red-600 animate-pulse" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
</div>
|
|
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">{{ _('È ora di misurare') }}</h3>
|
|
<p class="text-sm text-[var(--text-secondary)] mb-5">
|
|
{{ _('Ritorno alla misurazione tra') }}
|
|
<span class="font-bold font-mono" x-text="redirectIn"></span> s
|
|
</p>
|
|
<button @click="goToMeasurementTask()"
|
|
class="btn w-full justify-center gap-2 bg-red-600 hover:bg-red-700 text-white font-bold py-2.5">
|
|
{{ _('Vai alla misura') }}
|
|
<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 5l7 7-7 7"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
COMPLETION OVERLAY
|
|
================================================================ #}
|
|
<div x-show="showCompletionOverlay"
|
|
x-transition:enter="transition ease-out duration-300"
|
|
x-transition:enter-start="opacity-0"
|
|
x-transition:enter-end="opacity-100"
|
|
x-transition:leave="transition ease-in duration-200"
|
|
x-transition:leave-start="opacity-100"
|
|
x-transition:leave-end="opacity-0"
|
|
x-cloak
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-8 max-w-sm mx-4 text-center"
|
|
x-transition:enter="transition ease-out duration-300 delay-100"
|
|
x-transition:enter-start="opacity-0 scale-90"
|
|
x-transition:enter-end="opacity-100 scale-100">
|
|
|
|
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-measure-pass/10 mb-4">
|
|
<svg class="w-8 h-8 text-measure-pass" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
|
</svg>
|
|
</div>
|
|
|
|
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-1">{{ _('Misurazioni Complete') }}</h3>
|
|
<p class="text-sm text-[var(--text-secondary)] mb-2">
|
|
{{ _('Tutte le') }} <span class="font-bold font-mono" x-text="totalSubtasks"></span> {{ _('misurazioni sono state registrate.') }}
|
|
</p>
|
|
|
|
<div class="flex justify-center gap-4 mb-5">
|
|
<div class="text-center">
|
|
<div class="text-xl font-bold font-mono text-measure-pass" x-text="passCount"></div>
|
|
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Conformi') }}</div>
|
|
</div>
|
|
<div class="text-center" x-show="warningCount > 0">
|
|
<div class="text-xl font-bold font-mono text-measure-warning" x-text="warningCount"></div>
|
|
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Attenzione') }}</div>
|
|
</div>
|
|
<div class="text-center" x-show="failCount > 0">
|
|
<div class="text-xl font-bold font-mono text-measure-fail" x-text="failCount"></div>
|
|
<div class="text-[10px] uppercase tracking-wider text-[var(--text-muted)]">{{ _('Non Conf.') }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# #61: se il server rifiuta l'avvio produzione, l'errore si vede qui, nell'overlay #}
|
|
<p x-show="productionError" x-cloak
|
|
class="mb-2 text-sm text-red-700 dark:text-red-300 text-center" x-text="productionError"></p>
|
|
|
|
<div class="flex gap-2">
|
|
<button @click="goToSummary()"
|
|
class="btn btn-secondary flex-1 justify-center gap-2">
|
|
{{ _('Riepilogo') }}
|
|
</button>
|
|
|
|
{# Production phase: confirm the cycle. Goes through confirmCycle so the
|
|
server records it - setting the flag here only looked like it had. #}
|
|
<button x-show="productionStarted"
|
|
@click="confirmCycle()"
|
|
class="btn btn-primary flex-1 justify-center gap-2">
|
|
{{ _('Conferma ciclo') }}
|
|
<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>
|
|
|
|
{# START phase, more tasks to go: advance without blocking or exiting #}
|
|
<button x-show="!productionStarted && !isLastTask"
|
|
@click="goToNextTask()"
|
|
class="btn btn-primary flex-1 justify-center gap-2">
|
|
{{ _('Task successivo') }}
|
|
<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 5l7 7-7 7"/>
|
|
</svg>
|
|
</button>
|
|
|
|
{# START phase, last task: start production #}
|
|
<button x-show="!productionStarted && isLastTask"
|
|
@click="startProductionFromOverlay()"
|
|
class="btn flex-1 justify-center gap-2 bg-emerald-600 hover:bg-emerald-700 text-white">
|
|
{{ _('Avvio Produzione') }}
|
|
<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 3l14 9-14 9V3z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{# The piece can be turned over here, where the operator actually is when the
|
|
quotes are done - not only from the bar behind the overlay. #}
|
|
<button x-show="productionStarted"
|
|
@click="remeasure()"
|
|
class="mt-3 w-full text-sm text-[var(--text-secondary)] hover:text-primary
|
|
inline-flex items-center justify-center gap-1.5 py-1">
|
|
<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="M16.023 9.348h4.992V4.356M3.977 14.652H8.97v4.992m10.058-9.348a8.25 8.25 0 00-14.02-3.34L3.977 9.348m0 5.304a8.25 8.25 0 0014.02 3.34l1.03-1.03"/>
|
|
</svg>
|
|
{{ _('Girare il pezzo e rimisurare') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
#55 TASK DI CHIUSURA — before the capoturno's modal, the notes marked «alla fine
|
|
della produzione» are filled in and recorded on the run. The modal below is
|
|
untouched: it opens when this panel is done.
|
|
================================================================ #}
|
|
<div x-show="open" x-cloak
|
|
x-data="taskResultForm()"
|
|
x-init="ctx = () => ({ runId: productionRun ? productionRun.id : null, done: () => closeTasksRecorded() })"
|
|
@task-close-open.window="openClose($event.detail.ids)"
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-6 w-full max-w-xl mx-4 max-h-[90vh] overflow-y-auto border border-[var(--border-color)]">
|
|
<h3 class="text-lg font-bold text-[var(--text-primary)] mb-3">{{ _('Prima di chiudere la produzione') }}</h3>
|
|
<p x-show="working && !forms.length" class="text-sm text-[var(--text-muted)]">{{ _('Caricamento...') }}</p>
|
|
<template x-for="f in forms" :key="f.task_id">
|
|
<div class="mb-4" x-show="!f.recorded">
|
|
<div class="font-semibold text-[var(--text-primary)] mb-1" x-text="f.title"></div>
|
|
<div class="text-sm text-[var(--text-secondary)] mb-2" x-show="f.directive" x-text="f.directive"></div>
|
|
{{ result_fields() }}
|
|
<textarea x-model="f.note" rows="2" placeholder="{{ _('Nota') }}" class="input w-full text-base"></textarea>
|
|
<p x-show="f.error" class="mt-1 text-sm text-measure-fail" x-text="f.error"></p>
|
|
</div>
|
|
</template>
|
|
<p x-show="error" class="mb-2 text-sm text-measure-fail" x-text="error"></p>
|
|
<div class="flex gap-3 mt-2">
|
|
<button @click="cancelClose()" class="btn btn-secondary flex-1 justify-center">{{ _('Annulla') }}</button>
|
|
<button @click="submitClose()" :disabled="working || !forms.length"
|
|
class="btn flex-1 justify-center font-bold bg-measure-pass text-white hover:opacity-90 disabled:opacity-50">
|
|
{{ _('Registra e prosegui') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{# ================================================================
|
|
SUPERVISOR LOGIN MODAL
|
|
================================================================ #}
|
|
<div x-show="showSupervisorModal"
|
|
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-[60] flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
|
@click.self="closeSupervisorModal()">
|
|
<div class="bg-[var(--bg-card)] rounded-2xl shadow-2xl p-6 max-w-sm mx-4 w-full border border-[var(--border-color)]">
|
|
<div class="flex items-center gap-3 mb-4">
|
|
<div class="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
|
|
<svg class="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 class="text-lg font-bold text-[var(--text-primary)]">{{ _('Autorizzazione capoturno') }}</h3>
|
|
<p class="text-xs text-[var(--text-secondary)]" x-text="supervisorReason"></p>
|
|
</div>
|
|
</div>
|
|
|
|
{# #59: two ways to authorise - PIN (default, touch pad) or password. #}
|
|
<div class="flex gap-2 mb-4" role="tablist">
|
|
<button type="button" role="tab" @click="setSupervisorMode('pin')"
|
|
:disabled="!supervisorPinUsers.length"
|
|
class="btn flex-1 text-sm"
|
|
:class="supervisorMode === 'pin' ? 'btn-primary' : 'btn-secondary'">{{ _('PIN') }}</button>
|
|
<button type="button" role="tab" @click="setSupervisorMode('password')"
|
|
class="btn flex-1 text-sm"
|
|
:class="supervisorMode === 'password' ? 'btn-primary' : 'btn-secondary'">{{ _('Password') }}</button>
|
|
</div>
|
|
|
|
<div x-show="supervisorMode === 'pin'" class="mb-5">
|
|
{# name grid #}
|
|
<div x-show="!supervisorPinUser">
|
|
<p class="text-xs text-center text-[var(--text-secondary)] mb-3">{{ _('Chi sei?') }}</p>
|
|
<div class="grid grid-cols-2 gap-3 max-h-64 overflow-y-auto">
|
|
<template x-for="u in supervisorPinUsers" :key="u.id">
|
|
<button type="button" @click="selectSupervisorUser(u)"
|
|
class="btn btn-secondary min-h-[64px] text-base w-full" x-text="u.display_name"></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
{# pad #}
|
|
<div x-show="supervisorPinUser">
|
|
<p class="text-center font-semibold mb-1" x-text="supervisorPinUser && supervisorPinUser.display_name"></p>
|
|
<p class="text-xs text-center text-[var(--text-secondary)] mb-3">{{ _('Inserisci il PIN') }}</p>
|
|
<div class="flex justify-center gap-3 mb-3" aria-hidden="true">
|
|
<template x-for="i in 4" :key="i">
|
|
<span class="h-4 w-4 rounded-full border-2 border-[var(--border-color)]"
|
|
:class="supervisorPin.length >= i ? 'bg-primary-600 border-primary-600' : ''"></span>
|
|
</template>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-3 max-w-xs mx-auto">
|
|
<template x-for="d in ['1','2','3','4','5','6','7','8','9']" :key="d">
|
|
<button type="button" @click="pressSupervisorPin(d)" :disabled="supervisorValidating"
|
|
class="btn btn-secondary h-16 min-w-[64px] text-2xl" x-text="d"></button>
|
|
</template>
|
|
<button type="button" @click="backSupervisorUser()" :disabled="supervisorValidating"
|
|
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Indietro') }}</button>
|
|
<button type="button" @click="pressSupervisorPin('0')" :disabled="supervisorValidating"
|
|
class="btn btn-secondary h-16 min-w-[64px] text-2xl">0</button>
|
|
<button type="button" @click="supervisorPin = supervisorPin.slice(0, -1)" :disabled="supervisorValidating"
|
|
class="btn btn-secondary h-16 min-w-[64px] text-sm">{{ _('Cancella') }}</button>
|
|
</div>
|
|
</div>
|
|
<p x-show="supervisorError" class="text-sm text-center text-red-600 mt-3" role="alert" x-text="supervisorError"></p>
|
|
</div>
|
|
|
|
<div x-show="supervisorMode === 'password'" class="space-y-3 mb-5">
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('Username') }}</label>
|
|
<input type="text" x-model="supervisorUsername" class="tmf-input text-sm"
|
|
placeholder="{{ _('Username capoturno') }}" @keydown.enter="submitSupervisor()">
|
|
</div>
|
|
<div>
|
|
<label class="tmf-label text-xs">{{ _('Password') }}</label>
|
|
<input type="password" x-model="supervisorPassword" class="tmf-input text-sm"
|
|
placeholder="••••••••" @keydown.enter="submitSupervisor()">
|
|
</div>
|
|
<p x-show="supervisorError" class="text-xs text-red-600" x-text="supervisorError"></p>
|
|
</div>
|
|
|
|
<div class="flex gap-2">
|
|
{# Out of tolerance, the way out of this modal is measuring the quote
|
|
again - not cancelling. Closing it changes nothing: the quote stays
|
|
blocked, and the banner behind says so. #}
|
|
<button @click="closeSupervisorModal()"
|
|
class="btn btn-secondary flex-1 text-sm">
|
|
<span x-show="supervisorAction !== 'out_of_tolerance'">{{ _('Annulla') }}</span>
|
|
<span x-show="supervisorAction === 'out_of_tolerance'">{{ _('Rimisura la quota') }}</span>
|
|
</button>
|
|
<button @click="submitSupervisor()"
|
|
:disabled="!supervisorCanSubmit || supervisorValidating"
|
|
class="btn btn-primary flex-1 text-sm gap-1">
|
|
<svg x-show="supervisorValidating" class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
|
</svg>
|
|
<span x-text="supervisorMode === 'pin' ? '{{ _('Conferma') }}' : '{{ _('Autorizza') }}'"></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block extra_js %}
|
|
<script src="{{ url_for('static', filename='vendor/pdf-3.11.174.min.js') }}"></script>
|
|
<script>
|
|
pdfjsLib.GlobalWorkerOptions.workerSrc = '{{ url_for('static', filename='vendor/pdf.worker-3.11.174.min.js') }}';
|
|
</script>
|
|
<script src="{{ url_for('static', filename='js/numpad.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/annotation-viewer.js') }}?v=6"></script>
|
|
<script src="{{ url_for('static', filename='js/caliper.js') }}"></script>
|
|
{# #46: acquisition + overlay for camera_measure / dxf_compare #}
|
|
<script>
|
|
window.__visionCfg = {
|
|
executeUrl: '{{ url_for("measure.api_vision_execute") }}',
|
|
grabUrl: '{{ url_for("measure.api_vision_grab") }}',
|
|
checkUrl: '{{ url_for("measure.api_vision_check") }}', {# #97: analisi senza salvare #}
|
|
cameraAvailable: {{ (camera_enabled|default(false))|tojson }},
|
|
modo: {{ (camera_mode|default('impulsiva'))|tojson }},
|
|
imageUrlTpl: '{{ url_for("measure.api_vision_result_image", result_id=0) }}',
|
|
taskId: {{ task.id|tojson }},
|
|
taskType: {{ (task.task_type or '')|tojson }},
|
|
taskName: {{ (task.title or '')|tojson }}, {# #119: nome proposto per «Salva» #}
|
|
subtasks: window.__taskSubtasks,
|
|
labels: { requestFailed: {{ _('Richiesta non riuscita')|tojson }} },
|
|
};
|
|
</script>
|
|
{# #116: disegno overlay ed etichette quote; prima di vision_execute.js, che lo usa se presente #}
|
|
<script src="{{ url_for('static', filename='js/vision_overlay.js') }}"></script>
|
|
{# #119: «Salva» immagine (frame grezzo PNG) #}
|
|
<script src="{{ url_for('static', filename='js/save_image.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/vision_execute.js') }}"></script>
|
|
{# #53 #54 #55: result of check / note tasks (urls and words for the component) #}
|
|
<script>
|
|
window.__taskResultsCfg = {
|
|
saveUrl: '{{ url_for("measure.api_save_task_result") }}',
|
|
listUrl: '{{ url_for("measure.api_list_task_results") }}',
|
|
uploadUrl: '{{ url_for("measure.api_upload_attachment") }}',
|
|
taskUrlTpl: '{{ url_for("measure.api_get_task", task_id=0) }}',
|
|
labels: {
|
|
connection: {{ _('Errore di connessione')|tojson }},
|
|
sessionExpired: {{ _('Sessione scaduta: ricaricare la pagina')|tojson }},
|
|
serverError: {{ _('Errore di comunicazione con il server')|tojson }},
|
|
nokNeedsNote: {{ _('Per un NOK serve la nota')|tojson }},
|
|
notANumber: {{ _('Valore non numerico')|tojson }},
|
|
outcomeOk: {{ _('OK')|tojson }},
|
|
outcomeNok: {{ _('NOK')|tojson }},
|
|
outcomeDone: {{ _('Registrato')|tojson }},
|
|
},
|
|
};
|
|
</script>
|
|
<script src="{{ url_for('static', filename='js/task-results.js') }}"></script>
|
|
{# The clock's rules - how a delay reads, when the buzzer goes, where the
|
|
measurement lives - are shared with the other screens rather than restated. #}
|
|
<script src="{{ url_for('static', filename='js/production-clock.js') }}"></script>
|
|
|
|
<script>
|
|
/**
|
|
* Task Execute - Main Alpine.js component
|
|
* Manages measurement workflow state, save logic, and navigation.
|
|
* Layout: 3-column (marker sidebar | image center | info+numpad right)
|
|
*/
|
|
function taskExecute() {
|
|
return {
|
|
// ---- Data from server ----
|
|
task: window.__taskData,
|
|
subtasks: window.__taskSubtasks,
|
|
lotNumber: {{ (lot_number or '')|tojson }},
|
|
serialNumber: {{ (serial_number or '')|tojson }},
|
|
// ---- Measurement state ----
|
|
currentIndex: 0,
|
|
measurements: [], // [{subtask_id, value, pass_fail, deviation}, ...]
|
|
visionRecorded: false, // #120: task visione: il pezzo e' stato registrato («Registra»), le quote sono in measurements
|
|
saving: false,
|
|
// The single reentrancy guard for handleMeasurement (#13): true for the
|
|
// whole time a reading is being processed, including the 1s feedback
|
|
// pause after the save where "saving" alone used to go false too early.
|
|
// "saving" stays as it was - it only drives the UI spinner.
|
|
busy: false,
|
|
errorMessage: '',
|
|
showCompletionOverlay: false,
|
|
|
|
// ---- Measurement timer ----
|
|
// The interval is only used for display: the deadline itself lives on the server,
|
|
// so it survives the page. See loadProductionRun().
|
|
measurementIntervalMinutes: {{ measurement_interval_minutes|tojson if measurement_interval_minutes else 'null' }},
|
|
timerActive: false,
|
|
timerRemaining: 0,
|
|
_timerInterval: null,
|
|
_timerDeadline: null, // wall-clock ms the countdown is aimed at (#37.7)
|
|
cycleCount: 0,
|
|
productionStarted: false,
|
|
|
|
// ---- Production run (server-side state) ----
|
|
// Navigating between tasks is a full page load, so anything kept only here dies.
|
|
// This is read back from the server on every load instead.
|
|
productionRun: null,
|
|
productionError: '',
|
|
// The measurement tasks of this recipe, in order: the loop the operator stays
|
|
// in once production starts, and what says which task closes a cycle.
|
|
measurementTaskIds: [],
|
|
// #55: notes to fill in before the capoturno closes the production, and whether
|
|
// they were already recorded (so cancelling the modal does not ask them again).
|
|
closeTaskIds: [],
|
|
closeTasksDone: false,
|
|
// The interval has elapsed and it has been said out loud - buzzer, and the
|
|
// operator on their way back to the measurement. Kept so it is said once.
|
|
dueAnnounced: false,
|
|
redirectIn: 0,
|
|
|
|
// ---- Cycle & workflow state ----
|
|
cycleConfirmed: false,
|
|
showSupervisorModal: false,
|
|
supervisorAction: '', // 'out_of_tolerance', 'fermo_linea', 'fine_produzione'
|
|
supervisorUsername: '',
|
|
supervisorPassword: '',
|
|
// #59: the capoturno can authorise with name + 4-digit PIN instead of the password.
|
|
supervisorMode: 'pin', // 'pin' | 'password'
|
|
supervisorPinUsers: [], // [{id, display_name}] from /measure/api/pin-users
|
|
supervisorPinUser: null,
|
|
supervisorPin: '', // never kept past the modal: cleared on open and close
|
|
supervisorError: '',
|
|
supervisorValidating: false,
|
|
/* The out-of-tolerance measurement waiting for the capoturno:
|
|
{ id, subtask_id }, or null. While it is set the operator stays on that
|
|
quote - the server refuses the next one anyway. */
|
|
pendingFail: null,
|
|
|
|
// ---- Value from numpad / caliper ----
|
|
currentValue: null,
|
|
|
|
// ---- Input timing (ms spent on the active subtask) ----
|
|
inputStartedAt: null,
|
|
|
|
// ---- Image switching logic ----
|
|
get currentSubtaskImage() {
|
|
return this.currentSubtask?.image_path || null;
|
|
},
|
|
get showSubtaskImage() {
|
|
return !!this.currentSubtaskImage;
|
|
},
|
|
get showTaskImage() {
|
|
return !this.currentSubtaskImage && !!this.task.file_path;
|
|
},
|
|
|
|
// ---- Computed properties ----
|
|
get currentSubtask() {
|
|
return this.subtasks[this.currentIndex] || null;
|
|
},
|
|
|
|
get nextSubtask() {
|
|
return this.subtasks[this.currentIndex + 1] || null;
|
|
},
|
|
|
|
get totalSubtasks() {
|
|
return this.subtasks.length;
|
|
},
|
|
|
|
get completedCount() {
|
|
return this.measurements.length;
|
|
},
|
|
|
|
get isComplete() {
|
|
return this.completedCount >= this.totalSubtasks;
|
|
},
|
|
|
|
get progressPercent() {
|
|
return this.totalSubtasks > 0
|
|
? (this.completedCount / this.totalSubtasks * 100)
|
|
: 0;
|
|
},
|
|
|
|
/* How many quotes are still owed, worded rather than counted down in silence.
|
|
This is what the greyed «Fine ciclo misura» says instead of nothing. */
|
|
get missingQuotes() {
|
|
return Math.max(0, this.totalSubtasks - this.completedCount);
|
|
},
|
|
|
|
get missingQuotesLabel() {
|
|
// #120: task visione non registrato: le quote arrivano tutte col «Registra», non una a una
|
|
if (this.isVisionTask && !this.visionRecorded) return {{ _('Registra il pezzo')|tojson }};
|
|
const n = this.missingQuotes;
|
|
return n === 1
|
|
? {{ _('Manca 1 quota')|tojson }}
|
|
: {{ _('Mancano')|tojson }} + ' ' + n + ' ' + {{ _('quote')|tojson }};
|
|
},
|
|
|
|
/* Whether «Completato» can take the operator on.
|
|
|
|
A documental task is done when they say it is. A measurement task is done
|
|
when its cycle is closed - otherwise the button would be a way round «Fine
|
|
ciclo misura», and the quotes it exists to collect would be optional. */
|
|
get canLeaveTask() {
|
|
if (this.blockedByTolerance) return false;
|
|
// #120: un task visione si lascia solo dopo «Registra» (anche dxf_compare, prima sempre attivo)
|
|
if (this.isVisionTask && !this.visionRecorded) return false;
|
|
if (this.isMeasureTask && this.subtasks.length > 0) {
|
|
return this.cycleConfirmed;
|
|
}
|
|
return true;
|
|
},
|
|
|
|
get blockedReason() {
|
|
if (this.blockedByTolerance) {
|
|
return {{ _("Serve l'autorizzazione del capoturno")|tojson }};
|
|
}
|
|
if (this.isVisionTask && !this.visionRecorded) return this.missingQuotesLabel; // #120
|
|
if (!this.isComplete) return this.missingQuotesLabel;
|
|
return {{ _('Chiudi prima il ciclo di misura')|tojson }};
|
|
},
|
|
|
|
// ---- Pass/fail logic ----
|
|
get passFailStatus() {
|
|
if (this.currentValue === null || !this.currentSubtask) return null;
|
|
const v = this.currentValue;
|
|
const s = this.currentSubtask;
|
|
if (s.utl != null && v > s.utl) return 'fail';
|
|
if (s.ltl != null && v < s.ltl) return 'fail';
|
|
if (s.uwl != null && v > s.uwl) return 'warning';
|
|
if (s.lwl != null && v < s.lwl) return 'warning';
|
|
return 'pass';
|
|
},
|
|
|
|
get deviation() {
|
|
if (this.currentValue === null || !this.currentSubtask) return null;
|
|
return this.currentValue - (this.currentSubtask.nominal || 0);
|
|
},
|
|
|
|
get progressWidth() {
|
|
if (!this.currentSubtask || this.currentValue === null) return 50;
|
|
const s = this.currentSubtask;
|
|
if (s.utl == null || s.ltl == null) return 50;
|
|
const range = s.utl - s.ltl;
|
|
if (range <= 0) return 50;
|
|
const pos = (this.currentValue - s.ltl) / range * 100;
|
|
return Math.max(0, Math.min(100, pos));
|
|
},
|
|
|
|
// ---- Summary counts ----
|
|
get passCount() {
|
|
return this.measurements.filter(m => m.pass_fail === 'pass').length;
|
|
},
|
|
get warningCount() {
|
|
return this.measurements.filter(m => m.pass_fail === 'warning').length;
|
|
},
|
|
get failCount() {
|
|
return this.measurements.filter(m => m.pass_fail === 'fail').length;
|
|
},
|
|
|
|
// ---- Init ----
|
|
async init() {
|
|
this.subtasks.sort((a, b) => (a.order_index || 0) - (b.order_index || 0));
|
|
this.inputStartedAt = Date.now();
|
|
// Rejoin whatever production is already open at this station.
|
|
await this.loadProductionRun();
|
|
await this.loadCurrentCycle();
|
|
await this.loadPendingAuthorisation();
|
|
// A tablet left to sleep drifts (#37.7): catch the countdown back up to
|
|
// the server's own clock, the same source adoptProductionRun already
|
|
// trusts on load, as soon as the tab is visible again.
|
|
var self = this;
|
|
document.addEventListener('visibilitychange', function () {
|
|
if (document.visibilityState === 'visible') self.loadProductionRun();
|
|
});
|
|
},
|
|
|
|
/* Quotes already measured in the cycle under way (#40).
|
|
|
|
A reload, a timer redirect or an auto-logout used to bring every quote back
|
|
and measuring them again put duplicates in the SPC statistics. Only inside
|
|
a production: outside one there is no cycle, and the screen starts empty as
|
|
it always did. */
|
|
async loadCurrentCycle() {
|
|
if (!this.isMeasureTask || !this.subtasks.length || !this.productionRun) return;
|
|
try {
|
|
const resp = await fetch(
|
|
'{{ url_for("measure.api_current_cycle", task_id=task.id) }}',
|
|
);
|
|
if (!resp.ok) return;
|
|
const data = await resp.json();
|
|
const known = new Set(this.subtasks.map(s => s.id));
|
|
const restored = (data.measurements || []).filter(m => known.has(m.subtask_id));
|
|
if (!restored.length) return;
|
|
this.measurements = restored.map(m => {
|
|
const st = this.subtasks.find(s => s.id === m.subtask_id);
|
|
const nominal = st ? (st.nominal || 0) : 0;
|
|
return {
|
|
subtask_id: m.subtask_id,
|
|
value: m.value,
|
|
pass_fail: m.pass_fail,
|
|
deviation: m.value == null ? null : m.value - nominal, // #118: quota non misurata = NaN, nessuna deviazione
|
|
};
|
|
});
|
|
if (this.isVisionTask) this.visionRecorded = true; // #120: misure ripristinate = pezzo gia' registrato
|
|
const next = this.subtasks.findIndex(
|
|
s => !this.measurements.some(m => m.subtask_id === s.id),
|
|
);
|
|
this.currentIndex = next !== -1 ? next : this.subtasks.length - 1;
|
|
} catch (e) {
|
|
// Offline: the screen starts empty, as before.
|
|
}
|
|
},
|
|
|
|
/* Is a quote already waiting for the capoturno?
|
|
|
|
Reloading the page was the simplest way past the old gate. It is not one
|
|
now - the server refuses the next measurement either way - so the screen
|
|
picks the block back up as soon as it opens, on the quote it belongs to. */
|
|
async loadPendingAuthorisation() {
|
|
if (!this.isMeasureTask || !this.subtasks.length) return;
|
|
try {
|
|
const params = new URLSearchParams({ version_id: this.task.version_id });
|
|
if (this.productionRun) {
|
|
params.set('production_run_id', this.productionRun.id);
|
|
}
|
|
const resp = await fetch(
|
|
'{{ url_for("measure.api_pending_authorisation") }}?' + params.toString(),
|
|
);
|
|
if (!resp.ok) return;
|
|
const pending = await resp.json();
|
|
if (!pending || !pending.id) return;
|
|
|
|
this.pendingFail = { id: pending.id, subtask_id: pending.subtask_id };
|
|
const idx = this.subtasks.findIndex(s => s.id === pending.subtask_id);
|
|
if (idx !== -1) this.currentIndex = idx;
|
|
this.openSupervisorModal('out_of_tolerance');
|
|
} catch (e) {
|
|
// Offline: the banner is missing, but so is the ability to save anything.
|
|
}
|
|
},
|
|
|
|
// ---- Production run: read the state back from the server ----
|
|
|
|
async loadProductionRun() {
|
|
try {
|
|
const resp = await fetch('{{ url_for("measure.api_current_production") }}');
|
|
if (!resp.ok) {
|
|
// #61: si mostra il detail del server (es. 503 "Stazione non configurata"),
|
|
// che e' la causa vera; fallback generico se il corpo non e' JSON.
|
|
const errBody = await resp.json().catch(() => null);
|
|
this.productionError = (errBody && errBody.detail) || '{{ _("Errore di comunicazione con il server") }}';
|
|
return;
|
|
}
|
|
const run = await resp.json();
|
|
if (run && run.id) this.adoptProductionRun(run);
|
|
} catch (e) {
|
|
// #61: offline o server giu: pagina usabile, ma l'errore e' visibile.
|
|
this.productionError = '{{ _("Errore di connessione") }}';
|
|
}
|
|
},
|
|
|
|
/* Take the server's word for the state of the production. Called on load and
|
|
after every action that changes it, so the page never drifts from the truth. */
|
|
adoptProductionRun(run) {
|
|
this.productionRun = run;
|
|
this.productionStarted = run.status !== 'closed';
|
|
this.cycleCount = run.cycle_count;
|
|
this.measurementTaskIds = run.measurement_task_ids || [];
|
|
this.closeTaskIds = run.close_task_ids || []; // #55
|
|
|
|
const seconds = run.seconds_to_next_measurement;
|
|
if (seconds === null || seconds === undefined) {
|
|
this.stopMeasurementTimer();
|
|
return;
|
|
}
|
|
if (run.status !== 'running') {
|
|
// Paused: keep the figure on screen, frozen, so the banner can show how
|
|
// much of the interval is being held rather than going blank.
|
|
this.stopMeasurementTimer();
|
|
this.timerRemaining = seconds;
|
|
return;
|
|
}
|
|
// The server hands over seconds already computed rather than a timestamp: a
|
|
// naive datetime would be read in the browser's timezone and the countdown
|
|
// would be off by the UTC offset.
|
|
this.startCountdownFrom(seconds);
|
|
},
|
|
|
|
// ---- Check if a subtask has been measured ----
|
|
isMeasured(subtaskId) {
|
|
return this.measurements.some(m => m.subtask_id === subtaskId);
|
|
},
|
|
|
|
getMeasuredValue(subtaskId) {
|
|
const m = this.measurements.find(m => m.subtask_id === subtaskId);
|
|
return m ? m.value : null;
|
|
},
|
|
|
|
getMeasurementStatus(subtaskId) {
|
|
const m = this.measurements.find(m => m.subtask_id === subtaskId);
|
|
return m ? m.pass_fail : null;
|
|
},
|
|
|
|
/* The keypad refused a value because this recipe wants the caliper. Said on
|
|
the same line as any other measurement error, so there is one place to look
|
|
when a value does not go in. */
|
|
onNumpadRejected(detail) {
|
|
if (detail && detail.reason === 'manual_not_allowed') {
|
|
this.errorMessage = '{{ _("Questa ricetta non ammette valori digitati: usare il calibro") }}';
|
|
}
|
|
},
|
|
|
|
/* The numpad refused the reading itself (e.g. a caliper burst whose
|
|
integer part does not fit) rather than a rule about who may type it.
|
|
Same error line, so there is still one place to look. */
|
|
onNumpadError(detail) {
|
|
this.errorMessage = '{{ _("Lettura del calibro rifiutata: valore troppo grande") }}';
|
|
},
|
|
|
|
// ---- Handle numpad confirm ----
|
|
async handleMeasurement(value, inputMethod) {
|
|
if (!this.currentSubtask) return;
|
|
|
|
// Single reentrancy guard (#13): a second reading arriving while the
|
|
// first one is still being saved - or while the operator is stuck
|
|
// behind the modal/overlay the first one opened - must not become its
|
|
// own measurement (double record, one quote skipped). It is not
|
|
// silently dropped either: the same error line other rejections use
|
|
// says so.
|
|
if (this.busy || this.showCompletionOverlay || this.showSupervisorModal ||
|
|
this.isPaused || this.cycleConfirmed) {
|
|
this.errorMessage = '{{ _("Lettura ignorata: attendere") }}';
|
|
return;
|
|
}
|
|
|
|
this.busy = true;
|
|
this.currentValue = value;
|
|
this.errorMessage = '';
|
|
|
|
const pf = this.passFailStatus;
|
|
const dev = this.deviation;
|
|
const inputDurationMs = this.inputStartedAt !== null
|
|
? Math.max(0, Math.round(Date.now() - this.inputStartedAt))
|
|
: null;
|
|
|
|
this.saving = true;
|
|
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
|
|
|
const response = await fetch('{{ url_for("measure.save_measurement") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRFToken': csrfToken,
|
|
},
|
|
body: JSON.stringify({
|
|
subtask_id: this.currentSubtask.id,
|
|
version_id: this.task.version_id,
|
|
value: value,
|
|
lot_number: this.lotNumber,
|
|
serial_number: this.serialNumber,
|
|
input_method: inputMethod || 'manual',
|
|
input_duration_ms: inputDurationMs,
|
|
// Ties the value to the production under way, so fine produzione can
|
|
// emit the statistics file for the whole run.
|
|
production_run_id: this.productionRun ? this.productionRun.id : null,
|
|
}),
|
|
});
|
|
|
|
let result;
|
|
try {
|
|
result = await response.json();
|
|
} catch (parseErr) {
|
|
// The server answered but not with JSON: a CSRF-expired POST gets
|
|
// the login page's HTML back, not a payload. Distinguish that from
|
|
// a dead network so the operator is told to reload rather than to
|
|
// retry blindly; the unsaved reading stays on screen either way.
|
|
this.errorMessage = '{{ _("Sessione scaduta: ricaricare la pagina") }}';
|
|
this.saving = false;
|
|
this.busy = false;
|
|
return;
|
|
}
|
|
|
|
if (result && (result.csrf_expired || result.session_expired)) {
|
|
this.errorMessage = '{{ _("Sessione scaduta: ricaricare la pagina") }}';
|
|
this.saving = false;
|
|
this.busy = false;
|
|
return;
|
|
}
|
|
|
|
if (!response.ok || result.error) {
|
|
this.errorMessage = result.detail || '{{ _("Errore nel salvataggio della misurazione") }}';
|
|
this.saving = false;
|
|
this.busy = false;
|
|
return;
|
|
}
|
|
|
|
// The server decides pass/fail from the recipe's official tolerances;
|
|
// passFailStatus is only a live preview while the value is on screen,
|
|
// not what a capoturno block or the pass/warning/fail counts should
|
|
// be keyed on.
|
|
const serverPassFail = result.pass_fail || pf;
|
|
|
|
// Record measurement locally
|
|
const existingIdx = this.measurements.findIndex(m => m.subtask_id === this.currentSubtask.id);
|
|
const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: serverPassFail, deviation: dev };
|
|
if (existingIdx !== -1) {
|
|
this.measurements.splice(existingIdx, 1, mEntry);
|
|
} else {
|
|
this.measurements.push(mEntry);
|
|
}
|
|
|
|
this.saving = false;
|
|
|
|
// Pause to show result feedback
|
|
await new Promise(r => setTimeout(r, 1000));
|
|
|
|
// Out of tolerance: nothing moves until the capoturno says so, or until
|
|
// this same quote is measured again and comes out inside tolerance.
|
|
if (serverPassFail === 'fail') {
|
|
this.pendingFail = { id: result.id, subtask_id: this.currentSubtask.id };
|
|
this.openSupervisorModal('out_of_tolerance');
|
|
this.busy = false;
|
|
return;
|
|
}
|
|
|
|
// A second reading of the blocked quote, this time in tolerance: the quote
|
|
// is in tolerance now, so the block goes. Both readings stay on record -
|
|
// how many attempts are allowed is point 4's business, not this one's.
|
|
if (this.pendingFail && this.pendingFail.subtask_id === this.currentSubtask.id) {
|
|
this.pendingFail = null;
|
|
}
|
|
|
|
// Check if all done
|
|
if (this.completedCount >= this.totalSubtasks) {
|
|
// START run (production not yet started): don't block with the
|
|
// "Misurazioni Complete" overlay — just advance to the next task.
|
|
// The overlay is shown only on the last task (to offer "Avvio
|
|
// Produzione") or once production is running ("Conferma ciclo").
|
|
if (!this.productionStarted && !this.isLastTask) {
|
|
this.goToNextTask();
|
|
this.busy = false;
|
|
return;
|
|
}
|
|
this.showCompletionOverlay = true;
|
|
this.busy = false;
|
|
return;
|
|
}
|
|
|
|
this.advanceToNext();
|
|
this.busy = false;
|
|
|
|
} catch (err) {
|
|
console.error('Save measurement error:', err);
|
|
this.errorMessage = '{{ _("Errore di rete. Riprovare.") }}';
|
|
this.saving = false;
|
|
this.busy = false;
|
|
}
|
|
},
|
|
|
|
// ---- Advance to next unmeasured subtask ----
|
|
advanceToNext() {
|
|
this.currentValue = null;
|
|
this.inputStartedAt = Date.now();
|
|
|
|
for (let i = this.currentIndex + 1; i < this.totalSubtasks; i++) {
|
|
if (!this.isMeasured(this.subtasks[i].id)) {
|
|
this.currentIndex = i;
|
|
return;
|
|
}
|
|
}
|
|
for (let i = 0; i < this.currentIndex; i++) {
|
|
if (!this.isMeasured(this.subtasks[i].id)) {
|
|
this.currentIndex = i;
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
|
|
/* Out of tolerance and nobody has authorised it: the operator stays put.
|
|
|
|
This is the whole of point 5. The screen used to open a modal that could be
|
|
dismissed, and the measurement was already saved by then, so a value outside
|
|
tolerance could be left behind by clicking the backdrop. The server refuses
|
|
the next measurement now; these checks only spare the operator the trip. */
|
|
get blockedByTolerance() {
|
|
return this.pendingFail !== null;
|
|
},
|
|
|
|
/* Say why nothing is moving, and offer the way out. */
|
|
refuseWhileBlocked() {
|
|
// |tojson, not quotes: the Italian carries an apostrophe, and an apostrophe
|
|
// inside a single-quoted literal is how every Alpine binding on the page dies.
|
|
this.errorMessage = {{ _("Quota fuori tolleranza: serve l'autorizzazione del capoturno, oppure misurare di nuovo la quota")|tojson }};
|
|
this.openSupervisorModal('out_of_tolerance');
|
|
},
|
|
|
|
// ---- Navigation ----
|
|
goToSubtask(index) {
|
|
if (index < 0 || index >= this.totalSubtasks) return;
|
|
// Moving to another quote is exactly what a blocked quote forbids; going
|
|
// back to the blocked one to measure it again is not.
|
|
if (this.blockedByTolerance
|
|
&& this.subtasks[index].id !== this.pendingFail.subtask_id) {
|
|
this.refuseWhileBlocked();
|
|
return;
|
|
}
|
|
this.currentIndex = index;
|
|
this.currentValue = null;
|
|
this.errorMessage = '';
|
|
this.inputStartedAt = Date.now();
|
|
},
|
|
|
|
goToSubtaskByMarker(markerNumber) {
|
|
const idx = this.subtasks.findIndex(s => s.marker_number === markerNumber);
|
|
if (idx !== -1) {
|
|
this.goToSubtask(idx);
|
|
}
|
|
},
|
|
|
|
// ---- Confirm measurement cycle (Fine ciclo misura) ----
|
|
/* A cycle spans every measurement task of the recipe, so this one only closes
|
|
it if it is the last. The server decides that - it knows the sequence - and
|
|
the cycle count coming back tells us which of the two happened. */
|
|
async confirmCycle() {
|
|
// Closing the cycle would be another way of walking past a quote that is out
|
|
// of tolerance. The server refuses it too.
|
|
if (this.blockedByTolerance) {
|
|
this.refuseWhileBlocked();
|
|
return;
|
|
}
|
|
this.cycleConfirmed = true;
|
|
this.showCompletionOverlay = false;
|
|
|
|
// Recorded server-side so the count and the next deadline outlive this page.
|
|
if (this.productionRun) {
|
|
const countBefore = this.cycleCount;
|
|
const run = await this.postProduction(
|
|
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
|
|
{ task_id: this.task.id },
|
|
);
|
|
if (run) {
|
|
this.adoptProductionRun(run);
|
|
// Cycle still open: on to the next measurement task, interval untouched.
|
|
if (run.cycle_count === countBefore && this.nextMeasurementTaskId) {
|
|
this.goToTask(this.nextMeasurementTaskId);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// The server refused to close the cycle (line stopped, production
|
|
// closed, network down): the screen must not show a cycle that never
|
|
// actually closed. productionError already carries why.
|
|
this.cycleConfirmed = false;
|
|
this.showCompletionOverlay = true;
|
|
return;
|
|
}
|
|
|
|
// No production open (recipe run outside a production): keep the old local
|
|
// behaviour rather than leaving the operator without a timer.
|
|
this.cycleCount++;
|
|
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
|
|
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
|
|
}
|
|
},
|
|
|
|
/* Rimisura: turn the piece over and measure it again, inside the same cycle.
|
|
|
|
Not a new cycle - the deadline and the count stay where they are, so a second
|
|
reading of the same part does not buy another interval's worth of time. Both
|
|
readings stay in the statistics: that is what they are for. */
|
|
async remeasure() {
|
|
if (this.productionRun) {
|
|
const run = await this.postProduction(
|
|
'{{ url_for("measure.api_remeasure", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
|
|
{ task_id: this.task.id },
|
|
);
|
|
// Refused - the line is stopped, or the production is closed. The screen
|
|
// stays as it is: clearing it would hide the reason.
|
|
if (!run) return;
|
|
this.adoptProductionRun(run);
|
|
}
|
|
this.resetForNewCycle();
|
|
},
|
|
|
|
/* POST to a production endpoint, returning the updated run or null on failure. */
|
|
async postProduction(url, body) {
|
|
this.productionError = '';
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
|
body: JSON.stringify(body || {}),
|
|
});
|
|
const data = await resp.json().catch(() => null);
|
|
// Not JSON at all (CSRF-expired POSTs get the login page's HTML
|
|
// back), or the server flagged the session as gone: same "reload"
|
|
// message handleMeasurement gives for the same failure (#12).
|
|
if (!data || data.csrf_expired || data.session_expired) {
|
|
this.productionError = '{{ _("Sessione scaduta: ricaricare la pagina") }}';
|
|
return null;
|
|
}
|
|
if (!resp.ok) {
|
|
this.productionError = (data && data.detail) || '{{ _("Errore di comunicazione con il server") }}';
|
|
return null;
|
|
}
|
|
return data;
|
|
} catch (e) {
|
|
this.productionError = '{{ _("Errore di connessione") }}';
|
|
return null;
|
|
}
|
|
},
|
|
|
|
// ---- Measurement timer ----
|
|
/* Ticks locally for a smooth display, but the number it starts from always comes
|
|
from the server, and every page load resynchronises it.
|
|
It does not stop at zero: past the deadline it keeps counting the other way,
|
|
and the banner reads the delay off it. */
|
|
startCountdownFrom(seconds) {
|
|
this.stopMeasurementTimer();
|
|
this.timerRemaining = seconds;
|
|
this.timerActive = true;
|
|
// The wall clock, not a tick count (#37.7): a tablet asleep for a few
|
|
// minutes has to catch the countdown up in one jump when it wakes, not
|
|
// replay the ticks it missed while suspended - setInterval does not
|
|
// fire while a tab is backgrounded, so a tick-based countdown drifted
|
|
// behind by exactly however long the tab was hidden.
|
|
this._timerDeadline = Date.now() + seconds * 1000;
|
|
// Landing on a page that is already late is the same event as going late
|
|
// while sitting on it; being back in time re-arms the announcement.
|
|
if (seconds <= 0) {
|
|
this.onMeasurementDue();
|
|
} else {
|
|
this.dueAnnounced = false;
|
|
this.redirectIn = 0;
|
|
}
|
|
var self = this;
|
|
this._timerInterval = setInterval(function () { self.onSecond(); }, 1000);
|
|
},
|
|
|
|
onSecond() {
|
|
const wasAnnounced = this.dueAnnounced;
|
|
this.timerRemaining = Math.ceil((this._timerDeadline - Date.now()) / 1000);
|
|
if (this.timerRemaining <= 0) this.onMeasurementDue();
|
|
|
|
if (wasAnnounced && this.redirectIn > 0) {
|
|
this.redirectIn--;
|
|
if (this.redirectIn === 0) this.goToMeasurementTask();
|
|
}
|
|
},
|
|
|
|
stopMeasurementTimer() {
|
|
if (this._timerInterval) {
|
|
clearInterval(this._timerInterval);
|
|
this._timerInterval = null;
|
|
}
|
|
this.timerActive = false;
|
|
},
|
|
|
|
/* The interval has run out. Said once, then acted on.
|
|
|
|
Someone already measuring is late, not lost: the banner turns red and they
|
|
are left alone. Taking them elsewhere mid-cycle would wipe quotes they can
|
|
see on the screen and gain nothing.
|
|
|
|
Otherwise a new cycle starts, and it starts at the first measurement task of
|
|
the recipe - re-arming wherever the operator happens to be standing would
|
|
skip whatever comes before it and still count as a full cycle. */
|
|
onMeasurementDue() {
|
|
if (this.dueAnnounced) return;
|
|
this.dueAnnounced = true;
|
|
window.ProductionClock.playBuzzer();
|
|
|
|
if (this.isMeasurementTaskOfRun && !this.cycleConfirmed) return;
|
|
|
|
if (this.task.id === this.measurementTaskIds[0]) {
|
|
this.resetForNewCycle();
|
|
return;
|
|
}
|
|
this.redirectIn = this.measurementTaskUrl ? 5 : 0;
|
|
},
|
|
|
|
/* #120: «Registra» del pannello visione: le misure salvate dal server diventano le misure del
|
|
ciclo (conteggio quote, «Fine ciclo misura»/«Completato»). value null = quota non misurata. */
|
|
onVisionRecorded(detail) {
|
|
const ms = (detail && detail.measurements) || [];
|
|
this.measurements = ms.map(m => {
|
|
const st = this.subtasks.find(s => s.id === m.subtask_id);
|
|
const nominal = st ? (st.nominal || 0) : 0;
|
|
return {
|
|
subtask_id: m.subtask_id,
|
|
value: m.value == null ? null : m.value,
|
|
pass_fail: m.pass_fail,
|
|
deviation: m.value == null ? null : m.value - nominal,
|
|
};
|
|
});
|
|
this.visionRecorded = true;
|
|
},
|
|
|
|
/* Clear the screen for another pass over the same quotes.
|
|
Local only: everything already measured is on the server, and the next
|
|
reading is saved beside it rather than replacing it. */
|
|
resetForNewCycle() {
|
|
this.cycleConfirmed = false;
|
|
this.showCompletionOverlay = false;
|
|
this.measurements = [];
|
|
this.visionRecorded = false; // #120: nuovo ciclo = pezzo da registrare di nuovo
|
|
this.currentIndex = 0;
|
|
this.currentValue = null;
|
|
this.errorMessage = '';
|
|
this.inputStartedAt = Date.now();
|
|
window.dispatchEvent(new CustomEvent('task-result-reset')); // #54: a check / note loops on the spot
|
|
},
|
|
|
|
// ---- Where the measurement lives ----
|
|
|
|
get isMeasurementTaskOfRun() {
|
|
return this.measurementTaskIds.indexOf(this.task.id) !== -1;
|
|
},
|
|
|
|
get measurementTaskUrl() {
|
|
return window.ProductionClock.measurementUrl(
|
|
this.productionRun, '{{ url_for("measure.task_execute", task_id=0) }}',
|
|
);
|
|
},
|
|
|
|
/* The next measurement task of the recipe, or null if this is the last one -
|
|
which is the one that closes the cycle and restarts the interval. */
|
|
get nextMeasurementTaskId() {
|
|
const ids = this.measurementTaskIds;
|
|
const idx = ids.indexOf(this.task.id);
|
|
if (idx === -1 || idx >= ids.length - 1) return null;
|
|
return ids[idx + 1];
|
|
},
|
|
|
|
get isOverdue() {
|
|
return this.timerActive && this.timerRemaining <= 0;
|
|
},
|
|
|
|
get announcedRedirect() {
|
|
return this.dueAnnounced && this.redirectIn > 0;
|
|
},
|
|
|
|
goToMeasurementTask() {
|
|
const url = this.measurementTaskUrl;
|
|
if (url) window.location.href = url;
|
|
},
|
|
|
|
goToTask(taskId) {
|
|
window.location.href =
|
|
'{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskId);
|
|
},
|
|
|
|
// ---- Avvio Produzione ----
|
|
/* Opens a production run on the server. That row is what makes the timer, the
|
|
cycle count and the history survive a change of task.
|
|
The hand-off to the ERP (GAIA) plugs in on top of this, once the protocol is
|
|
agreed: everything before it works without waiting for that. */
|
|
async startProduction() {
|
|
const run = await this.postProduction(
|
|
'{{ url_for("measure.api_start_production") }}',
|
|
{
|
|
recipe_id: this.task.recipe_id,
|
|
version_id: this.task.version_id,
|
|
lot_number: this.lotNumber || null,
|
|
serial_number: this.serialNumber || null,
|
|
},
|
|
);
|
|
if (run) {
|
|
this.adoptProductionRun(run);
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
|
|
// Is the current task the last one in the recipe sequence?
|
|
get isLastTask() {
|
|
const taskIds = window.__allTaskIds || [];
|
|
const idx = taskIds.indexOf(this.task.id);
|
|
return idx === -1 || idx >= taskIds.length - 1;
|
|
},
|
|
|
|
// Start production from the completion overlay (last START task) and begin
|
|
// the measurement-interval cycle if the recipe defines one.
|
|
async startProductionFromOverlay() {
|
|
this.showCompletionOverlay = false;
|
|
const opened = await this.startProduction();
|
|
if (!opened) {
|
|
// #61: server rifiuta -> niente timer locale finto; si riapre l'overlay
|
|
// con l'errore (productionError) cosi' l'operatore puo' riprovare.
|
|
this.showCompletionOverlay = true;
|
|
return;
|
|
}
|
|
// The START-phase measurements have just been taken: record them as the first
|
|
// cycle, which is also what starts the interval running.
|
|
await this.confirmCycle();
|
|
},
|
|
|
|
/* mm:ss of the distance from the deadline, either side of it. The direction is
|
|
carried by the wording in the banner, not by a minus sign. */
|
|
get timerDisplay() {
|
|
return window.ProductionClock.format(this.timerRemaining);
|
|
},
|
|
|
|
/* #53: result recorded on a check / note task. In the loop of a production the
|
|
task counts as a step of the cycle exactly like a measurement task: the last
|
|
one closes the cycle (the server decides, as in confirmCycle), the others go
|
|
to the next one. If the server refuses, the panel stays with «Prosegui». */
|
|
async advanceAfterResult() {
|
|
if (this.productionRun && this.isMeasurementTaskOfRun) {
|
|
const countBefore = this.cycleCount;
|
|
const run = await this.postProduction(
|
|
'{{ url_for("measure.api_complete_cycle", run_id=0) }}'.replace('/0/', '/' + this.productionRun.id + '/'),
|
|
{ task_id: this.task.id },
|
|
);
|
|
if (!run) return;
|
|
this.adoptProductionRun(run);
|
|
if (run.cycle_count === countBefore && this.nextMeasurementTaskId) {
|
|
this.goToTask(this.nextMeasurementTaskId);
|
|
return;
|
|
}
|
|
}
|
|
this.goToNextTask();
|
|
},
|
|
|
|
/* #55: the end-of-production notes are recorded: now the capoturno's modal. */
|
|
closeTasksRecorded() {
|
|
this.closeTasksDone = true;
|
|
this.openSupervisorModal('fine_produzione');
|
|
},
|
|
|
|
// ---- Navigate to next task (Completato) ----
|
|
/* With a production open the operator stays in the measurement loop until the
|
|
capoturno closes it: after the last measurement task they go back to the
|
|
first, not out to the summary. Before production starts, and on the
|
|
documental tasks that come before the measurement, the plain sequence
|
|
applies - that is the run-up, not the loop. */
|
|
goToNextTask() {
|
|
if (this.blockedByTolerance) {
|
|
this.refuseWhileBlocked();
|
|
return;
|
|
}
|
|
if (this.productionStarted && this.isMeasurementTaskOfRun) {
|
|
const next = this.nextMeasurementTaskId || this.measurementTaskIds[0];
|
|
// A recipe with a single measurement task loops on the spot: reloading the
|
|
// same page to arrive at the same state would only cost a round trip.
|
|
if (next === this.task.id) {
|
|
this.resetForNewCycle();
|
|
return;
|
|
}
|
|
this.goToTask(next);
|
|
return;
|
|
}
|
|
|
|
const taskIds = window.__allTaskIds || [];
|
|
const currentIdx = taskIds.indexOf(this.task.id);
|
|
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
|
|
this.goToTask(taskIds[currentIdx + 1]);
|
|
} else {
|
|
this.goToSummary();
|
|
}
|
|
},
|
|
|
|
// ---- Open/close supervisor modal (always reset fields) ----
|
|
openSupervisorModal(action) {
|
|
// #61: fermo/ripresa/fine produzione agiscono su un run: senza run aperto
|
|
// si dice subito, prima di chiedere le credenziali.
|
|
if (['fermo_linea', 'ripresa', 'fine_produzione'].includes(action) && !this.productionRun) {
|
|
this.productionError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
|
|
return;
|
|
}
|
|
// #55: first the notes marked «alla fine della produzione»; the panel calls back
|
|
// here (closeTasksRecorded) when they are recorded.
|
|
if (action === 'fine_produzione' && this.closeTaskIds.length && !this.closeTasksDone) {
|
|
window.dispatchEvent(new CustomEvent('task-close-open', { detail: { ids: this.closeTaskIds } }));
|
|
return;
|
|
}
|
|
this.supervisorAction = action;
|
|
this.supervisorUsername = '';
|
|
this.supervisorPassword = '';
|
|
this.supervisorError = '';
|
|
this.resetSupervisorPin(); // #59: PIN fields cleared, names reloaded
|
|
this.showSupervisorModal = true;
|
|
},
|
|
|
|
closeSupervisorModal() {
|
|
this.showSupervisorModal = false;
|
|
this.supervisorUsername = '';
|
|
this.supervisorPassword = '';
|
|
this.supervisorError = '';
|
|
this.supervisorPin = ''; // #59: the PIN does not outlive the modal
|
|
this.supervisorPinUser = null;
|
|
},
|
|
|
|
/* #59: PIN mode of the capoturno modal. */
|
|
resetSupervisorPin() {
|
|
this.supervisorPin = '';
|
|
this.supervisorPinUser = null;
|
|
this.supervisorMode = 'pin';
|
|
this.loadSupervisorPinUsers();
|
|
},
|
|
|
|
// Names with a PIN; an empty list or a failure opens the Password tab instead.
|
|
async loadSupervisorPinUsers() {
|
|
try {
|
|
const resp = await fetch('{{ url_for("measure.api_pin_users") }}', { headers: { 'Accept': 'application/json' } });
|
|
const list = resp.ok ? await resp.json() : [];
|
|
this.supervisorPinUsers = Array.isArray(list) ? list : [];
|
|
} catch (e) {
|
|
this.supervisorPinUsers = [];
|
|
}
|
|
if (!this.supervisorPinUsers.length) this.supervisorMode = 'password';
|
|
},
|
|
|
|
setSupervisorMode(mode) {
|
|
this.supervisorMode = mode;
|
|
this.supervisorPin = '';
|
|
this.supervisorPinUser = null;
|
|
this.supervisorError = '';
|
|
},
|
|
|
|
selectSupervisorUser(u) {
|
|
this.supervisorPinUser = u;
|
|
this.supervisorPin = '';
|
|
this.supervisorError = '';
|
|
},
|
|
|
|
backSupervisorUser() {
|
|
this.supervisorPinUser = null;
|
|
this.supervisorPin = '';
|
|
this.supervisorError = '';
|
|
},
|
|
|
|
// Submits by itself on the fourth digit.
|
|
pressSupervisorPin(d) {
|
|
if (this.supervisorValidating || this.supervisorPin.length >= 4) return;
|
|
this.supervisorPin += d;
|
|
this.supervisorError = '';
|
|
if (this.supervisorPin.length === 4) this.submitSupervisor();
|
|
},
|
|
|
|
get supervisorCanSubmit() {
|
|
if (this.supervisorMode === 'pin') return !!this.supervisorPinUser && this.supervisorPin.length === 4;
|
|
return !!this.supervisorUsername && !!this.supervisorPassword;
|
|
},
|
|
|
|
// The pair the proxies forward: id + PIN in PIN mode, username + password otherwise.
|
|
supervisorCredentials() {
|
|
if (this.supervisorMode === 'pin') {
|
|
return { supervisor_user_id: this.supervisorPinUser && this.supervisorPinUser.id, supervisor_pin: this.supervisorPin };
|
|
}
|
|
return { supervisor_username: this.supervisorUsername, supervisor_password: this.supervisorPassword };
|
|
},
|
|
|
|
/* What kind of task this is, as declared in the recipe.
|
|
Deducing it from the quotes meant a measurement task whose quotes had not
|
|
been entered yet behaved as a note - the screen changed shape depending on
|
|
how finished the recipe was. Falls back to the old rule for tasks saved
|
|
before the type existed and not yet migrated. */
|
|
get isMeasureTask() {
|
|
const declared = this.task.task_type;
|
|
if (!declared) return this.subtasks.length > 0;
|
|
return declared === 'measure' || declared === 'camera_measure';
|
|
},
|
|
|
|
/* #46: image-based tasks run a VisionSuite recipe on the server. */
|
|
get isVisionTask() {
|
|
const t = this.task.task_type;
|
|
return t === 'camera_measure' || t === 'dxf_compare';
|
|
},
|
|
|
|
/* #54: «Controllo OK/NOK» - no quotes, the result is the outcome. */
|
|
get isCheckTask() {
|
|
return this.task.task_type === 'check';
|
|
},
|
|
|
|
/* #54 #55: tasks that bring their own result panel (a check, or a note with
|
|
fields). A note without fields stays the plain «Completato» task. */
|
|
get hasResultPanel() {
|
|
if (this.isCheckTask) return true;
|
|
return this.task.task_type === 'note' && Array.isArray(this.task.fields)
|
|
&& this.task.fields.length > 0;
|
|
},
|
|
|
|
/* A measurement task with no quotes yet: the recipe is unfinished. Said out
|
|
loud rather than silently rendering an empty screen. */
|
|
get isMeasureTaskWithoutQuotes() {
|
|
return this.isMeasureTask && this.subtasks.length === 0;
|
|
},
|
|
|
|
// ---- Is the production stopped? ----
|
|
get isPaused() {
|
|
return !!this.productionRun && this.productionRun.status === 'paused';
|
|
},
|
|
|
|
// ---- Supervisor reason text ----
|
|
get supervisorReason() {
|
|
if (this.supervisorAction === 'out_of_tolerance') return '{{ _("Misurazione fuori tolleranza") }}';
|
|
if (this.supervisorAction === 'fermo_linea') return '{{ _("Fermo linea richiesto") }}';
|
|
if (this.supervisorAction === 'ripresa') return '{{ _("Ripresa della produzione") }}';
|
|
if (this.supervisorAction === 'fine_produzione') return '{{ _("Fine produzione richiesta") }}';
|
|
return '';
|
|
},
|
|
|
|
/* Route the modal's confirm to what the action actually does. Fermo linea,
|
|
ripresa and fine produzione act on the production run; the out-of-tolerance
|
|
one is recorded on the measurement itself. */
|
|
async submitSupervisor() {
|
|
if (this.supervisorAction === 'fermo_linea') return this.runSupervisedAction('pause');
|
|
if (this.supervisorAction === 'ripresa') return this.runSupervisedAction('resume');
|
|
if (this.supervisorAction === 'fine_produzione') return this.runSupervisedAction('close');
|
|
return this.authoriseOutOfTolerance();
|
|
},
|
|
|
|
/* The capoturno lets an out-of-tolerance value stand.
|
|
|
|
The credentials go to the endpoint that writes the approval onto that
|
|
measurement. It used to be a bare credential check whose answer was thrown
|
|
away: nothing recorded who had said yes, and nothing depended on it, so
|
|
closing the modal was enough to walk past a value out of tolerance. */
|
|
async authoriseOutOfTolerance() {
|
|
if (!this.pendingFail) {
|
|
this.closeSupervisorModal();
|
|
return;
|
|
}
|
|
this.supervisorError = '';
|
|
this.supervisorValidating = true;
|
|
|
|
try {
|
|
const csrfToken = document.querySelector('meta[name=csrf-token]')?.content || '';
|
|
const url = '{{ url_for("measure.api_authorise_measurement", measurement_id=0) }}'
|
|
.replace('/0/', '/' + this.pendingFail.id + '/');
|
|
const resp = await fetch(url, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
|
|
body: JSON.stringify(this.supervisorCredentials()), // #59: PIN or password
|
|
});
|
|
const data = await resp.json().catch(() => null);
|
|
|
|
if (!resp.ok || (data && data.error)) {
|
|
this.supervisorError = (data && data.detail)
|
|
|| (this.supervisorMode === 'pin' && resp.status === 401 ? '{{ _("PIN errato") }}' : '')
|
|
|| '{{ _("Credenziali non valide o utente non autorizzato") }}';
|
|
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN stays in the modal, pad cleared
|
|
this.supervisorValidating = false;
|
|
return;
|
|
}
|
|
|
|
this.supervisorValidating = false;
|
|
this.closeSupervisorModal();
|
|
this.pendingFail = null;
|
|
|
|
if (this.completedCount >= this.totalSubtasks) {
|
|
this.showCompletionOverlay = true;
|
|
} else {
|
|
this.advanceToNext();
|
|
}
|
|
|
|
} catch (err) {
|
|
this.supervisorError = '{{ _("Errore di connessione") }}';
|
|
this.supervisorValidating = false;
|
|
}
|
|
},
|
|
|
|
/* Fermo linea, ripresa e fine produzione.
|
|
These used to open the modal and then do nothing. They now act on the
|
|
production run: the credentials go to the endpoint, which checks them and
|
|
records on the run's trace who authorised the stop or the closure. */
|
|
async runSupervisedAction(action) {
|
|
if (!this.productionRun) {
|
|
this.supervisorError = '{{ _("Nessuna produzione aperta su questa stazione") }}';
|
|
return;
|
|
}
|
|
this.supervisorError = '';
|
|
this.supervisorValidating = true;
|
|
|
|
const base = '{{ url_for("measure.api_pause_production", run_id=0) }}'
|
|
.replace('/0/pause', '/' + this.productionRun.id + '/' + action);
|
|
const run = await this.postProduction(base, this.supervisorCredentials()); // #59: PIN or password
|
|
|
|
this.supervisorValidating = false;
|
|
if (!run) {
|
|
// postProduction already holds the reason; show it inside the modal so the
|
|
// capoturno sees why the action was refused, rather than a silent no-op.
|
|
this.supervisorError = this.productionError || '{{ _("Azione non riuscita") }}';
|
|
this.productionError = '';
|
|
if (this.supervisorMode === 'pin') this.supervisorPin = ''; // #59: wrong PIN / lockout: pad cleared, modal stays
|
|
return;
|
|
}
|
|
|
|
this.closeSupervisorModal();
|
|
this.adoptProductionRun(run);
|
|
|
|
if (run.status === 'closed') {
|
|
this.goToSummary();
|
|
}
|
|
},
|
|
|
|
// ---- Go to summary ----
|
|
goToSummary() {
|
|
const recipeId = this.task.recipe_id || 0;
|
|
window.location.href = '{{ url_for("measure.task_complete", recipe_id=0) }}'.replace('/0', '/' + recipeId) +
|
|
'?version_id=' + encodeURIComponent(this.task.version_id || '');
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|