diff --git a/app/__init__.py b/app/__init__.py index ee8222e..c7aa0e5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -29,6 +29,7 @@ def create_app(config_name='default'): # render manual forms (no WTForms object) can still inject the CSRF token. from flask_wtf.csrf import generate_csrf app.jinja_env.globals['csrf_token'] = generate_csrf + app.jinja_env.globals['enumerate'] = enumerate os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) diff --git a/app/models/inspection.py b/app/models/inspection.py index a8c9bb9..cb53a44 100644 --- a/app/models/inspection.py +++ b/app/models/inspection.py @@ -6,22 +6,18 @@ import json class InspectionTemplate(db.Model): __tablename__ = 'inspection_templates' - id = db.Column(db.Integer, primary_key=True) - name = db.Column(db.String(255), nullable=False) + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), nullable=False) description = db.Column(db.Text) - frequency = db.Column(db.Enum('daily', 'weekly', 'monthly', 'quarterly')) - created_by = db.Column(db.Integer, db.ForeignKey('users.id')) - created_at = db.Column(db.DateTime, default=datetime.utcnow) - - # Form builder schema — stores the full dynamic field layout as JSON + frequency = db.Column(db.Enum('daily', 'weekly', 'monthly', 'quarterly')) + created_by = db.Column(db.Integer, db.ForeignKey('users.id')) + created_at = db.Column(db.DateTime, default=datetime.utcnow) form_schema = db.Column(db.JSON, nullable=True) - # Relationships checklist_items = db.relationship('ChecklistItem', backref='template', lazy='dynamic', cascade='all, delete-orphan') - inspections = db.relationship('Inspection', backref='template', lazy='dynamic') + inspections = db.relationship('Inspection', backref='template', lazy='dynamic') def get_form_schema(self): - """Return the form schema as a Python list, or empty list if not set.""" if self.form_schema is None: return [] if isinstance(self.form_schema, str): @@ -38,16 +34,15 @@ class InspectionTemplate(db.Model): class ChecklistItem(db.Model): __tablename__ = 'checklist_items' - id = db.Column(db.Integer, primary_key=True) - template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False) - category = db.Column(db.String(100)) + id = db.Column(db.Integer, primary_key=True) + template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False) + category = db.Column(db.String(100)) item_description = db.Column(db.Text, nullable=False) - scoring_type = db.Column(db.Enum('pass_fail', 'rating_5', 'rating_10')) - weight = db.Column(db.Numeric(3, 2), default=1.00) - requires_photo = db.Column(db.Boolean, default=False) - display_order = db.Column(db.Integer) + scoring_type = db.Column(db.Enum('pass_fail', 'rating_5', 'rating_10')) + weight = db.Column(db.Numeric(3, 2), default=1.00) + requires_photo = db.Column(db.Boolean, default=False) + display_order = db.Column(db.Integer) - # Relationships results = db.relationship('InspectionResult', backref='checklist_item', lazy='dynamic') def __repr__(self): @@ -57,20 +52,20 @@ class ChecklistItem(db.Model): class Inspection(db.Model): __tablename__ = 'inspections' - id = db.Column(db.Integer, primary_key=True) - template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False) - facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False) - area_id = db.Column(db.Integer, db.ForeignKey('areas.id')) - inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + id = db.Column(db.Integer, primary_key=True) + template_id = db.Column(db.Integer, db.ForeignKey('inspection_templates.id'), nullable=False) + facility_id = db.Column(db.Integer, db.ForeignKey('facilities.id'), nullable=False) + area_id = db.Column(db.Integer, db.ForeignKey('areas.id')) + inspector_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) inspection_date = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) - overall_score = db.Column(db.Numeric(5, 2)) - status = db.Column(db.Enum('in_progress', 'completed', 'flagged'), default='in_progress') - notes = db.Column(db.Text) - completed_at = db.Column(db.DateTime) + overall_score = db.Column(db.Numeric(5, 2)) + status = db.Column(db.Enum('in_progress', 'completed', 'flagged'), default='in_progress') + notes = db.Column(db.Text) # inspector free-text notes + form_data = db.Column(db.JSON) # filled form field responses {field_id: value} + completed_at = db.Column(db.DateTime) - # Relationships results = db.relationship('InspectionResult', backref='inspection', lazy='dynamic', cascade='all, delete-orphan') - issues = db.relationship('Issue', backref='inspection', lazy='dynamic') + issues = db.relationship('Issue', backref='inspection', lazy='dynamic') def __repr__(self): return f'' @@ -79,13 +74,13 @@ class Inspection(db.Model): class InspectionResult(db.Model): __tablename__ = 'inspection_results' - id = db.Column(db.Integer, primary_key=True) - inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id'), nullable=False) + id = db.Column(db.Integer, primary_key=True) + inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id'), nullable=False) checklist_item_id = db.Column(db.Integer, db.ForeignKey('checklist_items.id'), nullable=False) - score = db.Column(db.Numeric(5, 2)) - passed = db.Column(db.Boolean) - comments = db.Column(db.Text) - photo_path = db.Column(db.String(255)) + score = db.Column(db.Numeric(5, 2)) + passed = db.Column(db.Boolean) + comments = db.Column(db.Text) + photo_path = db.Column(db.String(255)) def __repr__(self): - return f'' \ No newline at end of file + return f'' diff --git a/app/routes/inspections.py b/app/routes/inspections.py index aebfd19..09d5c51 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -1,4 +1,5 @@ import os +import json import uuid from datetime import datetime from flask import (Blueprint, render_template, redirect, url_for, @@ -17,6 +18,12 @@ bp = Blueprint('inspections', __name__, url_prefix='/inspections') ALLOWED_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif'} +INPUT_FIELD_TYPES = { + 'text', 'textarea', 'number', 'date', 'email', + 'checkbox', 'checkbox_group', 'radio', 'select', + 'rating', 'signature', 'image', 'table' +} + def _save_photo(file_obj, subfolder='inspection_photos'): """Save an uploaded photo; return the relative path or None.""" @@ -25,63 +32,133 @@ def _save_photo(file_obj, subfolder='inspection_photos'): ext = file_obj.filename.rsplit('.', 1)[-1].lower() if ext not in ALLOWED_EXTENSIONS: return None - filename = f"{uuid.uuid4().hex}.{ext}" - dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder) + filename = f"{uuid.uuid4().hex}.{ext}" + dest_dir = os.path.join(current_app.config['UPLOAD_FOLDER'], subfolder) os.makedirs(dest_dir, exist_ok=True) file_obj.save(os.path.join(dest_dir, filename)) return f"uploads/{subfolder}/{filename}" -def _compute_score(inspection): +def _collect_form_responses(form_fields): """ - Weighted average of all scored checklist results. - pass_fail → 100 if passed else 0 - rating_5 → (score / 5) * 100 - rating_10 → (score / 10) * 100 - Returns a Decimal-compatible float or None if no results. + Walk the submitted form data and collect responses keyed by field ID. + Returns a dict: { field_id: value_or_list_or_path } + Photo uploads are saved to disk; their path is stored as the value. """ - results = inspection.results.join(ChecklistItem).all() - if not results: + responses = {} + for field in form_fields: + fid = field['id'] + ftype = field['type'] + + if ftype in ('label', 'section', 'button_submit', 'button_print', 'button_email'): + continue # display-only, nothing to capture + + key = f"field_{fid}" + + if ftype == 'checkbox': + responses[fid] = 'true' if request.form.get(key) else 'false' + + elif ftype == 'checkbox_group': + responses[fid] = request.form.getlist(key) + + elif ftype == 'image': + photo_file = request.files.get(key) + path = _save_photo(photo_file, subfolder='inspection_photos') + responses[fid] = path or '' + + elif ftype == 'table': + cols = field.get('col_headers') or ['Column 1'] + rows = int(field.get('table_rows') or 3) + table_data = [] + for r in range(rows): + row_data = {} + for c_idx, col in enumerate(cols): + cell_key = f"{key}_r{r}_c{c_idx}" + row_data[col] = request.form.get(cell_key, '') + table_data.append(row_data) + responses[fid] = table_data + + elif ftype == 'rating': + responses[fid] = request.form.get(key, '0') + + else: + # text, textarea, number, date, email, radio, select, signature + responses[fid] = request.form.get(key, '') + + return responses + + +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')] + if not scoreable: return None - total_weight = 0.0 - weighted_sum = 0.0 + total, earned = 0, 0 + for field in scoreable: + fid = field['id'] + val = responses.get(fid, '') - for r in results: - item = r.checklist_item - weight = float(item.weight or 1.0) + if field['type'] == 'rating': + try: + v = int(val) + earned += v + total += 5 # max rating is 5 stars + except (ValueError, TypeError): + total += 5 - if item.scoring_type == 'pass_fail': - pts = 100.0 if r.passed else 0.0 - elif item.scoring_type == 'rating_5': - pts = (float(r.score) / 5.0 * 100.0) if r.score is not None else 0.0 - elif item.scoring_type == 'rating_10': - pts = (float(r.score) / 10.0 * 100.0) if r.score is not None else 0.0 - else: - pts = 100.0 if r.passed else 0.0 + elif field['type'] == 'checkbox': + total += 1 + if val == 'true': + earned += 1 - weighted_sum += pts * weight - total_weight += weight + elif field['type'] == 'radio': + # Options that look like pass/yes/ok score 1; fail/no/na score 0 + total += 1 + if val.lower() in ('pass', 'yes', 'ok', 'good', 'acceptable', 'compliant'): + earned += 1 - return round(weighted_sum / total_weight, 2) if total_weight else None + return round((earned / total) * 100, 2) if total else None -# ── List ───────────────────────────────────────────────────────────────────── +def _validate_required(form_fields, responses): + """Return a list of labels for required fields that have empty responses.""" + missing = [] + for field in form_fields: + if not field.get('required'): + continue + ftype = field['type'] + if ftype in ('label', 'section', 'button_submit', 'button_print', 'button_email'): + continue + val = responses.get(field['id']) + empty = ( + val is None + or val == '' + or val == 'false' + or val == '0' + or val == [] + ) + if empty: + missing.append(field.get('label', 'Untitled field')) + return missing + + +# ── List ────────────────────────────────────────────────────────────────────── @bp.route('/') @login_required def index(): page = request.args.get('page', 1, type=int) - q = Inspection.query.order_by(Inspection.inspection_date.desc()) - # Inspectors only see their own if current_user.role == 'inspector': q = q.filter(Inspection.inspector_id == current_user.id) - # Optional filters status_filter = request.args.get('status', '') - facility_filter = request.args.get('facility_id', '', type=str) + facility_filter = request.args.get('facility_id', '') if status_filter: q = q.filter(Inspection.status == status_filter) if facility_filter.isdigit(): @@ -110,34 +187,31 @@ def start(): form.template_id.choices = [(t.id, t.name) for t in templates] form.facility_id.choices = [(f.id, f.name) for f in facilities] - # Area choices populated via AJAX based on selected facility selected_fid = form.facility_id.data or (facilities[0].id if facilities else None) areas = Area.query.filter_by(facility_id=selected_fid).order_by(Area.name).all() if selected_fid else [] form.area_id.choices = [(0, '— No specific area —')] + [(a.id, a.name) for a in areas] if form.validate_on_submit(): + template = InspectionTemplate.query.get_or_404(form.template_id.data) + + # Guard: template must have a form built in the form editor + if not template.get_form_schema(): + flash('This template has no form fields yet. Please build the form in the template editor first.', 'warning') + return redirect(url_for('inspections.start')) + inspection = Inspection( - template_id = form.template_id.data, - facility_id = form.facility_id.data, - area_id = form.area_id.data or None, - inspector_id = current_user.id, + template_id = template.id, + facility_id = form.facility_id.data, + area_id = form.area_id.data or None, + inspector_id = current_user.id, inspection_date = datetime.utcnow(), - status = 'in_progress', - notes = form.notes.data or None, + status = 'in_progress', + notes = form.notes.data or None, ) db.session.add(inspection) - db.session.flush() # get inspection.id - - # Pre-create blank InspectionResult rows for every checklist item - template = InspectionTemplate.query.get(form.template_id.data) - for item in template.checklist_items.order_by(ChecklistItem.display_order).all(): - db.session.add(InspectionResult( - inspection_id = inspection.id, - checklist_item_id = item.id, - )) - db.session.commit() - flash(f'Inspection started. Complete each item below.', 'success') + + flash('Inspection started. Fill in the form below and submit when complete.', 'info') return redirect(url_for('inspections.execute', inspection_id=inspection.id)) return render_template('inspections/start.html', form=form, facilities=facilities) @@ -152,14 +226,13 @@ def areas_for_facility(facility_id): return jsonify([{'id': a.id, 'name': a.name} for a in areas]) -# ── Execute ─────────────────────────────────────────────────────────────────── +# ── Execute — render and submit the template form ───────────────────────────── @bp.route('//execute', methods=['GET', 'POST']) @login_required def execute(inspection_id): inspection = Inspection.query.get_or_404(inspection_id) - # Inspectors can only work on their own inspections if current_user.role == 'inspector' and inspection.inspector_id != current_user.id: flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) @@ -167,82 +240,85 @@ def execute(inspection_id): if inspection.status == 'completed': return redirect(url_for('inspections.view', inspection_id=inspection_id)) - # Ordered checklist items with their result rows - results = ( - InspectionResult.query - .join(ChecklistItem) - .filter(InspectionResult.inspection_id == inspection_id) - .order_by(ChecklistItem.display_order) - .all() - ) + template = inspection.template + form_fields = template.get_form_schema() + + # Sort fields by grid position (row then col) for logical reading order + form_fields = sorted(form_fields, key=lambda f: (f.get('row', 0), f.get('col', 0))) + + # Load any previously saved draft responses + saved_responses = {} + if inspection.notes: + try: + parsed = json.loads(inspection.notes) + if isinstance(parsed, dict) and '_form_data' in parsed: + saved_responses = parsed['_form_data'] + except (json.JSONDecodeError, TypeError): + pass if request.method == 'POST': - action = request.form.get('action', 'save') + action = request.form.get('action', 'submit') - for result in results: - item = result.checklist_item - prefix = f"item_{result.id}_" + # Collect all field responses from the submitted form + responses = _collect_form_responses(form_fields) - if item.scoring_type == 'pass_fail': - passed_val = request.form.get(f"{prefix}passed", '') - result.passed = True if passed_val == 'pass' else \ - False if passed_val == 'fail' else None - result.score = None - elif item.scoring_type in ('rating_5', 'rating_10'): - raw = request.form.get(f"{prefix}score", '') - try: - result.score = float(raw) - result.passed = result.score > 0 - except (ValueError, TypeError): - result.score = None - result.passed = None - else: - result.passed = None - result.score = None - - result.comments = request.form.get(f"{prefix}comments", '').strip() or None - - # Photo upload - photo_file = request.files.get(f"{prefix}photo") - if photo_file and photo_file.filename: - path = _save_photo(photo_file) - if path: - result.photo_path = path - - if action == 'complete': - # Validate all required-photo items have a photo - missing_photos = [ - r for r in results - if r.checklist_item.requires_photo and not r.photo_path - ] - if missing_photos: - db.session.commit() - flash(f'{len(missing_photos)} item(s) require a photo before completing.', 'warning') + if action == 'submit': + # Validate required fields + missing = _validate_required(form_fields, responses) + if missing: + # Save draft so the inspector doesn't lose their work + _save_draft(inspection, responses) + flash( + f'Please complete all required fields before submitting: ' + f'{", ".join(missing[:5])}{"…" if len(missing) > 5 else ""}', + 'warning' + ) return redirect(url_for('inspections.execute', inspection_id=inspection_id)) - inspection.overall_score = _compute_score(inspection) + # Compute score and mark complete + score = _compute_score_from_form(form_fields, responses) + inspection.overall_score = score inspection.status = 'completed' inspection.completed_at = datetime.utcnow() + + # Persist the final form data alongside any inspector notes + _save_responses(inspection, responses) db.session.commit() - flash('Inspection completed successfully!', 'success') + + flash('Inspection submitted successfully!', 'success') return redirect(url_for('inspections.view', inspection_id=inspection_id)) - db.session.commit() - flash('Progress saved.', 'success') - return redirect(url_for('inspections.execute', inspection_id=inspection_id)) - - # Count answered vs total - answered = sum(1 for r in results if r.passed is not None or r.score is not None) - staff = User.query.filter(User.role.in_(['supervisor', 'inspector'])).order_by(User.username).all() + else: # save draft + _save_draft(inspection, responses) + db.session.commit() + flash('Draft saved. You can continue filling in the form later.', 'success') + return redirect(url_for('inspections.execute', inspection_id=inspection_id)) return render_template('inspections/execute.html', inspection=inspection, - results=results, - answered=answered, - staff=staff) + form_fields=form_fields, + saved_responses=saved_responses) -# ── View (completed) ────────────────────────────────────────────────────────── +def _save_responses(inspection, responses): + """Persist final form responses into inspection.notes as JSON.""" + existing = {} + if inspection.notes: + try: + existing = json.loads(inspection.notes) + except (json.JSONDecodeError, TypeError): + existing = {'_inspector_notes': inspection.notes} + existing['_form_data'] = responses + inspection.notes = json.dumps(existing) + + +def _save_draft(inspection, responses): + """Save a draft of form responses — same storage as final, just status stays in_progress.""" + _save_responses(inspection, responses) + db.session.commit() + + +# ── View ────────────────────────────────────────────────────────────────────── @bp.route('/') @login_required @@ -253,25 +329,26 @@ def view(inspection_id): flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) - results = ( - InspectionResult.query - .join(ChecklistItem) - .filter(InspectionResult.inspection_id == inspection_id) - .order_by(ChecklistItem.display_order) - .all() - ) + template = inspection.template + form_fields = sorted(template.get_form_schema(), + key=lambda f: (f.get('row', 0), f.get('col', 0))) - # Group by category - categories = {} - for r in results: - cat = r.checklist_item.category or 'General' - categories.setdefault(cat, []).append(r) + # Decode saved responses + form_data = {} + if inspection.notes: + try: + parsed = json.loads(inspection.notes) + if isinstance(parsed, dict): + form_data = parsed.get('_form_data', {}) + except (json.JSONDecodeError, TypeError): + pass issues = inspection.issues.order_by(Issue.reported_at.desc()).all() return render_template('inspections/view.html', inspection=inspection, - categories=categories, + form_fields=form_fields, + form_data=form_data, issues=issues) @@ -286,9 +363,9 @@ def flag_issue(inspection_id): flash('Access denied.', 'danger') return redirect(url_for('inspections.index')) - form = IssueForm() + form = IssueForm() areas = Area.query.filter_by(facility_id=inspection.facility_id).order_by(Area.name).all() - staff = User.query.filter(User.role.in_(['supervisor','inspector'])).order_by(User.username).all() + staff = User.query.filter(User.role.in_(['supervisor', 'inspector'])).order_by(User.username).all() form.area_id.choices = [(a.id, a.name) for a in areas] form.assigned_to.choices = [(0, '— Unassigned —')] + [(u.id, u.username) for u in staff] @@ -307,7 +384,6 @@ def flag_issue(inspection_id): ) db.session.add(issue) - # Auto-flag the inspection if a high/critical issue is logged if form.severity.data in ('high', 'critical') and inspection.status != 'completed': inspection.status = 'flagged' diff --git a/app/templates/inspections/execute.html b/app/templates/inspections/execute.html index 5625d39..8bf7e19 100644 --- a/app/templates/inspections/execute.html +++ b/app/templates/inspections/execute.html @@ -1,161 +1,458 @@ {% extends "base.html" %} -{% block title %}Execute Inspection{% endblock %} +{% block title %}{{ inspection.template.name }} — Inspection{% endblock %} + {% block extra_css %} + {% endblock %} + {% block content %} -
+
+ - {# Sticky toolbar #} -
-
-
{{ inspection.template.name }}
- {{ inspection.facility.name }}{% if inspection.area %} · {{ inspection.area.name }}{% endif %} -
-
-
-
+ {# ── Header ── #} +
+
+

{{ inspection.template.name }}

+
+ {{ inspection.facility.name }} + {% if inspection.area %} · {{ inspection.area.name }}{% endif %} +  ·  Inspector: {{ inspection.inspector.username }}
- {{ answered }}/{{ results|length }} answered
- - Flag Issue - - - +
+ {{ inspection.template.frequency|title }} + + Flag Issue + +
- {% if inspection.notes %} -
Notes: {{ inspection.notes }}
- {% endif %} + {# ── Form body ── #} +
+ {% if form_fields %} - {% set ns = namespace(current_cat='') %} - {% for result in results %} - {% set item = result.checklist_item %} - {% if item.category != ns.current_cat %} - {% set ns.current_cat = item.category %} -
- {{ item.category or 'General' }} -
- {% endif %} +
+ {% for field in form_fields %} + {% set fid = field.id %} + {% set saved = saved_responses.get(fid, '') %} - {% set is_answered = result.passed is not none or result.score is not none %} -
-
-
-
- {{ item.item_description }} - {% if item.requires_photo %} - Photo required - {% endif %} +
+ + {# ── Section label (display only) ── #} + {% if field.type == 'section' %} +
{{ field.label }}
+ + {# ── Static label ── #} + {% elif field.type == 'label' %} + {% set fs_map = {'small':'0.78rem','normal':'0.9rem','large':'1.05rem','x-large':'1.25rem'} %} +
+ {{ field.text_content or '' }}
- {{ item.scoring_type|replace('_',' ') }} -
- {# ── Pass/Fail ── #} - {% if item.scoring_type == 'pass_fail' %} -
- - - - -
+ {# ── Submit / Print / Email buttons (display in preview; no action needed here) ── #} + {% elif field.type in ('button_submit','button_print','button_email') %} + {# rendered by the sticky footer instead #} - {# ── Rating 5 ── #} - {% elif item.scoring_type == 'rating_5' %} -
- -
- {% for v in [1,2,3,4,5] %} -
- - -
+ {# ── Text ── #} + {% elif field.type == 'text' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Textarea ── #} + {% elif field.type == 'textarea' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Number ── #} + {% elif field.type == 'number' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Date ── #} + {% elif field.type == 'date' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Email ── #} + {% elif field.type == 'email' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Checkbox ── #} + {% elif field.type == 'checkbox' %} +
+ + +
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Checkbox group ── #} + {% elif field.type == 'checkbox_group' %} + + {% for opt in field.options %} +
+ + +
+ {% endfor %} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Radio ── #} + {% elif field.type == 'radio' %} + + {% for opt in field.options %} +
+ + +
+ {% endfor %} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Select / Dropdown ── #} + {% elif field.type == 'select' %} + + + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Rating (stars) ── #} + {% elif field.type == 'rating' %} + +
+ + {% for i in range(1, 6) %} + {% endfor %}
-
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} - {# ── Rating 10 ── #} - {% elif item.scoring_type == 'rating_10' %} -
- -
- {% for v in range(1,11) %} -
- - -
- {% endfor %} + {# ── Image / Photo upload ── #} + {% elif field.type == 'image' %} + + {% if saved %} + Uploaded photo + {% endif %} +
+ + + Click or drag to upload + + {% if saved %}{{ saved.split('/')[-1] }}{% endif %} +
-
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Signature ── #} + {% elif field.type == 'signature' %} + +
+ + + +
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + + {# ── Table ── #} + {% elif field.type == 'table' %} + +
+ + + {% for hdr in (field.col_headers or ['Column 1','Column 2','Column 3']) %} + + {% endfor %} + + + {% set tbl_data = saved if saved is iterable and saved is not string else [] %} + {% for r in range(field.table_rows or 3) %} + + {% set row_data = tbl_data[r] if r < tbl_data|length else {} %} + {% for hdr in (field.col_headers or ['Column 1','Column 2','Column 3']) %}{% set c_idx = loop.index0 %} + + {% endfor %} + + {% endfor %} + +
{{ hdr }}
+ +
+
+ {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% endif %} - -
-
- -
-
- {% if result.photo_path %} -
- Photo -
- {% endif %} - -
-
+ {% endfor %}
- {% endfor %} - {# Bottom submit bar #} -
- - + {% else %} +
+ +

This template has no form fields. Please add fields in the template editor.

+
+ {% endif %}
+ + {# ── Sticky footer ── #} + + +
{% endblock %} {% block extra_js %} {% endblock %} diff --git a/app/templates/inspections/view.html b/app/templates/inspections/view.html index ae09a3c..d9118d5 100644 --- a/app/templates/inspections/view.html +++ b/app/templates/inspections/view.html @@ -1,141 +1,264 @@ {% extends "base.html" %} -{% block title %}Inspection #{{ inspection.id }}{% endblock %} -{% block content %} -
-
-

Inspection Report

-

{{ inspection.template.name }} · {{ inspection.inspection_date.strftime('%B %d, %Y %H:%M') }}

-
-
- {% if current_user.role in ['admin','supervisor'] %} -
- - -
- {% endif %} - - Back - -
-
+{% block title %}Inspection #{{ inspection.id }} — Results{% endblock %} -{# Summary cards #} -
-
-
-
-

Overall Score

- {% if inspection.overall_score %} -

- {{ inspection.overall_score }}% -

- {% else %}

{% endif %} -
-
-
-
-
-
-

Status

- - {{ inspection.status|replace('_',' ')|title }} - -
-
-
-
-
-
-

Facility / Area

-

{{ inspection.facility.name }}

- {{ inspection.area.name if inspection.area else '—' }} -
-
-
-
-
-
-

Inspector

-

{{ inspection.inspector.username }}

- {% if inspection.completed_at %} - Completed {{ inspection.completed_at.strftime('%Y-%m-%d %H:%M') }} - {% endif %} -
-
-
-
+{% block extra_css %} + + +{% endblock %} + +{% block content %} +
+ + {# ── Action bar ── #} +
+ + Back to Inspections + +
+ + {% if current_user.role in ['admin','supervisor'] %} +
+ + +
+ {% endif %} +
+
+ + {# ── Header ── #} +
+
+

{{ inspection.template.name }}

+
+ {{ inspection.facility.name }}{% if inspection.area %} · {{ inspection.area.name }}{% endif %} +  ·  Inspector: {{ inspection.inspector.username }} +
+
+
+ + {{ inspection.status|replace('_',' ')|title }} + + {% if inspection.overall_score is not none %} +
+ {{ inspection.overall_score }}% +
+ {% endif %} +
+
+ +
+ + {# ── Meta row ── #} +
+
+ Date + {{ inspection.inspection_date.strftime('%B %d, %Y %H:%M') }} +
+ {% if inspection.completed_at %} +
+ Completed + {{ inspection.completed_at.strftime('%B %d, %Y %H:%M') }} +
+ {% endif %} +
+ Template + {{ inspection.template.name }} +
+
+ Frequency + {{ inspection.template.frequency|title }} +
+
+ + {# ── Form field responses ── #} + {% if form_fields %} +
+ {% for field in form_fields %} + {% set fid = field.id %} + {% set val = form_data.get(fid) %} + +
+ + {% if field.type == 'section' %} +
{{ field.label }}
+ + {% elif field.type == 'label' %} + {% set fs_map = {'small':'0.78rem','normal':'0.9rem','large':'1.05rem','x-large':'1.25rem'} %} +
+ {{ field.text_content or '' }} +
+ + {% elif field.type in ('button_submit','button_print','button_email') %} + {# not shown in read-only view #} + + {% elif field.type == 'image' %} + {{ field.label }} + {% if val %} + + Photo + + {% else %} +
No photo uploaded
+ {% endif %} + + {% elif field.type == 'signature' %} + {{ field.label }} + {% if val and val.startswith('data:') %} + Signature + {% else %} +
No signature
+ {% endif %} + + {% elif field.type == 'rating' %} + {{ field.label }} +
+ {% if val %} + + {% for i in range(1, 6) %}{{ '★' if i <= (val|int) else '☆' }}{% endfor %} + + ({{ val }}/5) + {% else %} + Not rated + {% endif %} +
+ + {% elif field.type == 'checkbox' %} + {{ field.label }} +
+ {% if val == 'true' %} + Checked + {% else %} + Unchecked + {% endif %} +
+ + {% elif field.type == 'checkbox_group' %} + {{ field.label }} +
+ {% if val %} + {% for item in (val if val is iterable and val is not string else []) %} + {{ item }} + {% else %} + None selected + {% endfor %} + {% else %} + None selected + {% endif %} +
+ + {% elif field.type == 'table' %} + {{ field.label }} + {% if val and val is iterable and val is not string %} +
+ + + {% for hdr in (field.col_headers or ['Column 1']) %}{% endfor %} + + + {% for row in val %} + + {% for hdr in (field.col_headers or ['Column 1']) %} + + {% endfor %} + + {% endfor %} + +
{{ hdr }}
{{ row.get(hdr, '') }}
+
+ {% else %} +
No data
+ {% endif %} + + {% else %} + {# text, textarea, number, date, email, radio, select ── #} + {{ field.label }} +
{{ val or 'Not answered' }}
+ {% endif %} + +
+ {% endfor %} +
+ + {% else %} +

No form fields found for this template.

+ {% endif %} + + {# ── Issues ── #} + {% if issues %} +
+
Issues Logged ({{ issues|length }})
+
+ + + + + + {% for issue in issues %} + + + + + + + + {% endfor %} + +
SeverityAreaDescriptionStatus
{{ issue.severity|title }}{{ issue.area.name }}{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}{{ issue.status|replace('_',' ')|title }}View
+
+ {% endif %} + +
+
{% endblock %}