Schermate operatore: un solo Completato grande, Fermo linea solo con run aperto, AVVIA ATTIVITA, pannello lotto/seriale dopo la scelta (#60 #61 #62 #63 #64)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 12:25:56 +02:00
parent 3856ee335a
commit 27dc5e77af
7 changed files with 149 additions and 98 deletions
@@ -13,6 +13,15 @@
barcodeInput: '', barcodeInput: '',
barcodeLoading: false, barcodeLoading: false,
barcodeError: '', barcodeError: '',
/* #63/#64: ricetta scelta che richiede lotto/seriale; il pannello compare solo allora. */
selected: null,
needsTrace(recipe) {
return !!(recipe && (recipe.requires_lot || recipe.requires_serial));
},
choose(recipe) {
this.selected = recipe;
this.$nextTick(() => { const el = document.getElementById('trace-panel'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); });
},
get filteredRecipes() { get filteredRecipes() {
if (!this.search) return this.recipes; if (!this.search) return this.recipes;
const q = this.search.toLowerCase(); const q = this.search.toLowerCase();
@@ -62,9 +71,12 @@
// The scanner is a way in like any other: a recipe that demands a lot // The scanner is a way in like any other: a recipe that demands a lot
// demands it here too, or the rule would last exactly as long as it // demands it here too, or the rule would last exactly as long as it
// took someone to reach for the barcode reader. // took someone to reach for the barcode reader.
const missing = this.missingTraceability(data); // #63/#64: se la ricetta chiede lotto/seriale mancanti, la si sceglie e
if (missing.length) { // compare il pannello con i soli campi richiesti.
this.barcodeError = {{ _("Compila prima:")|tojson_attr }} + ' ' + missing.join(', '); if (this.missingTraceability(data).length) {
this.barcodeModal = false;
this.barcodeInput = '';
this.choose(data);
return; return;
} }
this.barcodeModal = false; this.barcodeModal = false;
@@ -149,50 +161,6 @@
</div> </div>
</div> </div>
<!-- Traceability (data entry, NOT a search filter) -->
<div class="tmf-card mb-6 border-l-4 border-l-primary">
<div class="p-4 sm:p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-primary 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>
<div>
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}</h2>
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Lot Number -->
<div>
<label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-amber-500" fill="none" stroke="currentColor" stroke-width="2" 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>
{{ _('Numero Lotto') }}
</label>
<input type="text"
x-model="lot_number"
placeholder="{{ _('Es. LOT-2026-001') }}"
class="tmf-input font-mono">
</div>
<!-- Serial Number -->
<div>
<label class="tmf-label flex items-center gap-1.5">
<svg class="w-3.5 h-3.5 text-indigo-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
{{ _('Numero Seriale') }}
</label>
<input type="text"
x-model="serial_number"
placeholder="{{ _('Es. SN-000123') }}"
class="tmf-input font-mono">
</div>
</div>
</div>
</div>
<!-- Results Count --> <!-- Results Count -->
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<p class="text-sm text-[var(--text-secondary)]"> <p class="text-sm text-[var(--text-secondary)]">
@@ -269,30 +237,25 @@
<div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)] <div class="px-5 py-3.5 border-t border-[var(--border-color)] bg-[var(--bg-secondary)]
rounded-b-xl"> rounded-b-xl">
{# What this recipe insists on before anyone measures. Said on the card {# #63/#64: una ricetta con lotto/seriale obbligatori si sceglie prima
rather than after the fact: a measurement taken without its lot cannot (il pannello compare sotto la griglia); le altre partono subito. #}
be traced back, and finding that out later is finding it out too late. #} <template x-if="needsTrace(recipe)">
<template x-if="missingTraceability(recipe).length"> <button type="button" @click="choose(recipe)"
<p class="mb-2 flex items-start gap-1.5 text-xs text-amber-700 dark:text-amber-300"> :class="selected && selected.id === recipe.id ? 'ring-2 ring-primary' : ''"
<svg class="w-3.5 h-3.5 shrink-0 mt-px" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"> class="btn btn-primary w-full justify-center text-sm font-semibold">
<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"/> {{ _('Seleziona') }}
</svg> </button>
<span x-text="{{ _('Compila prima:')|tojson_attr }} + ' ' + missingTraceability(recipe).join(', ')"></span> </template>
</p> <template x-if="!needsTrace(recipe)">
<a :href="buildTaskUrl(recipe.id)"
class="btn btn-primary w-full justify-center text-sm font-semibold
transition-shadow duration-200 group-hover:shadow-md">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg>
{{ _('AVVIA ATTIVITÀ') }}
</a>
</template> </template>
<a :href="missingTraceability(recipe).length ? null : buildTaskUrl(recipe.id)"
:aria-disabled="missingTraceability(recipe).length > 0"
:class="missingTraceability(recipe).length
? 'opacity-40 cursor-not-allowed pointer-events-none'
: 'group-hover:shadow-md'"
class="btn btn-primary w-full justify-center text-sm font-semibold
transition-shadow duration-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg>
{{ _('AVVIA IN SEQUENZA') }}
</a>
{# The whole list, for when the operator wants to see it or go back to a {# The whole list, for when the operator wants to see it or go back to a
task left unfinished. Deliberately the quiet one of the two. #} task left unfinished. Deliberately the quiet one of the two. #}
@@ -306,6 +269,41 @@
</template> </template>
</div> </div>
<!-- Traceability (#63/#64): compare solo dopo aver scelto una ricetta con
requires_lot / requires_serial, con i soli campi richiesti. -->
<div id="trace-panel" x-show="selected && needsTrace(selected)" x-cloak
class="tmf-card mt-6 border-l-4 border-l-primary">
<div class="p-4 sm:p-5">
<div class="flex items-center gap-2 mb-3">
<svg class="w-5 h-5 text-primary 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>
<div>
<h2 class="text-sm font-semibold text-[var(--text-primary)] leading-tight">{{ _('Tracciabilità') }}: <span x-text="selected ? selected.name : ''"></span></h2>
<p class="text-xs text-[var(--text-muted)] leading-tight">{{ _('Dati del pezzo da misurare — compila prima di selezionare la ricetta') }}</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div x-show="selected && selected.requires_lot">
<label class="tmf-label">{{ _('Numero Lotto') }}</label>
<input type="text" x-model="lot_number"
placeholder="{{ _('Es. LOT-2026-001') }}" class="tmf-input font-mono">
</div>
<div x-show="selected && selected.requires_serial">
<label class="tmf-label">{{ _('Numero Seriale') }}</label>
<input type="text" x-model="serial_number"
placeholder="{{ _('Es. SN-000123') }}" class="tmf-input font-mono">
</div>
</div>
<a :href="selected && !missingTraceability(selected).length ? buildTaskUrl(selected.id) : null"
:aria-disabled="!selected || missingTraceability(selected).length > 0"
:class="(!selected || missingTraceability(selected).length) ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''"
class="btn btn-primary w-full justify-center text-sm font-semibold mt-4">
{{ _('AVVIA ATTIVITÀ') }}
</a>
</div>
</div>
<!-- Empty State --> <!-- Empty State -->
<div x-show="filteredRecipes.length === 0" x-cloak <div x-show="filteredRecipes.length === 0" x-cloak
class="text-center py-16"> class="text-center py-16">
@@ -115,20 +115,12 @@
</svg> </svg>
{{ _('Lista task') }} {{ _('Lista task') }}
</a> </a>
<button @click="goToNextTask()" {# #60: un solo «Completato», quello grande in fondo (touch); qui resta solo la lista. #}
:disabled="!canLeaveTask"
:title="canLeaveTask ? '' : blockedReason"
:class="canLeaveTask ? '' : 'opacity-40 cursor-not-allowed'"
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="M5 13l4 4L19 7"/>
</svg>
{{ _('Completato') }}
</button>
</div> </div>
{# Fermo linea + Fine produzione (measurement tasks only) #} {# Fermo linea + Fine produzione (measurement tasks only) #}
<template x-if="isMeasureTask"> <template x-if="isMeasureTask && productionRun">
{# #61: Fermo linea / Fine produzione solo con una produzione aperta #}
<div class="shrink-0 flex items-center gap-1.5"> <div class="shrink-0 flex items-center gap-1.5">
{# While the line is stopped this becomes the way to restart it, so the {# While the line is stopped this becomes the way to restart it, so the
capoturno never has to hunt for a second control. #} capoturno never has to hunt for a second control. #}
@@ -756,10 +748,12 @@
</div> </div>
</template> </template>
<template x-if="!isMeasureTask || subtasks.length === 0"> <template x-if="!isMeasureTask || subtasks.length === 0">
{# #60: unico «Completato», largo e alto almeno 64 px per il touch. #}
<button @click="goToNextTask()" <button @click="goToNextTask()"
class="btn text-xs shrink-0 gap-1 py-1.5 px-3 shadow-md :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"> 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"> <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"/> <path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
</svg> </svg>
{{ _('Completato') }} {{ _('Completato') }}
@@ -845,6 +839,10 @@
</div> </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"> <div class="flex gap-2">
<button @click="goToSummary()" <button @click="goToSummary()"
class="btn btn-secondary flex-1 justify-center gap-2"> class="btn btn-secondary flex-1 justify-center gap-2">
@@ -1250,11 +1248,16 @@ function taskExecute() {
async loadProductionRun() { async loadProductionRun() {
try { try {
const resp = await fetch('{{ url_for("measure.api_current_production") }}'); const resp = await fetch('{{ url_for("measure.api_current_production") }}');
if (!resp.ok) return; if (!resp.ok) {
// #61: l'errore si mostra, non si ignora
this.productionError = '{{ _("Errore di comunicazione con il server") }}';
return;
}
const run = await resp.json(); const run = await resp.json();
if (run && run.id) this.adoptProductionRun(run); if (run && run.id) this.adoptProductionRun(run);
} catch (e) { } catch (e) {
// Offline or server down: leave the page usable, just without the timer. // #61: offline o server giu: pagina usabile, ma l'errore e' visibile.
this.productionError = '{{ _("Errore di connessione") }}';
} }
}, },
@@ -1767,13 +1770,9 @@ function taskExecute() {
this.showCompletionOverlay = false; this.showCompletionOverlay = false;
const opened = await this.startProduction(); const opened = await this.startProduction();
if (!opened) { if (!opened) {
// Server refused: keep the operator working rather than stranding them // #61: server rifiuta -> niente timer locale finto; si riapre l'overlay
// mid-shift, but productionError is on screen so it is not silent. // con l'errore (productionError) cosi' l'operatore puo' riprovare.
this.cycleConfirmed = true; this.showCompletionOverlay = true;
this.cycleCount++;
if (this.measurementIntervalMinutes && this.measurementIntervalMinutes > 0) {
this.startCountdownFrom(this.measurementIntervalMinutes * 60);
}
return; return;
} }
// The START-phase measurements have just been taken: record them as the first // The START-phase measurements have just been taken: record them as the first
@@ -1821,6 +1820,12 @@ function taskExecute() {
// ---- Open/close supervisor modal (always reset fields) ---- // ---- Open/close supervisor modal (always reset fields) ----
openSupervisorModal(action) { 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;
}
this.supervisorAction = action; this.supervisorAction = action;
this.supervisorUsername = ''; this.supervisorUsername = '';
this.supervisorPassword = ''; this.supervisorPassword = '';
@@ -104,7 +104,7 @@
<svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"> <svg class="w-6 h-6" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/> <path stroke-linecap="round" stroke-linejoin="round" d="M5 3l14 9-14 9V3z"/>
</svg> </svg>
{{ _('AVVIA IN SEQUENZA') }} {{ _('AVVIA ATTIVITÀ') }}
</a> </a>
{% endif %} {% endif %}
{% endif %} {% endif %}
@@ -104,7 +104,7 @@ def test_the_selection_screen_starts_the_sequence(
def test_avvia_on_the_list_starts_the_sequence(logged_in_client, mock_api_client): def test_avvia_on_the_list_starts_the_sequence(logged_in_client, mock_api_client):
html = _list_page(logged_in_client, mock_api_client) html = _list_page(logged_in_client, mock_api_client)
assert "/measure/start/3" in html assert "/measure/start/3" in html
assert "AVVIA IN SEQUENZA" in html assert "AVVIA ATTIVITÀ" in html
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -163,6 +163,30 @@ def test_the_header_offers_completato_not_the_summary(
assert '@click="goToNextTask()"' in html assert '@click="goToNextTask()"' in html
def test_only_one_completato_and_it_is_big(
logged_in_client, mock_api_client, monkeypatch,
):
"""#60: «Completato» una sola volta (in basso), largo e alto per il touch."""
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
# the label as a button text on its own line (comments in the JS also name it)
assert [l.strip() for l in html.splitlines()].count("Completato") == 1
assert "min-h-[64px]" in html
def test_no_open_run_means_no_fermo_linea_and_no_fake_timer(
logged_in_client, mock_api_client, monkeypatch,
):
"""#61: senza produzione aperta i pulsanti non si vedono, la modale non si
apre e un rifiuto del server non avvia un timer locale finto."""
html = _execute_page(logged_in_client, mock_api_client, monkeypatch)
assert 'x-if="isMeasureTask && productionRun"' in html
assert "!this.productionRun" in html
assert "Nessuna produzione aperta su questa stazione" in html
# il vecchio ripiego locale non c'e' piu'
assert "this.startCountdownFrom(this.measurementIntervalMinutes * 60)" not in html.split(
"async startProductionFromOverlay()")[1].split("async confirmCycle")[0]
def test_fine_ciclo_is_shown_before_it_can_be_used( def test_fine_ciclo_is_shown_before_it_can_be_used(
logged_in_client, mock_api_client, monkeypatch, logged_in_client, mock_api_client, monkeypatch,
): ):
@@ -196,3 +220,27 @@ def test_the_operator_flow_never_links_to_the_statistics(
] ]
for html in pages: for html in pages:
assert "/statistics" not in html assert "/statistics" not in html
def test_trace_panel_only_for_recipes_that_require_it(
logged_in_client, mock_api_client, monkeypatch,
):
"""#63/#64: ricerca+griglia in alto, pannello lotto/seriale solo dopo la
scelta di una ricetta che lo richiede, con i soli campi richiesti."""
_italian(logged_in_client)
monkeypatch.setattr("blueprints.measure.Config.STATION_CODE", "ST-TEST")
mock_api_client.get_station_recipes.return_value = [
{"id": 1, "code": "A", "name": "Con lotto", "requires_lot": True,
"requires_serial": False},
{"id": 2, "code": "B", "name": "Libera"},
]
html = logged_in_client.get("/measure/select").get_data(as_text=True)
assert 'id="trace-panel"' in html
assert 'x-show="selected && needsTrace(selected)"' in html
assert 'x-show="selected && selected.requires_lot"' in html
assert 'x-show="selected && selected.requires_serial"' in html
# la ricerca e la griglia precedono il pannello
assert html.index("Cerca ricetta") < html.index('id="trace-panel"')
assert html.index("Recipe Grid") < html.index('id="trace-panel"')
# le ricette senza obblighi partono direttamente
assert 'x-if="!needsTrace(recipe)"' in html
@@ -1862,7 +1862,7 @@ msgstr "No description available"
#: templates/measure/select_recipe.html:294 #: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107 #: templates/measure/task_list.html:107
msgid "AVVIA IN SEQUENZA" msgid "AVVIA ATTIVITÀ"
msgstr "START THE SEQUENCE" msgstr "START THE SEQUENCE"
# Maker - Task Editor # Maker - Task Editor
@@ -1864,8 +1864,8 @@ msgstr "Nessuna descrizione disponibile"
#: templates/measure/select_recipe.html:294 #: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107 #: templates/measure/task_list.html:107
msgid "AVVIA IN SEQUENZA" msgid "AVVIA ATTIVITÀ"
msgstr "AVVIA IN SEQUENZA" msgstr "AVVIA ATTIVITÀ"
# Maker - Task Editor # Maker - Task Editor
#: templates/measure/select_recipe.html:302 #: templates/measure/select_recipe.html:302
@@ -1825,7 +1825,7 @@ msgstr ""
#: templates/measure/select_recipe.html:294 #: templates/measure/select_recipe.html:294
#: templates/measure/task_list.html:107 #: templates/measure/task_list.html:107
msgid "AVVIA IN SEQUENZA" msgid "AVVIA ATTIVITÀ"
msgstr "" msgstr ""
#: templates/measure/select_recipe.html:302 #: templates/measure/select_recipe.html:302