Files
LT_Janitorial_Quality_Control/app/templates/inspections/view.html
T

435 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Inspection #{{ inspection.id }} — Results{% endblock %}
{% block extra_css %}
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body { font-family:'DM Sans', sans-serif; background:#eef0f4; }
.insp-wrap { max-width:1000px; margin:0 auto; padding:0 1rem 4rem; }
.insp-header {
background:#1a1d23; color:#fff; padding:1.1rem 1.75rem;
border-radius:12px 12px 0 0;
display:flex; align-items:center; justify-content:space-between; gap:1rem;
}
.insp-header h4 { margin:0; font-weight:600; font-size:1.05rem; }
.insp-header .sub { font-size:.78rem; color:#94a3b8; margin-top:.2rem; }
.score-badge {
font-size:1.5rem; font-weight:700; padding:.4rem 1rem;
border-radius:8px; min-width:80px; text-align:center;
}
.insp-body {
background:#fff; border:1px solid #e2e8f0; border-top:none;
border-radius:0 0 12px 12px; padding:1.75rem; overflow-x:auto;
}
.meta-row { display:flex; flex-wrap:wrap; gap:1.5rem; margin-bottom:1.5rem; padding-bottom:1rem; border-bottom:1px solid #e2e8f0; }
.meta-item { display:flex; flex-direction:column; }
.meta-item .lbl { font-size:.72rem; color:#94a3b8; font-weight:500; text-transform:uppercase; letter-spacing:.04em; }
.meta-item .val { font-size:.9rem; color:#0f172a; font-weight:500; margin-top:.1rem; }
/* ── Grid: 12 cols × 36px rows, tight 3px gap ── */
.form-grid {
display:grid;
grid-template-columns: repeat(12, 72px);
grid-auto-rows: 36px;
gap: 3px 8px;
width: max-content;
}
.fg-cell {
overflow:hidden; display:flex; flex-direction:column;
padding:.1rem .4rem;
}
.fg-cell .field-lbl {
font-size:.68rem; font-weight:600; color:#94a3b8;
display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
flex-shrink:0; line-height:1.3;
}
.fg-cell .field-val {
font-size:.78rem; color:#0f172a; background:#f8fafc;
border:1px solid #e2e8f0; border-radius:4px;
padding:.1rem .35rem; flex:1; min-height:0;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
display:flex; align-items:center;
}
.fg-cell .field-val.empty { color:#94a3b8; font-style:italic; }
/* Section heading — spans full width, single row */
.section-head {
display:flex; align-items:flex-end; padding-bottom:.15rem;
border-bottom:2px solid #e2e8f0; height:100%;
}
.section-head strong { font-weight:700; color:#374151; font-size:.88rem; }
/* Rating — inline stars */
.rating-line { display:flex; align-items:center; gap:.28rem; flex:1; min-height:0; }
.rating-stars-ro { color:#f59e0b; font-size:.9rem; letter-spacing:.02rem; line-height:1; white-space:nowrap; }
.rating-score { font-size:.7rem; color:#64748b; white-space:nowrap; }
/* View Photo / Signature button */
.btn-view-media {
display:inline-flex; align-items:center; gap:.2rem;
font-size:.68rem; padding:.1rem .42rem; border-radius:4px;
border:1px solid #cbd5e1; background:#f8fafc; color:#374151;
cursor:pointer; white-space:nowrap;
transition:border-color .12s, background .12s;
}
.btn-view-media:hover { border-color:#2563eb; background:#eff6ff; color:#2563eb; }
/* Media lightbox */
.media-backdrop {
display:none; position:fixed; inset:0; z-index:1055;
background:rgba(0,0,0,.65); align-items:center; justify-content:center;
}
.media-backdrop.open { display:flex; }
.media-modal {
background:#fff; border-radius:10px; overflow:hidden;
max-width:min(92vw,700px); max-height:90vh;
display:flex; flex-direction:column;
box-shadow:0 8px 40px rgba(0,0,0,.35);
}
.media-modal-head {
display:flex; align-items:center; justify-content:space-between;
padding:.5rem .9rem; border-bottom:1px solid #e2e8f0;
font-size:.8rem; font-weight:600; color:#374151;
}
.media-modal-close { border:none; background:none; font-size:1.1rem; color:#64748b; cursor:pointer; line-height:1; padding:0; }
.media-modal-close:hover { color:#dc2626; }
.media-modal img { display:block; max-width:100%; max-height:calc(90vh - 46px); object-fit:contain; }
/* Issues */
.tbl-view { width:100%; border-collapse:collapse; font-size:.76rem; }
.tbl-view th { background:#f1f5f9; font-weight:600; color:#374151; padding:.2rem .4rem; border:1px solid #e2e8f0; }
.tbl-view td { border:1px solid #e2e8f0; padding:.15rem .4rem; color:#0f172a; }
</style>
{% endblock %}
{% block content %}
<div class="insp-wrap mt-3">
{# Action bar #}
<div class="d-flex justify-content-between align-items-center mb-3">
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Inspections
</a>
<div class="d-flex gap-2">
<button onclick="window.print()" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-printer"></i> Print
</button>
{% if current_user.role in ['admin','supervisor'] %}
<form method="post" action="{{ url_for('inspections.delete', inspection_id=inspection.id) }}"
onsubmit="return confirm('Delete this inspection permanently?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-outline-danger"><i class="bi bi-trash3"></i> Delete</button>
</form>
{% endif %}
</div>
</div>
{# Header #}
<div class="insp-header">
<div>
<h4><i class="bi bi-clipboard-check"></i> {{ inspection.template.name }}</h4>
<div class="sub">
{{ inspection.facility.name }}{% if inspection.area %} · {{ inspection.area.name }}{% endif %}
&nbsp;·&nbsp; Inspector: <strong style="color:#e2e8f0;">{{ inspection.inspector.username }}</strong>
</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-{{ 'success' if inspection.status == 'completed' else 'danger' if inspection.status == 'flagged' else 'secondary' }} fs-6">
{{ inspection.status|replace('_',' ')|title }}
</span>
{% if inspection.overall_score is not none %}
<div class="score-badge bg-{{ 'success' if inspection.overall_score >= 90 else 'warning' if inspection.overall_score >= 70 else 'danger' }} text-white">
{{ inspection.overall_score }}%
</div>
{% endif %}
</div>
</div>
<div class="insp-body">
{# Meta row #}
<div class="meta-row">
<div class="meta-item">
<span class="lbl">Date</span>
<span class="val">{{ inspection.inspection_date.strftime('%B %d, %Y %H:%M') }}</span>
</div>
{% if inspection.completed_at %}
<div class="meta-item">
<span class="lbl">Completed</span>
<span class="val">{{ inspection.completed_at.strftime('%B %d, %Y %H:%M') }}</span>
</div>
{% endif %}
<div class="meta-item">
<span class="lbl">Template</span>
<span class="val">{{ inspection.template.name }}</span>
</div>
<div class="meta-item">
<span class="lbl">Frequency</span>
<span class="val">{{ inspection.template.frequency|title }}</span>
</div>
</div>
{# ── Build the filtered field set ──────────────────────────────────────
Strategy:
1. Find all grid rows that contain at least one rated rating field.
2. Collect every field whose grid row overlaps a rated row.
3. Also collect section fields that immediately precede a rated group.
4. Re-number rows sequentially (1-based) so there are no gaps.
#}
{% if form_fields %}
{# Step 1: collect grid row numbers that have a rated rating field #}
{% set ns = namespace(rated_rows=[], section_rows=[], pending_section=none) %}
{% for field in form_fields %}
{% if field.type == 'section' %}
{% set ns.pending_section = field %}
{% elif field.type == 'rating' %}
{% set score = form_data.get(field.id | string, '0') | int %}
{% if score > 0 %}
{# Mark every grid row this rating cell spans #}
{% for r in range(field.row, field.row + field.rowSpan) %}
{% if r not in ns.rated_rows %}
{% set ns.rated_rows = ns.rated_rows + [r] %}
{% endif %}
{% endfor %}
{# Attach pending section to these rows #}
{% if ns.pending_section is not none %}
{% set ns.section_rows = ns.section_rows + [(ns.pending_section, field.row)] %}
{% set ns.pending_section = none %}
{% endif %}
{% else %}
{% set ns.pending_section = ns.pending_section %}
{% endif %}
{% else %}
{% set ns.pending_section = ns.pending_section %}
{% endif %}
{% endfor %}
{# Step 2: collect fields whose rows overlap rated_rows, plus section fields #}
{# Build list of (original_row, field) tuples to sort and re-number #}
{% set ns2 = namespace(visible=[], section_emitted=[]) %}
{# First pass: emit section fields just before the first rated row they precede #}
{% for sec_field, first_rated_row in ns.section_rows %}
{% if first_rated_row not in ns2.section_emitted %}
{% set ns2.visible = ns2.visible + [(first_rated_row - 0.5, sec_field)] %}
{% set ns2.section_emitted = ns2.section_emitted + [first_rated_row] %}
{% endif %}
{% endfor %}
{# Second pass: all fields whose row range overlaps rated_rows #}
{% for field in form_fields %}
{% if field.type not in ('section', 'label', 'button_submit', 'button_print', 'button_email') %}
{% set overlaps = namespace(found=false) %}
{% for r in range(field.row, field.row + field.rowSpan) %}
{% if r in ns.rated_rows %}
{% set overlaps.found = true %}
{% endif %}
{% endfor %}
{% if overlaps.found %}
{% set ns2.visible = ns2.visible + [(field.row, field)] %}
{% endif %}
{% endif %}
{% endfor %}
{# Step 3: sort visible list by sort_key (first element of tuple) #}
{# Jinja2 can't sort tuples directly, so use dictsort workaround via a temp dict #}
{# Instead, render in original form_fields order (already sorted by row,col) #}
{# Simpler approach: single pass in original order, re-number rows on the fly #}
{# Map original_row -> new_row using the sorted rated_rows list #}
{% set sorted_rated = ns.rated_rows | sort %}
{# Build row mapping: original row number -> compact row number #}
{# Sections get row = (first rated row they precede) - 1 in new numbering #}
{# We assign new row numbers: section=1, then its data rows=2,3,... etc. #}
{# Final render: walk form_fields in order, emit only visible ones with remapped rows #}
{% set remap = namespace(out_row=1, last_orig_row=-1, pending_sec=none, pending_sec_orig_row=-1) %}
<div class="form-grid">
{% for field in form_fields %}
{% set ftype = field.type %}
{# Track pending section #}
{% if ftype == 'section' %}
{% set remap.pending_sec = field %}
{% set remap.pending_sec_orig_row = field.row %}
{% elif ftype in ('label', 'button_submit', 'button_print', 'button_email') %}
{# skip #}
{% else %}
{# Check if this field overlaps any rated row #}
{% set vis = namespace(show=false) %}
{% for r in range(field.row, field.row + field.rowSpan) %}
{% if r in ns.rated_rows %}{% set vis.show = true %}{% endif %}
{% endfor %}
{% if vis.show %}
{# Flush pending section first #}
{% if remap.pending_sec is not none %}
{# Section: 1 row tall, spanning all 12 cols #}
<div class="fg-cell" style="grid-column:1 / span 12; grid-row:{{ remap.out_row }} / span 1;">
<div class="section-head"><strong>{{ remap.pending_sec.label }}</strong></div>
</div>
{% set remap.out_row = remap.out_row + 1 %}
{% set remap.pending_sec = none %}
{% set remap.last_orig_row = -1 %}
{% endif %}
{# Advance out_row when the original row changes #}
{% if field.row != remap.last_orig_row and remap.last_orig_row != -1 %}
{% set remap.out_row = remap.out_row + 1 %}
{% endif %}
{% set remap.last_orig_row = field.row %}
{# Render the field at its original col/colSpan but remapped row #}
<div class="fg-cell"
style="grid-column: {{ field.col }} / span {{ field.colSpan }};
grid-row: {{ remap.out_row }} / span 1;">
{% set fid = field.id | string %}
{% set val = form_data.get(fid, '') %}
{% if ftype == 'rating' %}
<span class="field-lbl">{{ field.label }}</span>
<div class="rating-line">
{% set score = val | int %}
{% if score > 0 %}
<span class="rating-stars-ro">{% for i in range(1,6) %}{{ '★' if i <= score else '☆' }}{% endfor %}</span>
<span class="rating-score">{{ score }}/5</span>
{% else %}
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">Not rated</span>
{% endif %}
</div>
{% elif ftype == 'image' %}
<span class="field-lbl">{{ field.label }}</span>
<div style="display:flex;align-items:center;flex:1;min-height:0;">
{% if val %}
<button type="button" class="btn-view-media"
onclick="openMedia('{{ url_for('static', filename=val) }}','{{ field.label | e }}')">
<i class="bi bi-image"></i> View Photo
</button>
{% else %}
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">No photo</span>
{% endif %}
</div>
{% elif ftype == 'signature' %}
<span class="field-lbl">{{ field.label }}</span>
<div style="display:flex;align-items:center;flex:1;min-height:0;">
{% if val and val.startswith('data:') %}
<button type="button" class="btn-view-media"
onclick="openMedia('{{ val }}','{{ field.label | e }}')">
<i class="bi bi-pen"></i> View Signature
</button>
{% else %}
<span style="font-size:.72rem;color:#94a3b8;font-style:italic;">No signature</span>
{% endif %}
</div>
{% elif ftype == 'checkbox' %}
<span class="field-lbl">{{ field.label }}</span>
<div class="field-val">
{% if val == 'yes' %}<span class="text-success"><i class="bi bi-check-circle-fill"></i> Yes</span>
{% else %}<span class="text-muted"><i class="bi bi-circle"></i> No</span>{% endif %}
</div>
{% elif ftype == 'checkbox_group' %}
<span class="field-lbl">{{ field.label }}</span>
<div class="field-val" style="white-space:normal;overflow:auto;">
{% if val and val is iterable and val is not string %}
{% for item in val %}<span class="badge bg-primary me-1" style="font-size:.62rem;">{{ item }}</span>{% endfor %}
{% else %}<span class="empty">None</span>{% endif %}
</div>
{% elif ftype == 'table' %}
<span class="field-lbl">{{ field.label }}</span>
<div style="overflow:auto;flex:1;min-height:0;">
{% if val and val is iterable and val is not string %}
<table class="tbl-view">
<thead><tr>{% for hdr in (field.col_headers or ['Col']) %}<th>{{ hdr }}</th>{% endfor %}</tr></thead>
<tbody>{% for row in val %}<tr>{% for hdr in (field.col_headers or ['Col']) %}<td>{{ row.get(hdr,'') }}</td>{% endfor %}</tr>{% endfor %}</tbody>
</table>
{% else %}<span style="font-size:.72rem;color:#94a3b8;">No data</span>{% endif %}
</div>
{% else %}
{# text, number, date, email, radio, select, textarea #}
<span class="field-lbl">{{ field.label }}</span>
<div class="field-val {{ 'empty' if not val }}">{{ val or '—' }}</div>
{% endif %}
</div>{# /fg-cell #}
{% endif %}
{% endif %}
{% endfor %}
</div>{# /form-grid #}
{% if not ns.rated_rows %}
<p class="text-muted">No rated fields in this inspection.</p>
{% endif %}
{% else %}
<p class="text-muted">No form fields found for this template.</p>
{% endif %}
{# Issues #}
{% if issues %}
<hr class="mt-4">
<h6 class="text-danger"><i class="bi bi-exclamation-triangle"></i> Issues Logged ({{ issues|length }})</h6>
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead class="table-light">
<tr><th>Severity</th><th>Area</th><th>Description</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{% for issue in issues %}
<tr>
<td><span class="badge bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning text-dark' if issue.severity == 'medium' else 'secondary' }}">{{ issue.severity|title }}</span></td>
<td>{{ issue.area.name }}</td>
<td>{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}</td>
<td><span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'warning text-dark' if issue.status == 'in_progress' else 'secondary' }}">{{ issue.status|replace('_',' ')|title }}</span></td>
<td><a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="btn btn-sm btn-outline-secondary">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
{# Media lightbox #}
<div class="media-backdrop" id="mediaModal" onclick="if(event.target.id==='mediaModal')closeMedia()">
<div class="media-modal">
<div class="media-modal-head">
<span id="mediaLabel">Photo</span>
<button class="media-modal-close" onclick="closeMedia()">&times;</button>
</div>
<img id="mediaImg" src="" alt="">
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function openMedia(src, label) {
document.getElementById('mediaImg').src = src;
document.getElementById('mediaLabel').textContent = label;
document.getElementById('mediaModal').classList.add('open');
}
function closeMedia() {
document.getElementById('mediaModal').classList.remove('open');
document.getElementById('mediaImg').src = '';
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeMedia(); });
</script>
{% endblock %}