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 = {
|
INPUT_FIELD_TYPES = {
|
||||||
'text', 'textarea', 'number', 'date', 'email',
|
'text', 'textarea', 'number', 'date', 'email',
|
||||||
'checkbox', 'checkbox_group', 'radio', 'select',
|
'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.
|
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.
|
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:
|
if not scoreable:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -144,6 +144,13 @@ def _compute_score_from_form(form_fields, responses):
|
|||||||
if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
|
if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'):
|
||||||
earned += 1
|
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
|
return round((earned / total) * 100, 2) if total else None
|
||||||
|
|
||||||
|
|
||||||
@@ -478,7 +485,7 @@ def view(inspection_id):
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
scoreable_types = ('rating', 'checkbox', 'radio')
|
scoreable_types = ('rating', 'checkbox', 'radio', 'pass_fail')
|
||||||
|
|
||||||
# Collect all text/textarea fields per row, keyed by (col, fid)
|
# Collect all text/textarea fields per row, keyed by (col, fid)
|
||||||
# so we can pick the leftmost non-empty value as the item name.
|
# 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
|
return 100.0 if val == 'true' else 0.0
|
||||||
if ft == 'radio':
|
if ft == 'radio':
|
||||||
return None if not val else 100.0
|
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
|
return None
|
||||||
|
|
||||||
cur_pct = _field_pct(cur_val, ftype)
|
cur_pct = _field_pct(cur_val, ftype)
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ def save_form_schema(template_id):
|
|||||||
'label': str(field.get('label', 'Untitled'))[:255],
|
'label': str(field.get('label', 'Untitled'))[:255],
|
||||||
'placeholder': str(field.get('placeholder', ''))[:255],
|
'placeholder': str(field.get('placeholder', ''))[:255],
|
||||||
'required': bool(field.get('required', False)),
|
'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],
|
'help_text': str(field.get('help_text', ''))[:500],
|
||||||
'order': int(field.get('order', 0)),
|
'order': int(field.get('order', 0)),
|
||||||
# Grid position & size
|
# Grid position & size
|
||||||
|
|||||||
@@ -330,6 +330,30 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
|
{% 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 ── #}
|
{# ── Select / Dropdown ── #}
|
||||||
{% elif field.type == 'select' %}
|
{% elif field.type == 'select' %}
|
||||||
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
|
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
|
||||||
|
|||||||
@@ -559,6 +559,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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' %}
|
{% elif ftype == 'checkbox' %}
|
||||||
<span class="field-lbl">{{ field.label }}</span>
|
<span class="field-lbl">{{ field.label }}</span>
|
||||||
<div class="field-val">
|
<div class="field-val">
|
||||||
|
|||||||
@@ -402,6 +402,7 @@
|
|||||||
('checkbox_group', 'bi-ui-checks', 'Checkbox Group'),
|
('checkbox_group', 'bi-ui-checks', 'Checkbox Group'),
|
||||||
('radio', 'bi-ui-radios', 'Radio Group'),
|
('radio', 'bi-ui-radios', 'Radio Group'),
|
||||||
('select', 'bi-menu-button-wide', 'Dropdown'),
|
('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 }}">
|
<div class="pal-item" draggable="true" data-ftype="{{ t }}" data-flabel="{{ lb }}">
|
||||||
<i class="bi {{ ic }}"></i> {{ lb }}
|
<i class="bi {{ ic }}"></i> {{ lb }}
|
||||||
@@ -480,7 +481,7 @@ const DEF_SIZE = {
|
|||||||
number: [3, 2], date: [4, 2],
|
number: [3, 2], date: [4, 2],
|
||||||
email: [6, 2], checkbox: [4, 2],
|
email: [6, 2], checkbox: [4, 2],
|
||||||
checkbox_group: [5, 4], radio: [5, 4],
|
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],
|
signature: [6, 3], rating: [5, 2],
|
||||||
section: [12, 1], table: [12, 5],
|
section: [12, 1], table: [12, 5],
|
||||||
label: [6, 1],
|
label: [6, 1],
|
||||||
@@ -490,7 +491,7 @@ const DEF_SIZE = {
|
|||||||
const LABELS = {
|
const LABELS = {
|
||||||
text:'Text Input', textarea:'Text Area', number:'Number', date:'Date',
|
text:'Text Input', textarea:'Text Area', number:'Number', date:'Date',
|
||||||
email:'Email', checkbox:'Checkbox', checkbox_group:'Checkbox Group',
|
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',
|
signature:'Signature', rating:'Rating (1–5)', section:'Section Header',
|
||||||
table:'Table', label:'Label / Text',
|
table:'Table', label:'Label / Text',
|
||||||
button_submit:'Submit Button', button_print:'Print Button', button_email:'Email Button',
|
button_submit:'Submit Button', button_print:'Print Button', button_email:'Email Button',
|
||||||
@@ -586,7 +587,7 @@ function mkField(type, col, row) {
|
|||||||
placeholder: '',
|
placeholder: '',
|
||||||
required: false,
|
required: false,
|
||||||
help_text: '',
|
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_cols: isTable ? 3 : undefined,
|
||||||
table_rows: isTable ? 3 : undefined,
|
table_rows: isTable ? 3 : undefined,
|
||||||
col_headers: isTable ? ['Column 1','Column 2','Column 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>`;
|
return `${lbl}<div class="mock-sig">Sign here…</div>`;
|
||||||
case 'rating':
|
case 'rating':
|
||||||
return `${lbl}<div style="font-size:1.2rem;color:#f59e0b;letter-spacing:.15rem;">★★★★★</div>`;
|
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':
|
case 'section':
|
||||||
return `<div style="border-top:2px solid #e2e8f0;padding-top:.4rem;">
|
return `<div style="border-top:2px solid #e2e8f0;padding-top:.4rem;">
|
||||||
<strong style="font-size:.86rem;color:#374151;">${esc(f.label)}</strong>
|
<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);
|
const f = fields.find(f => f.id === selectedId);
|
||||||
if (!f) return;
|
if (!f) return;
|
||||||
empty.style.display = 'none'; content.style.display = '';
|
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 isSec = f.type === 'section';
|
||||||
const isLabel = f.type === 'label';
|
const isLabel = f.type === 'label';
|
||||||
const isBtn = f.type.startsWith('button_');
|
const isBtn = f.type.startsWith('button_');
|
||||||
|
|||||||
@@ -248,6 +248,19 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
|
{% 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' %}
|
{% elif field.type == 'select' %}
|
||||||
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
|
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
|
||||||
<select class="form-select">
|
<select class="form-select">
|
||||||
|
|||||||
@@ -386,6 +386,8 @@ def _form_fields_section(form_fields, form_data, static_folder):
|
|||||||
if ftype == 'checkbox':
|
if ftype == 'checkbox':
|
||||||
# stored as 'true'/'false' by _collect_form_responses
|
# stored as 'true'/'false' by _collect_form_responses
|
||||||
return val not in ('yes', 'true')
|
return val not in ('yes', 'true')
|
||||||
|
if ftype == 'pass_fail':
|
||||||
|
return not val
|
||||||
if ftype == 'checkbox_group':
|
if ftype == 'checkbox_group':
|
||||||
return not val or not isinstance(val, list) or len(val) == 0
|
return not val or not isinstance(val, list) or len(val) == 0
|
||||||
if ftype == 'table':
|
if ftype == 'table':
|
||||||
@@ -429,6 +431,14 @@ def _form_fields_section(form_fields, form_data, static_folder):
|
|||||||
elif ftype == 'checkbox':
|
elif ftype == 'checkbox':
|
||||||
val_p = Paragraph('Yes', STYLES['FieldValue'])
|
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':
|
elif ftype == 'checkbox_group':
|
||||||
val_p = Paragraph(', '.join(val), STYLES['FieldValue'])
|
val_p = Paragraph(', '.join(val), STYLES['FieldValue'])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user