ixg_platform/templates/ops/specification_form.html
ixgadmin df2a90bc71 Add the product specification screen to the ops portal
Operations can now create and edit a specification and its parameters
together, over the existing model, mirroring the admin's inline.

The approval half is deliberately held: the form does not expose status,
is_current, approved_by or approved_at, so a portal-created specification
is a draft and cannot be approved here. The edit screen warns when the
record is not a draft, because its parameters are the limits QC results
are judged against. Nothing is removed from the admin.

The parameter formset refuses an empty specification (with no parameters
every QC result would pass), a numeric parameter with no limits, and a
visual parameter with no allowed values.
2026-09-25 16:35:45 +00:00

203 lines
8.1 KiB
HTML

{% extends 'ops/base_ops.html' %}
{% block title %}{{ page_title }} - IXG Operations{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h2>{{ page_title }}</h2>
<a href="{{ cancel_url }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
</div>
{% if messages %}
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
{% endif %}
{% if editing_approved %}
<div class="alert alert-warning">
<strong>This specification is not a draft.</strong> Its parameters are the limits QC results
are judged against, so editing them changes those limits. Approval is not available in the
portal yet — it is held until the approval control is decided.
</div>
{% endif %}
<form method="post" novalidate>
{% csrf_token %}
<div class="row g-3">
<div class="col-md-7">
<div class="stat-card mb-3">
<h6 class="mb-3">Specification</h6>
{% if form.non_field_errors %}
{% for e in form.non_field_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
{% endif %}
{% for field in form %}
<div class="mb-3">
<label class="form-label" for="{{ field.id_for_label }}">
{{ field.label }}{% if field.field.required %} <span class="text-danger">*</span>{% endif %}
</label>
{{ field }}
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
{% endfor %}
</div>
<div class="stat-card">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Parameters</h6>
<button type="button" class="btn btn-sm btn-outline-primary" id="add-param"><i class="bi bi-plus-lg"></i> Add parameter</button>
</div>
{{ formset.management_form }}
{% if formset.non_form_errors %}
{% for e in formset.non_form_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
{% endif %}
<div id="param-rows">
{% for pf in formset %}
<div class="param-row border rounded p-3 mb-3">
{{ pf.id }}
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small" for="{{ pf.parameter.id_for_label }}">Parameter</label>
{{ pf.parameter }}
{% for e in pf.parameter.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="col-md-3">
<label class="form-label small" for="{{ pf.unit.id_for_label }}">Unit</label>
{{ pf.unit }}
</div>
<div class="col-md-2">
<label class="form-label small" for="{{ pf.min_value.id_for_label }}">Minimum</label>
{{ pf.min_value }}
{% for e in pf.min_value.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="col-md-2">
<label class="form-label small" for="{{ pf.max_value.id_for_label }}">Maximum</label>
{{ pf.max_value }}
{% for e in pf.max_value.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="col-md-1">
<label class="form-label small" for="{{ pf.sort_order.id_for_label }}">Order</label>
{{ pf.sort_order }}
</div>
<div class="col-md-5">
<label class="form-label small" for="{{ pf.allowed_values.id_for_label }}">Allowed values (visual grade)</label>
{{ pf.allowed_values }}
{% for e in pf.allowed_values.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
<div class="col-md-3 d-flex align-items-center pt-3">
<div class="form-check">
{{ pf.required }}
<label class="form-check-label small" for="{{ pf.required.id_for_label }}">Required</label>
</div>
</div>
<div class="col-md-2 d-flex align-items-center pt-3">
<div class="form-check">
{{ pf.DELETE }}
<label class="form-check-label small text-danger" for="{{ pf.DELETE.id_for_label }}">Remove</label>
</div>
</div>
</div>
{% for e in pf.non_field_errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
{% endfor %}
</div>
</div>
</div>
<div class="col-md-5">
<div class="stat-card mb-3">
<h6 class="mb-3"><i class="bi bi-info-circle"></i> How a specification is used</h6>
<p class="small mb-2">
Each parameter is a limit. A numeric parameter is checked between its minimum and
maximum; a visual parameter is checked against its allowed grades. A QC result is
judged against the specification that is both <strong>approved</strong> and
<strong>current</strong>.
</p>
<p class="small mb-0 text-secondary">
A specification needs at least one parameter: with none, every QC result would pass.
</p>
</div>
<div class="stat-card">
<h6 class="mb-3"><i class="bi bi-shield-lock"></i> Approval is held</h6>
<p class="small mb-0">
Creating and editing is available here, but the portal does not approve a
specification. A new one is saved as a draft. Approving it — the step that makes
its limits the ones QC is judged against — waits for the approval control to be
decided.
</p>
</div>
</div>
</div>
<div class="mt-3">
<button type="submit" class="btn btn-primary">{{ submit_label }}</button>
<a href="{{ cancel_url }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
<template id="empty-param-row">
<div class="param-row border rounded p-3 mb-3">
{{ formset.empty_form.id }}
<div class="row g-2">
<div class="col-md-4">
<label class="form-label small">Parameter</label>
{{ formset.empty_form.parameter }}
</div>
<div class="col-md-3">
<label class="form-label small">Unit</label>
{{ formset.empty_form.unit }}
</div>
<div class="col-md-2">
<label class="form-label small">Minimum</label>
{{ formset.empty_form.min_value }}
</div>
<div class="col-md-2">
<label class="form-label small">Maximum</label>
{{ formset.empty_form.max_value }}
</div>
<div class="col-md-1">
<label class="form-label small">Order</label>
{{ formset.empty_form.sort_order }}
</div>
<div class="col-md-5">
<label class="form-label small">Allowed values (visual grade)</label>
{{ formset.empty_form.allowed_values }}
</div>
<div class="col-md-3 d-flex align-items-center pt-3">
<div class="form-check">
{{ formset.empty_form.required }}
<label class="form-check-label small">Required</label>
</div>
</div>
<div class="col-md-2 d-flex align-items-center pt-3">
<div class="form-check">
{{ formset.empty_form.DELETE }}
<label class="form-check-label small text-danger">Remove</label>
</div>
</div>
</div>
</div>
</template>
{% endblock %}
{% block extra_js %}
<script>
(function () {
var total = document.getElementById('id_params-TOTAL_FORMS');
var add = document.getElementById('add-param');
var tpl = document.getElementById('empty-param-row');
var rows = document.getElementById('param-rows');
if (!total || !add || !tpl || !rows) { return; }
add.addEventListener('click', function () {
var index = parseInt(total.value, 10);
var html = tpl.innerHTML.replace(/__prefix__/g, index);
var holder = document.createElement('div');
holder.innerHTML = html.trim();
rows.appendChild(holder.firstElementChild);
total.value = index + 1;
});
})();
</script>
{% endblock %}