March 17 2026: form editor - add pass/fail element
This commit is contained in:
@@ -30,7 +30,7 @@ ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'}
|
||||
INPUT_FIELD_TYPES = {
|
||||
'text', 'textarea', 'number', 'date', 'email',
|
||||
'checkbox', 'checkbox_group', 'radio', 'select',
|
||||
'rating', 'signature', 'image', 'table'
|
||||
'rating', 'pass_fail', 'signature', 'image', 'table'
|
||||
}
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ def _compute_score_from_form(form_fields, responses):
|
||||
Derive an overall score from rating fields and checkbox pass/fail fields.
|
||||
Returns a float 0–100 or None if the form has no scoreable fields.
|
||||
"""
|
||||
scoreable = [f for f in form_fields if f['type'] in ('rating', 'checkbox', 'radio')]
|
||||
scoreable = [f for f in form_fields if f['type'] in ('rating', 'checkbox', 'radio', 'pass_fail')]
|
||||
if not scoreable:
|
||||
return None
|
||||
|
||||
@@ -144,6 +144,13 @@ def _compute_score_from_form(form_fields, responses):
|
||||
if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
|
||||
earned += 1
|
||||
|
||||
elif field['type'] == 'pass_fail':
|
||||
if not val:
|
||||
continue
|
||||
total += 1
|
||||
if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
|
||||
earned += 1
|
||||
|
||||
return round((earned / total) * 100, 2) if total else None
|
||||
|
||||
|
||||
@@ -478,7 +485,7 @@ def view(inspection_id):
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
scoreable_types = ('rating', 'checkbox', 'radio')
|
||||
scoreable_types = ('rating', 'checkbox', 'radio', 'pass_fail')
|
||||
|
||||
# Collect all text/textarea fields per row, keyed by (col, fid)
|
||||
# so we can pick the leftmost non-empty value as the item name.
|
||||
@@ -557,6 +564,10 @@ def view(inspection_id):
|
||||
return 100.0 if val == 'true' else 0.0
|
||||
if ft == 'radio':
|
||||
return None if not val else 100.0
|
||||
if ft == 'pass_fail':
|
||||
if not val:
|
||||
return None
|
||||
return 100.0 if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant') else 0.0
|
||||
return None
|
||||
|
||||
cur_pct = _field_pct(cur_val, ftype)
|
||||
|
||||
@@ -229,7 +229,7 @@ def save_form_schema(template_id):
|
||||
'label': str(field.get('label', 'Untitled'))[:255],
|
||||
'placeholder': str(field.get('placeholder', ''))[:255],
|
||||
'required': bool(field.get('required', False)),
|
||||
'options': field.get('options', []) if ftype in ('radio', 'checkbox_group', 'select') else [],
|
||||
'options': field.get('options', []) if ftype in ('radio', 'checkbox_group', 'select', 'pass_fail') else [],
|
||||
'help_text': str(field.get('help_text', ''))[:500],
|
||||
'order': int(field.get('order', 0)),
|
||||
# Grid position & size
|
||||
|
||||
@@ -330,6 +330,30 @@
|
||||
{% endfor %}
|
||||
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
|
||||
|
||||
{# ── Pass / Fail ── #}
|
||||
{% elif field.type == 'pass_fail' %}
|
||||
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
|
||||
{% set pf_options = field.options if field.options else ['Pass', 'Fail'] %}
|
||||
<div class="pf-group" style="display:flex;gap:.5rem;flex-wrap:wrap;">
|
||||
{% for opt in pf_options %}
|
||||
{% set is_pass = opt.lower() in ('pass','yes','ok','good') %}
|
||||
<label class="pf-btn{{ ' active' if saved == opt }}" data-color="{{ '#16a34a' if is_pass else '#dc2626' }}"
|
||||
style="display:flex;align-items:center;gap:.35rem;cursor:pointer;
|
||||
padding:.4rem .85rem;border-radius:20px;font-size:.84rem;font-weight:600;
|
||||
border:2px solid {{ '#16a34a' if is_pass else '#dc2626' }};
|
||||
color:{{ ('#fff' if saved == opt else ('#16a34a' if is_pass else '#dc2626')) }};
|
||||
background:{{ (('#16a34a' if is_pass else '#dc2626') if saved == opt else 'transparent') }};
|
||||
transition:background .15s,color .15s;">
|
||||
<input type="radio" name="field_{{ fid }}" value="{{ opt }}"
|
||||
{{ 'checked' if saved == opt }}
|
||||
style="display:none;"
|
||||
onchange="var g=this.closest('.pf-group');g.querySelectorAll('.pf-btn').forEach(function(l){l.style.background='';l.style.color=l.dataset.color;});this.parentElement.style.background=this.parentElement.dataset.color;this.parentElement.style.color='#fff';">
|
||||
<span>{{ opt }}</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
|
||||
|
||||
{# ── Select / Dropdown ── #}
|
||||
{% elif field.type == 'select' %}
|
||||
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
|
||||
|
||||
@@ -559,6 +559,18 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif ftype == 'pass_fail' %}
|
||||
<span class="field-lbl">{{ field.label }}</span>
|
||||
<div class="field-val">
|
||||
{% if val and val.lower() in ('pass','yes','ok','good','acceptable','compliant') %}
|
||||
<span class="text-success"><i class="bi bi-check-circle-fill"></i> {{ val }}</span>
|
||||
{% elif val %}
|
||||
<span class="text-danger"><i class="bi bi-x-circle-fill"></i> {{ val }}</span>
|
||||
{% else %}
|
||||
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">Not answered</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% elif ftype == 'checkbox' %}
|
||||
<span class="field-lbl">{{ field.label }}</span>
|
||||
<div class="field-val">
|
||||
|
||||
@@ -402,6 +402,7 @@
|
||||
('checkbox_group', 'bi-ui-checks', 'Checkbox Group'),
|
||||
('radio', 'bi-ui-radios', 'Radio Group'),
|
||||
('select', 'bi-menu-button-wide', 'Dropdown'),
|
||||
('pass_fail', 'bi-check2-circle', 'Pass / Fail'),
|
||||
] %}
|
||||
<div class="pal-item" draggable="true" data-ftype="{{ t }}" data-flabel="{{ lb }}">
|
||||
<i class="bi {{ ic }}"></i> {{ lb }}
|
||||
@@ -480,7 +481,7 @@ const DEF_SIZE = {
|
||||
number: [3, 2], date: [4, 2],
|
||||
email: [6, 2], checkbox: [4, 2],
|
||||
checkbox_group: [5, 4], radio: [5, 4],
|
||||
select: [5, 2], image: [6, 4],
|
||||
select: [5, 2], pass_fail: [4, 2], image: [6, 4],
|
||||
signature: [6, 3], rating: [5, 2],
|
||||
section: [12, 1], table: [12, 5],
|
||||
label: [6, 1],
|
||||
@@ -490,7 +491,7 @@ const DEF_SIZE = {
|
||||
const LABELS = {
|
||||
text:'Text Input', textarea:'Text Area', number:'Number', date:'Date',
|
||||
email:'Email', checkbox:'Checkbox', checkbox_group:'Checkbox Group',
|
||||
radio:'Radio Group', select:'Dropdown', image:'Image Upload',
|
||||
radio:'Radio Group', select:'Dropdown', pass_fail:'Pass / Fail', image:'Image Upload',
|
||||
signature:'Signature', rating:'Rating (1–5)', section:'Section Header',
|
||||
table:'Table', label:'Label / Text',
|
||||
button_submit:'Submit Button', button_print:'Print Button', button_email:'Email Button',
|
||||
@@ -586,7 +587,7 @@ function mkField(type, col, row) {
|
||||
placeholder: '',
|
||||
required: false,
|
||||
help_text: '',
|
||||
options: ['radio','checkbox_group','select'].includes(type) ? ['Option 1','Option 2'] : [],
|
||||
options: type === 'pass_fail' ? ['Pass','Fail'] : ['radio','checkbox_group','select'].includes(type) ? ['Option 1','Option 2'] : [],
|
||||
table_cols: isTable ? 3 : undefined,
|
||||
table_rows: isTable ? 3 : undefined,
|
||||
col_headers: isTable ? ['Column 1','Column 2','Column 3'] : undefined,
|
||||
@@ -745,6 +746,12 @@ function mkPreview(f) {
|
||||
return `${lbl}<div class="mock-sig">Sign here…</div>`;
|
||||
case 'rating':
|
||||
return `${lbl}<div style="font-size:1.2rem;color:#f59e0b;letter-spacing:.15rem;">★★★★★</div>`;
|
||||
case 'pass_fail': {
|
||||
const pfOpts = f.options || ['Pass','Fail'];
|
||||
return `${lbl}<div style="display:flex;gap:.35rem;">${pfOpts.map(o =>
|
||||
`<span style="display:inline-block;padding:.15rem .55rem;border-radius:20px;font-size:.72rem;font-weight:600;border:1.5px solid ${o.toLowerCase()==='pass'?'#16a34a':'#dc2626'};color:${o.toLowerCase()==='pass'?'#16a34a':'#dc2626'};">${esc(o)}</span>`
|
||||
).join('')}</div>`;
|
||||
}
|
||||
case 'section':
|
||||
return `<div style="border-top:2px solid #e2e8f0;padding-top:.4rem;">
|
||||
<strong style="font-size:.86rem;color:#374151;">${esc(f.label)}</strong>
|
||||
@@ -815,7 +822,7 @@ function renderProperties() {
|
||||
const f = fields.find(f => f.id === selectedId);
|
||||
if (!f) return;
|
||||
empty.style.display = 'none'; content.style.display = '';
|
||||
const hasOpts = ['radio','checkbox_group','select'].includes(f.type);
|
||||
const hasOpts = ['radio','checkbox_group','select','pass_fail'].includes(f.type);
|
||||
const isSec = f.type === 'section';
|
||||
const isLabel = f.type === 'label';
|
||||
const isBtn = f.type.startsWith('button_');
|
||||
|
||||
@@ -248,6 +248,19 @@
|
||||
{% endfor %}
|
||||
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
|
||||
|
||||
{% elif field.type == 'pass_fail' %}
|
||||
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="text-danger">*</span>{% endif %}</label>
|
||||
{% set pf_options = field.options if field.options else ['Pass', 'Fail'] %}
|
||||
<div style="display:flex;gap:.4rem;margin-top:.2rem;">
|
||||
{% for opt in pf_options %}
|
||||
{% set is_pass = opt.lower() in ('pass','yes','ok','good') %}
|
||||
<span style="padding:.25rem .65rem;border-radius:20px;font-size:.78rem;font-weight:600;
|
||||
border:1.5px solid {{ '#16a34a' if is_pass else '#dc2626' }};
|
||||
color:{{ '#16a34a' if is_pass else '#dc2626' }};">{{ opt }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
|
||||
|
||||
{% elif field.type == 'select' %}
|
||||
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
|
||||
<select class="form-select">
|
||||
|
||||
@@ -386,6 +386,8 @@ def _form_fields_section(form_fields, form_data, static_folder):
|
||||
if ftype == 'checkbox':
|
||||
# stored as 'true'/'false' by _collect_form_responses
|
||||
return val not in ('yes', 'true')
|
||||
if ftype == 'pass_fail':
|
||||
return not val
|
||||
if ftype == 'checkbox_group':
|
||||
return not val or not isinstance(val, list) or len(val) == 0
|
||||
if ftype == 'table':
|
||||
@@ -429,6 +431,14 @@ def _form_fields_section(form_fields, form_data, static_folder):
|
||||
elif ftype == 'checkbox':
|
||||
val_p = Paragraph('Yes', STYLES['FieldValue'])
|
||||
|
||||
elif ftype == 'pass_fail':
|
||||
is_pass = str(val).lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant')
|
||||
colour = C_GREEN if is_pass else C_RED
|
||||
val_p = Paragraph(
|
||||
f'<font color="{colour.hexval()}">{str(val)}</font>',
|
||||
STYLES['FieldValue'],
|
||||
)
|
||||
|
||||
elif ftype == 'checkbox_group':
|
||||
val_p = Paragraph(', '.join(val), STYLES['FieldValue'])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user