feat: measurement workflow improvements and recipe update-in-place
- Auto-advance to next task after completing all subtask measurements - 1s pause between measurements to show pass/fail/warning result - Colored marker strip (green/red/amber) based on measurement status - Replace duplicate measurements instead of appending (fixes progress bar) - Add Task column and Date/Time column to measurement summary table - Enrich summary with task_info for each measurement - Update-in-place for recipe versions without measurements (no copy-on-write) - Dark theme improvements and navbar cleanup - Server config: ignore extra env vars Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -121,11 +121,21 @@ def task_execute(task_id: int):
|
|||||||
lot_number = session.get("lot_number", "")
|
lot_number = session.get("lot_number", "")
|
||||||
serial_number = session.get("serial_number", "")
|
serial_number = session.get("serial_number", "")
|
||||||
|
|
||||||
|
# Load all task IDs for this recipe (ordered) for auto-advance
|
||||||
|
recipe_id = task_resp.get("recipe_id")
|
||||||
|
all_task_ids = []
|
||||||
|
if recipe_id:
|
||||||
|
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||||
|
if isinstance(tasks_resp, list):
|
||||||
|
sorted_tasks = sorted(tasks_resp, key=lambda t: t.get("order_index", 0))
|
||||||
|
all_task_ids = [t["id"] for t in sorted_tasks]
|
||||||
|
|
||||||
return render_template(
|
return render_template(
|
||||||
"measure/task_execute.html",
|
"measure/task_execute.html",
|
||||||
task=task_resp,
|
task=task_resp,
|
||||||
lot_number=lot_number,
|
lot_number=lot_number,
|
||||||
serial_number=serial_number,
|
serial_number=serial_number,
|
||||||
|
all_task_ids=all_task_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -150,18 +160,44 @@ def task_complete(recipe_id: int):
|
|||||||
)
|
)
|
||||||
return redirect(url_for("measure.select_recipe"))
|
return redirect(url_for("measure.select_recipe"))
|
||||||
|
|
||||||
|
# Load tasks+subtasks for this recipe to build subtask and task lookup
|
||||||
|
tasks_resp = api_client.get(f"/api/recipes/{recipe_id}/tasks")
|
||||||
|
subtask_map = {}
|
||||||
|
subtask_task_map = {} # subtask_id → task info
|
||||||
|
if isinstance(tasks_resp, list):
|
||||||
|
for task in tasks_resp:
|
||||||
|
for st in task.get("subtasks", []):
|
||||||
|
subtask_map[st["id"]] = st
|
||||||
|
subtask_task_map[st["id"]] = {
|
||||||
|
"id": task["id"],
|
||||||
|
"title": task.get("title", ""),
|
||||||
|
"order_index": task.get("order_index", 0),
|
||||||
|
}
|
||||||
|
|
||||||
# Load measurements if version_id provided
|
# Load measurements if version_id provided
|
||||||
measurements = []
|
measurements = []
|
||||||
if version_id:
|
if version_id:
|
||||||
meas_resp = api_client.get(
|
meas_resp = api_client.get(
|
||||||
"/api/measurements",
|
"/api/measurements",
|
||||||
params={"version_id": version_id},
|
params={"version_id": version_id, "per_page": 500},
|
||||||
)
|
)
|
||||||
if not (isinstance(meas_resp, dict) and meas_resp.get("error")):
|
if not (isinstance(meas_resp, dict) and meas_resp.get("error")):
|
||||||
measurements = (
|
raw = (
|
||||||
meas_resp if isinstance(meas_resp, list)
|
meas_resp if isinstance(meas_resp, list)
|
||||||
else meas_resp.get("items", [])
|
else meas_resp.get("items", [])
|
||||||
)
|
)
|
||||||
|
# Enrich each measurement with nested subtask data
|
||||||
|
for m in raw:
|
||||||
|
st = subtask_map.get(m.get("subtask_id"), {})
|
||||||
|
m["subtask"] = st
|
||||||
|
m["task_info"] = subtask_task_map.get(m.get("subtask_id"), {})
|
||||||
|
# Compute deviation if not present
|
||||||
|
if m.get("deviation") is None and st.get("nominal") is not None:
|
||||||
|
try:
|
||||||
|
m["deviation"] = m["value"] - st["nominal"]
|
||||||
|
except (TypeError, KeyError):
|
||||||
|
m["deviation"] = 0.0
|
||||||
|
measurements = raw
|
||||||
|
|
||||||
lot_number = session.get("lot_number", "")
|
lot_number = session.get("lot_number", "")
|
||||||
serial_number = session.get("serial_number", "")
|
serial_number = session.get("serial_number", "")
|
||||||
@@ -243,25 +279,22 @@ def save_measurement():
|
|||||||
|
|
||||||
# Validate required fields
|
# Validate required fields
|
||||||
subtask_id = data.get("subtask_id")
|
subtask_id = data.get("subtask_id")
|
||||||
task_id = data.get("task_id")
|
version_id = data.get("version_id")
|
||||||
value = data.get("value")
|
value = data.get("value")
|
||||||
|
|
||||||
if subtask_id is None or task_id is None or value is None:
|
if subtask_id is None or version_id is None or value is None:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"error": True,
|
"error": True,
|
||||||
"detail": _("Dati mancanti: subtask_id, task_id e value sono obbligatori"),
|
"detail": _("Dati mancanti: subtask_id, version_id e value sono obbligatori"),
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Build payload for the FastAPI backend
|
# Build payload for the FastAPI backend
|
||||||
payload = {
|
payload = {
|
||||||
"subtask_id": subtask_id,
|
"subtask_id": subtask_id,
|
||||||
"task_id": task_id,
|
"version_id": version_id,
|
||||||
"value": value,
|
"value": value,
|
||||||
"pass_fail": data.get("pass_fail", ""),
|
|
||||||
"deviation": data.get("deviation"),
|
|
||||||
"lot_number": data.get("lot_number", session.get("lot_number", "")),
|
"lot_number": data.get("lot_number", session.get("lot_number", "")),
|
||||||
"serial_number": data.get("serial_number", session.get("serial_number", "")),
|
"serial_number": data.get("serial_number", session.get("serial_number", "")),
|
||||||
"measured_by": session.get("user_id"),
|
|
||||||
"input_method": data.get("input_method", "manual"),
|
"input_method": data.get("input_method", "manual"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,15 @@
|
|||||||
|
|
||||||
/* ---- Dark Theme ---- */
|
/* ---- Dark Theme ---- */
|
||||||
.dark {
|
.dark {
|
||||||
|
--color-primary: #60A5FA;
|
||||||
|
--color-primary-dark: #3B82F6;
|
||||||
|
--color-primary-light: #93C5FD;
|
||||||
|
--color-accent: #93C5FD;
|
||||||
|
|
||||||
|
--color-pass: #34D399;
|
||||||
|
--color-warning: #FBBF24;
|
||||||
|
--color-fail: #F87171;
|
||||||
|
|
||||||
--bg-primary: #0F172A;
|
--bg-primary: #0F172A;
|
||||||
--bg-secondary: #1E293B;
|
--bg-secondary: #1E293B;
|
||||||
--bg-card: #334155;
|
--bg-card: #334155;
|
||||||
@@ -48,7 +57,7 @@
|
|||||||
--text-muted: #64748B;
|
--text-muted: #64748B;
|
||||||
|
|
||||||
--border-color: #475569;
|
--border-color: #475569;
|
||||||
--border-focus: #3B82F6;
|
--border-focus: #60A5FA;
|
||||||
|
|
||||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
||||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
|
||||||
@@ -252,11 +261,22 @@ textarea:focus-visible {
|
|||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .btn-primary {
|
||||||
|
background: #3B82F6;
|
||||||
|
color: #FFFFFF;
|
||||||
|
border-color: #3B82F6;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
.btn-primary:hover:not(:disabled) {
|
||||||
background: var(--color-primary-dark);
|
background: var(--color-primary-dark);
|
||||||
border-color: var(--color-primary-dark);
|
border-color: var(--color-primary-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark .btn-primary:hover:not(:disabled) {
|
||||||
|
background: #2563EB;
|
||||||
|
border-color: #2563EB;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- TieMeasureFlow Navbar -->
|
<!-- TieMeasureFlow Navbar -->
|
||||||
<nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300"
|
<nav class="sticky top-0 z-40 bg-[var(--bg-card)] border-b border-[var(--border-color)] shadow-sm transition-colors duration-300"
|
||||||
x-data="{ mobileOpen: false, userDropdown: false, adminDropdown: false }">
|
x-data="{ mobileOpen: false, userDropdown: false }">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex items-center justify-between h-14">
|
<div class="flex items-center justify-between h-14">
|
||||||
|
|
||||||
@@ -15,9 +15,6 @@
|
|||||||
alt="TieMeasureFlow"
|
alt="TieMeasureFlow"
|
||||||
class="h-8 w-auto">
|
class="h-8 w-auto">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="text-lg font-bold text-[var(--text-primary)] tracking-tight hidden sm:inline">
|
|
||||||
TieMeasureFlow
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,53 +73,6 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Admin: dropdown #}
|
|
||||||
{% if current_user.get('is_admin') %}
|
|
||||||
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
|
|
||||||
<button @click="open = !open"
|
|
||||||
class="nav-link group flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors duration-200"
|
|
||||||
:class="{ 'text-primary bg-primary-50 dark:bg-primary-900/20': open }">
|
|
||||||
<!-- Cog Icon -->
|
|
||||||
<svg class="w-4.5 h-4.5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
||||||
</svg>
|
|
||||||
<span>{{ _('Admin') }}</span>
|
|
||||||
<svg class="w-3.5 h-3.5 transition-transform duration-200" :class="{ 'rotate-180': open }"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Admin Dropdown -->
|
|
||||||
<div x-show="open"
|
|
||||||
x-transition:enter="transition ease-out duration-150"
|
|
||||||
x-transition:enter-start="opacity-0 scale-95 -translate-y-1"
|
|
||||||
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
|
|
||||||
x-transition:leave="transition ease-in duration-100"
|
|
||||||
x-transition:leave-start="opacity-100 scale-100"
|
|
||||||
x-transition:leave-end="opacity-0 scale-95"
|
|
||||||
class="absolute left-0 mt-1 w-48 rounded-lg bg-[var(--bg-card)] border border-[var(--border-color)]
|
|
||||||
shadow-lg py-1 z-50">
|
|
||||||
<a href="#" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-secondary)]
|
|
||||||
hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Utenti') }}
|
|
||||||
</a>
|
|
||||||
<a href="#" class="flex items-center gap-2.5 px-4 py-2.5 text-sm text-[var(--text-secondary)]
|
|
||||||
hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20 transition-colors">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Impostazioni') }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -298,28 +248,6 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.get('is_admin') %}
|
|
||||||
<div class="border-t border-[var(--border-color)] my-2"></div>
|
|
||||||
<p class="px-3 py-1 text-xs font-semibold text-[var(--text-secondary)] uppercase tracking-wider">{{ _('Admin') }}</p>
|
|
||||||
<a href="#"
|
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Utenti') }}
|
|
||||||
</a>
|
|
||||||
<a href="#"
|
|
||||||
class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium
|
|
||||||
text-[var(--text-secondary)] hover:text-primary hover:bg-primary-50 dark:hover:bg-primary-900/20
|
|
||||||
transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="1.75" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"/>
|
|
||||||
</svg>
|
|
||||||
{{ _('Impostazioni') }}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,7 +164,8 @@
|
|||||||
<table class="tmf-table">
|
<table class="tmf-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-center">#</th>
|
<th class="text-center">{{ _('Data/Ora') }}</th>
|
||||||
|
<th>{{ _('Task') }}</th>
|
||||||
<th>{{ _('Descrizione') }}</th>
|
<th>{{ _('Descrizione') }}</th>
|
||||||
<th class="text-right">{{ _('Nominale') }}</th>
|
<th class="text-right">{{ _('Nominale') }}</th>
|
||||||
<th class="text-right">{{ _('Valore') }}</th>
|
<th class="text-right">{{ _('Valore') }}</th>
|
||||||
@@ -174,10 +175,13 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for m in measurements|sort(attribute='subtask.marker_number') %}
|
{% for m in measurements|sort(attribute='task_info.order_index,subtask.marker_number') %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="text-center font-mono font-medium" style="color: var(--text-primary);">
|
<td class="text-center font-mono text-xs" style="color: var(--text-secondary);">
|
||||||
{{ m.subtask.marker_number }}
|
{{ m.measured_at[:16]|replace('T', ' ') if m.measured_at else '-' }}
|
||||||
|
</td>
|
||||||
|
<td style="color: var(--text-secondary);" class="text-sm">
|
||||||
|
{{ m.task_info.title if m.task_info else '-' }}
|
||||||
</td>
|
</td>
|
||||||
<td style="color: var(--text-primary);">
|
<td style="color: var(--text-primary);">
|
||||||
{{ m.subtask.description }}
|
{{ m.subtask.description }}
|
||||||
@@ -203,14 +207,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
{% if m.method == 'manual' %}
|
{% if m.input_method == 'manual' %}
|
||||||
<div class="inline-flex items-center gap-1" style="color: var(--text-secondary);">
|
<div class="inline-flex items-center gap-1" style="color: var(--text-secondary);">
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-sm">{{ _('Manuale') }}</span>
|
<span class="text-sm">{{ _('Manuale') }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% elif m.method == 'caliper' %}
|
{% elif m.input_method == 'usb_caliper' %}
|
||||||
<div class="inline-flex items-center gap-1" style="color: var(--text-secondary);">
|
<div class="inline-flex items-center gap-1" style="color: var(--text-secondary);">
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"/>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<script>
|
<script>
|
||||||
window.__taskAnnotations = {{ task.annotations_json|default('null')|tojson }};
|
window.__taskAnnotations = {{ task.annotations_json|default('null')|tojson }};
|
||||||
|
window.__allTaskIds = {{ all_task_ids|tojson }};
|
||||||
</script>
|
</script>
|
||||||
<div class="min-h-[calc(100vh-3.5rem)] flex flex-col"
|
<div class="min-h-[calc(100vh-3.5rem)] flex flex-col"
|
||||||
x-data="taskExecute()"
|
x-data="taskExecute()"
|
||||||
@@ -210,23 +211,43 @@
|
|||||||
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs font-medium transition-all duration-200"
|
class="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-xs font-medium transition-all duration-200"
|
||||||
:class="idx === currentIndex
|
:class="idx === currentIndex
|
||||||
? 'bg-primary text-white border-primary shadow-md marker-active-pulse'
|
? 'bg-primary text-white border-primary shadow-md marker-active-pulse'
|
||||||
: isMeasured(st.id)
|
: getMeasurementStatus(st.id) === 'pass'
|
||||||
? 'bg-measure-pass/10 text-measure-pass border-measure-pass/30 hover:bg-measure-pass/20'
|
? 'bg-measure-pass/10 text-measure-pass border-measure-pass/30 hover:bg-measure-pass/20'
|
||||||
: 'bg-[var(--bg-card)] text-[var(--text-secondary)] border-[var(--border-color)] hover:border-primary/50 hover:text-primary'
|
: getMeasurementStatus(st.id) === 'fail'
|
||||||
|
? 'bg-red-50 dark:bg-red-900/20 text-measure-fail border-measure-fail/30 hover:bg-red-100 dark:hover:bg-red-900/30'
|
||||||
|
: getMeasurementStatus(st.id) === 'warning'
|
||||||
|
? 'bg-amber-50 dark:bg-amber-900/20 text-measure-warning border-measure-warning/30 hover:bg-amber-100 dark:hover:bg-amber-900/30'
|
||||||
|
: 'bg-[var(--bg-card)] text-[var(--text-secondary)] border-[var(--border-color)] hover:border-primary/50 hover:text-primary'
|
||||||
">
|
">
|
||||||
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-bold"
|
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full text-[10px] font-bold"
|
||||||
:class="idx === currentIndex
|
:class="idx === currentIndex
|
||||||
? 'bg-white/20'
|
? 'bg-white/20'
|
||||||
: isMeasured(st.id)
|
: getMeasurementStatus(st.id) === 'pass'
|
||||||
? 'bg-measure-pass/20'
|
? 'bg-measure-pass/20'
|
||||||
: 'bg-[var(--bg-secondary)]'
|
: getMeasurementStatus(st.id) === 'fail'
|
||||||
|
? 'bg-measure-fail/20'
|
||||||
|
: getMeasurementStatus(st.id) === 'warning'
|
||||||
|
? 'bg-measure-warning/20'
|
||||||
|
: 'bg-[var(--bg-secondary)]'
|
||||||
"
|
"
|
||||||
x-text="st.marker_number"></span>
|
x-text="st.marker_number"></span>
|
||||||
<span x-text="st.description" class="truncate max-w-[100px]"></span>
|
<span x-text="st.description" class="truncate max-w-[100px]"></span>
|
||||||
{# Check icon if measured #}
|
{# Status icon: pass=check, fail=X, warning=! #}
|
||||||
<svg x-show="isMeasured(st.id)" class="w-3.5 h-3.5 text-measure-pass shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
<template x-if="getMeasurementStatus(st.id) === 'pass'">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
<svg class="w-3.5 h-3.5 text-measure-pass shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24">
|
||||||
</svg>
|
<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.5 h-3.5 text-measure-fail shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" 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.5 h-3.5 text-measure-warning shrink-0" fill="none" stroke="currentColor" stroke-width="2.5" 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>
|
||||||
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -628,6 +649,11 @@ function taskExecute() {
|
|||||||
return m ? m.value : null;
|
return m ? m.value : null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getMeasurementStatus(subtaskId) {
|
||||||
|
const m = this.measurements.find(m => m.subtask_id === subtaskId);
|
||||||
|
return m ? m.pass_fail : null;
|
||||||
|
},
|
||||||
|
|
||||||
// ---- Handle numpad confirm ----
|
// ---- Handle numpad confirm ----
|
||||||
async handleMeasurement(value, inputMethod) {
|
async handleMeasurement(value, inputMethod) {
|
||||||
if (!this.currentSubtask || this.saving) return;
|
if (!this.currentSubtask || this.saving) return;
|
||||||
@@ -652,10 +678,8 @@ function taskExecute() {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
subtask_id: this.currentSubtask.id,
|
subtask_id: this.currentSubtask.id,
|
||||||
task_id: this.task.id,
|
version_id: this.task.version_id,
|
||||||
value: value,
|
value: value,
|
||||||
pass_fail: pf,
|
|
||||||
deviation: dev,
|
|
||||||
lot_number: this.lotNumber,
|
lot_number: this.lotNumber,
|
||||||
serial_number: this.serialNumber,
|
serial_number: this.serialNumber,
|
||||||
input_method: inputMethod || 'manual',
|
input_method: inputMethod || 'manual',
|
||||||
@@ -670,20 +694,32 @@ function taskExecute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record measurement locally
|
// Record measurement locally (replace if already measured)
|
||||||
this.measurements.push({
|
const existingIdx = this.measurements.findIndex(m => m.subtask_id === this.currentSubtask.id);
|
||||||
subtask_id: this.currentSubtask.id,
|
const mEntry = { subtask_id: this.currentSubtask.id, value, pass_fail: pf, deviation: dev };
|
||||||
value: value,
|
if (existingIdx !== -1) {
|
||||||
pass_fail: pf,
|
this.measurements.splice(existingIdx, 1, mEntry);
|
||||||
deviation: dev,
|
} else {
|
||||||
});
|
this.measurements.push(mEntry);
|
||||||
|
}
|
||||||
|
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
|
|
||||||
|
// Pause 1s to let user see the result (pass/fail/warning)
|
||||||
|
await new Promise(r => setTimeout(r, 1000));
|
||||||
|
|
||||||
// Check if all done
|
// Check if all done
|
||||||
if (this.completedCount >= this.totalSubtasks) {
|
if (this.completedCount >= this.totalSubtasks) {
|
||||||
// Show completion overlay
|
// Auto-advance to next task, or show overlay on last task
|
||||||
this.showCompletionOverlay = true;
|
const taskIds = window.__allTaskIds || [];
|
||||||
|
const currentIdx = taskIds.indexOf(this.task.id);
|
||||||
|
if (currentIdx >= 0 && currentIdx < taskIds.length - 1) {
|
||||||
|
// Navigate to next task
|
||||||
|
window.location.href = '{{ url_for("measure.task_execute", task_id=0) }}'.replace('/0', '/' + taskIds[currentIdx + 1]);
|
||||||
|
} else {
|
||||||
|
// Last task (or unknown): show completion overlay
|
||||||
|
this.showCompletionOverlay = true;
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -52,7 +52,7 @@ class Settings(BaseSettings):
|
|||||||
"""Absolute path to upload directory."""
|
"""Absolute path to upload directory."""
|
||||||
return Path(__file__).parent / self.upload_dir
|
return Path(__file__).parent / self.upload_dir
|
||||||
|
|
||||||
model_config = {"env_file": "../.env", "env_file_encoding": "utf-8"}
|
model_config = {"env_file": "../.env", "env_file_encoding": "utf-8", "extra": "ignore"}
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -93,10 +93,19 @@ async def update_recipe(
|
|||||||
user: User = Depends(require_maker),
|
user: User = Depends(require_maker),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Update a recipe - creates a new version via copy-on-write. Requires Maker role."""
|
"""Update a recipe. Creates a new version only if the current one has measurements.
|
||||||
new_version = await recipe_service.create_new_version(db, recipe_id, data, user)
|
Otherwise updates in-place. Requires Maker role.
|
||||||
|
"""
|
||||||
|
current = await recipe_service.get_current_version(db, recipe_id)
|
||||||
|
has_measurements = await recipe_service.version_has_measurements(db, current.id)
|
||||||
|
|
||||||
|
if has_measurements:
|
||||||
|
# Copy-on-write: create new version to preserve measurement data
|
||||||
|
await recipe_service.create_new_version(db, recipe_id, data, user)
|
||||||
|
else:
|
||||||
|
# No measurements yet: update in-place
|
||||||
|
await recipe_service.update_current_version(db, recipe_id, data)
|
||||||
|
|
||||||
# Reload full recipe for consistent response
|
|
||||||
recipe = await recipe_service.get_recipe_detail(db, recipe_id)
|
recipe = await recipe_service.get_recipe_detail(db, recipe_id)
|
||||||
return _recipe_to_response(recipe)
|
return _recipe_to_response(recipe)
|
||||||
|
|
||||||
|
|||||||
+13
-5
@@ -153,11 +153,19 @@ async def create_task(
|
|||||||
detail="Cannot modify an inactive recipe",
|
detail="Cannot modify an inactive recipe",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Copy-on-write: create new version with existing tasks cloned
|
# Check if current version has measurements
|
||||||
from schemas.recipe import RecipeUpdate
|
current = await recipe_service.get_current_version(db, recipe_id)
|
||||||
new_version = await recipe_service.create_new_version(
|
has_measurements = await recipe_service.version_has_measurements(db, current.id)
|
||||||
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
|
|
||||||
)
|
if has_measurements:
|
||||||
|
# Copy-on-write: create new version preserving measurement data
|
||||||
|
from schemas.recipe import RecipeUpdate
|
||||||
|
new_version = await recipe_service.create_new_version(
|
||||||
|
db, recipe_id, RecipeUpdate(change_notes=f"Added task: {data.title}"), user
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No measurements: use current version directly
|
||||||
|
new_version = current
|
||||||
|
|
||||||
# Determine order_index (append at end)
|
# Determine order_index (append at end)
|
||||||
max_order = max((t.order_index for t in new_version.tasks), default=-1)
|
max_order = max((t.order_index for t in new_version.tasks), default=-1)
|
||||||
|
|||||||
@@ -357,6 +357,82 @@ async def get_measurement_count(
|
|||||||
return count_result.scalar_one()
|
return count_result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def version_has_measurements(db: AsyncSession, version_id: int) -> bool:
|
||||||
|
"""Check if any measurements exist for the given version."""
|
||||||
|
result = await db.execute(
|
||||||
|
select(func.count()).select_from(Measurement).where(
|
||||||
|
Measurement.version_id == version_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one() > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def update_current_version(
|
||||||
|
db: AsyncSession,
|
||||||
|
recipe_id: int,
|
||||||
|
data: RecipeUpdate,
|
||||||
|
) -> RecipeVersion:
|
||||||
|
"""Update recipe header in-place on the current version (no copy-on-write)."""
|
||||||
|
# Apply header updates (name, description)
|
||||||
|
update_fields: dict = {}
|
||||||
|
if data.name is not None:
|
||||||
|
update_fields["name"] = data.name
|
||||||
|
if data.description is not None:
|
||||||
|
update_fields["description"] = data.description
|
||||||
|
if update_fields:
|
||||||
|
await db.execute(
|
||||||
|
update(Recipe).where(Recipe.id == recipe_id).values(**update_fields)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply task-level file updates to first task (same logic as create_new_version)
|
||||||
|
current = await _get_current_version(db, recipe_id)
|
||||||
|
if data.file_path is not None or data.annotations_json is not None:
|
||||||
|
result_tasks = await db.execute(
|
||||||
|
select(RecipeTask)
|
||||||
|
.where(RecipeTask.version_id == current.id)
|
||||||
|
.order_by(RecipeTask.order_index)
|
||||||
|
)
|
||||||
|
tasks = list(result_tasks.scalars().all())
|
||||||
|
if tasks:
|
||||||
|
first_task = tasks[0]
|
||||||
|
if data.file_path is not None:
|
||||||
|
first_task.file_path = data.file_path
|
||||||
|
if data.file_type:
|
||||||
|
first_task.file_type = data.file_type
|
||||||
|
elif data.file_path.lower().endswith(".pdf"):
|
||||||
|
first_task.file_type = "pdf"
|
||||||
|
else:
|
||||||
|
first_task.file_type = "image"
|
||||||
|
if data.annotations_json is not None:
|
||||||
|
first_task.annotations_json = data.annotations_json
|
||||||
|
else:
|
||||||
|
# No tasks yet — create a default task to hold the drawing
|
||||||
|
default_task = RecipeTask(
|
||||||
|
version_id=current.id,
|
||||||
|
order_index=0,
|
||||||
|
title="Technical Drawing",
|
||||||
|
file_path=data.file_path,
|
||||||
|
file_type=data.file_type or (
|
||||||
|
"pdf" if data.file_path and data.file_path.lower().endswith(".pdf")
|
||||||
|
else "image"
|
||||||
|
),
|
||||||
|
annotations_json=data.annotations_json,
|
||||||
|
)
|
||||||
|
db.add(default_task)
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Reload version with tasks for response
|
||||||
|
result = await db.execute(
|
||||||
|
select(RecipeVersion)
|
||||||
|
.where(RecipeVersion.id == current.id)
|
||||||
|
.options(
|
||||||
|
selectinload(RecipeVersion.tasks).selectinload(RecipeTask.subtasks)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
async def list_recipes(
|
async def list_recipes(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user