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

454 lines
20 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; padding-bottom:3rem; overflow-x:auto;
-webkit-overflow-scrolling: touch;
}
.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: minmax(36px, auto);
gap: 3px 8px;
width: max-content;
}
/* iPad fluid grid override */
@media (max-width: 1194px) {
.form-grid {
--_cell: calc((min(calc(100vw - 2rem), 900px) - 11 * 8px) / 12);
grid-template-columns: repeat(12, var(--_cell));
grid-auto-rows: minmax(calc(var(--_cell) * 0.5), auto);
width: 100%;
}
.insp-body { padding: 1rem; }
}
.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; }
/* Textarea values: wrap text, scroll vertically, align to top */
.fg-cell .field-val.multiline {
white-space:pre-wrap; overflow-y:auto; overflow-x:hidden;
text-overflow:clip; align-items:flex-start; word-break:break-word;
min-height:calc(0.78rem * 1.5 * 4 + 0.2rem * 2); /* 4 lines × line-height + padding */
flex:none;
}
/* 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">
<a href="{{ url_for('inspections.export_pdf', inspection_id=inspection.id) }}"
class="btn btn-sm btn-danger">
<i class="bi bi-file-earmark-pdf"></i> Export PDF
</a>
<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 %}
{# Pass 1: find all rated rows AND which label IDs have a rated field after them #}
{% set ns = namespace(rated_rows=[], visible_label_ids=[]) %}
{# 1a: collect rows that have any answered/filled field (not just ratings) #}
{% set _skip_types = ['label', 'section', 'button_submit', 'button_print', 'button_email'] %}
{% for field in form_fields %}
{% if field.type not in _skip_types %}
{% set fval = form_data.get(field.id | string, '') %}
{# A row is "visible" if: rating has score>0, OR any other field has a non-empty value #}
{% if field.type == 'rating' %}
{% set score = fval | int %}
{% if score > 0 %}
{% 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 %}
{% endif %}
{% elif fval and fval != '' and fval != [] %}
{% 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 %}
{% endif %}
{% endif %}
{% endfor %}
{# 1b: walk fields in order; accumulate label IDs.
When we hit a rated field (score>0), mark ALL accumulated label IDs as visible
and clear the buffer. Never clear the buffer for non-rating fields — labels
can sit several rows above the ratings they introduce. #}
{% set lbuf = namespace(ids=[]) %}
{% for field in form_fields %}
{% if field.type == 'label' %}
{% set lbuf.ids = lbuf.ids + [field.id] %}
{% elif field.type == 'rating' %}
{% set score = form_data.get(field.id | string, '0') | int %}
{% if score > 0 %}
{% for lid in lbuf.ids %}
{% if lid not in ns.visible_label_ids %}
{% set ns.visible_label_ids = ns.visible_label_ids + [lid] %}
{% endif %}
{% endfor %}
{% set lbuf.ids = [] %}
{% endif %}
{% endif %}
{% endfor %}
{# Pass 2: render — labels only if in visible_label_ids,
data fields only if row overlaps rated_rows, sections always buffered. #}
{% set remap = namespace(out_row=1, last_orig_row=-1, pending_sec=none) %}
<div class="form-grid">
{% for field in form_fields %}
{% set ftype = field.type %}
{# Track pending section #}
{% if ftype == 'section' %}
{% set remap.pending_sec = field %}
{% elif ftype in ('button_submit', 'button_print', 'button_email') %}
{# skip — action buttons not shown in read-only view #}
{% elif ftype == 'label' %}
{# Only show labels that are ancestors of rated fields #}
{% if field.id in ns.visible_label_ids %}
{% if remap.last_orig_row != -1 %}
{% set remap.out_row = remap.out_row + 1 %}
{% endif %}
{% set remap.last_orig_row = field.row %}
{% set fs_map = {'small':'0.72rem','normal':'0.82rem','large':'0.96rem','x-large':'1.1rem'} %}
<div class="fg-cell"
style="grid-column: {{ field.col }} / span {{ field.colSpan }};
grid-row: {{ remap.out_row }} / span 1;">
<div style="font-size:{{ fs_map.get(field.font_size or 'normal','0.82rem') }};
font-weight:{{ field.font_weight or 'normal' }};
color:#374151; overflow:hidden; display:flex; align-items:center; height:100%;">
{{ field.text_content or '' }}
</div>
</div>
{% endif %}
{% 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 {{ 'multiline' if ftype == 'textarea' }} {{ '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 %}