Files
LT_Janitorial_Quality_Control/app/templates/dashboard.html
T

466 lines
23 KiB
HTML

{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
{# ── Grouped dashboard styling (colored section cards + white stat tiles) ──── #}
<style>
.group-card { border-radius:16px; padding:14px 18px 18px; margin-bottom:22px; }
.group-title { font-weight:800; font-size:1.4rem; letter-spacing:.2px; margin:2px 0 12px; }
.group-blue { background:#4a90d9; } /* A — solid blue */
.group-green { background:#d9ead3; } /* B — light green */
.group-orange { background:#fce5cd; } /* C — light orange */
.group-grey { background:#efefef; } /* D — light grey */
.gt-white { color:#ffffff; }
.gt-green { color:#38761d; }
.gt-orange { color:#b45f06; }
.gt-red { color:#cc0000; }
.stat-tile { background:#fff; border:2px solid #cbd3dd; border-radius:11px;
padding:12px 16px; height:100%; display:flex; align-items:center;
justify-content:space-between; gap:10px; transition:box-shadow .15s; }
a.tile-link:hover .stat-tile { box-shadow:0 3px 10px rgba(0,0,0,.12); }
.st-label { font-size:.9rem; font-weight:600; color:#3f4652; line-height:1.15; }
.st-sub { font-size:.68rem; color:#8a93a2; margin-top:3px; }
.st-value { font-size:1.9rem; font-weight:800; line-height:1; white-space:nowrap; }
/* Scheduled-inspection table inside the solid-blue section A */
.sched-table { background:#fff; border-radius:8px; overflow:hidden; margin-bottom:0; }
.sched-table thead th { background:#5b9bd5; color:#fff; border-color:#4a90d9; font-weight:600; }
</style>
{# Handler ("Handled by") breakdown chips for an issue KPI tile. `base` is a
dict of extra issues.index query params identifying the tile's scope. #}
{% macro handler_chips(bd, base) %}
<div class="mt-1 d-flex flex-wrap align-items-center gap-1" style="font-size:.7rem;">
<span style="opacity:.75;">Handled by:</span>
{% if bd.internal > 0 %}
<a href="{{ url_for('issues.index', handler_type='internal', **base) }}"
class="badge bg-primary text-decoration-none" title="Janitorial Staff — our crew">Janitorial {{ bd.internal }}</a>{% endif %}
{% if bd.facility > 0 %}
<a href="{{ url_for('issues.index', handler_type='facility', **base) }}"
class="badge bg-info text-dark text-decoration-none" title="Facility Staff — the facility's own on-site staff">Facility {{ bd.facility }}</a>{% endif %}
{% if bd.vendor > 0 %}
<a href="{{ url_for('issues.index', handler_type='vendor', **base) }}"
class="badge bg-light text-dark border text-decoration-none" title="External Vendor — an outside contractor">Vendor {{ bd.vendor }}</a>{% endif %}
</div>
{% endmacro %}
{# One white stat tile: label (+ optional subtitle OR handler chips) left, big
colored number right. Numbers render two-digit (01, 02, …) to match the design.
When chips_bd is given and the value is > 0, the handler chips replace the
subtitle; because the chips are themselves links, the whole tile is NOT
wrapped in an anchor (nested <a> is invalid) — only the number links. #}
{% macro stat_tile(label, value, accent, href=None, subtitle=None, chips_bd=None, chips_base=None) %}
{% set has_chips = chips_bd is not none and value and value > 0 %}
<div class="col">
{% if href and not has_chips %}<a href="{{ href }}" class="text-decoration-none tile-link d-block h-100">{% endif %}
<div class="stat-tile" style="border-color:{{ accent }};">
<div class="st-text">
<div class="st-label">{{ label }}</div>
{% if has_chips %}
{{ handler_chips(chips_bd, chips_base) }}
{% elif subtitle %}
<div class="st-sub">{{ subtitle }}</div>
{% endif %}
</div>
<div class="st-value" style="color:{{ accent }};">
{% if href and has_chips %}
<a href="{{ href }}" class="text-decoration-none" style="color:inherit;">{{ '%02d'|format(value) }}</a>
{% else %}
{{ '%02d'|format(value) if value is number else value }}
{% endif %}
</div>
</div>
{% if href and not has_chips %}</a>{% endif %}
</div>
{% endmacro %}
<div class="row mb-3 align-items-center">
<div class="col">
<h2 class="mb-0">Welcome, {{ current_user.display_name }}!</h2>
<span class="badge bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% elif current_user.role == 'project_manager' %}primary{% elif current_user.role == 'auditor' %}secondary{% elif current_user.role == 'customer' %}success{% else %}info{% endif %} mt-1">
{{ current_user.role.replace('_',' ')|title }}
</span>
</div>
</div>
{# ══════════════════════════════════════════════════════════════════════════
A. SCHEDULED INSPECTION (blue)
══════════════════════════════════════════════════════════════════════════ #}
{% if current_user.role != 'customer' %}
<div class="group-card group-blue">
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="group-title gt-white mb-0">Scheduled Inspection</span>
<a href="{{ url_for('scheduled_inspections.index') }}" class="btn btn-sm btn-light">View all</a>
</div>
{% if sched_overdue_count %}
<div class="alert alert-danger py-2 mb-2">
<i class="bi bi-alarm-fill"></i>
<strong>{{ sched_overdue_count }}</strong> scheduled inspection{{ 's' if sched_overdue_count != 1 }}
{{ 'are' if sched_overdue_count != 1 else 'is' }} <strong>overdue</strong>.
</div>
{% endif %}
{% if sched_upcoming %}
<div class="table-responsive">
<table class="table table-hover align-middle sched-table">
<thead>
<tr>
<th>Facility Name</th><th>Inspection Template</th><th>Inspector Name</th>
<th>How Often?</th><th>Due</th><th class="text-end"></th>
</tr>
</thead>
<tbody>
{% for s in sched_upcoming %}
<tr>
<td>{{ s.facility.name if s.facility else '—' }}</td>
<td class="small">{{ s.template.name if s.template else '—' }}</td>
<td class="small">{{ s.inspector.display_name if s.inspector else '—' }}</td>
<td class="small text-muted">{{ s.recurrence_label }}</td>
<td class="small">{{ s.next_due_date.strftime('%b %d') }}</td>
<td class="text-end text-nowrap">
{# Start is shown only to the assignee — the inspection is theirs to do. #}
{% if s.inspector_id and s.inspector_id == current_user.id %}
{# Receipt confirmation (phase47) — assignee confirms, or shows confirmed. #}
{% if s.is_acknowledged %}
<span class="badge bg-success" title="You confirmed receipt"><i class="bi bi-check-circle"></i> Confirmed</span>
{% else %}
<form method="POST" class="d-inline" action="{{ url_for('scheduled_inspections.acknowledge', schedule_id=s.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-success py-0" title="Confirm you received this request">
<i class="bi bi-check-lg"></i> Confirm</button>
</form>
{% endif %}
{% set open_id = sched_open_inspections.get(s.id) %}
{% if open_id %}
<a href="{{ url_for('inspections.execute', inspection_id=open_id) }}"
class="btn btn-sm btn-warning py-0" title="You already started this — resume it">
<i class="bi bi-pencil-square"></i> Continue</a>
{% else %}
<a href="{{ url_for('scheduled_inspections.start', schedule_id=s.id) }}"
class="btn btn-sm btn-success py-0"><i class="bi bi-play-fill"></i> Start</a>
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="bg-white rounded p-3 text-muted small mb-0">
<i class="bi bi-info-circle me-1"></i>No inspections due in the next 7 days.
</div>
{% endif %}
</div>
{% endif %}
{# ══════════════════════════════════════════════════════════════════════════
B. QUICK REVIEW (green)
══════════════════════════════════════════════════════════════════════════ #}
{% set G = '#4e8a2f' %}
<div class="group-card group-green">
<div class="group-title gt-green">Quick Review</div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-xl-5 g-3">
{{ stat_tile('Submitted Today', completed_today, G,
url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str),
'Completed & submitted today') }}
{{ stat_tile('Submitted This Week', submitted_this_week, G,
url_for('inspections.index', status='completed', date_from=week_start_str, date_to=today_str),
'Completed & submitted this week') }}
{% if current_user.role != 'customer' %}
{{ stat_tile('In Progress', in_progress_total, G,
url_for('inspections.index', status='in_progress'),
(stale_in_progress ~ ' stale >24h') if stale_in_progress else 'Started, not yet submitted') }}
{{ stat_tile('Pending Follow-ups', pending_followups, G,
url_for('inspections.index', status='follow_up'),
'Flagged for re-inspection') }}
{{ stat_tile('On Schedules', sched_total, G,
url_for('scheduled_inspections.index'),
'Active scheduled plans') }}
{% endif %}
</div>
</div>
{# ══════════════════════════════════════════════════════════════════════════
C. REVIEW ISSUES (orange)
══════════════════════════════════════════════════════════════════════════ #}
{% set O = '#d5761a' %}
<div class="group-card group-orange">
<div class="group-title gt-orange">Review Issues</div>
<div class="row row-cols-2 row-cols-sm-3 row-cols-xl-5 g-3">
{{ stat_tile('Open · Janitorial', handler_breakdown.internal, O,
url_for('issues.index', status='open', handler_type='internal'),
'Handled by our crew') }}
{{ stat_tile('Open · Facility Staff', handler_breakdown.facility, O,
url_for('issues.index', status='open', handler_type='facility'),
'Handled by facility staff') }}
{{ stat_tile('Open · Vendors', handler_breakdown.vendor, O,
url_for('issues.index', status='open', handler_type='vendor'),
'Handled by outside vendor') }}
{% if current_user.role != 'customer' %}
{{ stat_tile('Open · Unassigned', unassigned_open, O,
url_for('issues.index', status='open', unassigned='1'),
'No one assigned yet',
chips_bd=unassigned_handler, chips_base={'status': 'open', 'unassigned': '1'}) }}
{{ stat_tile('Pending · Verification', pending_verification, O,
url_for('issues.index', status='pending_verification'),
'Awaiting sign-off') }}
{% endif %}
{# All the original Issues cards are kept here in the same colored group. #}
{{ stat_tile('Issues Opened Today', issues_opened_today, O,
url_for('issues.index', date_from=today_str, date_to=today_str),
'New issues reported today',
chips_bd=opened_today_handler, chips_base={'date_from': today_str, 'date_to': today_str}) }}
{{ stat_tile('Resolved Today', resolved_today, O,
url_for('issues.index', status='resolved', date_from=today_str, date_to=today_str),
'Issues closed today') }}
</div>
</div>
{# ══════════════════════════════════════════════════════════════════════════
D. SLA ALERT (grey)
══════════════════════════════════════════════════════════════════════════ #}
{# Section D shows only when there is at least one at-risk or breached issue. #}
{% if sla_breached > 0 or sla_at_risk > 0 %}
{% set R = '#cc0000' %}
<div class="group-card group-grey">
<div class="group-title gt-red">SLA Alert</div>
<div class="row row-cols-1 row-cols-sm-2 g-3">
{{ stat_tile('SLA At Risk', sla_at_risk, R,
url_for('issues.index', sla='at_risk'),
'Approaching their SLA deadline') }}
{{ stat_tile('SLA Alert', sla_breached, R,
url_for('issues.index', sla='breached'),
'Past their SLA deadline') }}
</div>
</div>
{% endif %}
{# ── Recent activity ─────────────────────────────────────────────────────── #}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-clock-history me-1"></i>Recent Activity
</div>
<div class="card-body p-0">
{% if recent_inspections %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Facility</th>
<th>Area</th>
{% if not current_user.is_inspector %}<th>Inspector</th>{% endif %}
<th>Score</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for insp in recent_inspections %}
<tr style="cursor:pointer;" onclick="window.location='{{ url_for('inspections.view', inspection_id=insp.id) }}'">
<td><small>{{ insp.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>{{ insp.facility.name }}</td>
<td>{{ insp.area.name if insp.area else '—' }}</td>
{% if not current_user.is_inspector %}<td>{{ insp.inspector.display_name }}</td>{% endif %}
<td>
{% if insp.overall_score %}
<span class="badge bg-{% if insp.overall_score >= 90 %}success{% elif insp.overall_score >= 70 %}warning{% else %}danger{% endif %}">
{{ insp.overall_score }}%
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'flagged' %}danger{% else %}secondary{% endif %}">
{{ 'Submitted' if insp.status == 'completed' else insp.status|title }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4 text-muted">
<i class="bi bi-inbox fs-2 d-block mb-2"></i>No recent inspections.
</div>
{% endif %}
</div>
</div>
{# ── My open issues (inspector widget) ──────────────────────────────────── #}
{% if my_issues %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-person-check me-1 text-primary"></i>My Open Issues</span>
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">View all</a>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0" style="font-size:.85rem;">
<thead class="table-light">
<tr>
<th width="50">ID</th>
<th width="80">Severity</th>
<th>Facility / Description</th>
<th width="90">Status</th>
<th width="110">SLA</th>
</tr>
</thead>
<tbody>
{% for issue in my_issues %}
{% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td class="text-muted">
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
</td>
<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>
<div>{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</div>
<div class="text-muted small">{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</div>
</td>
<td>
<span class="badge bg-{{ 'warning text-dark' if issue.status == 'in_progress' else 'danger' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</td>
<td>
{% if sla == 'breached' %}
<span class="badge bg-danger"><i class="bi bi-alarm me-1"></i>Breached</span>
{% elif sla == 'at_risk' %}
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>{{ sla_hours_remaining(issue)|abs|round(1) }}h left</span>
{% else %}
<span class="badge bg-secondary">OK</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Customer portal: scoped facilities panel ───────────────────────────── #}
{% if current_user.role == 'customer' %}
<div class="row g-3 mt-2">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center"
role="button" data-bs-toggle="collapse" data-bs-target="#facilitiesBody"
aria-expanded="true" aria-controls="facilitiesBody" style="cursor:pointer;">
<span><i class="bi bi-building me-1"></i> Your Facilities
<i class="bi bi-chevron-down ms-1 small" id="facilitiesChevron"></i>
</span>
{% if customer_facilities %}
<span class="badge bg-secondary rounded-pill">{{ customer_facilities|length }}</span>
{% endif %}
</div>
<div class="collapse show" id="facilitiesBody">
{% if customer_facilities %}
<div class="card-body pb-2">
{% if customer_facilities|length > 6 %}
<div class="mb-3">
<input type="text" id="facilitySearch" class="form-control form-control-sm"
placeholder="Search facilities…">
</div>
{% endif %}
<div class="row g-2" id="facilityGrid">
{% for f in customer_facilities %}
<div class="col-12 col-sm-6 col-lg-4 facility-col">
<div class="border rounded p-2 h-100 d-flex flex-column facility-card">
<div class="fw-semibold mb-1 small">{{ f.name }}</div>
<div class="text-muted" style="font-size:.8rem;">{{ f.address or '—' }}</div>
<div class="my-1">
<span class="badge bg-light text-dark border" style="font-size:.75rem;">
{{ f.project.name if f.project else '—' }}
</span>
</div>
<div class="mt-auto pt-1">
<a href="{{ url_for('facilities.view_facility', facility_id=f.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View
</a>
<a href="{{ url_for('reports.facility_report', facility_id=f.id) }}"
class="btn btn-sm btn-outline-secondary ms-1">
<i class="bi bi-graph-up"></i> Report
</a>
</div>
</div>
</div>
{% endfor %}
</div>
{% if customer_facilities|length > 9 %}
<div id="facilityShowMore" class="text-center mt-3">
<button class="btn btn-sm btn-link text-muted" id="toggleFacilities">
Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>
</button>
</div>
{% endif %}
</div>
{% else %}
<div class="card-body text-muted small">
<i class="bi bi-info-circle me-1"></i>
No facilities have been assigned to your account yet. Please contact your administrator.
</div>
{% endif %}
</div>
</div>
</div>
</div>
<script>
(function () {
var collapseEl = document.getElementById('facilitiesBody');
var chevron = document.getElementById('facilitiesChevron');
collapseEl.addEventListener('hide.bs.collapse', function () {
chevron.classList.replace('bi-chevron-down', 'bi-chevron-up');
});
collapseEl.addEventListener('show.bs.collapse', function () {
chevron.classList.replace('bi-chevron-up', 'bi-chevron-down');
});
{% if customer_facilities and customer_facilities|length > 9 %}
var VISIBLE = 9;
var cols = document.querySelectorAll('#facilityGrid .facility-col');
var btn = document.getElementById('toggleFacilities');
var more = document.getElementById('facilityShowMore');
var expanded = false;
cols.forEach(function (c, i) { if (i >= VISIBLE) c.style.display = 'none'; });
btn.addEventListener('click', function () {
expanded = !expanded;
cols.forEach(function (c, i) {
if (i >= VISIBLE) c.style.display = expanded ? '' : 'none';
});
btn.innerHTML = expanded
? 'Show fewer <i class="bi bi-chevron-up"></i>'
: 'Show all {{ customer_facilities|length }} facilities <i class="bi bi-chevron-down"></i>';
});
{% endif %}
{% if customer_facilities and customer_facilities|length > 6 %}
document.getElementById('facilitySearch').addEventListener('input', function () {
var q = this.value.toLowerCase();
document.querySelectorAll('#facilityGrid .facility-col').forEach(function (col) {
var match = col.querySelector('.facility-card').textContent.toLowerCase().includes(q);
col.style.display = match ? '' : 'none';
});
var more2 = document.getElementById('facilityShowMore');
if (more2) more2.style.display = this.value ? 'none' : '';
});
{% endif %}
})();
</script>
{% endif %}
{% endblock %}