First commit

This commit is contained in:
2026-06-26 09:04:34 -04:00
commit 77678ed724
166 changed files with 34842 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
{#
_sla_badge.html — Reusable SLA status badge macro.
Usage:
{% from '_sla_badge.html' import sla_badge %}
{{ sla_badge(issue) }}
#}
{% macro sla_badge(issue) %}
{% set status = sla_status(issue) %}
{% if status == 'breached' %}
<span class="badge sla-breached" title="SLA breached — past deadline">
<i class="bi bi-alarm me-1"></i>SLA Breached
</span>
{% elif status == 'at_risk' %}
{% set hrs = sla_hours_remaining(issue) %}
<span class="badge sla-at-risk" title="SLA at risk — {{ hrs }}h remaining">
<i class="bi bi-alarm me-1"></i>At Risk{% if hrs is not none %} · {{ hrs }}h{% endif %}
</span>
{% elif status == 'ok' %}
{% set hrs = sla_hours_remaining(issue) %}
<span class="badge sla-ok" title="Within SLA — {{ hrs }}h remaining">
<i class="bi bi-check-circle me-1"></i>On Track{% if hrs is not none %} · {{ hrs }}h{% endif %}
</span>
{% endif %}
{# resolved issues show nothing #}
{% endmacro %}
+225
View File
@@ -0,0 +1,225 @@
{% extends "base.html" %}
{% block title %}Broadcast Notifications{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2><i class="bi bi-megaphone-fill me-2"></i>Broadcast Notification</h2>
<p class="text-muted mb-0">
Send an in-app notification to all active iOS app users in the selected roles.
Messages are delivered within 60 seconds via the app's background poll.
</p>
</div>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
<div class="row g-4">
{# ── Compose Form ─────────────────────────────────────────────────── #}
<div class="col-lg-5">
<div class="card shadow-sm h-100">
<div class="card-header bg-primary text-white">
<i class="bi bi-send-fill me-2"></i><strong>Compose Message</strong>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('broadcast.send') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="title" class="form-label fw-semibold">
Title <span class="text-danger">*</span>
</label>
<input type="text" class="form-control" id="title" name="title"
maxlength="255" required placeholder="e.g. App Update Available">
<div class="form-text">Appears as the notification banner title on iPad.</div>
</div>
<div class="mb-3">
<label for="body" class="form-label fw-semibold">
Message <span class="text-danger">*</span>
</label>
<textarea class="form-control" id="body" name="body"
rows="4" required maxlength="500"
placeholder="e.g. A new version of JanitorialQC is available. Please update to v1.3 from the App Store."></textarea>
<div class="d-flex justify-content-between align-items-center mt-1">
<span class="form-text mb-0">The full message body shown in the notification and in-app inbox.</span>
<span id="bodyCounter" class="form-text mb-0 text-muted">0 / 500</span>
</div>
</div>
<div class="mb-4">
<label class="form-label fw-semibold">
Target Roles <span class="text-danger">*</span>
</label>
<div class="d-flex flex-wrap gap-3">
{% for role in roles %}
<div class="form-check">
<input class="form-check-input" type="checkbox"
name="roles" value="{{ role }}"
id="role_{{ role }}"
{% if role == 'inspector' %}checked{% endif %}>
<label class="form-check-label" for="role_{{ role }}">
{{ role_labels[role] }}
</label>
</div>
{% endfor %}
</div>
<div class="form-text">Only active users in the selected roles will receive this message.</div>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg"
onclick="return confirm('Send this broadcast to all selected users?')">
<i class="bi bi-send-fill me-2"></i>Send Broadcast
</button>
</div>
</form>
</div>
</div>
</div>
{# ── Broadcast History ─────────────────────────────────────────────── #}
<div class="col-lg-7">
<div class="card shadow-sm">
<div class="card-header">
<i class="bi bi-clock-history me-2"></i><strong>Recent Broadcasts</strong>
<span class="text-muted fw-normal ms-2">(last 50)</span>
</div>
<div class="card-body p-0">
{% if history %}
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>Sent</th>
<th>Title</th>
<th>Roles</th>
<th class="text-center">Recipients</th>
<th>By</th>
</tr>
</thead>
<tbody>
{% for b in history %}
<tr class="broadcast-row" style="cursor:pointer;"
data-title="{{ b.title | e }}"
data-body="{{ b.body | e }}"
data-sent-at="{{ b.sent_at.strftime('%b %-d, %Y %I:%M %p') }}"
data-sent-by="{{ (b.sent_by.display_name if b.sent_by else '—') | e }}"
data-recipients="{{ b.recipient_count }}"
data-roles="{{ b.target_roles | join(',') }}">
<td class="text-nowrap text-muted small">
{{ b.sent_at.strftime('%b %-d, %Y') }}<br>
<span class="text-muted" style="font-size:.75rem;">
{{ b.sent_at.strftime('%I:%M %p') }}
</span>
</td>
<td>
<div class="fw-semibold">{{ b.title }}</div>
<div class="text-muted small text-truncate" style="max-width:220px;">{{ b.body }}</div>
</td>
<td>
{% for role in b.target_roles %}
<span class="badge bg-secondary me-1">
{{ role_labels.get(role, role) }}
</span>
{% endfor %}
</td>
<td class="text-center">
<span class="badge bg-primary rounded-pill">
{{ b.recipient_count }}
</span>
</td>
<td class="small text-muted">
{{ b.sent_by.display_name if b.sent_by else '—' }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-megaphone fs-1 d-block mb-2 opacity-25"></i>
No broadcasts sent yet.
</div>
{% endif %}
</div>
</div>
</div>
</div><!-- /row -->
{# ── Broadcast Detail Modal ──────────────────────────────────────────────── #}
<div class="modal fade" id="broadcastDetailModal" tabindex="-1" aria-labelledby="broadcastDetailTitle" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="broadcastDetailTitle">
<i class="bi bi-megaphone-fill me-2"></i><span id="bdTitle"></span>
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p id="bdBody" class="mb-3" style="white-space:pre-wrap;"></p>
<hr>
<dl class="row mb-0 small text-muted">
<dt class="col-sm-4">Sent</dt>
<dd class="col-sm-8" id="bdSentAt"></dd>
<dt class="col-sm-4">By</dt>
<dd class="col-sm-8" id="bdSentBy"></dd>
<dt class="col-sm-4">Recipients</dt>
<dd class="col-sm-8" id="bdRecipients"></dd>
<dt class="col-sm-4">Roles</dt>
<dd class="col-sm-8" id="bdRoles"></dd>
</dl>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
var ROLE_LABELS = {{ role_labels | tojson }};
document.querySelectorAll('.broadcast-row').forEach(function(row) {
row.addEventListener('click', function() {
var roles = this.dataset.roles ? this.dataset.roles.split(',') : [];
document.getElementById('bdTitle').textContent = this.dataset.title;
document.getElementById('bdBody').textContent = this.dataset.body;
document.getElementById('bdSentAt').textContent = this.dataset.sentAt;
document.getElementById('bdSentBy').textContent = this.dataset.sentBy;
document.getElementById('bdRecipients').textContent = this.dataset.recipients + ' user(s)';
document.getElementById('bdRoles').textContent = roles.map(function(r) {
return ROLE_LABELS[r] || r;
}).join(', ');
new bootstrap.Modal(document.getElementById('broadcastDetailModal')).show();
});
});
(function () {
var textarea = document.getElementById('body');
var counter = document.getElementById('bodyCounter');
var MAX = 500;
function update() {
var len = textarea.value.length;
counter.textContent = len + ' / ' + MAX;
counter.className = 'form-text mb-0 ' + (len >= MAX ? 'text-danger fw-semibold' : len >= MAX * 0.9 ? 'text-warning' : 'text-muted');
}
textarea.addEventListener('input', update);
update();
})();
</script>
{% endblock %}
+254
View File
@@ -0,0 +1,254 @@
{% extends "base.html" %}
{% block title %}Audit Trail{% endblock %}
{% block content %}
<div class="row mb-3 align-items-center">
<div class="col">
<h2><i class="bi bi-shield-check"></i> Audit Trail</h2>
<p class="text-muted mb-0">Complete, immutable log of all system actions.</p>
</div>
</div>
<!-- ── Filter Bar ──────────────────────────────────────────────────────────── -->
<div class="card shadow-sm mb-4">
<div class="card-body py-3">
<form method="GET" action="{{ url_for('audit.index') }}" class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold mb-1">User</label>
<select name="user_id" class="form-select form-select-sm">
<option value="">All Users</option>
{% for u in users %}
<option value="{{ u.id }}" {% if filter_user == u.id|string %}selected{% endif %}>
{{ u.username }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold mb-1">Action</label>
<select name="action" class="form-select form-select-sm">
<option value="">All Actions</option>
{% for a in distinct_actions %}
<option value="{{ a }}" {% if filter_action == a %}selected{% endif %}>{{ a }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold mb-1">Entity Type</label>
<select name="entity_type" class="form-select form-select-sm">
<option value="">All Types</option>
{% for t in distinct_entity_types %}
<option value="{{ t }}" {% if filter_entity_type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold mb-1">From Date</label>
<input type="date" name="date_from" class="form-control form-control-sm"
value="{{ filter_date_from }}">
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold mb-1">To Date</label>
<input type="date" name="date_to" class="form-control form-control-sm"
value="{{ filter_date_to }}">
</div>
<div class="col-md-2 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm flex-fill">
<i class="bi bi-funnel me-1"></i>Filter
</button>
<a href="{{ url_for('audit.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-x-circle"></i>
</a>
</div>
</form>
</div>
</div>
<!-- ── Results ─────────────────────────────────────────────────────────────── -->
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-semibold">
<i class="bi bi-list-ul me-1"></i>
{{ logs.total }} record{{ 's' if logs.total != 1 else '' }}
{% if filter_user or filter_action or filter_entity_type or filter_date_from or filter_date_to %}
<span class="badge bg-info ms-1">Filtered</span>
{% endif %}
</span>
<div class="d-flex align-items-center gap-2">
<small class="text-muted">Page {{ logs.page }} of {{ logs.pages }}</small>
<button type="button" class="btn btn-sm btn-outline-danger"
data-bs-toggle="modal" data-bs-target="#purgeModal">
<i class="bi bi-trash me-1"></i>Purge Old Logs
</button>
</div>
</div>
<div class="card-body p-0">
{% if logs.items %}
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th style="width:160px">Timestamp</th>
<th>User</th>
<th>Role</th>
<th>Action</th>
<th>Entity</th>
<th>Label</th>
<th>IP Address</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
{% for entry in logs.items %}
<tr>
<td class="text-nowrap text-muted small">
{{ entry.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
</td>
<td>
<strong>{{ entry.username }}</strong>
</td>
<td>
<span class="badge bg-{% if entry.user_role == 'admin' %}danger{% elif entry.user_role == 'director' %}warning{% else %}info{% endif %} bg-opacity-75">
{{ entry.user_role | title }}
</span>
</td>
<td>
<span class="badge
{% if entry.action == 'CREATE' %}bg-success
{% elif entry.action == 'UPDATE' %}bg-primary
{% elif entry.action == 'DELETE' %}bg-danger
{% elif entry.action == 'LOGIN' %}bg-secondary
{% elif entry.action == 'LOGOUT' %}bg-secondary
{% elif entry.action == 'EXPORT' %}bg-warning text-dark
{% else %}bg-light text-dark{% endif %}">
{{ entry.action }}
</span>
</td>
<td class="text-muted small">{{ entry.entity_type }}</td>
<td class="small">
{{ entry.entity_label or '—' }}
{% if entry.entity_id %}
<span class="text-muted">#{{ entry.entity_id }}</span>
{% endif %}
</td>
<td class="text-muted small font-monospace">
{{ entry.ip_address or '—' }}
</td>
<td>
<a href="{{ url_for('audit.view', log_id=entry.id) }}"
class="btn btn-sm btn-outline-secondary py-0 px-2">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-shield-check fs-2 d-block mb-2"></i>
No audit records match the current filters.
</div>
{% endif %}
</div>
<!-- Pagination -->
{% if logs.pages > 1 %}
<div class="card-footer bg-light d-flex justify-content-center">
<nav>
<ul class="pagination pagination-sm mb-0">
{% if logs.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('audit.index', page=logs.prev_num,
user_id=filter_user, action=filter_action,
entity_type=filter_entity_type,
date_from=filter_date_from, date_to=filter_date_to) }}">
&laquo; Prev
</a>
</li>
{% endif %}
{% for p in logs.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == logs.page %}active{% endif %}">
<a class="page-link" href="{{ url_for('audit.index', page=p,
user_id=filter_user, action=filter_action,
entity_type=filter_entity_type,
date_from=filter_date_from, date_to=filter_date_to) }}">
{{ p }}
</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
{% if logs.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('audit.index', page=logs.next_num,
user_id=filter_user, action=filter_action,
entity_type=filter_entity_type,
date_from=filter_date_from, date_to=filter_date_to) }}">
Next &raquo;
</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
</div>
<!-- ── Purge Modal ──────────────────────────────────────────────────────────── -->
<div class="modal fade" id="purgeModal" tabindex="-1" aria-labelledby="purgeModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="purgeModalLabel">
<i class="bi bi-trash me-2"></i>Purge Old Audit Logs
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form method="POST" action="{{ url_for('audit.purge') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<div class="alert alert-warning d-flex align-items-center gap-2 mb-3">
<i class="bi bi-exclamation-triangle-fill fs-5 flex-shrink-0"></i>
<span>This action is <strong>permanent and irreversible.</strong>
Deleted log entries cannot be recovered.</span>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Delete logs older than</label>
<select name="older_than" class="form-select" id="purgeOlderThan" required>
<option value="">— Select a threshold —</option>
<option value="7">7 days</option>
<option value="30">30 days</option>
<option value="60">60 days</option>
<option value="90">90 days</option>
<option value="180">180 days</option>
<option value="365">1 year</option>
</select>
</div>
<p class="text-muted small mb-0">
All audit log entries created before the selected threshold will be
permanently deleted. A single audit entry recording this purge will
be retained.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger" id="purgeSubmitBtn" disabled>
<i class="bi bi-trash me-1"></i>Purge Logs
</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.getElementById('purgeOlderThan').addEventListener('change', function () {
document.getElementById('purgeSubmitBtn').disabled = !this.value;
});
</script>
{% endblock %}
+111
View File
@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}Audit Entry #{{ entry.id }}{% endblock %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item">
<a href="{{ url_for('audit.index') }}">Audit Trail</a>
</li>
<li class="breadcrumb-item active">Entry #{{ entry.id }}</li>
</ol>
</nav>
<h2 class="mb-0"><i class="bi bi-shield-check"></i> Audit Entry #{{ entry.id }}</h2>
</div>
<div class="col-auto">
<a href="{{ url_for('audit.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Audit Trail
</a>
</div>
</div>
<div class="row g-4">
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-info-circle me-1"></i>Event Details
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-muted">Action</dt>
<dd class="col-sm-8">
<span class="badge fs-6
{% if entry.action == 'CREATE' %}bg-success
{% elif entry.action == 'UPDATE' %}bg-primary
{% elif entry.action == 'DELETE' %}bg-danger
{% elif entry.action in ('LOGIN','LOGOUT') %}bg-secondary
{% elif entry.action == 'EXPORT' %}bg-warning text-dark
{% else %}bg-light text-dark{% endif %}">
{{ entry.action }}
</span>
</dd>
<dt class="col-sm-4 text-muted">Entity Type</dt>
<dd class="col-sm-8">{{ entry.entity_type }}</dd>
<dt class="col-sm-4 text-muted">Entity ID</dt>
<dd class="col-sm-8">
{% if entry.entity_id %}#{{ entry.entity_id }}{% else %}<span class="text-muted"></span>{% endif %}
</dd>
<dt class="col-sm-4 text-muted">Label</dt>
<dd class="col-sm-8">{{ entry.entity_label or '—' }}</dd>
<dt class="col-sm-4 text-muted">Details</dt>
<dd class="col-sm-8">
{% if entry.details %}
<code class="text-break">{{ entry.details }}</code>
{% else %}
<span class="text-muted"></span>
{% endif %}
</dd>
</dl>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-person-circle me-1"></i>Actor &amp; Context
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-muted">Username</dt>
<dd class="col-sm-8">
<strong>{{ entry.username }}</strong>
{% if entry.user_id %}
<span class="text-muted small">(ID #{{ entry.user_id }})</span>
{% else %}
<span class="badge bg-secondary ms-1">Deleted</span>
{% endif %}
</dd>
<dt class="col-sm-4 text-muted">Role at Time</dt>
<dd class="col-sm-8">
<span class="badge bg-{% if entry.user_role == 'admin' %}danger{% elif entry.user_role == 'director' %}warning{% else %}info{% endif %}">
{{ entry.user_role | title }}
</span>
</dd>
<dt class="col-sm-4 text-muted">Timestamp</dt>
<dd class="col-sm-8">
<span class="font-monospace">
{{ entry.created_at.strftime('%Y-%m-%d %H:%M:%S') }}
</span>
<small class="text-muted ms-1">Eastern Time</small>
</dd>
<dt class="col-sm-4 text-muted">IP Address</dt>
<dd class="col-sm-8">
<code>{{ entry.ip_address or '—' }}</code>
</dd>
</dl>
</div>
</div>
</div>
</div>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forgot Password — Janitorial QC</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background: #eef0f4; font-family: 'Segoe UI', Arial, sans-serif; }
.setup-card {
max-width: 460px; margin: 80px auto;
border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
}
.setup-header {
background: #1a1d23; color: #fff;
border-radius: 12px 12px 0 0;
padding: 1.5rem 1.75rem 1.25rem;
}
.setup-header h4 { margin: 0; font-weight: 600; }
.setup-header p { color: #94a3b8; font-size: .85rem; margin: .35rem 0 0; }
.setup-body { background: #fff; border-radius: 0 0 12px 12px; padding: 1.75rem; }
</style>
</head>
<body>
<div class="setup-card">
<div class="setup-header">
<h4><i class="bi bi-envelope-open me-2"></i>Forgot Your Password?</h4>
<p>Enter the email address on your account and we'll send you a reset link.</p>
</div>
<div class="setup-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ 'danger' if cat == 'danger' else 'warning' if cat == 'warning' else 'info' if cat == 'info' else 'success' }}
alert-dismissible fade show py-2 mb-3" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="email" class="form-label fw-semibold">Email Address</label>
<input type="email"
id="email"
name="email"
class="form-control {{ 'is-invalid' if form.email.errors else '' }}"
autocomplete="email"
autofocus
placeholder="you@example.com">
{% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-send me-1"></i>Send Reset Link
</button>
</form>
<div class="text-center mt-3">
<a href="{{ url_for('auth.login') }}" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Back to Login
</a>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}Contract Assignments — {{ user.display_name }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2><i class="bi bi-briefcase text-primary me-2"></i>Contract Assignments</h2>
<p class="text-muted mb-0">
Inspector <strong>{{ user.display_name }}</strong> can only access facilities
belonging to the contracts ticked below.
</p>
</div>
<a href="{{ url_for('auth.list_users') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Users
</a>
</div>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<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">Active Contracts</span>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary" id="assignedCount">{{ assigned_pids|length }} assigned</span>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setAll(true)">Select All</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setAll(false)">Deselect All</button>
</div>
</div>
{% if projects %}
<div class="list-group list-group-flush">
{% for project in projects %}
<label class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-3">
<input class="form-check-input flex-shrink-0" type="checkbox"
name="project_ids" value="{{ project.id }}"
{{ 'checked' if project.id in assigned_pids }}>
<div>
<div class="fw-semibold">{{ project.name }}</div>
{% if project.description %}
<div class="text-muted small">{{ project.description }}</div>
{% endif %}
<div class="text-muted small">
{{ project.facilities.count() }} facilit{{ 'ies' if project.facilities.count() != 1 else 'y' }}
</div>
</div>
</label>
{% endfor %}
</div>
{% else %}
<div class="card-body text-muted">
No active contracts exist. Create a contract first.
</div>
{% endif %}
</div>
{% if projects %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-floppy me-1"></i>Save Assignments
</button>
<a href="{{ url_for('auth.list_users') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
{% endif %}
</form>
{% endblock %}
{% block extra_js %}
<script>
function setAll(checked) {
document.querySelectorAll('input[name="project_ids"]').forEach(cb => cb.checked = checked);
updateCount();
}
function updateCount() {
const n = document.querySelectorAll('input[name="project_ids"]:checked').length;
document.getElementById('assignedCount').textContent = n + ' assigned';
}
document.querySelectorAll('input[name="project_ids"]').forEach(cb => {
cb.addEventListener('change', updateCount);
});
</script>
{% endblock %}
+334
View File
@@ -0,0 +1,334 @@
{% extends "base.html" %}
{% block title %}Login - Janitorial QC{% endblock %}
{% block extra_css %}
<style>
/* ════════════════════════════════════════════════════════════════════
Login page — split-screen layout
Theme colors preserved: brand #1a56db / #1a4ab5 / #1551c7,
quality-green #16a34a, amber #d97706 / #f59e0b, light-blue surfaces.
════════════════════════════════════════════════════════════════════ */
body.login-page { min-height: 100vh; background: #fff; }
/* Let the split fill the viewport: neutralize base.html content wrapper */
body.login-page .container-fluid.mt-4 {
margin-top: 0 !important;
padding: 0;
max-width: none;
}
/* Flash messages float as top-center toasts instead of pushing layout */
body.login-page .container-fluid.mt-4 > .alert {
position: fixed;
top: 1rem; left: 50%;
transform: translateX(-50%);
z-index: 1080;
min-width: 320px; max-width: 92vw;
box-shadow: 0 8px 28px rgba(0,0,0,.18);
}
.login-split { display: flex; min-height: 100vh; }
/* ── LEFT: brand / QC showcase ──────────────────────────────────────── */
.login-brand {
flex: 1 1 55%;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #1a56db 0%, #1a4ab5 55%, #1551c7 100%);
color: #fff;
padding: 3rem 3.5rem;
display: flex;
flex-direction: column;
justify-content: center;
gap: 1.75rem;
}
.login-brand::before { /* dotted texture */
content: "";
position: absolute; inset: 0;
background-image: radial-gradient(rgba(255,255,255,.12) 1.5px, transparent 1.5px);
background-size: 26px 26px;
opacity: .55; pointer-events: none;
}
.login-brand::after { /* soft glow */
content: "";
position: absolute; width: 460px; height: 460px;
right: -140px; top: -120px;
background: radial-gradient(circle, rgba(255,255,255,.14), transparent 70%);
border-radius: 50%; pointer-events: none;
}
.login-brand > * { position: relative; z-index: 2; max-width: 500px; }
.brand-logo { display: flex; align-items: center; gap: .8rem; }
.brand-logo .logo-badge {
width: 50px; height: 50px; border-radius: 13px;
background: rgba(255,255,255,.16);
border: 1px solid rgba(255,255,255,.22);
display: flex; align-items: center; justify-content: center;
font-size: 1.6rem;
}
.brand-logo .name { font-weight: 700; font-size: 1.3rem; line-height: 1.1; }
.brand-logo .sub { font-size: .8rem; opacity: .82; }
.brand-headline { font-size: 2.05rem; font-weight: 700; line-height: 1.22; }
.brand-sub { opacity: .86; font-size: 1.02rem; line-height: 1.55; }
.feature-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 1.05rem; }
.feature-list li { display: flex; align-items: flex-start; gap: .85rem; }
.feature-list .fi {
flex: 0 0 auto; width: 32px; height: 32px; border-radius: 50%;
background: #16a34a; color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: .95rem; box-shadow: 0 0 0 4px rgba(22,163,74,.22);
}
.feature-list .ft { font-weight: 600; line-height: 1.2; }
.feature-list .fd { font-size: .85rem; opacity: .82; }
.stat-row { display: flex; gap: .75rem; flex-wrap: wrap; }
.stat-chip {
background: rgba(255,255,255,.12);
border: 1px solid rgba(255,255,255,.20);
border-radius: 12px; padding: .6rem 1.05rem; min-width: 102px;
}
.stat-chip .v { font-size: 1.4rem; font-weight: 700; line-height: 1; }
.stat-chip .l { font-size: .7rem; opacity: .82; text-transform: uppercase; letter-spacing: .6px; margin-top: .25rem; }
.stat-chip.g .v { color: #86efac; }
.stat-chip.a .v { color: #fcd34d; }
/* Decorative janitorial / QC illustration anchored bottom-right */
.brand-illustration {
position: absolute; right: -8px; bottom: -10px;
width: 340px; max-width: 42%; height: auto;
z-index: 1; opacity: .95; pointer-events: none;
}
/* ── RIGHT: sign-in form ────────────────────────────────────────────── */
.login-form-side {
flex: 1 1 45%;
display: flex; align-items: center; justify-content: center;
padding: 2.5rem 1.5rem; background: #fff;
}
.login-form-inner { width: 100%; max-width: 400px; }
.form-mobile-brand { display: none; } /* shown only on small screens */
.form-mobile-brand .logo-badge {
width: 56px; height: 56px; border-radius: 15px;
background: #1a56db; color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 1.8rem;
}
.form-mobile-brand .name { font-weight: 700; color: #1a56db; font-size: 1.2rem; margin-top: .55rem; }
.form-mobile-brand .sub { color: #6b7280; font-size: .8rem; }
.login-form-inner h2 { font-weight: 700; color: #16264a; margin-bottom: .25rem; }
.login-form-inner .lead-sub { color: #6b7280; margin-bottom: 1.75rem; }
.login-form-side .input-group-text {
background: #f0f7ff; border-color: #d4e1f4; color: #1a56db;
}
.login-form-side .form-control:focus {
border-color: #1a56db; box-shadow: 0 0 0 .2rem rgba(26,86,219,.18);
}
.login-form-side .pw-toggle {
border-color: #d4e1f4; color: #6b8fc7; background: #fff;
}
.login-form-side .pw-toggle:hover { color: #1a56db; }
.btn-login {
background: #1a56db; border-color: #1a56db; color: #fff; font-weight: 600;
}
.btn-login:hover, .btn-login:focus { background: #1648b8; border-color: #1648b8; color: #fff; }
.login-foot { margin-top: 2rem; text-align: center; color: #9aa3af; font-size: .8rem; }
/* ── Responsive: collapse to single column ──────────────────────────── */
@media (max-width: 991.98px) {
.login-brand { display: none; }
.form-mobile-brand {
display: flex; flex-direction: column; align-items: center;
text-align: center; margin-bottom: 1.75rem;
}
.login-form-inner h2, .login-form-inner .lead-sub { text-align: center; }
}
</style>
{% endblock %}
{% block content %}
<script>document.body.classList.add('login-page');</script>
<div class="login-split">
{# ══ LEFT: brand / quality-control showcase ══ #}
<section class="login-brand">
<div class="brand-logo">
<span class="logo-badge"><i class="bi bi-clipboard-check-fill"></i></span>
<span>
<span class="name d-block">Janitorial Quality Control System</span>
<span class="sub">By L.T Services, Inc</span>
</span>
</div>
<div>
<h1 class="brand-headline mb-2">Quality control for spotless facilities.</h1>
<p class="brand-sub mb-0">Standardized inspections, real-time issue tracking, and
clear performance reporting &mdash; keeping every facility audit-ready.</p>
</div>
<ul class="feature-list">
<li>
<span class="fi"><i class="bi bi-clipboard-check"></i></span>
<span><span class="ft d-block">Standardized facility inspections</span>
<span class="fd">Consistent checklists across every site.</span></span>
</li>
<li>
<span class="fi"><i class="bi bi-search"></i></span>
<span><span class="ft d-block">Real-time issue tracking</span>
<span class="fd">Log, assign, and resolve quality issues fast.</span></span>
</li>
<li>
<span class="fi"><i class="bi bi-bar-chart-line"></i></span>
<span><span class="ft d-block">Automated quality reporting</span>
<span class="fd">Scores and trends, ready to share.</span></span>
</li>
</ul>
<div class="stat-row">
<div class="stat-chip g"><div class="v">98%</div><div class="l">Pass rate</div></div>
<div class="stat-chip"> <div class="v">247</div><div class="l">Inspections</div></div>
<div class="stat-chip a"><div class="v">4.8</div><div class="l">Avg score</div></div>
</div>
{# ── Decorative inspection / janitorial illustration ── #}
<svg class="brand-illustration" viewBox="0 0 360 340" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- clipboard card -->
<rect x="48" y="36" width="210" height="252" rx="16" fill="#ffffff" opacity="0.97"/>
<rect x="48" y="36" width="210" height="52" rx="16" fill="#1a56db"/>
<rect x="48" y="64" width="210" height="24" fill="#1a56db"/>
<rect x="118" y="26" width="70" height="24" rx="10" fill="#dceeff" stroke="#93c5fd" stroke-width="2"/>
<text x="153" y="70" font-family="Arial,sans-serif" font-size="17" font-weight="700"
fill="#ffffff" text-anchor="middle">Inspection</text>
<!-- row 1 (pass) -->
<circle cx="78" cy="124" r="11" fill="#dcfce7" stroke="#86efac" stroke-width="2"/>
<polyline points="73,124 78,130 85,118" fill="none" stroke="#16a34a" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round"/>
<line x1="98" y1="124" x2="236" y2="124" stroke="#cfe0f5" stroke-width="5" stroke-linecap="round"/>
<!-- row 2 (pass) -->
<circle cx="78" cy="160" r="11" fill="#dcfce7" stroke="#86efac" stroke-width="2"/>
<polyline points="73,160 78,166 85,154" fill="none" stroke="#16a34a" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round"/>
<line x1="98" y1="160" x2="236" y2="160" stroke="#cfe0f5" stroke-width="5" stroke-linecap="round"/>
<!-- row 3 (flag / amber) -->
<circle cx="78" cy="196" r="11" fill="#fef9c3" stroke="#fde047" stroke-width="2"/>
<line x1="78" y1="190" x2="78" y2="197" stroke="#d97706" stroke-width="3" stroke-linecap="round"/>
<circle cx="78" cy="201" r="1.6" fill="#d97706"/>
<line x1="98" y1="196" x2="206" y2="196" stroke="#cfe0f5" stroke-width="5" stroke-linecap="round"/>
<!-- row 4 -->
<circle cx="78" cy="232" r="11" fill="#eef4fc" stroke="#c2d9f8" stroke-width="2"/>
<line x1="98" y1="232" x2="236" y2="232" stroke="#e4edf9" stroke-width="5" stroke-linecap="round"/>
<!-- shield-check badge -->
<g transform="translate(214,210)">
<path d="M30 0 L58 10 L58 38 Q58 62 30 74 Q2 62 2 38 L2 10 Z"
fill="#16a34a"/>
<polyline points="16,38 26,49 46,25" fill="none" stroke="#ffffff" stroke-width="5"
stroke-linecap="round" stroke-linejoin="round"/>
</g>
<!-- magnifying glass -->
<circle cx="118" cy="206" r="56" fill="none" stroke="#1a56db" stroke-width="0" />
<circle cx="118" cy="206" r="46" fill="#dbeafe" fill-opacity="0.55" stroke="#1a56db" stroke-width="7"/>
<line x1="151" y1="239" x2="182" y2="270" stroke="#1a56db" stroke-width="11" stroke-linecap="round"/>
<text x="118" y="214" font-family="Arial,sans-serif" font-size="26" font-weight="800"
fill="#1a56db" text-anchor="middle">QC</text>
</svg>
</section>
{# ══ RIGHT: sign-in form ══ #}
<section class="login-form-side">
<div class="login-form-inner">
<div class="form-mobile-brand">
<span class="logo-badge"><i class="bi bi-clipboard-check-fill"></i></span>
<span class="name">Janitorial Quality Control System</span>
<span class="sub">By L.T Services, Inc</span>
</div>
<h2>Welcome</h2>
<p class="lead-sub">Sign in to access your inspections and reports.</p>
<form method="POST" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label fw-semibold") }}
<div class="input-group input-group-lg">
<span class="input-group-text"><i class="bi bi-person"></i></span>
{{ form.username(class="form-control", placeholder="Enter username", autofocus=true) }}
</div>
{% if form.username.errors %}
<div class="text-danger small mt-1">
{% for error in form.username.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-4">
<div class="d-flex justify-content-between align-items-baseline">
{{ form.password.label(class="form-label fw-semibold mb-0") }}
<a href="{{ url_for('auth.forgot_password') }}"
class="small text-muted text-decoration-none">Forgot password?</a>
</div>
<div class="input-group input-group-lg mt-1">
<span class="input-group-text"><i class="bi bi-lock"></i></span>
{{ form.password(class="form-control", placeholder="Enter password") }}
<button class="btn pw-toggle" type="button" id="pw-toggle" tabindex="-1"
aria-label="Show password">
<i class="bi bi-eye" id="pw-toggle-icon"></i>
</button>
</div>
{% if form.password.errors %}
<div class="text-danger small mt-1">
{% for error in form.password.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-4 form-check">
{{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label text-muted") }}
</div>
<button type="submit" class="btn btn-login btn-lg w-100">
<i class="bi bi-box-arrow-in-right me-1"></i> Log In
</button>
</form>
<div class="login-foot">
&copy; <span id="copy-year"></span> Janitorial QC System
</div>
</div>
</section>
</div>
<script>
document.getElementById('copy-year').textContent = new Date().getFullYear();
// Password visibility toggle
(function () {
var btn = document.getElementById('pw-toggle');
var icon = document.getElementById('pw-toggle-icon');
var input = document.getElementById('password');
if (btn && input) {
btn.addEventListener('click', function () {
var show = input.type === 'password';
input.type = show ? 'text' : 'password';
icon.classList.toggle('bi-eye', !show);
icon.classList.toggle('bi-eye-slash', show);
btn.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
});
}
})();
// Remove .login-page when navigating away (SPA-style guard)
window.addEventListener('pagehide', function () {
document.body.classList.remove('login-page');
});
</script>
{% endblock %}
+218
View File
@@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}Notification Matrix{% endblock %}
{% block extra_css %}
<style>
.matrix-wrap { max-width: 1100px; margin: 0 auto; }
/* ── Matrix table ── */
.matrix-tbl { border-collapse: collapse; width: 100%; font-size: .82rem; }
.matrix-tbl th, .matrix-tbl td {
border: 1px solid #e2e8f0;
padding: .35rem .55rem;
vertical-align: middle;
}
.matrix-tbl thead th {
background: #1a1d23; color: #fff; font-weight: 600;
text-align: center; white-space: nowrap;
}
.matrix-tbl thead th.event-col { text-align: left; }
.matrix-tbl thead th.group-hdr {
background: #2563eb; font-size: .75rem; letter-spacing: .04em;
text-transform: uppercase;
}
.matrix-tbl tbody tr:nth-child(even) td { background: #f8fafc; }
.matrix-tbl tbody tr:hover td { background: #eff6ff; }
.matrix-tbl td.event-name { font-weight: 500; color: #1a1d23; white-space: nowrap; }
.matrix-tbl td.check-cell { text-align: center; }
/* Checkbox styling */
.matrix-check {
width: 1.1rem; height: 1.1rem; cursor: pointer;
accent-color: #2563eb;
}
/* Disabled row (event has no meaningful broadcast) */
.matrix-tbl tr.implicit td { color: #94a3b8; }
.matrix-tbl tr.implicit td.event-name { color: #64748b; font-style: italic; }
/* Custom emails cell */
.custom-cell { min-width: 180px; }
.custom-input {
width: 100%; font-size: .75rem;
border: 1px solid #e2e8f0; border-radius: 4px;
padding: .2rem .4rem; color: #374151;
background: #f8fafc;
}
.custom-input:focus {
outline: none; border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37,99,235,.15);
}
/* Legend */
.legend { font-size: .78rem; color: #64748b; }
.legend span { display: inline-flex; align-items: center; gap: .3rem; margin-right: 1rem; }
/* Implicit badge */
.badge-implicit {
font-size: .62rem; background: #f1f5f9; color: #64748b;
border: 1px solid #e2e8f0; border-radius: 4px;
padding: 1px 5px; vertical-align: middle;
}
</style>
{% endblock %}
{% block content %}
<div class="matrix-wrap mt-3">
{# ── Page header ── #}
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-grid-3x3-gap-fill text-primary me-2"></i>Notification Matrix</h4>
<p class="text-muted mb-0 mt-1" style="font-size:.85rem;">
Control which roles receive email notifications for each event.
<strong>Assignee</strong>, <strong>followers</strong>, and <strong>customer portal</strong>
recipients are handled automatically where marked.
</p>
</div>
<a href="{{ url_for('auth.list_users') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Users
</a>
</div>
{# ── Legend ── #}
<div class="legend mb-3 d-flex flex-wrap align-items-center">
<span><input type="checkbox" checked disabled class="matrix-check"> Enabled by default</span>
<span><input type="checkbox" disabled class="matrix-check"> Disabled by default</span>
<span><span class="badge-implicit">implicit</span> Always notified — not controlled here</span>
</div>
<form method="POST" action="{{ url_for('auth.notification_matrix') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="table-responsive shadow-sm rounded">
<table class="matrix-tbl">
<thead>
{# Row 1: spanning group headers #}
<tr>
<th class="event-col" rowspan="2" style="min-width:200px;">Event</th>
<th class="group-hdr" colspan="4">Internal Recipients</th>
<th class="group-hdr" colspan="1">Customer</th>
<th class="group-hdr" colspan="1">Custom Recipients</th>
</tr>
{# Row 2: individual role headers #}
<tr>
{% for role_key, role_label in matrix_roles if role_key != 'custom' and role_key != 'customer' %}
<th style="min-width:80px;">{{ role_label }}</th>
{% endfor %}
<th style="min-width:80px;">Customer</th>
<th style="min-width:200px;">Email addresses<br><span style="font-weight:400;font-size:.7rem;color:#94a3b8;">(comma-separated)</span></th>
</tr>
</thead>
<tbody>
{% for event_key, event_label in matrix_events.items() %}
{% set event_state = state.get(event_key, {}) %}
<tr>
<td class="event-name">
{{ event_label }}
{# Show implicit labels where assignee/followers are always notified #}
{% if event_key in ('issue_assigned', 'issue_reassigned', 'issue_unassigned',
'issue_status', 'issue_comment') %}
<span class="badge-implicit ms-1">assignee</span>
{% endif %}
{% if event_key == 'issue_follow_update' %}
<span class="badge-implicit ms-1">followers</span>
{% endif %}
{% if event_key == 'sla_alert' %}
<span class="badge-implicit ms-1">assignee</span>
<span class="badge-implicit ms-1">followers</span>
{% endif %}
</td>
{# Internal role checkboxes (admin, supervisor, inspector, project_manager) #}
{% for role_key, _ in matrix_roles if role_key not in ('custom', 'customer') %}
{% set row = event_state.get(role_key) %}
{% if row is not none %}
{% set checked = row.enabled %}
{% else %}
{% set checked = defaults.get((event_key, role_key), false) %}
{% endif %}
<td class="check-cell">
<input type="checkbox"
class="matrix-check"
name="matrix_{{ event_key }}_{{ role_key }}"
value="1"
{% if checked %}checked{% endif %}>
</td>
{% endfor %}
{# Customer checkbox #}
{% set cust_row = event_state.get('customer') %}
{% if cust_row is not none %}
{% set cust_checked = cust_row.enabled %}
{% else %}
{% set cust_checked = defaults.get((event_key, 'customer'), false) %}
{% endif %}
<td class="check-cell">
<input type="checkbox"
class="matrix-check"
name="matrix_{{ event_key }}_customer"
value="1"
{% if cust_checked %}checked{% endif %}>
</td>
{# Custom emails text input #}
{% set custom_row = event_state.get('custom') %}
{% if custom_row is not none %}
{% set custom_val = custom_row.get_custom_emails() | join(', ') %}
{% else %}
{% set custom_val = '' %}
{% endif %}
<td class="custom-cell">
<input type="text"
class="custom-input"
name="custom_{{ event_key }}"
value="{{ custom_val }}"
placeholder="e.g. ops@company.com, mgr@co.com">
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-3 d-flex gap-2 align-items-center">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2-circle me-1"></i>Save Matrix
</button>
<a href="{{ url_for('auth.notification_matrix') }}" class="btn btn-outline-secondary">
Reset
</a>
<span class="text-muted ms-2" style="font-size:.8rem;">
<i class="bi bi-info-circle me-1"></i>
Changes take effect immediately for all subsequent notifications.
</span>
</div>
</form>
{# ── Notes card ── #}
<div class="card mt-4 border-0 bg-light">
<div class="card-body py-2 px-3">
<p class="mb-1" style="font-size:.8rem;">
<strong>Internal:</strong> Admin, Supervisor, Inspector, and Project Manager users
receive in-app notifications and emails based on their individual
<a href="{{ url_for('notifications.preferences') }}">preference settings</a>.
</p>
<p class="mb-1" style="font-size:.8rem;">
<strong>Customer:</strong> Customer-portal users are notified only for facilities
they are assigned to via their contract/facility assignments.
</p>
<p class="mb-0" style="font-size:.8rem;">
<strong>Custom Recipients:</strong> Additional email addresses (e.g. external managers)
receive a plain email. They do not get in-app notifications and are not affected
by individual user preference settings.
</p>
</div>
</div>
</div>
{% endblock %}
+224
View File
@@ -0,0 +1,224 @@
{% extends "base.html" %}
{% block title %}My Profile{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2><i class="bi bi-person-circle"></i> My Profile</h2>
<p class="text-muted mb-0">Manage your account information and review your activity.</p>
</div>
</div>
<div class="row g-4">
<!-- ── Left column: account card + stats ─────────────────────────── -->
<div class="col-lg-4">
<!-- Account Info Card -->
<div class="card shadow-sm mb-4">
<div class="card-body text-center py-4">
<div class="mb-3">
<span class="display-1 text-primary">
<i class="bi bi-person-circle"></i>
</span>
</div>
<h4 class="mb-1 fw-bold">{{ current_user.display_name }}</h4>
{% if current_user.full_name %}
<p class="text-muted mb-1" style="font-size:.85rem;">@{{ current_user.username }}</p>
{% endif %}
<p class="text-muted mb-2">{{ current_user.email }}</p>
<span class="badge fs-6 bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% else %}info{% endif %}">
<i class="bi bi-{% if current_user.role == 'admin' %}shield-fill{% elif current_user.role == 'director' %}star-fill{% else %}person-badge-fill{% endif %} me-1"></i>
{{ current_user.role | title }}
</span>
<hr>
<small class="text-muted">
<i class="bi bi-calendar3 me-1"></i>
Member since {{ current_user.created_at.strftime('%B %d, %Y') }}
</small>
</div>
</div>
<!-- Activity Stats Card -->
<div class="card shadow-sm">
<div class="card-header bg-light">
<h6 class="mb-0 fw-semibold"><i class="bi bi-bar-chart-fill me-1"></i>Activity Summary</h6>
</div>
<div class="card-body">
<div class="row text-center g-3">
<div class="col-6">
<div class="p-2 rounded bg-primary bg-opacity-10">
<div class="fs-3 fw-bold text-primary">{{ total_inspections }}</div>
<small class="text-muted">Total Inspections</small>
</div>
</div>
<div class="col-6">
<div class="p-2 rounded bg-success bg-opacity-10">
<div class="fs-3 fw-bold text-success">{{ completed_inspections }}</div>
<small class="text-muted">Completed</small>
</div>
</div>
<div class="col-6">
<div class="p-2 rounded bg-warning bg-opacity-10">
<div class="fs-3 fw-bold text-warning">{{ open_issues }}</div>
<small class="text-muted">Open Issues</small>
</div>
</div>
<div class="col-6">
<div class="p-2 rounded bg-info bg-opacity-10">
{% if total_inspections > 0 %}
<div class="fs-3 fw-bold text-info">
{{ ((completed_inspections / total_inspections) * 100) | int }}%
</div>
{% else %}
<div class="fs-3 fw-bold text-info"></div>
{% endif %}
<small class="text-muted">Completion Rate</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Right column: edit form + recent inspections ──────────────── -->
<div class="col-lg-8">
<!-- Edit Profile Form -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h6 class="mb-0 fw-semibold"><i class="bi bi-pencil-square me-1"></i>Edit Profile</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('auth.profile') }}">
{{ form.hidden_tag() }}
<!-- Full Name -->
<div class="mb-3">
{{ form.full_name.label(class="form-label fw-semibold") }}
{{ form.full_name(class="form-control", placeholder="e.g. Jane Smith") }}
{% for error in form.full_name.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
</div>
<!-- Email -->
<div class="mb-3">
{{ form.email.label(class="form-label fw-semibold") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
placeholder="your@email.com") }}
{% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<hr class="my-3">
<p class="text-muted small mb-3">
<i class="bi bi-lock me-1"></i>Leave the password fields blank to keep your current password.
</p>
<!-- Current Password -->
<div class="mb-3">
{{ form.current_password.label(class="form-label fw-semibold") }}
{{ form.current_password(class="form-control" + (" is-invalid" if form.current_password.errors else ""),
autocomplete="current-password") }}
{% for error in form.current_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<!-- New Password -->
<div class="mb-3">
{{ form.new_password.label(class="form-label fw-semibold") }}
{{ form.new_password(class="form-control" + (" is-invalid" if form.new_password.errors else ""),
autocomplete="new-password") }}
{% for error in form.new_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="form-text">Minimum 6 characters.</div>
</div>
<!-- Confirm New Password -->
<div class="mb-4">
{{ form.confirm_password.label(class="form-label fw-semibold") }}
{{ form.confirm_password(class="form-control" + (" is-invalid" if form.confirm_password.errors else ""),
autocomplete="new-password") }}
{% for error in form.confirm_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Save Changes
</button>
</form>
</div>
</div>
<!-- Recent Inspections -->
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold"><i class="bi bi-clipboard-check me-1"></i>Recent Inspections</h6>
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-primary">
View All
</a>
</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>Template</th>
<th>Score</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for insp in recent_inspections %}
<tr>
<td>{{ insp.inspection_date.strftime('%Y-%m-%d') }}</td>
<td>{{ insp.facility.name if insp.facility else '—' }}</td>
<td>{{ insp.template.name if insp.template else '—' }}</td>
<td>
{% if insp.overall_score is not none %}
<span class="badge bg-{% if insp.overall_score >= 80 %}success{% elif insp.overall_score >= 60 %}warning{% else %}danger{% endif %}">
{{ "%.1f"|format(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 == 'in_progress' %}primary{% else %}warning{% endif %}">
{{ insp.status | replace('_', ' ') | title }}
</span>
</td>
<td>
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4 text-muted">
<i class="bi bi-clipboard-x fs-3 d-block mb-2"></i>
No inspections recorded yet.
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
+151
View File
@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Password — Janitorial QC</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background: #eef0f4; font-family: 'Segoe UI', Arial, sans-serif; }
.setup-card {
max-width: 460px; margin: 80px auto;
border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
}
.setup-header {
background: #1a1d23; color: #fff;
border-radius: 12px 12px 0 0;
padding: 1.5rem 1.75rem 1.25rem;
}
.setup-header h4 { margin: 0; font-weight: 600; }
.setup-header p { color: #94a3b8; font-size: .85rem; margin: .35rem 0 0; }
.setup-body { background: #fff; border-radius: 0 0 12px 12px; padding: 1.75rem; }
.req-item { font-size: .8rem; color: #64748b; }
.req-item.met { color: #16a34a; }
.strength-bar { height: 4px; border-radius: 2px; transition: all .3s; }
</style>
</head>
<body>
<div class="setup-card">
<div class="setup-header">
<h4><i class="bi bi-shield-lock me-2"></i>Set a New Password</h4>
<p>Hi {{ user.display_name }}. Choose a new secure password for your account.</p>
</div>
<div class="setup-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ 'danger' if cat == 'danger' else 'warning' if cat == 'warning' else 'success' }}
alert-dismissible fade show py-2 mb-3" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="password" class="form-label fw-semibold">New Password</label>
<input type="password"
id="password"
name="password"
class="form-control {{ 'is-invalid' if form.password.errors else '' }}"
autocomplete="new-password"
autofocus>
{% for error in form.password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="mt-2 mb-1">
<div class="bg-light rounded" style="height:4px;">
<div id="strengthBar" class="strength-bar bg-secondary" style="width:0%;"></div>
</div>
</div>
<div class="d-flex flex-wrap gap-3 mt-2">
<span class="req-item" id="req-len">
<i class="bi bi-circle me-1"></i>8+ characters
</span>
<span class="req-item" id="req-upper">
<i class="bi bi-circle me-1"></i>Uppercase letter
</span>
<span class="req-item" id="req-num">
<i class="bi bi-circle me-1"></i>Number
</span>
</div>
</div>
<div class="mb-4">
<label for="confirm_password" class="form-label fw-semibold">Confirm Password</label>
<input type="password"
id="confirm_password"
name="confirm_password"
class="form-control {{ 'is-invalid' if form.confirm_password.errors else '' }}"
autocomplete="new-password">
{% for error in form.confirm_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div id="matchFeedback" class="form-text" style="display:none;"></div>
</div>
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
<i class="bi bi-check2-circle me-1"></i>Reset Password &amp; Log In
</button>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function () {
'use strict';
var pwEl = document.getElementById('password');
var cfEl = document.getElementById('confirm_password');
var bar = document.getElementById('strengthBar');
var reqLen = document.getElementById('req-len');
var reqUpper = document.getElementById('req-upper');
var reqNum = document.getElementById('req-num');
var matchFb = document.getElementById('matchFeedback');
function markReq(el, met) {
el.className = 'req-item' + (met ? ' met' : '');
el.querySelector('i').className = (met ? 'bi bi-check-circle-fill' : 'bi bi-circle') + ' me-1';
}
function updateStrength(pw) {
var hasLen = pw.length >= 8;
var hasUpper = /[A-Z]/.test(pw);
var hasNum = /[0-9]/.test(pw);
var score = [hasLen, hasUpper, hasNum, pw.length >= 12].filter(Boolean).length;
markReq(reqLen, hasLen);
markReq(reqUpper, hasUpper);
markReq(reqNum, hasNum);
var color = score <= 1 ? 'danger' : score === 2 ? 'warning' : score === 3 ? 'info' : 'success';
bar.style.width = (score * 25) + '%';
bar.className = 'strength-bar bg-' + color;
}
function checkMatch() {
if (!cfEl.value) { matchFb.style.display = 'none'; return; }
matchFb.style.display = '';
if (pwEl.value === cfEl.value) {
matchFb.textContent = '✓ Passwords match';
matchFb.style.color = '#16a34a';
} else {
matchFb.textContent = '✗ Passwords do not match';
matchFb.style.color = '#dc2626';
}
}
pwEl.addEventListener('input', function () { updateStrength(this.value); checkMatch(); });
cfEl.addEventListener('input', checkMatch);
})();
</script>
</body>
</html>
+130
View File
@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 offset-md-2">
{# ── Account Status card (edit mode only, not own account) ── #}
{% if user and user.id != current_user.id %}
<div class="card shadow-sm mb-3">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<span class="fw-semibold me-2">Account Status:</span>
<span class="badge fs-6 bg-{{ 'success' if user.active else 'secondary' }}">
{{ 'Active' if user.active else 'Disabled' }}
</span>
<div class="form-text mt-1">
{% if user.active %}
Disabling this account will immediately prevent the user from logging in.
{% else %}
This account is currently disabled — the user cannot log in.
{% endif %}
</div>
</div>
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-sm {{ 'btn-outline-secondary' if user.active else 'btn-outline-success' }}"
onclick="return confirm('{{ 'Disable' if user.active else 'Enable' }} user {{ user.username }}?')">
<i class="bi bi-{{ 'person-slash' if user.active else 'person-check' }} me-1"></i>
{{ 'Disable Account' if user.active else 'Enable Account' }}
</button>
</form>
</div>
</div>
{% endif %}
{# ── Edit form ── #}
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">{{ title }}</h4>
</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-6 mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control") }}
{% if form.username.errors %}
<div class="text-danger small mt-1">
{% for error in form.username.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
{{ form.full_name.label(class="form-label") }}
{{ form.full_name(class="form-control", placeholder="e.g. Jane Smith") }}
{% if form.full_name.errors %}
<div class="text-danger small mt-1">
{% for error in form.full_name.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% if form.email.errors %}
<div class="text-danger small mt-1">
{% for error in form.email.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }}
{% if form.password.errors %}
<div class="text-danger small mt-1">
{% for error in form.password.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
{{ form.confirm_password.label(class="form-label") }}
{{ form.confirm_password(class="form-control") }}
{% if form.confirm_password.errors %}
<div class="text-danger small mt-1">
{% for error in form.confirm_password.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="mb-4">
{{ form.role.label(class="form-label") }}
{% if director_editing %}
{# Directors can see the current role but cannot change it #}
<div class="form-control bg-light text-muted" style="cursor: not-allowed;">
{{ (user.role if user else 'Inspector').replace('_', ' ')|title }}
</div>
<div class="form-text">Role assignment requires Administrator access.</div>
{% else %}
{{ form.role(class="form-select") }}
{% endif %}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Save User
</button>
<a href="{{ url_for('auth.list_users') }}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+99
View File
@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}User Management{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-md-6">
<h2><i class="bi bi-people-fill"></i> Internal User Management</h2>
</div>
<div class="col-md-6 text-end">
<a href="{{ url_for('auth.create_user') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add User
</a>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Username</th>
<th>Full Name</th>
<th>Email</th>
<th>Role</th>
<th>Contracts</th>
<th>Created</th>
<th>Status</th>
<th width="220">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="{{ 'table-secondary text-muted' if not user.active else '' }}">
<td><strong>{{ user.username }}</strong></td>
<td>{{ user.full_name or '—' }}</td>
<td>{{ user.email }}</td>
<td>
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
{{ user.role.replace('_',' ')|title }}
</span>
</td>
<td>
{% if user.role == 'inspector' %}
{% set cnt = inspector_contract_counts.get(user.id, 0) %}
{% if cnt > 0 %}
<span class="badge bg-success">{{ cnt }} contract{{ 's' if cnt != 1 else '' }}</span>
{% else %}
<span class="badge bg-warning text-dark" title="No contracts assigned — inspector sees nothing">None</span>
{% endif %}
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if user.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Disabled</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('auth.edit_user', user_id=user.id) }}" class="btn btn-sm btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
{% if user.role == 'inspector' %}
<a href="{{ url_for('auth.assign_inspector_contracts', user_id=user.id) }}"
class="btn btn-sm btn-outline-secondary" title="Assign contracts">
<i class="bi bi-briefcase"></i>
</a>
{% endif %}
{% if user.id != current_user.id %}
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-sm {{ 'btn-outline-secondary' if user.active else 'btn-outline-success' }}"
title="{{ 'Disable' if user.active else 'Enable' }}"
onclick="return confirm('{{ 'Disable' if user.active else 'Enable' }} user {{ user.username }}?')">
<i class="bi bi-{{ 'person-slash' if user.active else 'person-check' }}"></i>
</button>
</form>
<form method="POST" action="{{ url_for('auth.delete_user', user_id=user.id) }}" class="d-inline" onsubmit="return confirm('Delete this user?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+463
View File
@@ -0,0 +1,463 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<!-- iOS / iPadOS web app meta tags -->
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="mobile-web-app-capable" content="yes">
<title>{% block title %}Janitorial QC System{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/ipad_responsive.css') }}">
{% block extra_css %}{% endblock %}
<style>
/* ── Notification bell styles ── */
.notif-bell-wrapper { position: relative; }
.notif-badge {
position: absolute;
top: 2px; right: 2px;
font-size: 0.6rem;
min-width: 16px; height: 16px; line-height: 16px;
padding: 0 4px; border-radius: 8px;
pointer-events: none;
}
.notif-dropdown {
width: 380px;
max-height: 520px;
overflow-y: auto;
padding: 0;
}
.notif-item {
border-left: 3px solid transparent;
transition: background 0.15s;
cursor: pointer;
}
.notif-item.unread {
border-left-color: #0d6efd;
background-color: #f0f6ff;
}
.notif-item:hover { background-color: #e8f0fe; }
.notif-title { font-size: 0.85rem; font-weight: 600; margin-bottom: 2px; }
.notif-body { font-size: 0.78rem; color: #555; white-space: normal; }
.notif-time { font-size: 0.7rem; color: #999; }
.notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; }
/* ── Active nav tab ── */
.navbar-dark .navbar-nav .nav-link.active {
background-color: rgba(255, 255, 255, 0.18);
color: #ffffff !important;
border-radius: 6px;
font-weight: 600;
box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6);
}
.navbar-dark .navbar-nav .nav-link:not(.active):hover {
background-color: rgba(255, 255, 255, 0.08);
border-radius: 6px;
}
</style>
</head>
<body>
{% if current_user.is_authenticated %}
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
<i class="bi bi-clipboard-check"></i> Janitorial QC
</a>
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-lg-none">
<!-- Notification bell (always visible) -->
<div class="dropdown">
<a class="nav-link position-relative notif-bell-wrapper text-white"
href="#"
id="notifDropdownMobile"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu-mobile">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
style="font-size:.75rem;">Mark all as read</button>
</div>
<div class="notif-list-mobile">
<div class="notif-empty">Loading…</div>
</div>
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</div>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('dashboard.') }}" href="{{ url_for('dashboard.index') }}">Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and (request.endpoint.startswith('reports.') or request.endpoint.startswith('scheduled_reports.')) }}" href="{{ url_for('reports.index') }}">Reports</a>
</li>
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('projects.') }}" href="{{ url_for('projects.index') }}">Contracts</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('facilities.') }}" href="{{ url_for('facilities.list_facilities') }}">Facilities</a>
</li>
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('templates.') }}" href="{{ url_for('templates.index') }}">Templates</a>
</li>
{% endif %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('inspections.') }}" href="{{ url_for('inspections.index') }}">Inspections</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('issues.') and request.endpoint != 'issues.verification_queue' }}" href="{{ url_for('issues.index') }}">Issues</a>
</li>
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link d-flex align-items-center gap-1 {{ 'active' if request.endpoint == 'issues.verification_queue' }}"
href="{{ url_for('issues.verification_queue') }}">
Verify
{% if pending_verification_count and pending_verification_count > 0 %}
<span class="badge bg-info text-dark"
style="font-size:.65rem;line-height:1;">
{{ pending_verification_count }}
</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('auth.') and 'user' in request.endpoint }}" href="{{ url_for('auth.list_users') }}">Users</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('customers.') }}" href="{{ url_for('customers.index') }}">Customers</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="{{ url_for('support.admin_tickets') }}">
Support
{% if open_support_tickets_count > 0 %}
<span class="badge bg-danger ms-1">{{ open_support_tickets_count }}</span>
{% endif %}
</a>
</li>
{% endif %}
{% if current_user.role == 'customer' %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle {{ 'active' if request.endpoint and request.endpoint.startswith('support.') }}"
href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-chat-dots me-1"></i>Support
</a>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="{{ url_for('support.chat') }}">
<i class="bi bi-chat-dots me-2"></i>Ask a Question
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('support.my_tickets') }}">
<i class="bi bi-inbox me-2"></i>My Requests
</a>
</li>
</ul>
</li>
{% endif %}
{% if current_user.role == 'admin' %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('audit.') }}" href="{{ url_for('audit.index') }}">Audit Trail</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'auth.notification_matrix' }}" href="{{ url_for('auth.notification_matrix') }}">
<i class="bi bi-grid-3x3-gap-fill"></i> Notif. Matrix
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('broadcast.') }}"
href="{{ url_for('broadcast.index') }}">
<i class="bi bi-megaphone-fill"></i> Broadcast
</a>
</li>
{% endif %}
</ul>
<ul class="navbar-nav align-items-center">
<!-- ── Notification Bell (desktop lg+ only) ── -->
<li class="nav-item dropdown me-2 d-none d-lg-block">
<a class="nav-link position-relative notif-bell-wrapper"
href="#"
id="notifDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center
px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none"
id="mark-all-read-btn" style="font-size:.75rem;">
Mark all as read
</button>
</div>
<!-- Items -->
<div id="notif-list">
<div class="notif-empty">Loading…</div>
</div>
<!-- Footer -->
<div class="border-top d-flex justify-content-between px-3 py-2"
style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}"
class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}"
class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</li>
<!-- ── End Notification Bell ── -->
<!-- User menu -->
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" id="navbarDropdown"
role="button" data-bs-toggle="dropdown">
<i class="bi bi-person-circle"></i> {{ current_user.username }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item"
href="{{ url_for('auth.profile') }}">
<i class="bi bi-person-circle me-1"></i>My Profile
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item"
href="{{ url_for('notifications.preferences') }}">
<i class="bi bi-bell-slash me-1"></i>Notification Preferences
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="bi bi-box-arrow-right me-1"></i>Logout
</a>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
{% endif %}
<div class="container-fluid mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
{% if current_user.is_authenticated %}
<script>
(function () {
'use strict';
const FEED_URL = '{{ url_for("notifications.feed") }}';
const MARK_READ_BASE = '/notifications/';
const MARK_ALL_URL = '{{ url_for("notifications.mark_all_read") }}';
const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds
// ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg)
const badgeDesktop = document.getElementById('notif-count-badge');
const badgeMobile = document.getElementById('notif-count-badge-mobile');
const listDesktop = document.getElementById('notif-list');
const listMobile = document.querySelector('.notif-list-mobile');
// ── Update both badge instances ────────────────────────────────────────
function updateBadge(count) {
[badgeDesktop, badgeMobile].forEach(function(badge) {
if (!badge) return;
if (count > 0) {
badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none');
} else {
badge.textContent = '';
badge.classList.add('d-none');
}
});
}
// ── Render notification items into a given container ───────────────────
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) {
container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return;
}
container.innerHTML = notifications.map(function(n) {
return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
+ (n.is_read ? '' : 'unread') + '"'
+ ' data-notif-id="' + n.id + '"'
+ ' data-link="' + escapeAttr(n.link || '') + '">'
+ '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
+ '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
+ '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
+ '</div>';
}).join('');
container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
var id = this.dataset.notifId;
var link = this.dataset.link;
markRead(id, function() {
el.classList.remove('unread');
if (link) window.location.href = link;
});
});
});
}
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
renderInto(listMobile, notifications);
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function escapeAttr(str) { return escapeHtml(str); }
// ── Fetch + update ─────────────────────────────────────────────────────
window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' })
.then(function(r) { return r.json(); })
.then(function(data) {
updateBadge(data.unread_count);
window._jqcNotifications = data.notifications;
var deskEl = document.getElementById('notifDropdown');
var mobileEl = document.getElementById('notifDropdownMobile');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
if (deskOpen || mobileOpen) {
renderNotifications(data.notifications);
}
})
.catch(function() {});
};
function markRead(id, callback) {
fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() { if (callback) callback(); fetchNotifications(); })
.catch(function() { if (callback) callback(); });
}
// ── Show dropdown → render cached data immediately ─────────────────────
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications);
} else {
fetchNotifications();
}
});
});
// ── Mark all read — works from either bell ─────────────────────────────
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
fetch(MARK_ALL_URL, {
method: 'POST',
headers: {
'X-CSRFToken': CSRF_TOKEN,
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
})
.then(function(r) { return r.json(); })
.then(function() {
updateBadge(0);
document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) {
window._jqcNotifications.forEach(function(n) { n.is_read = true; });
}
})
.catch(function() {});
});
});
fetchNotifications();
setInterval(fetchNotifications, POLL_INTERVAL);
})();
</script>
{% endif %}
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-7 offset-md-2">
{% if customer %}
<div class="card shadow-sm mb-3">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<span class="fw-semibold me-2">Account Status:</span>
<span class="badge fs-6 bg-{{ 'success' if customer.active else 'secondary' }}">
{{ 'Active' if customer.active else 'Disabled' }}
</span>
<div class="form-text mt-1">
{% if customer.active %}
Disabling prevents the customer from logging in immediately.
{% else %}
This account is currently disabled — the customer cannot log in.
{% endif %}
</div>
</div>
<form method="POST" action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }} me-1"></i>
{{ 'Disable Account' if customer.active else 'Enable Account' }}
</button>
</form>
</div>
</div>
{% endif %}
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0"><i class="bi bi-person-badge me-2"></i>{{ title }}</h4>
</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-6 mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control") }}
{% if form.username.errors %}
<div class="text-danger small mt-1">
{% for e in form.username.errors %}{{ e }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
{{ form.full_name.label(class="form-label") }}
{{ form.full_name(class="form-control", placeholder="e.g. Jane Smith") }}
{% if form.full_name.errors %}
<div class="text-danger small mt-1">
{% for e in form.full_name.errors %}{{ e }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% if form.email.errors %}
<div class="text-danger small mt-1">
{% for e in form.email.errors %}{{ e }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control",
placeholder="Leave blank to keep current" if customer else "Min. 8 characters") }}
{% if form.password.errors %}
<div class="text-danger small mt-1">
{% for e in form.password.errors %}{{ e }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
{{ form.confirm_password.label(class="form-label") }}
{{ form.confirm_password(class="form-control") }}
{% if form.confirm_password.errors %}
<div class="text-danger small mt-1">
{% for e in form.confirm_password.errors %}{{ e }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="d-flex gap-2 mt-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>
{{ 'Save Changes' if customer else 'Create Customer' }}
</button>
{% if customer %}
<a href="{{ url_for('customers.manage', customer_id=customer.id) }}"
class="btn btn-secondary">
<i class="bi bi-x-circle me-1"></i> Cancel
</a>
{% else %}
<a href="{{ url_for('customers.index') }}" class="btn btn-secondary">
<i class="bi bi-x-circle me-1"></i> Cancel
</a>
{% endif %}
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+160
View File
@@ -0,0 +1,160 @@
{% extends "base.html" %}
{% block title %}Bulk Customer Import{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2><i class="bi bi-upload text-primary me-2"></i>Bulk Customer Import</h2>
<p class="text-muted mb-0">Create multiple customer accounts and assignments from a CSV file.</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('customers.import_template') }}" class="btn btn-sm btn-outline-success">
<i class="bi bi-download me-1"></i>Download Template
</a>
<a href="{{ url_for('customers.index') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Customers
</a>
</div>
</div>
{# ── Format guide ── #}
<div class="card border-0 bg-light mb-4">
<div class="card-body py-3 px-4">
<h6 class="fw-semibold mb-2"><i class="bi bi-info-circle me-1 text-primary"></i>CSV Format</h6>
<div class="row g-3 small">
<div class="col-md-4">
<strong>Required columns</strong>
<ul class="mb-0 mt-1 ps-3">
<li><code>username</code> — unique login name</li>
<li><code>email</code> — unique email address</li>
<li><code>password</code> — min 8 characters</li>
</ul>
</div>
<div class="col-md-4">
<strong>Optional columns</strong>
<ul class="mb-0 mt-1 ps-3">
<li><code>project_name</code> — exact contract name</li>
<li><code>facility_name</code> — exact facility name within contract (leave blank for all)</li>
</ul>
</div>
<div class="col-md-4">
<strong>Tips</strong>
<ul class="mb-0 mt-1 ps-3">
<li>Repeat a username on multiple rows to assign them to multiple contracts</li>
<li>Leave <code>facility_name</code> blank to grant access to all facilities in the contract</li>
<li>Existing usernames/emails will be flagged as errors before anything is saved</li>
</ul>
</div>
</div>
</div>
</div>
{# ── Upload form ── #}
{% if not preview_rows %}
<div class="card shadow-sm">
<div class="card-header bg-primary text-white fw-semibold">
<i class="bi bi-file-earmark-arrow-up me-1"></i> Upload CSV File
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold">Select CSV File</label>
<input type="file" name="csv_file" accept=".csv" class="form-control" required>
<div class="form-text">Maximum recommended file size: 500 KB · UTF-8 encoding.</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-search me-1"></i> Parse &amp; Preview
</button>
</form>
</div>
</div>
{% else %}
{# ── Preview results ── #}
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center
bg-{{ 'danger' if has_errors else 'success' }} text-white">
<span class="fw-semibold">
<i class="bi bi-{{ 'x-circle' if has_errors else 'check-circle' }} me-1"></i>
Preview — {{ preview_rows|length }} row(s) parsed
</span>
<span>
<span class="badge bg-white text-success">{{ valid_count }} valid</span>
{% set err_count = preview_rows|length - valid_count %}
{% if err_count > 0 %}
<span class="badge bg-white text-danger ms-1">{{ err_count }} error(s)</span>
{% endif %}
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" style="font-size:.85rem;">
<thead class="table-light">
<tr>
<th width="50">Row</th>
<th>Username</th>
<th>Email</th>
<th>Contract</th>
<th>Facility Scope</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for row in preview_rows %}
<tr class="{{ 'table-danger' if row.status == 'error' else '' }}">
<td class="text-muted">{{ row.row }}</td>
<td>{{ row.username or '—' }}</td>
<td>{{ row.email or '—' }}</td>
<td>{{ row.project }}</td>
<td>{{ row.facility }}</td>
<td>
{% if row.status == 'ok' %}
<span class="badge bg-success">Ready</span>
{% else %}
<span class="badge bg-danger">Error</span>
<ul class="mb-0 ps-3 text-danger" style="font-size:.78rem;">
{% for e in row.errors %}<li>{{ e }}</li>{% endfor %}
</ul>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ── Action buttons ── #}
{% if has_errors %}
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>Errors found.</strong> Fix the issues above and re-upload.
Error rows will be skipped — only valid rows can be imported.
{% if valid_count > 0 %}
You may still import the {{ valid_count }} valid row(s) by clicking below.
{% endif %}
</div>
{% endif %}
<div class="d-flex gap-3 align-items-center">
{% if valid_count > 0 %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="confirmed" value="1">
<input type="hidden" name="rows_json" value="{{ rows_json }}">
<button type="submit" class="btn btn-success"
onclick="return confirm('Create {{ valid_count }} customer account(s) and their assignments?')">
<i class="bi bi-person-check me-1"></i>
Import {{ valid_count }} Valid Row{{ 's' if valid_count != 1 else '' }}
</button>
</form>
{% endif %}
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-counterclockwise me-1"></i> Upload Different File
</a>
</div>
{% endif %}
{% endblock %}
+152
View File
@@ -0,0 +1,152 @@
{% extends "base.html" %}
{% block title %}Customer Management{% endblock %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col">
<h2><i class="bi bi-person-badge"></i> Customer Management</h2>
<p class="text-muted mb-0">Manage portal access for all customer accounts.</p>
</div>
<div class="col-auto d-flex gap-2">
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-success">
<i class="bi bi-upload"></i> Import CSV
</a>
<a href="{{ url_for('customers.create') }}" class="btn btn-primary">
<i class="bi bi-person-plus"></i> New Customer
</a>
</div>
</div>
{% if customers %}
{% if expired_invitations %}
<div class="alert alert-warning d-flex align-items-start gap-3 mb-3" role="alert">
<i class="bi bi-exclamation-triangle-fill fs-5 mt-1 flex-shrink-0"></i>
<div>
<strong>{{ expired_invitations|length }} invitation{{ 's' if expired_invitations|length != 1 else '' }} expired</strong>
— the following customer{{ 's' if expired_invitations|length != 1 else '' }} never completed account setup
and {{ 'their' if expired_invitations|length != 1 else 'their' }} link has expired:
<ul class="mb-2 mt-1">
{% for c in expired_invitations %}
<li>
<strong>{{ c.display_name }}</strong> ({{ c.email }}) —
expired {{ c.set_password_token_expires.strftime('%Y-%m-%d %H:%M') }}
&nbsp;
<form method="POST" action="{{ url_for('customers.resend_invite', customer_id=c.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-warning py-0 px-2"
style="font-size:.75rem;">
<i class="bi bi-send me-1"></i>Resend
</button>
</form>
</li>
{% endfor %}
</ul>
<span class="text-muted small">Resend a fresh 72-hour invitation link or delete the account if it is no longer needed.</span>
</div>
</div>
{% endif %}
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Username</th>
<th>Full Name</th>
<th>Email</th>
<th>Status</th>
<th>Assigned Contracts</th>
<th>Accessible Facilities</th>
<th>Created</th>
<th width="160"></th>
</tr>
</thead>
<tbody>
{% for customer in customers %}
{% set assignments = assignment_map[customer.id] %}
{% set facility_ids = scope_map[customer.id] %}
<tr class="{{ 'table-secondary text-muted' if not customer.active else '' }}">
<td>
<strong>
<a href="{{ url_for('customers.manage', customer_id=customer.id) }}"
class="text-decoration-none">
{{ customer.username }}
</a>
</strong>
</td>
<td>{{ customer.full_name or '—' }}</td>
<td class="small text-muted">{{ customer.email }}</td>
<td>
{% if customer.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Disabled</span>
{% endif %}
</td>
<td>
{% if assignments %}
{% set project_names = assignments | map(attribute='project') | map(attribute='name') | unique | list %}
{% for pname in project_names %}
<span class="badge bg-primary me-1">{{ pname }}</span>
{% endfor %}
{% else %}
<span class="text-muted small">— None —</span>
{% endif %}
</td>
<td>
{% if facility_ids %}
<span class="badge bg-info text-dark">{{ facility_ids|length }} facilit{{ 'y' if facility_ids|length == 1 else 'ies' }}</span>
{% else %}
<span class="text-muted small">— None —</span>
{% endif %}
</td>
<td class="small text-muted">{{ customer.created_at.strftime('%Y-%m-%d') }}</td>
<td class="text-end">
<a href="{{ url_for('customers.manage', customer_id=customer.id) }}"
class="btn btn-sm btn-outline-primary" title="Manage">
<i class="bi bi-gear"></i>
</a>
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<form method="POST"
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
title="{{ 'Disable' if customer.active else 'Enable' }}"
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }}"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ── Summary footer ── #}
<div class="mt-3 text-muted small">
{{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total
· {{ customers|selectattr('active')|list|length }} active
</div>
{% else %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-person-badge fs-1 d-block mb-3 opacity-25"></i>
<p class="mb-3">No customer accounts have been created yet.</p>
<a href="{{ url_for('customers.create') }}" class="btn btn-primary">
<i class="bi bi-person-plus"></i> Create First Customer
</a>
</div>
</div>
{% endif %}
{% endblock %}
+75
View File
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}Create Customer Account{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="bi bi-person-plus-fill me-2"></i>Create Customer Account
</h4>
</div>
<div class="card-body">
<p class="text-muted mb-4" style="font-size:.9rem;">
Enter the customer's name and email address. An invitation email will be
sent automatically with a secure link where they can choose their own
username and password. The account will be activated once they complete that step.
</p>
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.full_name.label(class="form-label fw-semibold") }}
{{ form.full_name(class="form-control" + (" is-invalid" if form.full_name.errors else ""),
placeholder="e.g. Jane Smith", autofocus=true) }}
{% for error in form.full_name.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<div class="mb-4">
{{ form.email.label(class="form-label fw-semibold") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
placeholder="jane@example.com") }}
{% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="form-text">
<i class="bi bi-envelope me-1"></i>
An invitation email with an account setup link will be sent to this address.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Create &amp; Send Invitation
</button>
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i>Cancel
</a>
</div>
</form>
</div>
</div>
<div class="card mt-3 border-0 bg-light">
<div class="card-body py-2 px-3">
<p class="mb-1" style="font-size:.8rem;">
<i class="bi bi-info-circle me-1 text-primary"></i>
<strong>What happens next:</strong>
</p>
<ol class="mb-0 ps-3" style="font-size:.8rem; color:#555;">
<li>An invitation email is sent to the customer with a secure 72-hour link.</li>
<li>The customer clicks the link and chooses their own username and password.</li>
<li>The account becomes fully active and they can log in immediately.</li>
</ol>
</div>
</div>
</div>
</div>
{% endblock %}
+249
View File
@@ -0,0 +1,249 @@
{% extends "base.html" %}
{% block title %}{{ customer.username }} — Customer Portal{% endblock %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col">
<h2>
<i class="bi bi-person-badge"></i> {{ customer.display_name }}
{% if not customer.active %}
<span class="badge bg-secondary ms-2 fs-6">Disabled</span>
{% else %}
<span class="badge bg-success ms-2 fs-6">Active</span>
{% endif %}
</h2>
<p class="text-muted mb-0 small">{{ customer.email }}{% if customer.full_name %} · @{{ customer.username }}{% endif %}</p>
</div>
<div class="col-auto d-flex gap-2">
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil"></i> Edit Account
</a>
<form method="POST"
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }} me-1"></i>
{{ 'Disable' if customer.active else 'Enable' }}
</button>
</form>
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-arrow-left"></i> All Customers
</a>
</div>
</div>
<div class="row g-4">
{# ── Left column: account info + scoped facilities ── #}
<div class="col-md-4">
<div class="card shadow-sm mb-3">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-person-circle me-1"></i> Account Details
</div>
<div class="card-body">
<dl class="row mb-0 small">
<dt class="col-5 text-muted">Full Name</dt>
<dd class="col-7">{{ customer.full_name or '—' }}</dd>
<dt class="col-5 text-muted">Username</dt>
<dd class="col-7">{{ customer.username }}</dd>
<dt class="col-5 text-muted">Email</dt>
<dd class="col-7">{{ customer.email }}</dd>
<dt class="col-5 text-muted">Status</dt>
<dd class="col-7">
<span class="badge bg-{{ 'success' if customer.active else 'secondary' }}">
{{ 'Active' if customer.active else 'Disabled' }}
</span>
</dd>
<dt class="col-5 text-muted">Password</dt>
<dd class="col-7">
{% if customer.password_set %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Set</span>
{% else %}
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>Pending setup</span>
{% endif %}
</dd>
<dt class="col-5 text-muted">Created</dt>
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
<dt class="col-5 text-muted">Assignments</dt>
<dd class="col-7">{{ assignments|length }}</dd>
<dt class="col-5 text-muted">Facilities</dt>
<dd class="col-7">{{ facilities|length }}</dd>
</dl>
{% if not customer.password_set %}
<hr class="my-3">
<form method="POST"
action="{{ url_for('customers.resend_invite', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-primary btn-sm w-100"
onclick="return confirm('Resend invitation email to {{ customer.email }}?')">
<i class="bi bi-send me-1"></i>Resend Invitation Email
</button>
</form>
{% endif %}
</div>
</div>
{# ── Scoped facilities ── #}
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-building me-1"></i> Accessible Facilities
</div>
{% if facilities %}
<div class="card-body p-0">
<ul class="list-group list-group-flush">
{% for f in facilities %}
<li class="list-group-item d-flex justify-content-between align-items-center
py-2 px-3 small">
<span>
<i class="bi bi-building text-muted me-1"></i>{{ f.name }}
</span>
{% if f.project %}
<span class="badge bg-primary" style="font-size:.65rem;">{{ f.project.name }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% else %}
<div class="card-body text-muted small">
No facilities accessible yet — add an assignment below.
</div>
{% endif %}
</div>
</div>
{# ── Right column: assignments ── #}
<div class="col-md-8">
{# ── Current assignments table ── #}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
<span><i class="bi bi-diagram-3 me-1"></i> Contract Assignments</span>
</div>
{% if assignments %}
<div class="card-body p-0">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>Contract</th>
<th>Facility Scope</th>
<th>Assigned</th>
<th width="60"></th>
</tr>
</thead>
<tbody>
{% for a in assignments %}
<tr>
<td class="small">
<a href="{{ url_for('projects.view', project_id=a.project_id) }}"
class="text-decoration-none">
{{ a.project.name }}
</a>
</td>
<td>
{% if a.facility %}
<span class="badge bg-info text-dark small">{{ a.facility.name }}</span>
{% else %}
<span class="badge bg-secondary small">All facilities</span>
{% endif %}
</td>
<td class="text-muted small">{{ a.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<form method="POST"
action="{{ url_for('customers.remove_assignment', assignment_id=a.id) }}"
onsubmit="return confirm('Remove this assignment?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Remove">
<i class="bi bi-x-lg"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body text-muted small">No assignments yet.</div>
{% endif %}
</div>
{# ── Add assignment form ── #}
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-plus-circle me-1"></i> Add Assignment
</div>
<div class="card-body">
<form method="POST"
action="{{ url_for('customers.add_assignment', customer_id=customer.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row g-3 align-items-end">
<div class="col-md-5">
<label class="form-label small fw-semibold">Contract</label>
<select name="project_id" id="proj-select" class="form-select form-select-sm"
required>
<option value="">— Select contract —</option>
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-5">
<label class="form-label small fw-semibold">Facility Scope</label>
<select name="facility_id" id="fac-select" class="form-select form-select-sm">
<option value="">— All facilities in contract —</option>
</select>
<div class="form-text" style="font-size:.72rem;">
Leave blank to grant access to all facilities in the contract.
</div>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary btn-sm w-100">
<i class="bi bi-plus-circle me-1"></i> Add
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
const projSelect = document.getElementById('proj-select');
const facSelect = document.getElementById('fac-select');
projSelect.addEventListener('change', function () {
const projectId = this.value;
facSelect.innerHTML = '<option value="">— All facilities in contract —</option>';
if (!projectId) return;
fetch('/customers/facilities-for-project/' + projectId, { credentials: 'same-origin' })
.then(r => r.json())
.then(data => {
data.forEach(function (f) {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name;
facSelect.appendChild(opt);
});
})
.catch(function () {});
});
}());
</script>
{% endblock %}
+179
View File
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Set Your Password — Janitorial QC</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background: #eef0f4; font-family: 'Segoe UI', Arial, sans-serif; }
.setup-card {
max-width: 460px; margin: 80px auto;
border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
}
.setup-header {
background: #1a1d23; color: #fff;
border-radius: 12px 12px 0 0;
padding: 1.5rem 1.75rem 1.25rem;
}
.setup-header h4 { margin: 0; font-weight: 600; }
.setup-header p { color: #94a3b8; font-size: .85rem; margin: .35rem 0 0; }
.setup-body { background: #fff; border-radius: 0 0 12px 12px; padding: 1.75rem; }
.req-item { font-size: .8rem; color: #64748b; }
.req-item.met { color: #16a34a; }
.strength-bar { height: 4px; border-radius: 2px; transition: all .3s; }
</style>
</head>
<body>
<div class="setup-card">
<div class="setup-header">
<h4><i class="bi bi-shield-lock me-2"></i>Set Your Password</h4>
<p>Welcome, {{ user.display_name }}. Choose your username and a secure password to activate your account.</p>
</div>
<div class="setup-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ 'danger' if cat == 'danger' else 'warning' if cat == 'warning' else 'success' }}
alert-dismissible fade show py-2 mb-3" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
<form method="POST" id="setPasswordForm" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="username" class="form-label fw-semibold">Choose a Username</label>
<input type="text"
id="username"
name="username"
class="form-control {{ 'is-invalid' if form.username.errors else '' }}"
autocomplete="username"
autofocus
maxlength="100">
{% for error in form.username.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="form-text" style="font-size:.78rem;">
3100 characters. You will use this to log in.
</div>
</div>
<div class="mb-3">
<label for="password" class="form-label fw-semibold">New Password</label>
<input type="password"
id="password"
name="password"
class="form-control {{ 'is-invalid' if form.password.errors else '' }}"
autocomplete="new-password">
{% for error in form.password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
{# Strength bar #}
<div class="mt-2 mb-1">
<div class="bg-light rounded" style="height:4px;">
<div id="strengthBar" class="strength-bar bg-secondary" style="width:0%;"></div>
</div>
</div>
{# Requirements checklist #}
<div class="d-flex flex-wrap gap-3 mt-2">
<span class="req-item" id="req-len">
<i class="bi bi-circle me-1"></i>8+ characters
</span>
<span class="req-item" id="req-upper">
<i class="bi bi-circle me-1"></i>Uppercase letter
</span>
<span class="req-item" id="req-num">
<i class="bi bi-circle me-1"></i>Number
</span>
</div>
</div>
<div class="mb-4">
<label for="confirm_password" class="form-label fw-semibold">Confirm Password</label>
<input type="password"
id="confirm_password"
name="confirm_password"
class="form-control {{ 'is-invalid' if form.confirm_password.errors else '' }}"
autocomplete="new-password">
{% for error in form.confirm_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div id="matchFeedback" class="form-text" style="display:none;"></div>
</div>
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
<i class="bi bi-check2-circle me-1"></i>Activate Account &amp; Log In
</button>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function () {
'use strict';
var pwEl = document.getElementById('password');
var cfEl = document.getElementById('confirm_password');
var bar = document.getElementById('strengthBar');
var reqLen = document.getElementById('req-len');
var reqUpper = document.getElementById('req-upper');
var reqNum = document.getElementById('req-num');
var matchFb = document.getElementById('matchFeedback');
var submitBtn = document.getElementById('submitBtn');
function markReq(el, met) {
el.className = 'req-item' + (met ? ' met' : '');
el.querySelector('i').className = (met ? 'bi bi-check-circle-fill' : 'bi bi-circle') + ' me-1';
}
function updateStrength(pw) {
var score = 0;
var hasLen = pw.length >= 8;
var hasUpper = /[A-Z]/.test(pw);
var hasNum = /[0-9]/.test(pw);
if (hasLen) score++;
if (hasUpper) score++;
if (hasNum) score++;
if (pw.length >= 12) score++;
markReq(reqLen, hasLen);
markReq(reqUpper, hasUpper);
markReq(reqNum, hasNum);
var pct = score * 25;
var color = score <= 1 ? 'danger' : score === 2 ? 'warning' : score === 3 ? 'info' : 'success';
bar.style.width = pct + '%';
bar.className = 'strength-bar bg-' + color;
}
function checkMatch() {
if (!cfEl.value) { matchFb.style.display = 'none'; return; }
matchFb.style.display = '';
if (pwEl.value === cfEl.value) {
matchFb.textContent = '✓ Passwords match';
matchFb.style.color = '#16a34a';
} else {
matchFb.textContent = '✗ Passwords do not match';
matchFb.style.color = '#dc2626';
}
}
pwEl.addEventListener('input', function () {
updateStrength(this.value);
checkMatch();
});
cfEl.addEventListener('input', checkMatch);
})();
</script>
</body>
</html>
+483
View File
@@ -0,0 +1,483 @@
{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
<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 == 'customer' %}success{% else %}info{% endif %} mt-1">
{{ current_user.role.replace('_',' ')|title }}
</span>
</div>
</div>
{# ── Inspections section ─────────────────────────────────────────────────── #}
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-clipboard-data-fill text-primary"></i>
<span class="fw-bold text-uppercase" style="font-size:.78rem;letter-spacing:.07em;color:#64748b;">Inspections</span>
<div style="flex:1;height:1px;background:#e2e8f0;"></div>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-md">
<a href="{{ url_for('inspections.index', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
<div class="card text-white bg-primary h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small text-white-50 fw-semibold">Today's Inspections</span>
<i class="bi bi-clipboard-data" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ today_inspections }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">All inspections started today</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-md">
<a href="{{ url_for('inspections.index', status='completed', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
<div class="card text-white bg-success h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small text-white-50 fw-semibold">Submitted Today</span>
<i class="bi bi-check-circle" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ completed_today }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Fully completed &amp; submitted today</div>
</div>
</div>
</a>
</div>
{% if current_user.role != 'customer' %}
<div class="col-6 col-md">
<a href="{{ url_for('inspections.index', status='in_progress') }}" class="text-decoration-none">
<div class="card text-white h-100" style="background:#b45309;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Stale In-Progress</span>
<i class="bi bi-hourglass-split" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ stale_in_progress }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Started &gt;24 h ago, not yet submitted</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-md">
<a href="{{ url_for('inspections.index', status='follow_up') }}" class="text-decoration-none">
<div class="card text-white h-100" style="background:#7c3aed;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Pending Follow-ups</span>
<i class="bi bi-arrow-repeat" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ pending_followups }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Flagged for a follow-up re-inspection</div>
</div>
</div>
</a>
</div>
{% endif %}
</div>
{# ── Issues section ──────────────────────────────────────────────────────── #}
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-exclamation-triangle-fill text-danger"></i>
<span class="fw-bold text-uppercase" style="font-size:.78rem;letter-spacing:.07em;color:#64748b;">Issues</span>
<div style="flex:1;height:1px;background:#e2e8f0;"></div>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-md">
<a href="{{ url_for('issues.index', status='open') }}" class="text-decoration-none">
<div class="card text-white bg-warning h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small text-white-50 fw-semibold">Open Issues</span>
<i class="bi bi-exclamation-triangle" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ open_issues }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.75;">
Active issues not yet resolved
{% if open_issues > 0 %}
· {% if severity_breakdown.critical > 0 %}<span class="badge bg-danger">{{ severity_breakdown.critical }}C</span> {% endif %}
{% if severity_breakdown.high > 0 %}<span class="badge bg-danger">{{ severity_breakdown.high }}H</span> {% endif %}
{% if severity_breakdown.medium > 0 %}<span class="badge bg-dark">{{ severity_breakdown.medium }}M</span> {% endif %}
{% if severity_breakdown.low > 0 %}<span class="badge bg-secondary">{{ severity_breakdown.low }}L</span>{% endif %}
{% endif %}
</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-md">
<a href="{{ url_for('issues.index', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
<div class="card text-white h-100" style="background:#ea580c;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Issues Opened Today</span>
<i class="bi bi-flag" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ issues_opened_today }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">New issues reported today</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-md">
<a href="{{ url_for('issues.index', status='resolved', date_from=today_str, date_to=today_str) }}" class="text-decoration-none">
<div class="card text-white bg-info h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small text-white-50 fw-semibold">Resolved Today</span>
<i class="bi bi-check2-all" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ resolved_today }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Issues closed and resolved today</div>
</div>
</div>
</a>
</div>
{% if current_user.role != 'customer' %}
<div class="col-6 col-md">
<a href="{{ url_for('issues.index', status='pending_verification') }}" class="text-decoration-none">
<div class="card text-white h-100" style="background:#2563eb;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Pending Verification</span>
<i class="bi bi-clipboard2-check" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ pending_verification }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Resolved but awaiting supervisor sign-off</div>
</div>
</div>
</a>
</div>
<div class="col-6 col-md">
<a href="{{ url_for('issues.index', status='open') }}" class="text-decoration-none">
<div class="card text-white h-100" style="background:#16a34a;">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-1">
<span class="small fw-semibold" style="color:rgba(255,255,255,.6);">Unassigned Open</span>
<i class="bi bi-person-dash" style="font-size:1.4rem;opacity:.3;"></i>
</div>
<div class="fs-1 fw-bold lh-1">{{ unassigned_open }}</div>
<div class="mt-2" style="font-size:.72rem;opacity:.7;">Open issues with no one assigned</div>
</div>
</div>
</a>
</div>
{% endif %}
</div>
{# ── SLA Summary ─────────────────────────────────────────────────────────── #}
{% if sla_breached > 0 or sla_at_risk > 0 %}
<div class="row g-3 mb-4">
{% if sla_breached > 0 %}
<div class="col-6 col-md-3">
<a href="{{ url_for('issues.index', sla='breached') }}" class="text-decoration-none">
<div class="card border-danger h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<div class="small text-danger fw-semibold">SLA Breached</div>
<div class="fs-2 fw-bold text-danger">{{ sla_breached }}</div>
</div>
<i class="bi bi-alarm text-danger" style="font-size:2.5rem;opacity:.3;"></i>
</div>
</div>
</a>
</div>
{% endif %}
{% if sla_at_risk > 0 %}
<div class="col-6 col-md-3">
<a href="{{ url_for('issues.index', sla='at_risk') }}" class="text-decoration-none">
<div class="card border-warning h-100">
<div class="card-body d-flex justify-content-between align-items-center">
<div>
<div class="small text-warning fw-semibold">SLA At Risk</div>
<div class="fs-2 fw-bold text-warning">{{ sla_at_risk }}</div>
</div>
<i class="bi bi-alarm text-warning" style="font-size:2.5rem;opacity:.3;"></i>
</div>
</div>
</a>
</div>
{% endif %}
</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 current_user.role != '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 current_user.role != '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>
{# ── Inspector activity today (admin / director / PM) ───────────────────── #}
{% if inspector_activity %}
<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-people me-1"></i>Inspector Activity Today</span>
<a href="{{ url_for('inspections.index', date_from=today_str, date_to=today_str) }}"
class="btn btn-sm btn-outline-secondary">View all</a>
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0" style="font-size:.85rem;">
<thead class="table-light">
<tr>
<th>Inspector</th>
<th class="text-center" style="width:120px;">Submitted Today</th>
<th style="width:200px;"></th>
</tr>
</thead>
<tbody>
{% for row in inspector_activity %}
<tr class="{{ 'table-success' if row.count > 0 else '' }}">
<td>{{ row.name }}</td>
<td class="text-center">
{% if row.count > 0 %}
<span class="badge bg-success">{{ row.count }}</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<div class="progress" style="height:6px;margin-top:4px;">
{% set max_count = inspector_activity | map(attribute='count') | max %}
{% set pct = (row.count / max_count * 100) | int if max_count > 0 else 0 %}
<div class="progress-bar bg-success" style="width:{{ pct }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── 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 %}
+39
View File
@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">{{ title }} - {{ facility.name }}</h4>
</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control") }}
</div>
<div class="mb-4">
{{ form.area_type.label(class="form-label") }}
{{ form.area_type(class="form-select") }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Save Area
</button>
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+63
View File
@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">{{ title }}</h4>
</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control") }}
</div>
<div class="mb-3">
{{ form.address.label(class="form-label") }}
{{ form.address(class="form-control", rows=3) }}
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.contact_person.label(class="form-label") }}
{{ form.contact_person(class="form-control") }}
</div>
<div class="col-md-6 mb-3">
{{ form.contact_phone.label(class="form-label") }}
{{ form.contact_phone(class="form-control") }}
</div>
</div>
<div class="mb-3">
{{ form.project_id.label(class="form-label") }}
{{ form.project_id(class="form-select") }}
<div class="form-text">Link this facility to a contract for customer portal access.</div>
</div>
<div class="mb-4">
<div class="form-check">
{{ form.active(class="form-check-input") }}
{{ form.active.label(class="form-check-label") }}
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Save Facility
</button>
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+194
View File
@@ -0,0 +1,194 @@
{% extends "base.html" %}
{% block title %}Facilities{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-md-6">
<h2><i class="bi bi-building"></i> Facilities</h2>
</div>
<div class="col-md-6 text-end">
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.create_facility') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Facility
</a>
{% endif %}
</div>
</div>
{% if grouped %}
{% for group_key, group in grouped.items() %}
{# ── Contract group header ────────────────────────────────────────────── #}
{% set collapse_id = 'contract-' ~ loop.index %}
<div class="mb-4">
<div class="d-flex align-items-center mb-2">
<button class="btn btn-link text-decoration-none p-0 d-flex align-items-center gap-2 fw-semibold fs-5"
type="button"
data-bs-toggle="collapse"
data-bs-target="#{{ collapse_id }}"
aria-expanded="false"
aria-controls="{{ collapse_id }}">
<i class="bi bi-chevron-down contract-chevron" style="transition: transform .2s; transform: rotate(-90deg);"></i>
{% if group.project %}
<i class="bi bi-briefcase text-primary"></i>
{{ group.project.name }}
{% else %}
<i class="bi bi-dash-circle text-secondary"></i>
<span class="text-secondary">No Contract Assigned</span>
{% endif %}
</button>
<span class="badge bg-secondary ms-2">{{ group.facilities|length }}</span>
{% if group.project and current_user.role in ['admin', 'director', 'project_manager'] %}
<a href="{{ url_for('projects.view', project_id=group.project.id) }}"
class="btn btn-sm btn-outline-secondary ms-2"
title="View Contract">
<i class="bi bi-arrow-right-circle"></i>
</a>
{% endif %}
</div>
{# ── Collapsible card grid ─────────────────────────────────────────── #}
<div class="collapse" id="{{ collapse_id }}">
<div class="row">
{% for facility in group.facilities %}
<div class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card shadow-sm h-100">
<div class="card-body py-2 px-3">
<div class="mb-1" style="font-size:.875rem;font-weight:600;line-height:1.3;">
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="text-decoration-none">
{{ facility.name }}
</a>
{% if not facility.active %}
<span class="badge bg-secondary" style="font-size:.7rem;">Inactive</span>
{% endif %}
</div>
{% if facility.address %}
<p class="card-text text-muted mb-1" style="font-size:.78rem;">
<i class="bi bi-geo-alt"></i> {{ facility.address }}
</p>
{% endif %}
<div class="mt-1">
<small class="text-muted" style="font-size:.78rem;">
<i class="bi bi-diagram-3"></i> {{ facility.areas.count() }} areas
</small>
</div>
</div>
<div class="card-footer bg-transparent d-flex gap-2 py-2 px-3">
<a href="{{ url_for('facilities.view_facility', facility_id=facility.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View Details
</a>
{% if current_user.role == 'admin' %}
<button type="button"
class="btn btn-sm btn-outline-danger ms-auto"
data-bs-toggle="modal"
data-bs-target="#deleteModal"
data-facility-id="{{ facility.id }}"
data-facility-name="{{ facility.name }}"
data-inspection-count="{{ facility.inspections.count() }}">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No facilities configured yet.
</div>
{% endif %}
{% if current_user.role == 'admin' %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteModalLabel">
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>You are about to permanently delete:</p>
<p class="fw-bold fs-5" id="modalFacilityName"></p>
<div id="modalWarningBlock" class="alert alert-danger d-none">
<i class="bi bi-x-circle-fill"></i>
<strong>Cannot delete this facility.</strong> It has existing inspection records.
Please remove all associated inspections first.
</div>
<div id="modalConfirmBlock">
<p class="text-muted mb-0">This action is <strong>irreversible</strong>. All areas associated with this facility will also be deleted.</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<form id="deleteFacilityForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" id="confirmDeleteBtn" class="btn btn-danger">
<i class="bi bi-trash-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// ── Rotate chevron on collapse toggle ────────────────────────────────
document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(function (btn) {
const target = document.querySelector(btn.getAttribute('data-bs-target'));
if (!target) return;
const chevron = btn.querySelector('.contract-chevron');
target.addEventListener('hide.bs.collapse', function () {
if (chevron) chevron.style.transform = 'rotate(-90deg)';
});
target.addEventListener('show.bs.collapse', function () {
if (chevron) chevron.style.transform = 'rotate(0deg)';
});
});
{% if current_user.role == 'admin' %}
// ── Delete modal wiring ──────────────────────────────────────────────
const deleteModal = document.getElementById('deleteModal');
deleteModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const facilityId = button.getAttribute('data-facility-id');
const facilityName = button.getAttribute('data-facility-name');
const inspectionCount = parseInt(button.getAttribute('data-inspection-count'));
document.getElementById('modalFacilityName').textContent = facilityName;
document.getElementById('deleteFacilityForm').action = '/facilities/' + facilityId + '/delete';
const warningBlock = document.getElementById('modalWarningBlock');
const confirmBlock = document.getElementById('modalConfirmBlock');
const confirmBtn = document.getElementById('confirmDeleteBtn');
if (inspectionCount > 0) {
warningBlock.classList.remove('d-none');
confirmBlock.classList.add('d-none');
confirmBtn.disabled = true;
} else {
warningBlock.classList.add('d-none');
confirmBlock.classList.remove('d-none');
confirmBtn.disabled = false;
}
});
{% endif %}
});
</script>
{% endblock %}
+210
View File
@@ -0,0 +1,210 @@
{% extends "base.html" %}
{% block title %}{{ facility.name }}{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-md-8">
<h2><i class="bi bi-building"></i> {{ facility.name }}</h2>
</div>
<div class="col-md-4 text-end d-flex gap-2 justify-content-end align-items-start">
<a href="{{ url_for('facilities.list_facilities') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Facilities
</a>
{% if current_user.role in ['admin', 'director', 'project_manager', 'customer'] %}
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
class="btn btn-outline-info">
<i class="bi bi-graph-up-arrow"></i> Scorecard
</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_facility', facility_id=facility.id) }}" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Edit
</a>
<a href="{{ url_for('facilities.create_area', facility_id=facility.id) }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add Area
</a>
{% endif %}
{% if current_user.role == 'admin' %}
<button type="button"
class="btn btn-danger"
data-bs-toggle="modal"
data-bs-target="#deleteModal">
<i class="bi bi-trash-fill"></i> Delete Facility
</button>
{% endif %}
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Facility Information</h5>
</div>
<div class="card-body">
<table class="table table-sm table-borderless">
<tr>
<th width="40%">Address:</th>
<td>{{ facility.address or 'N/A' }}</td>
</tr>
<tr>
<th>Contact Person:</th>
<td>{{ facility.contact_person or 'N/A' }}</td>
</tr>
<tr>
<th>Contact Phone:</th>
<td>{{ facility.contact_phone or 'N/A' }}</td>
</tr>
<tr>
<th>Status:</th>
<td>
<span class="badge bg-{{ 'success' if facility.active else 'secondary' }}">
{{ 'Active' if facility.active else 'Inactive' }}
</span>
</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0">Statistics</h5>
</div>
<div class="card-body">
<div class="row text-center g-2">
<div class="col-4">
<h3 class="text-primary">{{ areas|length }}</h3>
<small class="text-muted">Areas</small>
</div>
<div class="col-4">
<h3 class="text-info">{{ facility.inspections.count() }}</h3>
<small class="text-muted">Inspections</small>
</div>
<div class="col-4">
{%- set ns = namespace(open=0) -%}
{%- for area in areas -%}
{%- set ns.open = ns.open + area.issues.filter_by(status='open').count() + area.issues.filter_by(status='in_progress').count() -%}
{%- endfor -%}
<h3 class="text-{{ 'danger' if ns.open > 0 else 'success' }}">{{ ns.open }}</h3>
<small class="text-muted">Open Issues</small>
</div>
</div>
<div class="text-center mt-3">
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
class="btn btn-sm btn-outline-info">
<i class="bi bi-graph-up-arrow me-1"></i>View Full Scorecard
</a>
</div>
</div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="bi bi-diagram-3"></i> Areas</h5>
</div>
<div class="card-body">
{% if areas %}
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Area Name</th>
<th>Type</th>
<th>Inspections</th>
<th width="150">Actions</th>
</tr>
</thead>
<tbody>
{% for area in areas %}
<tr>
<td><strong>{{ area.name }}</strong></td>
<td>
<span class="badge bg-secondary">{{ area.area_type|title if area.area_type else 'N/A' }}</span>
</td>
<td>{{ area.inspections.count() }}</td>
<td>
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('facilities.edit_area', area_id=area.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-pencil"></i>
</a>
{% set area_issue_count = area.issues.count() %}
{% set area_insp_count = area.inspections.count() %}
<form method="POST" action="{{ url_for('facilities.delete_area', area_id=area.id) }}" class="d-inline"
onsubmit="return confirm('Delete area '{{ area.name }}'? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger"
{% if area_insp_count > 0 or area_issue_count > 0 %}
disabled
title="Cannot delete — {{ area_insp_count }} inspection(s) and {{ area_issue_count }} issue(s) on file"
{% endif %}>
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i> No areas defined for this facility yet.
</div>
{% endif %}
</div>
</div>
{% if current_user.role == 'admin' %}
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteModalLabel">
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>You are about to permanently delete:</p>
<p class="fw-bold fs-5">{{ facility.name }}</p>
{% if facility.inspections.count() > 0 %}
<div class="alert alert-danger mb-0">
<i class="bi bi-x-circle-fill"></i>
<strong>Cannot delete this facility.</strong> It has
<strong>{{ facility.inspections.count() }} inspection record(s)</strong> on file.
Please remove all associated inspections first.
</div>
{% else %}
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle-fill"></i>
This action is <strong>irreversible</strong>. All
<strong>{{ areas|length }} area(s)</strong> associated with this facility will also be deleted.
</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<form method="POST" action="{{ url_for('facilities.delete_facility', facility_id=facility.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-danger"
{% if facility.inspections.count() > 0 %}disabled{% endif %}>
<i class="bi bi-trash-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}Flag Issue{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow-sm border-danger">
<div class="card-header bg-danger text-white">
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Flag Issue During Inspection</h5>
</div>
<div class="card-body">
<div class="alert alert-light border mb-3">
<strong>Inspection:</strong> {{ inspection.template.name }}<br>
<strong>Facility:</strong> {{ inspection.facility.name }}
</div>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{{ form.facility_id(type="hidden") }}
<div class="mb-3">
{{ form.severity.label(class="form-label fw-semibold") }}
{{ form.severity(class="form-select") }}
</div>
<div class="mb-3">
{{ form.description.label(class="form-label fw-semibold") }}
{{ form.description(class="form-control", rows=4, placeholder="Describe the issue in detail…") }}
</div>
<div class="mb-3">
{{ form.photo.label(class="form-label fw-semibold") }}
{{ form.photo(class="form-control") }}
</div>
<div class="mb-4">
{{ form.assigned_to.label(class="form-label fw-semibold") }}
{{ form.assigned_to(class="form-select") }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger"><i class="bi bi-flag"></i> Log Issue</button>
<a href="{{ url_for('inspections.execute', inspection_id=inspection.id) }}"
class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+286
View File
@@ -0,0 +1,286 @@
{% extends "base.html" %}
{% block title %}Inspections{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-clipboard-data"></i> Inspections</h2>
{% if current_user.role != 'customer' %}
<a href="{{ url_for('inspections.start') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Inspection
</a>
{% endif %}
</div>
{# Filters #}
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get">
<div class="row g-2 align-items-end">
<div class="col-md-1">
<label class="form-label small mb-1">Inspection #</label>
<input type="number" name="inspection_id" class="form-control form-control-sm"
min="1" placeholder="ID" value="{{ inspection_id_filter }}">
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="">All Statuses</option>
{% for s in ['in_progress','completed','flagged'] %}
<option value="{{ s }}" {% if status_filter == s %}selected{% endif %}>{{ 'Submitted' if s == 'completed' else s|replace('_',' ')|title }}</option>
{% endfor %}
<option value="follow_up" {% if status_filter == 'follow_up' %}selected{% endif %}>Flagged Follow-up</option>
<option value="has_issues" {% if status_filter == 'has_issues' %}selected{% endif %}>Has Logged Issues</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small mb-1">Contract</label>
<select name="contract_id" id="insp_filter_contract" class="form-select form-select-sm">
<option value="">All Contracts</option>
{% for p in projects %}
<option value="{{ p.id }}" {% if contract_filter == p.id|string %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" id="insp_filter_facility" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {% if facility_filter == f.id|string %}selected{% endif %}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
{% if inspectors %}
<div class="col-md-2">
<label class="form-label small mb-1">Inspector</label>
<select name="inspector_id" class="form-select form-select-sm">
<option value="">All Inspectors</option>
{% for u in inspectors %}
<option value="{{ u.id }}" {% if inspector_filter == u.id|string %}selected{% endif %}>{{ u.display_name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
<div class="row g-2 align-items-end mt-1">
<div class="col-md-2">
<label class="form-label small mb-1">Date From</label>
<input type="date" name="date_from" class="form-control form-control-sm"
value="{{ date_from_filter }}">
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Date To</label>
<input type="date" name="date_to" class="form-control form-control-sm"
value="{{ date_to_filter }}">
</div>
<div class="col-md-1">
<label class="form-label small mb-1">Min Score</label>
<input type="number" name="score_min" class="form-control form-control-sm"
min="0" max="100" placeholder="0"
value="{{ score_min_filter }}">
</div>
<div class="col-md-1">
<label class="form-label small mb-1">Max Score</label>
<input type="number" name="score_max" class="form-control form-control-sm"
min="0" max="100" placeholder="100"
value="{{ score_max_filter }}">
</div>
<div class="col-auto d-flex align-items-end gap-2 flex-wrap">
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
<a id="exportPdfBtn"
href="{{ url_for('inspections.export_list_pdf', **request.args) }}"
class="btn btn-sm btn-outline-danger">
<i class="bi bi-file-earmark-pdf"></i> Export PDF
</a>
</div>
</div>
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
{% if inspections.items %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>#</th><th>Date</th><th>Contract</th><th>Facility</th><th>Area</th>
<th>Template</th><th>Inspector</th><th>Score</th>
<th>Status</th><th></th>
</tr>
</thead>
<tbody>
{% for ins in inspections.items %}
<tr>
<td><small class="text-muted">#{{ ins.id }}</small></td>
<td>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</td>
<td><small>{{ ins.facility.project.name if ins.facility and ins.facility.project else '—' }}</small></td>
<td>{{ ins.facility.name }}</td>
<td>{% if ins.area %}{{ ins.area.name }}{% else %}<span class="text-muted"></span>{% endif %}</td>
<td>{{ ins.template.name }}</td>
<td>{{ ins.inspector.display_name }}</td>
<td>
{% if ins.overall_score %}
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning' if ins.overall_score >= 70 else 'danger' }}">
{{ ins.overall_score }}%
</span>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td>
<span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
{{ 'Submitted' if ins.status == 'completed' else ins.status|replace('_',' ')|title }}
</span>
{% if ins.status == 'in_progress' %}
{% set hours_open = ((now - ins.inspection_date).total_seconds() / 3600) %}
{% if hours_open > 24 %}
<span class="badge bg-warning text-dark ms-1" title="In progress for over 24 hours — may be stale">
<i class="bi bi-clock-history"></i> Stale
</span>
{% endif %}
{% endif %}
{% if ins.follow_up_required and not ins.follow_ups.count() %}
<span class="badge bg-danger ms-1" title="Follow-up re-inspection required">
<i class="bi bi-arrow-repeat"></i> Follow-up
</span>
{% endif %}
</td>
<td class="text-nowrap">
{% if ins.status == 'in_progress' or ins.status == 'flagged' %}
<a href="{{ url_for('inspections.execute', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-primary insp-list-link">Continue</a>
{% else %}
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary insp-list-link">View</a>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<button type="button"
class="btn btn-sm btn-outline-danger ms-1"
data-bs-toggle="modal"
data-bs-target="#deleteInspectionModal"
data-inspection-id="{{ ins.id }}"
data-inspection-label="{{ ins.template.name }} — {{ ins.facility.name }} ({{ ins.inspection_date.strftime('%Y-%m-%d') }})"
title="Delete inspection">
<i class="bi bi-trash3"></i>
</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Pagination #}
{% if inspections.pages > 1 %}
<div class="d-flex justify-content-center py-3">
<nav><ul class="pagination pagination-sm mb-0">
{% for p in inspections.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {{ 'active' if p == inspections.page }}">
<a class="page-link" href="{{ url_for('inspections.index', page=p, inspection_id=inspection_id_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, score_min=score_min_filter, score_max=score_max_filter, inspector_id=inspector_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
</ul></nav>
</div>
{% endif %}
{% else %}
<div class="alert alert-info m-3"><i class="bi bi-info-circle"></i> No inspections found.</div>
{% endif %}
</div>
</div>
{% if current_user.role in ['admin', 'director'] %}
<!-- Delete Inspection Confirmation Modal -->
<div class="modal fade" id="deleteInspectionModal" tabindex="-1" aria-labelledby="deleteInspectionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteInspectionModalLabel">
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>You are about to permanently delete the following inspection:</p>
<p class="fw-bold" id="deleteInspectionLabel"></p>
<p class="text-muted mb-0">This will also remove all associated results, flagged issues, and uploaded photos. This action is <strong>irreversible</strong>.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<form id="deleteInspectionForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash3-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
// Save current filtered URL so view/execute pages can restore it on Back
var links = document.querySelectorAll('.insp-list-link');
links.forEach(function (a) {
a.addEventListener('click', function () {
sessionStorage.setItem('insp_list_back_url', window.location.href);
});
});
}());
</script>
<script>
(function () {
'use strict';
var contractSel = document.getElementById('insp_filter_contract');
var facilitySel = document.getElementById('insp_filter_facility');
if (!contractSel || !facilitySel) return;
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
contractSel.addEventListener('change', function () {
var projectId = this.value;
facilitySel.value = '';
if (!projectId) {
facilitySel.innerHTML = '<option value="">All Facilities</option>';
return;
}
facilitySel.disabled = true;
facilitySel.innerHTML = '<option value="">Loading…</option>';
fetch(FACILITIES_URL + projectId)
.then(function (r) { return r.json(); })
.then(function (data) {
var html = '<option value="">All Facilities</option>';
data.forEach(function (f) {
html += '<option value="' + f.id + '">' + f.name + '</option>';
});
facilitySel.innerHTML = html;
facilitySel.disabled = false;
})
.catch(function () { facilitySel.disabled = false; });
});
}());
</script>
{% if current_user.role in ['admin', 'director'] %}
<script>
document.addEventListener('DOMContentLoaded', function () {
const modal = document.getElementById('deleteInspectionModal');
modal.addEventListener('show.bs.modal', function (event) {
const btn = event.relatedTarget;
const id = btn.getAttribute('data-inspection-id');
const label = btn.getAttribute('data-inspection-label');
document.getElementById('deleteInspectionLabel').textContent = label;
document.getElementById('deleteInspectionForm').action = '/inspections/' + id + '/delete';
});
});
</script>
{% endif %}
{% endblock %}
+153
View File
@@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block title %}New Inspection{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-play-circle"></i> New Inspection</h5>
</div>
<div class="card-body">
<form method="post">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.template_id.label(class="form-label fw-semibold") }}
{{ form.template_id(class="form-select" + (" is-invalid" if form.template_id.errors else "")) }}
{% for e in form.template_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.project_id.label(class="form-label fw-semibold") }}
{{ form.project_id(class="form-select" + (" is-invalid" if form.project_id.errors else ""), id="projectSelect") }}
{% for e in form.project_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.facility_id.label(class="form-label fw-semibold") }}
{{ form.facility_id(class="form-select" + (" is-invalid" if form.facility_id.errors else ""), id="facilitySelect") }}
{% for e in form.facility_id.errors %}<div class="invalid-feedback">{{ e }}</div>{% endfor %}
<div id="facilitySpinner" class="form-text text-muted d-none">
<span class="spinner-border spinner-border-sm" role="status"></span> Loading facilities…
</div>
<div id="facilityEmpty" class="form-text text-warning d-none">
No active facilities found for this contract.
</div>
</div>
<div class="mb-3" id="areaGroup" style="display:none;">
{{ form.area_id.label(class="form-label fw-semibold") }}
{{ form.area_id(class="form-select", id="areaSelect") }}
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-play-fill"></i> Begin Inspection
</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
(function () {
const projectSel = document.getElementById('projectSelect');
const facilitySel = document.getElementById('facilitySelect');
const spinner = document.getElementById('facilitySpinner');
const emptyMsg = document.getElementById('facilityEmpty');
const areaGroup = document.getElementById('areaGroup');
const areaSel = document.getElementById('areaSelect');
const FACILITIES_URL = `{{ url_for('inspections.facilities_for_project', project_id=0) }}`.replace('/0', '/');
const AREAS_URL = `{{ url_for('inspections.areas_for_facility', facility_id=0) }}`.replace('/0', '/');
function loadAreas(facilityId, selectedAreaId) {
if (!facilityId) {
areaGroup.style.display = 'none';
areaSel.innerHTML = '<option value="0">— No specific area —</option>';
return;
}
fetch(AREAS_URL + facilityId)
.then(r => r.json())
.then(data => {
if (data.length === 0) {
areaGroup.style.display = 'none';
areaSel.innerHTML = '<option value="0">— No specific area —</option>';
} else {
areaSel.innerHTML = '<option value="0">— No specific area —</option>';
data.forEach(a => {
const opt = document.createElement('option');
opt.value = a.id;
opt.textContent = a.name;
if (selectedAreaId && a.id === selectedAreaId) opt.selected = true;
areaSel.appendChild(opt);
});
areaGroup.style.display = '';
}
})
.catch(() => {
areaGroup.style.display = 'none';
});
}
function loadFacilities(projectId, selectedFacilityId, selectedAreaId) {
if (!projectId) return;
spinner.classList.remove('d-none');
emptyMsg.classList.add('d-none');
facilitySel.disabled = true;
areaGroup.style.display = 'none';
fetch(FACILITIES_URL + projectId)
.then(r => r.json())
.then(data => {
facilitySel.innerHTML = '';
if (data.length === 0) {
emptyMsg.classList.remove('d-none');
facilitySel.innerHTML = '<option value="">— no facilities —</option>';
} else {
data.forEach(f => {
const opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name;
if (selectedFacilityId && f.id === selectedFacilityId) opt.selected = true;
facilitySel.appendChild(opt);
});
loadAreas(facilitySel.value, selectedAreaId);
}
})
.catch(() => {
facilitySel.innerHTML = '<option value="">— error loading facilities —</option>';
})
.finally(() => {
spinner.classList.add('d-none');
facilitySel.disabled = false;
});
}
projectSel.addEventListener('change', function () {
loadFacilities(this.value, null, null);
});
facilitySel.addEventListener('change', function () {
loadAreas(this.value, null);
});
// On page load: if project already selected (e.g. validation error or reinspect),
// reload facility list preserving the currently selected facility and area values.
const initProject = projectSel.value;
const initFacility = facilitySel.value ? parseInt(facilitySel.value, 10) : null;
const initArea = areaSel.value ? parseInt(areaSel.value, 10) : null;
if (initProject) {
const hasRealOptions = Array.from(facilitySel.options).some(o => parseInt(o.value, 10) > 0);
if (!hasRealOptions) {
loadFacilities(initProject, initFacility, initArea);
} else {
// Facilities already rendered server-side — just load areas for the selected facility
if (initFacility) loadAreas(initFacility, initArea);
}
}
})();
</script>
{% endblock %}
+903
View File
@@ -0,0 +1,903 @@
{% 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; }
/* ── Print styles ── */
@media print {
/* ── 1. Hide all screen-only chrome ── */
nav, .navbar, header,
.d-flex.justify-content-between.align-items-center.mb-3,
.alert-warning,
.modal, .modal-backdrop,
.media-backdrop,
a.btn, button,
.btn { display: none !important; }
/* ── 2. Page / body reset ── */
html, body { background: #fff !important;
font-family: Helvetica, Arial, sans-serif;
font-size: 9pt; color: #1a1d23; }
.insp-wrap { max-width: 100% !important;
padding: 0 !important; margin: 0 !important; }
@page { margin: 0.65in; size: letter; }
/* ── 3. Dark header band (matches PDF header) ── */
.insp-header { background: #1a1d23 !important;
color: #fff !important;
border-radius: 0 !important;
padding: 10pt 14pt !important;
display: flex !important;
justify-content: space-between;
align-items: center;
page-break-after: avoid;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
.insp-header h4 { font-size: 12pt !important;
color: #fff !important; margin: 0; }
.insp-header .sub { font-size: 8pt !important;
color: #94a3b8 !important; }
/* Score badge in header */
.score-badge { font-size: 13pt !important;
font-weight: 700;
padding: 4pt 10pt !important;
border-radius: 6pt !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
/* ── 4. Body panel ── */
.insp-body { border: 1pt solid #e2e8f0 !important;
border-top: none !important;
border-radius: 0 !important;
box-shadow: none !important;
padding: 12pt !important; }
/* ── 5. Meta row — render as a bordered grid (matches PDF _meta_table) ── */
.meta-row { display: grid !important;
grid-template-columns: repeat(3, 1fr);
gap: 0 !important;
border: 0.5pt solid #e2e8f0;
background: #f1f5f9 !important;
margin-bottom: 10pt !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
.meta-item { padding: 4pt 6pt !important;
border: 0.25pt solid #e2e8f0 !important;
background: #f1f5f9 !important; }
.meta-item .lbl { font-size: 7pt !important;
color: #64748b !important;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
display: block; }
.meta-item .val { font-size: 9pt !important;
color: #1a1d23 !important;
font-weight: 700;
display: block; margin-top: 1pt; }
/* ── 6. Score / status comparison alerts (parent/child links) ── */
.alert-info, .alert-secondary { display: block !important;
font-size: 7.5pt;
border: 0.5pt solid #e2e8f0 !important;
padding: 4pt 8pt !important;
margin-bottom: 6pt !important;
background: #f8fafc !important;
border-radius: 0 !important; }
/* ── 7. Section headings (matches PDF SectionHead + HR rule) ── */
.section-head { background: none !important;
border-bottom: 1.5pt solid #e2e8f0 !important;
padding: 0 0 2pt 0 !important;
margin: 10pt 0 4pt 0 !important;
page-break-after: avoid; }
.section-head strong { font-size: 9.5pt !important;
font-weight: 700 !important;
color: #1a1d23 !important; }
/* ── 8. Form grid — keep CSS grid, scale columns to page width ──
Letter page at 0.65in margins → ~7.7in usable.
12 equal columns: each unit = 7.7in / 12 ≈ 0.642in.
We replicate the iPad fluid override pattern but for print. ── */
.form-grid { display: grid !important;
--_cell: calc((100% - 11 * 6pt) / 12);
grid-template-columns: repeat(12, var(--_cell)) !important;
grid-auto-rows: minmax(28pt, auto) !important;
gap: 3pt 6pt !important;
width: 100% !important; }
.fg-cell { overflow: visible !important;
display: flex !important;
flex-direction: column !important;
padding: 2pt 3pt !important;
min-height: 0 !important; }
/* Field label (matches PDF FieldLabel: 7.5pt, slate) */
.fg-cell .field-lbl { font-size: 7pt !important;
color: #64748b !important;
font-weight: 600;
display: block !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
flex-shrink: 0;
margin-bottom: 1pt; }
/* Field value box (matches PDF: light border + #f8fafc bg) */
.fg-cell .field-val { font-size: 8pt !important;
color: #1a1d23 !important;
background: #f8fafc !important;
border: 0.5pt solid #e2e8f0 !important;
border-radius: 2pt !important;
padding: 2pt 4pt !important;
display: flex !important;
align-items: center !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
flex: 1 !important;
min-height: 0 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
.fg-cell .field-val.multiline { white-space: pre-wrap !important;
overflow: auto !important; }
.fg-cell .field-val.empty { color: #94a3b8 !important;
font-style: italic; }
/* Rating stars */
.rating-stars-ro { font-size: 9pt !important;
color: #f59e0b !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
.rating-line { display: flex !important;
align-items: center;
gap: 4pt; }
.rating-score { font-size: 7pt !important;
color: #64748b !important; }
/* Images: show inline, constrained */
.fg-cell img { max-width: 2.8in !important;
max-height: 1.8in !important;
object-fit: contain;
display: block;
margin-top: 3pt; }
/* Photo/signature view buttons — replace with static label */
.btn-view-media { display: none !important; }
/* ── 9. Issues table (matches PDF dark header, alternating rows) ── */
.tbl-view { width: 100%; border-collapse: collapse;
font-size: 7.5pt; margin-top: 4pt; }
.tbl-view thead th { background: #1a1d23 !important;
color: #fff !important;
font-weight: 700;
padding: 3pt 5pt !important;
border: 0.25pt solid #e2e8f0 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
.tbl-view tbody tr:nth-child(even) td
{ background: #f1f5f9 !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
.tbl-view td { padding: 3pt 5pt !important;
border: 0.25pt solid #e2e8f0 !important;
vertical-align: top; }
/* Severity badge colours in issues table */
.badge.bg-danger { color: #dc2626 !important;
background: none !important;
font-weight: 700; }
.badge.bg-warning { color: #d97706 !important;
background: none !important;
font-weight: 700; }
.badge.bg-secondary { color: #64748b !important;
background: none !important; }
.badge.bg-success { color: #16a34a !important;
background: none !important;
font-weight: 700; }
/* ── 10. Notes block (matches PDF #fffbeb background) ── */
[style*="white-space:pre-wrap"]
{ white-space: pre-wrap !important;
background: #fffbeb !important;
border: 0.5pt solid #e2e8f0 !important;
padding: 5pt 7pt !important;
font-size: 8.5pt !important;
-webkit-print-color-adjust: exact;
print-color-adjust: exact; }
/* ── 11. Print sign-off block ── */
.print-signoff { display: block !important;
margin-top: 24pt;
page-break-inside: avoid; }
/* ── 12. Page-break hints ── */
.insp-header { page-break-after: avoid; }
.section-head { page-break-after: avoid; }
hr { border-color: #e2e8f0 !important; }
}
</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 id="backToInspectionsBtn" 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 not in ['customer'] %}
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
class="btn btn-sm btn-outline-primary"
title="Start a follow-up re-inspection with the same template and facility">
<i class="bi bi-arrow-repeat"></i> Re-inspect
</a>
{% endif %}
{% if current_user.role in ['admin','director'] %}
{% if not inspection.follow_up_required %}
<button type="button" class="btn btn-sm btn-outline-warning"
data-bs-toggle="modal" data-bs-target="#followupModal"
title="Flag this inspection as requiring a follow-up">
<i class="bi bi-flag"></i> Flag Follow-up
</button>
{% else %}
<form method="post"
action="{{ url_for('inspections.clear_followup', inspection_id=inspection.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="btn btn-sm btn-warning">
<i class="bi bi-flag-fill"></i> Clear Follow-up
</button>
</form>
{% endif %}
<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>
{# ── Follow-up required alert ── #}
{% if inspection.follow_up_required %}
<div class="alert alert-warning d-flex align-items-start gap-2 mb-3">
<i class="bi bi-flag-fill mt-1"></i>
<div>
<strong>Follow-up Inspection Required</strong>
{% if inspection.follow_up_note %}<br><span class="small">{{ inspection.follow_up_note }}</span>{% endif %}
<div class="mt-2">
<a href="{{ url_for('inspections.reinspect', inspection_id=inspection.id) }}"
class="btn btn-sm btn-warning">
<i class="bi bi-arrow-repeat me-1"></i>Start Re-inspection
</a>
</div>
</div>
</div>
{% endif %}
{# ── Parent/child inspection links ── #}
{% if inspection.parent %}
<div class="alert alert-info small mb-3">
<i class="bi bi-arrow-up-circle me-1"></i>
This is a re-inspection of
<a href="{{ url_for('inspections.view', inspection_id=inspection.parent.id) }}"
class="alert-link">Inspection #{{ inspection.parent.id }}</a>
({{ inspection.parent.inspection_date.strftime('%Y-%m-%d') }},
score: {{ inspection.parent.overall_score|round(1) if inspection.parent.overall_score else 'N/A' }}%).
</div>
{% endif %}
{% set followups = inspection.follow_ups.all() %}
{% if followups %}
<div class="alert alert-secondary small mb-3">
<i class="bi bi-arrow-down-circle me-1"></i>
Follow-up inspection(s):
{% for fu in followups %}
<a href="{{ url_for('inspections.view', inspection_id=fu.id) }}" class="alert-link">
#{{ fu.id }} ({{ fu.inspection_date.strftime('%Y-%m-%d') }},
score: {{ fu.overall_score|round(1) if fu.overall_score else 'N/A' }}%)
</a>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
{# ── Score comparison card (re-inspections only) ── #}
{% if comparison %}
<div class="card shadow-sm mb-3 border-0">
<div class="card-header d-flex justify-content-between align-items-center
bg-{{ 'success' if comparison.score_delta and comparison.score_delta > 0
else 'danger' if comparison.score_delta and comparison.score_delta < 0
else 'secondary' }} text-white">
<span class="fw-semibold">
<i class="bi bi-arrow-left-right me-1"></i>
Score Comparison vs. Inspection #{{ comparison.parent_id }}
<span class="small opacity-75 ms-2">
{{ comparison.parent_date.strftime('%Y-%m-%d') }}
</span>
</span>
<span class="d-flex gap-3 align-items-center">
{# Overall delta badge #}
{% if comparison.score_delta is not none %}
{% if comparison.score_delta > 0 %}
<span class="badge bg-white text-success fw-bold fs-6">
<i class="bi bi-arrow-up-short"></i>+{{ comparison.score_delta }}%
</span>
{% elif comparison.score_delta < 0 %}
<span class="badge bg-white text-danger fw-bold fs-6">
<i class="bi bi-arrow-down-short"></i>{{ comparison.score_delta }}%
</span>
{% else %}
<span class="badge bg-white text-secondary fw-bold fs-6">No change</span>
{% endif %}
{% endif %}
{# Score pills #}
<span class="small opacity-75">
{{ comparison.parent_score|round(1) if comparison.parent_score else '—' }}%
&rarr;
{{ comparison.current_score|round(1) if comparison.current_score else '—' }}%
</span>
</span>
</div>
</div>
{% endif %}
{# 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.display_name }}</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">Start 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>
{# ── Submission GPS (admin / director only) ──────────────────────────── #}
{% if current_user.role in ['admin', 'director'] and inspection.submit_latitude and inspection.submit_longitude %}
{% set _lat = inspection.submit_latitude | float %}
{% set _lng = inspection.submit_longitude | float %}
<div class="mb-4">
<div class="lbl mb-1" style="font-size:.72rem;color:#94a3b8;font-weight:500;text-transform:uppercase;letter-spacing:.04em;">
<i class="bi bi-geo-alt-fill text-danger me-1"></i>Submission Location
</div>
<div style="border-radius:10px;overflow:hidden;border:1px solid #e2e8f0;max-width:420px;">
<iframe
src="https://maps.google.com/maps?q={{ _lat }},{{ _lng }}&z=15&output=embed"
width="420" height="220"
style="border:0;display:block;width:100%;"
allowfullscreen="" loading="lazy"
referrerpolicy="no-referrer-when-downgrade">
</iframe>
</div>
<div class="mt-1" style="font-size:.8rem;color:#64748b;">
{{ '%.6f' | format(_lat) }}, {{ '%.6f' | format(_lng) }}
<a href="https://www.google.com/maps?q={{ _lat }},{{ _lng }}" target="_blank"
class="ms-2 text-decoration-none small">
<i class="bi bi-box-arrow-up-right"></i> Open in Maps
</a>
</div>
</div>
{% endif %}
{# ── 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 any answered field (rating > 0, pass_fail answered, text filled, etc.),
mark ALL accumulated label IDs as visible and clear the buffer. #}
{% set lbuf = namespace(ids=[]) %}
{% for field in form_fields %}
{% if field.type == 'label' %}
{% set lbuf.ids = lbuf.ids + [field.id] %}
{% elif field.type not in _skip_types %}
{% set fval = form_data.get(field.id | string, '') %}
{% set answered = namespace(v=false) %}
{% if field.type == 'rating' %}
{% if fval | int > 0 %}{% set answered.v = true %}{% endif %}
{% elif fval and fval != '' and fval != [] %}
{% set answered.v = true %}
{% endif %}
{% if answered.v %}
{% 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 %}
{# Advance row only when original row actually changes #}
{% if field.row != remap.last_orig_row %}
{% if remap.last_orig_row != -1 %}{% set remap.out_row = remap.out_row + 1 %}{% endif %}
{# Flush pending section before the first field on this new row #}
{% if remap.pending_sec is not none %}
<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 %}
{% endif %}
{% set remap.last_orig_row = field.row %}
{% endif %}
{% 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 %}
{# Advance row only when original row actually changes.
Section flush is also guarded here so a label already placed on
this row at the same out_row cannot be displaced. #}
{% if field.row != remap.last_orig_row %}
{% if remap.last_orig_row != -1 %}{% set remap.out_row = remap.out_row + 1 %}{% endif %}
{% if remap.pending_sec is not none %}
<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 %}
{% endif %}
{% set remap.last_orig_row = field.row %}
{% endif %}
{# 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 == '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' %}
<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>
{# ── Print-only sign-off block (hidden on screen, visible @media print) ── #}
<div class="print-signoff" style="display:none; font-family:Helvetica,Arial,sans-serif;">
<table style="width:100%; border-collapse:collapse; font-size:8pt; color:#64748b;
margin-top:10pt;">
<tr>
<td colspan="6" style="font-size:9.5pt; font-weight:700; color:#1a1d23;
padding-bottom:6pt; border-bottom:0.5pt solid #e2e8f0;">
Sign-off
</td>
</tr>
<tr><td colspan="6" style="height:18pt;"></td></tr>
{# Inspector row #}
<tr>
<td style="width:18%; border-top:0.75pt solid #1a1d23; padding-top:3pt;
font-size:7.5pt; color:#64748b;">Inspector Signature</td>
<td style="width:4%;"></td>
<td style="width:38%; border-top:0.75pt solid #1a1d23; padding-top:3pt;
font-size:7.5pt; color:#64748b;">Printed Name</td>
<td style="width:4%;"></td>
<td style="width:32%; border-top:0.75pt solid #1a1d23; padding-top:3pt;
font-size:7.5pt; color:#64748b;">Date</td>
<td style="width:4%;"></td>
</tr>
<tr><td colspan="6" style="height:22pt;"></td></tr>
{# Supervisor row #}
<tr>
<td style="border-top:0.75pt solid #1a1d23; padding-top:3pt;
font-size:7.5pt; color:#64748b;">Supervisor Signature</td>
<td></td>
<td style="border-top:0.75pt solid #1a1d23; padding-top:3pt;
font-size:7.5pt; color:#64748b;">Printed Name</td>
<td></td>
<td style="border-top:0.75pt solid #1a1d23; padding-top:3pt;
font-size:7.5pt; color:#64748b;">Date</td>
<td></td>
</tr>
</table>
</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 () {
var backUrl = sessionStorage.getItem('insp_list_back_url');
if (backUrl) {
var btn = document.getElementById('backToInspectionsBtn');
if (btn) btn.href = backUrl;
}
}());
</script>
<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>
{# ── Flag follow-up modal ── #}
<div class="modal fade" id="followupModal" tabindex="-1">
<div class="modal-dialog">
<form method="POST" action="{{ url_for('inspections.flag_followup', inspection_id=inspection.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-flag me-2"></i>Flag Follow-up Required</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label fw-semibold">Reason / Notes <span class="text-muted small">(optional)</span></label>
<textarea name="follow_up_note" class="form-control" rows="3"
placeholder="Describe what needs to be addressed in the follow-up inspection…"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">
<i class="bi bi-flag me-1"></i>Flag Follow-up
</button>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
+114
View File
@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-danger text-white">
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> {{ title }}</h5>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Contract selector — UI only; narrows the facility list via AJAX #}
<div class="mb-3">
<label class="form-label fw-semibold" for="contract_select">Contract</label>
<select id="contract_select" class="form-select">
<option value="">— Select Contract —</option>
{% for p in projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
{% endfor %}
</select>
</div>
{# Facility — populated by JS once a contract is chosen #}
<div class="mb-3">
{{ form.facility_id.label(class="form-label fw-semibold") }}
{{ form.facility_id(class="form-select", id="facility_id") }}
{% for e in form.facility_id.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
{# Remaining fields — assigned_to hidden from customer role #}
{% for field in [form.severity, form.description, form.photo] %}
<div class="mb-3">
{{ field.label(class="form-label fw-semibold") }}
{{ field(class="form-select" if field.type == 'SelectField' else "form-control", rows=4 if field.type == 'TextAreaField' else none) }}
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
{% endfor %}
{% if current_user.role != 'customer' %}
<div class="mb-3">
{{ form.assigned_to.label(class="form-label fw-semibold") }}
{{ form.assigned_to(class="form-select") }}
{% for e in form.assigned_to.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
</div>
{% endif %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">Log Issue</button>
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
var contractSel = document.getElementById('contract_select');
var facilitySel = document.getElementById('facility_id');
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
// Values injected from the route (non-null only after a POST validation error)
var preProjectId = {{ selected_project_id | tojson }};
var preFacilityId = {{ form.facility_id.data | tojson }};
function setPlaceholder() {
facilitySel.innerHTML = '<option value="">— Select a Contract first —</option>';
facilitySel.disabled = true;
}
function loadFacilities(projectId, restoreFacilityId) {
facilitySel.disabled = true;
facilitySel.innerHTML = '<option value="">Loading…</option>';
fetch(FACILITIES_URL + projectId)
.then(function (r) { return r.json(); })
.then(function (data) {
facilitySel.innerHTML = '<option value="">— Select Facility —</option>';
data.forEach(function (f) {
var opt = document.createElement('option');
opt.value = f.id;
opt.textContent = f.name;
if (restoreFacilityId && f.id === restoreFacilityId) { opt.selected = true; }
facilitySel.appendChild(opt);
});
facilitySel.disabled = false;
})
.catch(function () {
facilitySel.innerHTML = '<option value="">Could not load facilities</option>';
});
}
contractSel.addEventListener('change', function () {
if (this.value) {
loadFacilities(this.value, null);
} else {
setPlaceholder();
}
});
// Restore state after a POST validation error
if (preProjectId) {
contractSel.value = String(preProjectId);
loadFacilities(preProjectId, preFacilityId);
} else {
setPlaceholder();
}
}());
</script>
{% endblock %}
+325
View File
@@ -0,0 +1,325 @@
{% extends "base.html" %}
{% block title %}Issues{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-exclamation-triangle"></i> Issues</h2>
{% if current_user.role in ['admin','director','customer'] %}
<a href="{{ url_for('issues.create') }}" class="btn btn-danger">
<i class="bi bi-plus-circle"></i> Log Issue
</a>
{% endif %}
</div>
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-1">
<label class="form-label small mb-1">Issue #</label>
<input type="number" name="issue_id" class="form-control form-control-sm"
min="1" placeholder="ID" value="{{ issue_id_filter }}">
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Severity</label>
<select name="severity" class="form-select form-select-sm">
<option value="">All</option>
{% for s in ['critical','high','medium','low'] %}
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Status</label>
<select name="status" class="form-select form-select-sm">
<option value="">All</option>
{% for s in ['open','in_progress','pending_verification','resolved'] %}
<option value="{{ s }}" {{ 'selected' if status_filter == s }}>{{ s|replace('_',' ')|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small mb-1">SLA</label>
<select name="sla" class="form-select form-select-sm">
<option value="">All</option>
<option value="breached" {{ 'selected' if sla_filter == 'breached' }}>Breached</option>
<option value="at_risk" {{ 'selected' if sla_filter == 'at_risk' }}>At Risk</option>
<option value="ok" {{ 'selected' if sla_filter == 'ok' }}>OK</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Contract</label>
<select name="contract_id" id="filter_contract_id" class="form-select form-select-sm">
<option value="">All Contracts</option>
{% for p in projects %}
<option value="{{ p.id }}" {{ 'selected' if contract_filter == p.id|string }}>{{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" id="filter_facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {{ 'selected' if facility_filter == f.id|string }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Reported From</label>
<input type="date" name="date_from" class="form-control form-control-sm"
value="{{ date_from_filter }}">
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Reported To</label>
<input type="date" name="date_to" class="form-control form-control-sm"
value="{{ date_to_filter }}">
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Reporter</label>
<select name="reporter_id" class="form-select form-select-sm">
<option value="">All Reporters</option>
{% for u in reporters %}
<option value="{{ u.id }}" {{ 'selected' if reporter_filter == u.id|string }}>{{ u.display_name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto d-flex align-items-end gap-2 flex-wrap">
<button type="submit" class="btn btn-sm btn-outline-primary">Filter</button>
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
<a href="{{ url_for('issues.export_list_pdf', **request.args) }}"
class="btn btn-sm btn-outline-danger">
<i class="bi bi-file-earmark-pdf"></i> Export PDF
</a>
</div>
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
{% if issues.items %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Reported</th>
<th>Severity</th>
<th>Contract</th>
<th>Facility / Area</th>
<th>Description</th>
<th>Status</th>
<th>SLA</th>
<th>Reporter</th>
<th>Assigned</th>
<th></th>
</tr>
</thead>
<tbody>
{% for issue in issues.items %}
{% set is_following = issue.id in followed_ids %}
{% set sla = sla_status(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td><small class="text-muted">#{{ issue.id }}</small></td>
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</small></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>
{% set _c = issue.resolved_facility.project if issue.resolved_facility else none %}
<small>{{ _c.name if _c else '—' }}</small>
</td>
<td>
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
<small class="text-muted">{{ issue.area.name if issue.area else '—' }}</small>
</td>
<td>{{ issue.description[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td>
<td>
<span class="badge bg-{{ 'success' if issue.status == 'resolved' else 'info text-dark' if issue.status == 'pending_verification' else '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" title="SLA deadline has passed"><i class="bi bi-alarm me-1"></i>Breached</span>
{% elif sla == 'at_risk' %}
{% set hrs = sla_hours_remaining(issue) %}
<span class="badge bg-warning text-dark" title="Over 75% of SLA window elapsed"><i class="bi bi-hourglass-split me-1"></i>{{ hrs|abs|round(1) }}h left</span>
{% elif sla == 'ok' %}
<span class="badge bg-secondary">OK</span>
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
<td>
{% if issue.reporter %}
<small>{{ issue.reporter.display_name }}</small>
{% else %}<span class="text-muted"></span>{% endif %}
</td>
<td>
{% if current_user.role in ['admin', 'director'] and issue.status != 'resolved' %}
<div class="d-flex align-items-center gap-1 quick-assign-wrap" data-issue-id="{{ issue.id }}">
<select class="form-select form-select-sm quick-assign-select" style="min-width:110px;font-size:.78rem;">
<option value="">— Unassigned —</option>
{% for u in staff %}
<option value="{{ u.id }}" {{ 'selected' if issue.assigned_to == u.id }}>{{ u.display_name }}</option>
{% endfor %}
</select>
<span class="quick-assign-spinner spinner-border spinner-border-sm text-secondary d-none" role="status"></span>
</div>
{% else %}
{% if issue.assigned_user %}{{ issue.assigned_user.display_name }}
{% else %}<span class="text-muted"></span>{% endif %}
{% endif %}
</td>
<td class="text-nowrap">
{# Following badge + inline unfollow #}
{% if is_following %}
<span class="badge bg-primary me-1" title="You are following this issue">
<i class="bi bi-bell-fill"></i> Following
</span>
<form method="post"
action="{{ url_for('issues.unfollow', issue_id=issue.id) }}"
class="d-inline"
title="Unfollow this issue">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="next" value="{{ url_for('issues.index', page=issues.page, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter) }}">
<button type="submit" class="btn btn-sm btn-outline-primary p-0 px-1 me-1"
title="Unfollow">
<i class="bi bi-bell-slash" style="font-size:.75rem;"></i>
</button>
</form>
{% endif %}
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
class="btn btn-sm btn-outline-secondary">
{% if current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
<i class="bi bi-pencil"></i> Edit
{% else %}
<i class="bi bi-eye"></i> View
{% endif %}
</a>
{% if current_user.role in ['admin', 'director'] %}
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}"
class="d-inline"
onsubmit="return confirm('Permanently delete Issue #{{ issue.id }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Delete Issue #{{ issue.id }}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if issues.pages > 1 %}
<div class="d-flex justify-content-center py-3">
<nav><ul class="pagination pagination-sm mb-0">
{% for p in issues.iter_pages(left_edge=1,right_edge=1,left_current=2,right_current=2) %}
{% if p %}
<li class="page-item {{ 'active' if p == issues.page }}">
<a class="page-link"
href="{{ url_for('issues.index', page=p, issue_id=issue_id_filter, severity=severity_filter, status=status_filter, sla=sla_filter, contract_id=contract_filter, facility_id=facility_filter, date_from=date_from_filter, date_to=date_to_filter, reporter_id=reporter_filter) }}">{{ p }}</a>
</li>
{% else %}<li class="page-item disabled"><span class="page-link"></span></li>{% endif %}
{% endfor %}
</ul></nav>
</div>
{% endif %}
{% else %}
<div class="alert alert-info m-3"><i class="bi bi-info-circle"></i> No issues found.</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
var contractSel = document.getElementById('filter_contract_id');
var facilitySel = document.getElementById('filter_facility_id');
if (!contractSel || !facilitySel) return;
var FACILITIES_URL = '{{ url_for("inspections.facilities_for_project", project_id=0) }}'.replace('/0', '/');
contractSel.addEventListener('change', function () {
var projectId = this.value;
facilitySel.value = ''; // reset facility selection
if (!projectId) {
// No contract selected — restore all-facilities placeholder and submit
// (server will return unfiltered facility list)
facilitySel.innerHTML = '<option value="">All Facilities</option>';
return;
}
facilitySel.disabled = true;
facilitySel.innerHTML = '<option value="">Loading…</option>';
fetch(FACILITIES_URL + projectId)
.then(function (r) { return r.json(); })
.then(function (data) {
var html = '<option value="">All Facilities</option>';
data.forEach(function (f) {
html += '<option value="' + f.id + '">' + f.name + '</option>';
});
facilitySel.innerHTML = html;
facilitySel.disabled = false;
})
.catch(function () { facilitySel.disabled = false; });
});
}());
</script>
{% if current_user.role in ['admin', 'director'] %}
<script>
(function () {
'use strict';
document.querySelectorAll('.quick-assign-select').forEach(function (sel) {
sel.dataset.previous = sel.value;
sel.addEventListener('change', function () {
const wrap = sel.closest('.quick-assign-wrap');
const issueId = wrap.dataset.issueId;
const spinner = wrap.querySelector('.quick-assign-spinner');
const userId = sel.value || null;
sel.disabled = true;
spinner.classList.remove('d-none');
fetch('/issues/' + issueId + '/quick-assign', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}',
},
body: JSON.stringify({ user_id: userId ? parseInt(userId) : null }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.ok) {
alert('Assignment failed: ' + (data.error || 'Unknown error'));
sel.value = sel.dataset.previous;
} else {
sel.dataset.previous = sel.value;
}
})
.catch(function () {
alert('Network error — assignment not saved.');
sel.value = sel.dataset.previous;
})
.finally(function () {
sel.disabled = false;
spinner.classList.add('d-none');
});
});
});
}());
</script>
{% endif %}
{% endblock %}
@@ -0,0 +1,236 @@
{% extends "base.html" %}
{% block title %}Verification Queue{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2><i class="bi bi-patch-check text-info me-2"></i>Verification Queue</h2>
<p class="text-muted mb-0">
Issues awaiting director sign-off before they are fully closed.
</p>
</div>
<div class="d-flex gap-2 align-items-center">
{% if total_pending > 0 %}
<span class="badge bg-info text-dark fs-6">{{ total_pending }} pending</span>
{% else %}
<span class="badge bg-success fs-6"><i class="bi bi-check-all me-1"></i>All clear</span>
{% endif %}
<a href="{{ url_for('issues.index') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> All Issues
</a>
</div>
</div>
{# ── Bulk-verify toolbar — shown when at least one issue is pending ── #}
{% if grouped %}
<form method="POST" action="{{ url_for('issues.bulk_verify') }}" id="bulkVerifyForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# Issue checkboxes are rendered inside the per-facility tables below;
hidden inputs with their IDs are inserted here by JS on submission. #}
<div class="card bg-light border-0 mb-3 p-2 d-flex flex-row align-items-center gap-3 flex-wrap" id="bulkToolbar">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="selectAllIssues">
<label class="form-check-label fw-semibold" for="selectAllIssues">Select all</label>
</div>
<span class="text-muted small" id="selectedCount">0 selected</span>
<button type="submit" class="btn btn-success btn-sm" id="bulkVerifyBtn" disabled
onclick="return injectBulkIds(this.form)">
<i class="bi bi-patch-check-fill me-1"></i>Verify Selected
</button>
</div>
</form>
{% endif %}
{% if not grouped %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-patch-check fs-1 d-block mb-3 opacity-25"></i>
<p class="mb-0 fs-5">No issues are currently awaiting verification.</p>
<p class="small mt-1">When inspectors request sign-off, their issues will appear here.</p>
</div>
</div>
{% else %}
{# ── Per-facility groups ── #}
{% for facility, issues in grouped %}
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center bg-light">
<span class="fw-semibold">
<i class="bi bi-building me-1 text-muted"></i>{{ facility.name }}
</span>
<span class="badge bg-info text-dark">
{{ issues|length }} issue{{ 's' if issues|length != 1 else '' }}
</span>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light" style="font-size:.82rem;">
<tr>
<th width="36"><span class="visually-hidden">Select</span></th>
<th width="60">ID</th>
<th width="90">Severity</th>
<th>Area / Description</th>
<th>Requested By</th>
<th>Reported</th>
<th>SLA</th>
<th width="220">Verify</th>
</tr>
</thead>
<tbody>
{% for issue in issues %}
{% set sla = sla_status(issue) %}
{% set hrs = sla_hours_remaining(issue) %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
{# Bulk-select checkbox #}
<td class="align-middle text-center">
<input class="form-check-input issue-checkbox" type="checkbox"
value="{{ issue.id }}" aria-label="Select issue #{{ issue.id }}">
</td>
{# ID #}
<td class="text-muted small align-middle">
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
class="text-decoration-none fw-semibold">#{{ issue.id }}</a>
</td>
{# Severity #}
<td class="align-middle">
<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>
{# Area / Description #}
<td class="align-middle">
<div class="fw-semibold small">{{ issue.area.name if issue.area else '—' }}</div>
<div class="text-muted small">
{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}
</div>
</td>
{# Requested by / assignee #}
<td class="align-middle small">
{% if issue.assigned_user %}
<i class="bi bi-person-circle text-muted me-1"></i>{{ issue.assigned_user.display_name }}
{% else %}
<span class="text-muted">— unassigned —</span>
{% endif %}
</td>
{# Reported date #}
<td class="align-middle small text-muted">
{{ issue.reported_at.strftime('%Y-%m-%d') }}<br>
<span style="font-size:.75rem;">{{ issue.reported_at.strftime('%H:%M') }}</span>
</td>
{# SLA indicator #}
<td class="align-middle">
{% 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>
{% if hrs is not none %}{{ hrs|abs|round(1) }}h left{% else %}At Risk{% endif %}
</span>
{% else %}
<span class="badge bg-secondary">
{% if hrs is not none %}{{ hrs|round(1) }}h left{% else %}OK{% endif %}
</span>
{% endif %}
</td>
{# Inline verify form #}
<td class="align-middle">
<form method="POST"
action="{{ url_for('issues.verify', issue_id=issue.id) }}"
class="d-flex gap-1 align-items-center">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="text"
name="verification_note"
class="form-control form-control-sm"
placeholder="Note (optional)"
style="width:130px;">
<button type="submit" class="btn btn-sm btn-success flex-shrink-0"
title="Verify &amp; close this issue"
onclick="return confirm('Verify and close Issue #{{ issue.id }}?')">
<i class="bi bi-patch-check"></i>
</button>
<a href="{{ url_for('issues.view', issue_id=issue.id) }}"
class="btn btn-sm btn-outline-secondary flex-shrink-0"
title="View full issue">
<i class="bi bi-eye"></i>
</a>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endfor %}
{# ── Legend ── #}
<div class="card border-0 bg-light mt-2">
<div class="card-body py-2 px-3 d-flex gap-4 flex-wrap" style="font-size:.78rem;">
<span><span class="badge bg-danger me-1">Breached</span>Past SLA deadline</span>
<span><span class="badge bg-warning text-dark me-1">At Risk</span>&gt;75% of SLA window elapsed</span>
<span><span class="badge bg-secondary me-1">OK</span>Within SLA</span>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
// ── Bulk-verify checkbox management ─────────────────────────────────────────
(function () {
const selectAll = document.getElementById('selectAllIssues');
const countEl = document.getElementById('selectedCount');
const verifyBtn = document.getElementById('bulkVerifyBtn');
function updateToolbar() {
const checked = document.querySelectorAll('.issue-checkbox:checked');
const n = checked.length;
if (countEl) countEl.textContent = n + ' selected';
if (verifyBtn) verifyBtn.disabled = n === 0;
if (selectAll) selectAll.indeterminate = n > 0 && n < document.querySelectorAll('.issue-checkbox').length;
if (selectAll) selectAll.checked = n > 0 && n === document.querySelectorAll('.issue-checkbox').length;
}
document.querySelectorAll('.issue-checkbox').forEach(cb => {
cb.addEventListener('change', updateToolbar);
});
if (selectAll) {
selectAll.addEventListener('change', function () {
document.querySelectorAll('.issue-checkbox').forEach(cb => { cb.checked = this.checked; });
updateToolbar();
});
}
updateToolbar();
}());
// ── Inject checked issue IDs into the bulk-verify form before submit ─────────
function injectBulkIds(form) {
// Remove any previously injected inputs
form.querySelectorAll('input[name="issue_ids"]').forEach(el => el.remove());
const checked = document.querySelectorAll('.issue-checkbox:checked');
if (!checked.length) { alert('Please select at least one issue.'); return false; }
if (!confirm('Verify and close ' + checked.length + ' selected issue(s)?')) return false;
checked.forEach(cb => {
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = 'issue_ids';
hidden.value = cb.value;
form.appendChild(hidden);
});
return true;
}
</script>
{% endblock %}
+482
View File
@@ -0,0 +1,482 @@
{% extends "base.html" %}
{% block title %}Issue #{{ issue.id }}{% endblock %}
{% block extra_css %}
<style>
.comment-avatar {
width: 38px; height: 38px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: .9rem; color: #fff;
flex-shrink: 0;
}
.comment-bubble {
background: #f8f9fa; border: 1px solid #e9ecef;
border-radius: .5rem; padding: .75rem 1rem;
flex-grow: 1;
}
</style>
{% endblock %}
{% block content %}
{% set can_edit = current_user.role in ['admin','director'] or issue.assigned_to == current_user.id %}
<div class="row">
{# ══════════════════════════════════ LEFT COLUMN ══════════════════════════════════ #}
<div class="col-lg-8">
{# ── Issue details ──────────────────────────────────────────────────── #}
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center
bg-{{ 'danger' if issue.severity in ['critical','high'] else 'warning' if issue.severity == 'medium' else 'secondary' }}
text-{{ 'white' if issue.severity in ['critical','high','low'] else 'dark' }}">
<h5 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Issue #{{ issue.id }} — {{ issue.severity|title }} Severity</h5>
<span class="badge bg-{{ 'info text-dark' if issue.status == 'pending_verification' else 'light text-dark' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Reported</dt>
<dd class="col-sm-9">{{ issue.reported_at.strftime('%Y-%m-%d %H:%M') }}</dd>
<dt class="col-sm-3">Reported By</dt>
<dd class="col-sm-9">
{% if issue.reporter %}
{{ issue.reporter.display_name }}
{% if issue.reporter.role == 'customer' %}
<span class="badge bg-info text-dark ms-1">Customer</span>
{% else %}
<span class="badge bg-secondary ms-1">{{ issue.reporter.role|replace('_',' ')|title }}</span>
{% endif %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</dd>
<dt class="col-sm-3">Contract</dt>
<dd class="col-sm-9">
{% if issue.resolved_facility and issue.resolved_facility.project %}
{{ issue.resolved_facility.project.name }}
{% else %}—{% endif %}
</dd>
<dt class="col-sm-3">Facility</dt>
<dd class="col-sm-9">{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}</dd>
<dt class="col-sm-3">Area</dt>
<dd class="col-sm-9">{{ issue.area.name if issue.area else '—' }}</dd>
{% if issue.inspection %}
<dt class="col-sm-3">Inspection</dt>
<dd class="col-sm-9">
<a href="{{ url_for('inspections.view', inspection_id=issue.inspection_id) }}">#{{ issue.inspection_id }}</a>
</dd>
{% endif %}
<dt class="col-sm-3">Assigned To</dt>
<dd class="col-sm-9">{{ issue.assigned_user.display_name if issue.assigned_user else '— Unassigned —' }}</dd>
{% if issue.vendor_name %}
<dt class="col-sm-3">Contractor</dt>
<dd class="col-sm-9">
<i class="bi bi-person-gear text-secondary me-1"></i>
<strong>{{ issue.vendor_name }}</strong>
{% if issue.vendor_contact %}
<span class="text-muted ms-2">{{ issue.vendor_contact }}</span>
{% endif %}
{% if issue.vendor_notes %}
<div class="text-muted small mt-1" style="white-space:pre-wrap;">{{ issue.vendor_notes }}</div>
{% endif %}
</dd>
{% endif %}
{% if issue.resolved_at %}
<dt class="col-sm-3">Resolved</dt>
<dd class="col-sm-9">{{ issue.resolved_at.strftime('%Y-%m-%d %H:%M') }}</dd>
{% endif %}
</dl>
<hr>
<h6>Description</h6>
<p class="mb-0" style="white-space:pre-wrap;">{{ issue.description }}</p>
{% if issue.photo_path or issue.mobile_photo_paths %}
<hr>
<h6>Photo Evidence</h6>
<div class="d-flex flex-wrap gap-2">
{% if issue.photo_path %}
<a href="{{ url_for('static', filename=issue.photo_path) }}" target="_blank">
<img src="{{ url_for('static', filename=issue.photo_path) }}"
class="img-fluid rounded" style="max-height:300px; max-width:100%;">
</a>
{% endif %}
{% for photo in (issue.mobile_photo_paths or []) %}
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
<img src="{{ url_for('static', filename=photo) }}"
class="rounded border" style="max-height:300px; max-width:100%; object-fit:cover;">
</a>
{% endfor %}
</div>
{% endif %}
{% if issue.result_notes or issue.result_photos %}
<hr>
<h6><i class="bi bi-clipboard2-check text-success"></i> Resolution Details</h6>
{% if issue.result_notes %}
<p class="mb-2" style="white-space:pre-wrap;">{{ issue.result_notes }}</p>
{% endif %}
{% if issue.result_photos %}
<div class="d-flex flex-wrap gap-2 mt-2">
{% for photo in issue.result_photos %}
<a href="{{ url_for('static', filename=photo) }}" target="_blank">
<img src="{{ url_for('static', filename=photo) }}"
class="rounded border" style="max-height:120px; max-width:160px; object-fit:cover;"
alt="Result photo">
</a>
{% endfor %}
</div>
{% endif %}
{% endif %}
{# ── Verification panel ── #}
{% if issue.verified_at %}
<hr>
<div class="alert alert-success py-2 mb-0">
<i class="bi bi-patch-check-fill me-1"></i>
<strong>Verified</strong> by {{ issue.verifier.display_name if issue.verifier else 'unknown' }}
on {{ issue.verified_at.strftime('%Y-%m-%d %H:%M') }}.
{% if issue.verification_note %}<br><span class="small">{{ issue.verification_note }}</span>{% endif %}
</div>
{% elif issue.status == 'pending_verification' %}
<hr>
<div class="alert alert-info py-2 mb-0">
<i class="bi bi-hourglass-split me-1"></i>
<strong>Awaiting director verification.</strong>
{% if current_user.role in ['admin','director'] %}
<form method="POST" action="{{ url_for('issues.verify', issue_id=issue.id) }}" class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2">
<input type="text" name="verification_note" class="form-control form-control-sm"
placeholder="Verification note (optional)">
</div>
<button type="submit" class="btn btn-sm btn-success">
<i class="bi bi-patch-check me-1"></i>Verify &amp; Close
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
{# ── Comments ───────────────────────────────────────────────────────── #}
<div class="card shadow-sm mb-4" id="comments-section">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="bi bi-chat-left-text me-1"></i>Comments
<span class="badge bg-secondary rounded-pill ms-1">{{ comments|length }}</span>
</h6>
</div>
{# Comment list #}
{% if comments %}
<div class="p-3 pb-0" id="comments-list">
{% set avatar_colors = ['#4f46e5','#0891b2','#059669','#d97706','#dc2626','#7c3aed','#db2777'] %}
{% for c in comments %}
{% set avatar_color = avatar_colors[c.author.id % (avatar_colors | length)] %}
<div class="d-flex gap-3 mb-3">
<div class="comment-avatar" style="background:{{ avatar_color }};">
{{ c.author.display_name[0] | upper }}
</div>
<div class="comment-bubble">
<div class="d-flex justify-content-between align-items-start flex-wrap gap-1 mb-1">
<div class="d-flex align-items-center gap-2">
<span class="fw-semibold" style="font-size:.9rem;">{{ c.author.display_name }}</span>
{% if c.author.role == 'customer' %}
<span class="badge bg-info text-dark" style="font-size:.65rem;">Customer</span>
{% else %}
<span class="badge bg-secondary" style="font-size:.65rem;">{{ c.author.role|replace('_',' ')|title }}</span>
{% endif %}
{# Visibility indicator — staff only #}
{% if current_user.role != 'customer' %}
{% if c.is_customer_visible %}
<span class="badge bg-success bg-opacity-10 text-success border border-success"
style="font-size:.6rem;" title="Customer can see this comment">
<i class="bi bi-eye me-1"></i>Customer visible
</span>
{% else %}
<span class="badge bg-secondary bg-opacity-10 text-secondary border border-secondary"
style="font-size:.6rem;" title="Hidden from customer">
<i class="bi bi-eye-slash me-1"></i>Staff only
</span>
{% endif %}
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-{{ 'success' if c.status_at_time == 'resolved' else 'info text-dark' if c.status_at_time == 'pending_verification' else 'warning text-dark' if c.status_at_time == 'in_progress' else 'danger' }}"
style="font-size:.65rem;">
{{ c.status_at_time|replace('_',' ')|title }}
</span>
<small class="text-muted">{{ c.created_at.strftime('%b %d, %Y %H:%M') }}</small>
</div>
</div>
<p class="mb-0" style="white-space:pre-wrap; font-size:.9rem;">{{ c.body }}</p>
</div>
</div>
{% endfor %}
</div>
<hr class="mx-3 my-0">
{% elif current_user.role == 'customer' %}
<div class="px-3 pt-3 pb-0">
<p class="text-muted small"><i class="bi bi-chat-left me-1"></i>No comments yet.</p>
</div>
<hr class="mx-3 my-0">
{% endif %}
{# ── Add Comment form ─────────────────────────────────────────────── #}
{% set can_customer_comment = current_user.role == 'customer' and (is_following or issue.reported_by == current_user.id) %}
{% if can_edit %}
{# Staff comment form with visibility checkbox #}
<div class="card-body">
<p class="fw-semibold small mb-2">Add Comment</p>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" value="{{ issue.status }}">
<input type="hidden" name="assigned_to" value="{{ issue.assigned_to or 0 }}">
<div class="mb-2">
<textarea name="update_notes" class="form-control" rows="3"
placeholder="Write a comment…" required></textarea>
</div>
<div class="d-flex align-items-center justify-content-between flex-wrap gap-2">
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox"
name="is_customer_visible" id="is_customer_visible" value="1">
<label class="form-check-label small text-muted" for="is_customer_visible">
<i class="bi bi-eye me-1"></i>Share with customer
</label>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-send me-1"></i>Post Comment
</button>
</div>
</form>
</div>
{% elif can_customer_comment %}
{# Customer comment form — visible to all by design #}
<div class="card-body">
<p class="fw-semibold small mb-2">Add Comment</p>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-2">
<textarea name="update_notes" class="form-control" rows="3"
placeholder="Write a comment…" required></textarea>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-send me-1"></i>Post Comment
</button>
</form>
</div>
{% elif current_user.role == 'customer' %}
<div class="card-body py-2">
<p class="text-muted small mb-0">
<i class="bi bi-bell me-1"></i>Follow this issue to add comments.
</p>
</div>
{% else %}
<div class="card-body py-2">
<p class="text-muted small mb-0">
<i class="bi bi-lock me-1"></i>Only assigned staff can add comments.
</p>
</div>
{% endif %}
</div>
</div>{# /col-lg-8 #}
{# ══════════════════════════════════ RIGHT COLUMN ═════════════════════════════════ #}
<div class="col-lg-4">
{# ── Follow / Unfollow ──────────────────────────────────────────────── #}
<div class="card shadow-sm mb-3">
<div class="card-body d-flex align-items-center justify-content-between py-2">
<div>
<i class="bi bi-bell{{ '-fill text-primary' if is_following else ' text-muted' }} me-1"></i>
<span class="fw-semibold" style="font-size:.9rem;">
{% if is_following %}Following{% else %}Not following{% endif %}
</span>
<span class="text-muted ms-2" style="font-size:.8rem;">
{{ issue.followers.count() }} follower{{ 's' if issue.followers.count() != 1 else '' }}
</span>
</div>
{% if is_following %}
<form method="post" action="{{ url_for('issues.unfollow', issue_id=issue.id) }}" class="mb-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-bell-slash"></i> Unfollow
</button>
</form>
{% else %}
<form method="post" action="{{ url_for('issues.follow', issue_id=issue.id) }}" class="mb-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-bell"></i> Follow
</button>
</form>
{% endif %}
</div>
</div>
{# ── Update Form ────────────────────────────────────────────────────── #}
{% if can_edit %}
<div class="card shadow-sm">
<div class="card-header bg-light"><h6 class="mb-0">Update Issue</h6></div>
<div class="card-body">
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
{{ form.status.label(class="form-label fw-semibold") }}
{{ form.status(class="form-select") }}
</div>
{% if current_user.role in ['admin','director'] %}
<div class="mb-3">
{{ form.assigned_to.label(class="form-label fw-semibold") }}
{{ form.assigned_to(class="form-select") }}
</div>
{% endif %}
<div class="mb-3">
{{ form.result_notes.label(class="form-label fw-semibold") }}
{{ form.result_notes(class="form-control", rows=3,
placeholder="Describe what was done to resolve this issue…",
value=issue.result_notes or '') }}
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Result Photos</label>
<input type="file" name="result_photos" id="result_photos"
class="form-control" accept="image/*" multiple>
<div class="form-text">Attach one or more photos showing the resolution.</div>
{% if issue.result_photos %}
<div class="mt-2">
<small class="text-muted">{{ issue.result_photos|length }} photo(s) already uploaded</small>
</div>
{% endif %}
</div>
{% if current_user.role in ['admin','director','project_manager'] %}
<hr class="my-3">
<p class="fw-semibold small mb-2">
<i class="bi bi-person-gear me-1 text-secondary"></i>External Contractor
</p>
<div class="mb-2">
{{ form.vendor_name.label(class="form-label small fw-semibold mb-1") }}
{{ form.vendor_name(class="form-control form-control-sm",
placeholder="Contractor or vendor name",
value=issue.vendor_name or '') }}
</div>
<div class="mb-2">
{{ form.vendor_contact.label(class="form-label small fw-semibold mb-1") }}
{{ form.vendor_contact(class="form-control form-control-sm",
placeholder="Phone or email",
value=issue.vendor_contact or '') }}
</div>
<div class="mb-3">
{{ form.vendor_notes.label(class="form-label small fw-semibold mb-1") }}
{{ form.vendor_notes(class="form-control form-control-sm", rows=2,
placeholder="Notes about what the contractor is handling…") }}
</div>
{% endif %}
<button type="submit" class="btn btn-primary w-100">Save Update</button>
</form>
{% if issue.status in ['in_progress', 'resolved'] and current_user.role not in ['customer'] %}
<form method="POST"
action="{{ url_for('issues.request_verification', issue_id=issue.id) }}"
class="mt-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-info w-100"
onclick="return confirm('Mark this issue as pending director verification?')">
<i class="bi bi-hourglass-split me-1"></i> Request Verification
</button>
</form>
{% endif %}
</div>
</div>
{% endif %}
</div>{# /col-lg-4 #}
</div>
<div class="d-flex align-items-center gap-2 mt-2">
<a href="{{ url_for('issues.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Issues
</a>
<a href="{{ url_for('issues.export_pdf', issue_id=issue.id) }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-file-earmark-pdf"></i> Export PDF
</a>
{% if current_user.role in ['admin', 'director'] %}
<button type="button" class="btn btn-outline-danger btn-sm"
data-bs-toggle="modal" data-bs-target="#deleteIssueModal">
<i class="bi bi-trash"></i> Delete Issue
</button>
{% endif %}
</div>
{% if current_user.role in ['admin', 'director'] %}
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteIssueModalLabel">
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>You are about to permanently delete:</p>
<p class="fw-bold">Issue #{{ issue.id }} — {{ issue.severity|title }} severity in {{ issue.area.name if issue.area else '—' }}</p>
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle-fill"></i>
This action is <strong>irreversible</strong>. All comments, photos, and
follower records associated with this issue will also be deleted.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<form method="POST" action="{{ url_for('issues.delete', issue_id=issue.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% block extra_js %}
<script>
(function () {
'use strict';
// Refresh bell badge after a successful update
if (document.querySelector('.alert-success')) {
if (typeof fetchNotifications === 'function') {
fetchNotifications();
}
}
// Scroll to bottom of comments list after posting a comment
if (document.querySelector('.alert-success')) {
var section = document.getElementById('comments-section');
if (section) {
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
})();
</script>
{% endblock %}
{% endblock %}
+48
View File
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Login - Janitorial QC{% endblock %}
{% block content %}
<div class="row justify-content-center mt-5">
<div class="col-md-5 col-lg-4">
<div class="card shadow-lg">
<div class="card-header bg-primary text-white text-center py-4">
<h3><i class="bi bi-clipboard-check-fill"></i> Janitorial QC</h3>
<p class="mb-0">Quality Control System</p>
</div>
<div class="card-body p-4">
<form method="POST" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control form-control-lg", placeholder="Enter username") }}
{% if form.username.errors %}
<div class="text-danger small mt-1">
{% for error in form.username.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-4">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control form-control-lg", placeholder="Enter password") }}
{% if form.password.errors %}
<div class="text-danger small mt-1">
{% for error in form.password.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary btn-lg w-100">
<i class="bi bi-box-arrow-in-right"></i> Login
</button>
</form>
</div>
<div class="card-footer text-center text-muted small">
&copy; 2025 Janitorial QC System
</div>
</div>
</div>
</div>
{% endblock %}
+164
View File
@@ -0,0 +1,164 @@
{% extends "base.html" %}
{% block title %}Notifications{% endblock %}
{% block content %}
<div class="row mb-3 align-items-center">
<div class="col">
<h4 class="mb-0">
<i class="bi bi-bell-fill text-primary me-2"></i>Notifications
{% if unread_count > 0 %}
<span class="badge bg-danger ms-1" style="font-size:.6rem;vertical-align:middle;">
{{ unread_count }} unread
</span>
{% endif %}
</h4>
</div>
<div class="col-auto d-flex gap-2">
<a href="{{ url_for('notifications.preferences') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-gear"></i> Preferences
</a>
{% if unread_count > 0 %}
<form method="post" action="{{ url_for('notifications.mark_all_read') }}" class="mb-0">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-primary btn-sm">
<i class="bi bi-check2-all"></i> Mark all read
</button>
</form>
{% endif %}
</div>
</div>
{# ── Filter tabs ── #}
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link {% if filter_read == 'all' %}active{% endif %}"
href="{{ url_for('notifications.index', filter='all') }}">All</a>
</li>
<li class="nav-item">
<a class="nav-link {% if filter_read == 'unread' %}active{% endif %}"
href="{{ url_for('notifications.index', filter='unread') }}">Unread</a>
</li>
<li class="nav-item">
<a class="nav-link {% if filter_read == 'read' %}active{% endif %}"
href="{{ url_for('notifications.index', filter='read') }}">Read</a>
</li>
</ul>
{# ── Notification list ── #}
{% if notifications.items %}
<div class="card shadow-sm">
<ul class="list-group list-group-flush">
{% for n in notifications.items %}
<li class="list-group-item px-3 py-3
{% if not n.is_read %}list-group-item-light border-start border-primary border-3{% endif %}">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-center gap-2 mb-1">
{% if not n.is_read %}
<span class="badge bg-primary" style="font-size:.65rem;">New</span>
{% endif %}
<span class="fw-semibold" style="font-size:.9rem;">{{ n.title }}</span>
</div>
<p class="mb-1 text-secondary" style="font-size:.85rem;">{{ n.body }}</p>
<small class="text-muted">
<i class="bi bi-clock me-1"></i>{{ n.created_at.strftime('%b %d, %Y %I:%M %p') }}
</small>
</div>
<div class="d-flex gap-2 ms-3 flex-shrink-0">
{% if n.link %}
<a href="{{ n.link }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-right"></i> View
</a>
{% endif %}
{% if not n.is_read %}
<button type="button"
class="btn btn-sm btn-outline-secondary mark-read-btn"
data-notif-id="{{ n.id }}"
data-url="{{ url_for('notifications.mark_read', notif_id=n.id) }}"
title="Mark as read">
<i class="bi bi-check2"></i>
</button>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
{# ── Pagination ── #}
{% if notifications.pages > 1 %}
<nav class="mt-3">
<ul class="pagination pagination-sm justify-content-center">
<li class="page-item {% if not notifications.has_prev %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('notifications.index', page=notifications.prev_num, filter=filter_read) }}">
&laquo; Prev
</a>
</li>
{% for p in notifications.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == notifications.page %}active{% endif %}">
<a class="page-link"
href="{{ url_for('notifications.index', page=p, filter=filter_read) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
<li class="page-item {% if not notifications.has_next %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('notifications.index', page=notifications.next_num, filter=filter_read) }}">
Next &raquo;
</a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-5 text-muted">
<i class="bi bi-bell-slash fs-1 d-block mb-3"></i>
<p class="mb-0">No notifications found.</p>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script>
document.querySelectorAll('.mark-read-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var url = btn.dataset.url;
var csrfToken = {{ csrf_token() | tojson }};
btn.disabled = true;
fetch(url, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'csrf_token=' + encodeURIComponent(csrfToken),
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.ok) {
var li = btn.closest('li');
li.classList.remove('list-group-item-light', 'border-start', 'border-primary', 'border-3');
var badge = li.querySelector('.badge.bg-primary');
if (badge) badge.remove();
btn.remove();
var unreadBadge = document.querySelector('.badge.bg-danger');
if (unreadBadge) {
var count = parseInt(unreadBadge.textContent) - 1;
if (count <= 0) {
unreadBadge.remove();
var markAllForm = document.querySelector('form[action*="mark-all-read"]');
if (markAllForm) markAllForm.closest('.col-auto') && markAllForm.remove();
} else {
unreadBadge.textContent = count + ' unread';
}
}
}
})
.catch(function() { btn.disabled = false; });
});
});
</script>
{% endblock %}
@@ -0,0 +1,347 @@
{% extends "base.html" %}
{% block title %}Notification Preferences{% endblock %}
{% block content %}
<div class="row mb-3 align-items-center">
<div class="col">
<h4 class="mb-0">
<i class="bi bi-gear-fill text-secondary me-2"></i>Notification Preferences
</h4>
<p class="text-muted mb-0 mt-1" style="font-size:.85rem;">
Control how and when you receive notifications for each event type.
</p>
</div>
<div class="col-auto">
<a href="{{ url_for('notifications.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-bell"></i> View Notifications
</a>
</div>
</div>
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{# ── Pause all emails banner ── #}
{% set any_email_on = prefs_map.values() | selectattr('email_enabled') | list | length > 0
or prefs_map | length == 0 %}
<div class="alert alert-light border d-flex align-items-center justify-content-between py-2 mb-3">
<div>
<i class="bi bi-pause-circle me-2 text-secondary"></i>
<strong>Pause all email notifications</strong>
<span class="text-muted ms-2" style="font-size:.85rem;">— in-app notifications are unaffected</span>
</div>
<button type="button" id="pauseAllBtn" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pause-fill me-1"></i>Pause All Emails
</button>
</div>
<div class="card shadow-sm">
<div class="card-header bg-light">
<div class="row fw-semibold text-muted" style="font-size:.8rem;">
<div class="col-md-4">Event</div>
<div class="col-md-2 text-center">Email Alerts</div>
<div class="col-md-2 text-center">Digest Mode</div>
<div class="col-md-3 text-center">Digest Frequency</div>
<div class="col-md-1"></div>
</div>
</div>
{# ── Internal staff events ── #}
{% set internal_events = [
'issue_assigned', 'issue_status', 'issue_comment',
'issue_follow_update', 'inspection_completed', 'sla_alert'
] %}
{# ── Customer portal events ── #}
{% set customer_events = [
'customer_inspection_completed', 'customer_issue_updated'
] %}
<ul class="list-group list-group-flush">
<li class="list-group-item bg-light py-1 px-3">
<small class="text-muted fw-semibold text-uppercase" style="font-size:.7rem;">
<i class="bi bi-people-fill me-1"></i>Internal Events
</small>
</li>
{% for event_type, label in event_types.items() if event_type in internal_events %}
{% set pref = prefs_map.get(event_type) %}
{% set email_on = pref.email_enabled if pref else True %}
{% set digest_on = pref.digest_mode if pref else False %}
{% set freq = pref.digest_frequency if pref else 'daily' %}
<li class="list-group-item px-3 py-3" id="row-{{ event_type }}">
<div class="row align-items-center">
{# Event label #}
<div class="col-md-4">
<span class="fw-semibold" style="font-size:.9rem;">{{ label }}</span>
</div>
{# Email toggle #}
<div class="col-md-2 text-center">
<div class="form-check form-switch d-inline-block">
<input class="form-check-input email-toggle"
type="checkbox"
name="email_{{ event_type }}"
id="email_{{ event_type }}"
value="1"
data-event="{{ event_type }}"
{% if email_on %}checked{% endif %}>
<label class="form-check-label visually-hidden"
for="email_{{ event_type }}">Email</label>
</div>
</div>
{# Digest mode toggle #}
<div class="col-md-2 text-center">
<div class="form-check form-switch d-inline-block">
<input class="form-check-input digest-toggle"
type="checkbox"
name="digest_{{ event_type }}"
id="digest_{{ event_type }}"
value="1"
data-event="{{ event_type }}"
{% if digest_on %}checked{% endif %}
{% if not email_on %}disabled{% endif %}>
<label class="form-check-label visually-hidden"
for="digest_{{ event_type }}">Digest</label>
</div>
</div>
{# Digest frequency #}
<div class="col-md-3 text-center">
<select class="form-select form-select-sm freq-select"
name="freq_{{ event_type }}"
id="freq_{{ event_type }}"
style="width:auto;margin:auto;"
{% if not email_on or not digest_on %}disabled{% endif %}>
<option value="hourly" {% if freq == 'hourly' %}selected{% endif %}>Hourly</option>
<option value="daily" {% if freq == 'daily' %}selected{% endif %}>Daily</option>
</select>
</div>
{# Status label #}
<div class="col-md-1 text-end">
<span class="badge status-badge
{% if not email_on %}bg-secondary
{% elif digest_on %}bg-warning text-dark
{% else %}bg-success{% endif %}"
style="font-size:.65rem;"
id="badge-{{ event_type }}">
{% if not email_on %}Off
{% elif digest_on %}Digest
{% else %}Live{% endif %}
</span>
</div>
</div>
</li>
{% endfor %}
<li class="list-group-item bg-light py-1 px-3">
<small class="text-muted fw-semibold text-uppercase" style="font-size:.7rem;">
<i class="bi bi-building me-1"></i>Customer Portal Events
</small>
</li>
{% for event_type, label in event_types.items() if event_type in customer_events %}
{% set pref = prefs_map.get(event_type) %}
{% set email_on = pref.email_enabled if pref else True %}
{% set digest_on = pref.digest_mode if pref else False %}
{% set freq = pref.digest_frequency if pref else 'daily' %}
<li class="list-group-item px-3 py-3" id="row-{{ event_type }}">
<div class="row align-items-center">
{# Event label #}
<div class="col-md-4">
<span class="fw-semibold" style="font-size:.9rem;">{{ label }}</span>
<span class="badge bg-success ms-1" style="font-size:.65rem;">Portal</span>
</div>
{# Email toggle #}
<div class="col-md-2 text-center">
<div class="form-check form-switch d-inline-block">
<input class="form-check-input email-toggle"
type="checkbox"
name="email_{{ event_type }}"
id="email_{{ event_type }}"
value="1"
data-event="{{ event_type }}"
{% if email_on %}checked{% endif %}>
<label class="form-check-label visually-hidden"
for="email_{{ event_type }}">Email</label>
</div>
</div>
{# Digest mode toggle #}
<div class="col-md-2 text-center">
<div class="form-check form-switch d-inline-block">
<input class="form-check-input digest-toggle"
type="checkbox"
name="digest_{{ event_type }}"
id="digest_{{ event_type }}"
value="1"
data-event="{{ event_type }}"
{% if digest_on %}checked{% endif %}
{% if not email_on %}disabled{% endif %}>
<label class="form-check-label visually-hidden"
for="digest_{{ event_type }}">Digest</label>
</div>
</div>
{# Digest frequency #}
<div class="col-md-3 text-center">
<select class="form-select form-select-sm freq-select"
name="freq_{{ event_type }}"
id="freq_{{ event_type }}"
style="width:auto;margin:auto;"
{% if not email_on or not digest_on %}disabled{% endif %}>
<option value="hourly" {% if freq == 'hourly' %}selected{% endif %}>Hourly</option>
<option value="daily" {% if freq == 'daily' %}selected{% endif %}>Daily</option>
</select>
</div>
{# Status label #}
<div class="col-md-1 text-end">
<span class="badge status-badge
{% if not email_on %}bg-secondary
{% elif digest_on %}bg-warning text-dark
{% else %}bg-success{% endif %}"
style="font-size:.65rem;"
id="badge-{{ event_type }}">
{% if not email_on %}Off
{% elif digest_on %}Digest
{% else %}Live{% endif %}
</span>
</div>
</div>
</li>
{% endfor %}
</ul>
</div>
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2-circle me-1"></i>Save Preferences
</button>
<a href="{{ url_for('notifications.index') }}" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
<div class="card mt-4 border-0 bg-light">
<div class="card-body py-2 px-3">
<p class="mb-1" style="font-size:.8rem;"><strong>Email Alerts:</strong>
Send an immediate email every time this event occurs.</p>
<p class="mb-1" style="font-size:.8rem;"><strong>Digest Mode:</strong>
Hold notifications and deliver them in a single batched email on your chosen schedule.</p>
<p class="mb-0" style="font-size:.8rem;"><strong>Off:</strong>
In-app notifications still appear in the bell — only email is suppressed.</p>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
document.querySelectorAll('.email-toggle').forEach(function (emailChk) {
emailChk.addEventListener('change', function () {
var event = this.dataset.event;
var digestChk = document.getElementById('digest_' + event);
var freqSelect = document.getElementById('freq_' + event);
var badge = document.getElementById('badge_' + event) ||
document.getElementById('badge-' + event);
var emailOn = this.checked;
var digestOn = digestChk.checked;
// Cascade: disabling email disables digest and frequency
digestChk.disabled = !emailOn;
freqSelect.disabled = !emailOn || !digestOn;
if (!emailOn) {
digestChk.checked = false;
digestOn = false;
}
updateBadge(badge, emailOn, digestOn);
});
});
document.querySelectorAll('.digest-toggle').forEach(function (digestChk) {
digestChk.addEventListener('change', function () {
var event = this.dataset.event;
var freqSelect = document.getElementById('freq_' + event);
var badge = document.getElementById('badge_' + event) ||
document.getElementById('badge-' + event);
var emailChk = document.getElementById('email_' + event);
var emailOn = emailChk.checked;
var digestOn = this.checked;
freqSelect.disabled = !emailOn || !digestOn;
updateBadge(badge, emailOn, digestOn);
});
});
function updateBadge(badge, emailOn, digestOn) {
if (!badge) return;
badge.className = badge.className
.replace(/bg-\S+/g, '')
.replace(/text-\S+/g, '')
.trim();
if (!emailOn) {
badge.classList.add('bg-secondary');
badge.textContent = 'Off';
} else if (digestOn) {
badge.classList.add('bg-warning', 'text-dark');
badge.textContent = 'Digest';
} else {
badge.classList.add('bg-success');
badge.textContent = 'Live';
}
}
// ── Pause all emails button ──────────────────────────────────────────────
var pauseBtn = document.getElementById('pauseAllBtn');
if (pauseBtn) {
pauseBtn.addEventListener('click', function () {
var allEmailToggles = document.querySelectorAll('.email-toggle');
var anyOn = Array.from(allEmailToggles).some(function (c) { return c.checked; });
allEmailToggles.forEach(function (emailChk) {
var event = emailChk.dataset.event;
var digestChk = document.getElementById('digest_' + event);
var freqSelect = document.getElementById('freq_' + event);
var badge = document.getElementById('badge-' + event);
if (anyOn) {
// Pause: turn everything off
emailChk.checked = false;
digestChk.checked = false;
digestChk.disabled = true;
freqSelect.disabled = true;
updateBadge(badge, false, false);
} else {
// Resume: re-enable email (digest stays off until user opts back in)
emailChk.checked = true;
digestChk.disabled = false;
updateBadge(badge, true, false);
}
});
// Update button label to reflect new state
var nowPaused = !anyOn ? false : true;
pauseBtn.innerHTML = nowPaused
? '<i class="bi bi-play-fill me-1"></i>Resume All Emails'
: '<i class="bi bi-pause-fill me-1"></i>Pause All Emails';
});
}
})();
</script>
{% endblock %}
@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="bi bi-person-plus"></i> {{ title }}
</h4>
<small class="text-white-50">Contract: {{ project.name }}</small>
</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.user_id.label(class="form-label") }}
{{ form.user_id(class="form-select") }}
{% if form.user_id.errors %}
<div class="text-danger small mt-1">
{% for e in form.user_id.errors %}{{ e }}{% endfor %}
</div>
{% endif %}
<div class="form-text">Only active users with the Customer role are listed.</div>
</div>
<div class="mb-4">
{{ form.facility_id.label(class="form-label") }}
{{ form.facility_id(class="form-select") }}
<div class="form-text">
Select "All facilities in contract" to grant access to every facility
within this contract, or choose a specific facility to restrict access.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-person-check"></i> Assign Customer
</button>
<a href="{{ url_for('projects.view', project_id=project.id) }}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">{{ title }}</h4>
</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control") }}
{% if form.name.errors %}
<div class="text-danger small mt-1">
{% for e in form.name.errors %}{{ e }}{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.description.label(class="form-label") }}
{{ form.description(class="form-control", rows=3) }}
</div>
<div class="mb-3">
{{ form.project_manager_id.label(class="form-label") }}
{{ form.project_manager_id(class="form-select") }}
<div class="form-text">Only users with the Project Manager role are listed.</div>
</div>
<div class="mb-4">
<div class="form-check">
{{ form.active(class="form-check-input") }}
{{ form.active.label(class="form-check-label") }}
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Save Contract
</button>
<a href="{{ url_for('projects.index') }}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+168
View File
@@ -0,0 +1,168 @@
{% extends "base.html" %}
{% block title %}Import Contracts & Facilities{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2><i class="bi bi-file-earmark-excel text-success me-2"></i>Import Contracts &amp; Facilities</h2>
<p class="text-muted mb-0">Bulk-create contracts and their facilities from an Excel workbook.</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('projects.import_template') }}" class="btn btn-sm btn-outline-success">
<i class="bi bi-download me-1"></i>Download Template
</a>
<a href="{{ url_for('projects.index') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Contracts
</a>
</div>
</div>
{# ── Format guide ── #}
<div class="card border-0 bg-light mb-4">
<div class="card-body py-3 px-4">
<h6 class="fw-semibold mb-2"><i class="bi bi-info-circle me-1 text-primary"></i>Excel Format — two sheets</h6>
<div class="row g-3 small">
<div class="col-md-4">
<strong>Sheet: <code>Contracts</code></strong>
<ul class="mb-0 mt-1 ps-3">
<li><code>contract_name</code> <span class="text-danger">*required</span></li>
<li><code>description</code> — optional</li>
<li><code>active</code> — yes/no (default: yes)</li>
</ul>
</div>
<div class="col-md-4">
<strong>Sheet: <code>Facilities</code></strong>
<ul class="mb-0 mt-1 ps-3">
<li><code>contract_name</code> <span class="text-danger">*required</span> — must match a row in Contracts sheet</li>
<li><code>facility_name</code> <span class="text-danger">*required</span></li>
<li><code>address</code>, <code>contact_person</code>, <code>contact_phone</code> — optional</li>
<li><code>active</code> — yes/no (default: yes)</li>
</ul>
</div>
<div class="col-md-4">
<strong>Tips</strong>
<ul class="mb-0 mt-1 ps-3">
<li>Contracts that already exist (by name) are reused — not duplicated</li>
<li>Facilities that already exist within the same contract are skipped</li>
<li>The Facilities sheet is optional — you can import contracts only</li>
<li>Download the template to see the expected structure</li>
</ul>
</div>
</div>
</div>
</div>
{# ── Upload form (Phase 1) ── #}
{% if not preview_rows %}
<div class="card shadow-sm">
<div class="card-header bg-success text-white fw-semibold">
<i class="bi bi-file-earmark-arrow-up me-1"></i> Upload Excel File
</div>
<div class="card-body">
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold">Select .xlsx File</label>
<input type="file" name="xlsx_file" accept=".xlsx,.xlsm" class="form-control" required>
<div class="form-text">Maximum recommended file size: 2 MB.</div>
</div>
<button type="submit" class="btn btn-success">
<i class="bi bi-search me-1"></i> Parse &amp; Preview
</button>
</form>
</div>
</div>
{% else %}
{# ── Preview results (Phase 1 response) ── #}
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center
bg-{{ 'danger' if has_errors else 'success' }} text-white">
<span class="fw-semibold">
<i class="bi bi-{{ 'x-circle' if has_errors else 'check-circle' }} me-1"></i>
Preview — {{ preview_rows|length }} row(s) parsed
</span>
<span>
<span class="badge bg-white text-success">{{ valid_count }} ready</span>
{% set exists_count = preview_rows | selectattr('status', 'equalto', 'exists') | list | length %}
{% if exists_count > 0 %}
<span class="badge bg-white text-warning ms-1">{{ exists_count }} existing</span>
{% endif %}
{% set err_count = preview_rows | selectattr('status', 'equalto', 'error') | list | length %}
{% if err_count > 0 %}
<span class="badge bg-white text-danger ms-1">{{ err_count }} error(s)</span>
{% endif %}
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" style="font-size:.85rem;">
<thead class="table-light">
<tr>
<th width="50">Row</th>
<th width="100">Sheet</th>
<th>Contract Name</th>
<th>Facility Name</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for row in preview_rows %}
<tr class="{{ 'table-danger' if row.status == 'error' else ('table-warning' if row.status == 'exists' else '') }}">
<td class="text-muted">{{ row.row }}</td>
<td><span class="badge bg-secondary">{{ row.sheet }}</span></td>
<td>{{ row.contract_name or '—' }}</td>
<td>{{ row.facility_name or '—' }}</td>
<td>
{% if row.status == 'ok' %}
<span class="badge bg-success">Ready</span>
{% elif row.status == 'exists' %}
<span class="badge bg-warning text-dark">Exists</span>
<span class="text-muted ms-1" style="font-size:.78rem;">{{ row.note }}</span>
{% else %}
<span class="badge bg-danger">Error</span>
<ul class="mb-0 ps-3 text-danger" style="font-size:.78rem;">
{% for e in row.errors %}<li>{{ e }}</li>{% endfor %}
</ul>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ── Action buttons (Phase 2 trigger) ── #}
{% if has_errors %}
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>Errors found.</strong> Fix the issues above and re-upload.
{% if valid_count > 0 %}
You may still import the {{ valid_count }} valid row(s) by clicking below.
{% endif %}
</div>
{% endif %}
<div class="d-flex gap-3 align-items-center">
{% if valid_count > 0 %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="confirmed" value="1">
<input type="hidden" name="rows_json" value="{{ rows_json }}">
<button type="submit" class="btn btn-success"
onclick="return confirm('Proceed with importing {{ valid_count }} valid row(s)?')">
<i class="bi bi-check-circle me-1"></i>
Import {{ valid_count }} Valid Row{{ 's' if valid_count != 1 else '' }}
</button>
</form>
{% endif %}
<a href="{{ url_for('projects.bulk_import') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-counterclockwise me-1"></i> Upload Different File
</a>
</div>
{% endif %}
{% endblock %}
+84
View File
@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}Contracts{% endblock %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col">
<h2><i class="bi bi-folder2-open"></i> Contracts</h2>
</div>
{% if current_user.role in ['admin', 'director'] %}
<div class="col-auto d-flex gap-2">
<a href="{{ url_for('projects.bulk_import') }}" class="btn btn-outline-success">
<i class="bi bi-file-earmark-excel"></i> Import
</a>
<a href="{{ url_for('projects.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Contract
</a>
</div>
{% endif %}
</div>
<div class="row">
{% for project in projects %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card shadow-sm h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">
<a href="{{ url_for('projects.view', project_id=project.id) }}" class="text-decoration-none">
{{ project.name }}
</a>
</h5>
{% if not project.active %}
<span class="badge bg-secondary ms-2">Inactive</span>
{% else %}
<span class="badge bg-success ms-2">Active</span>
{% endif %}
</div>
{% if project.description %}
<p class="card-text text-muted small">{{ project.description }}</p>
{% endif %}
<div class="mt-3 d-flex gap-3">
<small class="text-muted">
<i class="bi bi-building"></i> {{ project.facilities.count() }} facilities
</small>
{% if project.project_manager %}
<small class="text-muted">
<i class="bi bi-person-badge"></i> {{ project.project_manager.display_name }}
</small>
{% endif %}
</div>
</div>
<div class="card-footer bg-transparent d-flex gap-2">
<a href="{{ url_for('projects.view', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View
</a>
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('projects.edit', project_id=project.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit
</a>
{% endif %}
{% if current_user.role == 'admin' %}
<form method="POST" action="{{ url_for('projects.delete', project_id=project.id) }}"
class="ms-auto d-inline"
onsubmit="return confirm('Delete contract \'{{ project.name }}\'? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No contracts have been created yet.
</div>
</div>
{% endfor %}
</div>
{% endblock %}
+169
View File
@@ -0,0 +1,169 @@
{% extends "base.html" %}
{% block title %}{{ project.name }}{% endblock %}
{% block content %}
<div class="row mb-4 align-items-center">
<div class="col">
<h2>
<i class="bi bi-folder2-open"></i> {{ project.name }}
{% if not project.active %}
<span class="badge bg-secondary ms-2 fs-6">Inactive</span>
{% endif %}
</h2>
{% if project.description %}
<p class="text-muted">{{ project.description }}</p>
{% endif %}
</div>
<div class="col-auto d-flex gap-2">
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('projects.edit', project_id=project.id) }}" class="btn btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit
</a>
{% endif %}
<a href="{{ url_for('projects.index') }}" class="btn btn-outline-primary">
<i class="bi bi-arrow-left"></i> All Contracts
</a>
</div>
</div>
<div class="row g-4">
{# ── Contract Info ── #}
<div class="col-md-4">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-info-circle"></i> Contract Details
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-5 text-muted small">Status</dt>
<dd class="col-7">
<span class="badge bg-{{ 'success' if project.active else 'secondary' }}">
{{ 'Active' if project.active else 'Inactive' }}
</span>
</dd>
<dt class="col-5 text-muted small">Contract Manager</dt>
<dd class="col-7 small">
{{ project.project_manager.display_name if project.project_manager else '—' }}
</dd>
<dt class="col-5 text-muted small">Created</dt>
<dd class="col-7 small">{{ project.created_at.strftime('%Y-%m-%d') }}</dd>
<dt class="col-5 text-muted small">Facilities</dt>
<dd class="col-7 small">{{ facilities|length }}</dd>
</dl>
</div>
</div>
</div>
{# ── Facilities ── #}
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-building"></i> Facilities</span>
</div>
<div class="card-body p-0">
{% if facilities %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Address</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for f in facilities %}
<tr>
<td><strong>{{ f.name }}</strong></td>
<td class="text-muted small">{{ f.address or '—' }}</td>
<td>
<span class="badge bg-{{ 'success' if f.active else 'secondary' }}">
{{ 'Active' if f.active else 'Inactive' }}
</span>
</td>
<td>
<a href="{{ url_for('facilities.view_facility', facility_id=f.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-muted small">No facilities linked to this contract yet.</div>
{% endif %}
</div>
</div>
</div>
{# ── Customer Assignments ── #}
{% if current_user.role == 'admin' %}
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-semibold"><i class="bi bi-people"></i> Customer Assignments</span>
<a href="{{ url_for('projects.add_assignment', project_id=project.id) }}"
class="btn btn-sm btn-primary">
<i class="bi bi-person-plus"></i> Add Customer
</a>
</div>
<div class="card-body p-0">
{% if assignments %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Customer Username</th>
<th>Email</th>
<th>Facility Scope</th>
<th>Assigned</th>
<th width="80"></th>
</tr>
</thead>
<tbody>
{% for a in assignments %}
<tr>
<td><strong>{{ a.user.username }}</strong></td>
<td class="text-muted small">{{ a.user.email }}</td>
<td>
{% if a.facility %}
<span class="badge bg-info text-dark">{{ a.facility.name }}</span>
{% else %}
<span class="badge bg-secondary">All facilities</span>
{% endif %}
</td>
<td class="small text-muted">{{ a.created_at.strftime('%Y-%m-%d') }}</td>
<td>
<form method="POST"
action="{{ url_for('projects.remove_assignment', assignment_id=a.id) }}"
onsubmit="return confirm('Remove assignment for {{ a.user.username }}?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger"
title="Remove assignment">
<i class="bi bi-person-dash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-muted small">
No customer users assigned yet.
<a href="{{ url_for('projects.add_assignment', project_id=project.id) }}">Add one now.</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
<ul class="nav nav-pills mb-4 flex-wrap gap-1">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.index' else '' }}"
href="{{ url_for('reports.index') }}">
<i class="bi bi-bar-chart me-1"></i>Overview &amp; Trends
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.issues_aging' else '' }}"
href="{{ url_for('reports.issues_aging') }}">
<i class="bi bi-clock-history me-1"></i>Issues Aging
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.sla_compliance' else '' }}"
href="{{ url_for('reports.sla_compliance') }}">
<i class="bi bi-shield-check me-1"></i>SLA Compliance
</a>
</li>
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.followup_closure' else '' }}"
href="{{ url_for('reports.followup_closure') }}">
<i class="bi bi-arrow-repeat me-1"></i>Follow-up Closure
</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'reports.inspector_performance' else '' }}"
href="{{ url_for('reports.inspector_performance') }}">
<i class="bi bi-person-lines-fill me-1"></i>Inspector Performance
</a>
</li>
{% endif %}
{% if current_user.role in ['admin', 'director', 'project_manager'] %}
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint and request.endpoint.startswith('scheduled_reports.') else '' }}"
href="{{ url_for('scheduled_reports.index') }}">
<i class="bi bi-calendar-check me-1"></i>Scheduled Reports
</a>
</li>
{% endif %}
</ul>
+156
View File
@@ -0,0 +1,156 @@
{% extends "base.html" %}
{% block title %}{{ facility.name }} — Facility Report{% endblock %}
{% block extra_css %}
<style>.chart-container { position:relative; height:240px; }</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h2><i class="bi bi-building"></i> {{ facility.name }}</h2>
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('reports.export_inspections', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-outline-success"><i class="bi bi-download"></i> Export CSV</a>
<a href="{{ url_for('reports.index') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Reports
</a>
</div>
</div>
{# KPI row #}
{% set completed = inspections | selectattr('status','eq','completed') | list %}
{% set avg = (completed | map(attribute='overall_score') | select | list) %}
<div class="row mb-4">
<div class="col-md-3 mb-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body">
<p class="text-muted small mb-1">Total Inspections</p>
<h3 class="fw-bold">{{ inspections|length }}</h3>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body">
<p class="text-muted small mb-1">Completed</p>
<h3 class="fw-bold text-success">{{ completed|length }}</h3>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body">
<p class="text-muted small mb-1">Open Issues</p>
<h3 class="fw-bold text-danger">{{ open_issues|length }}</h3>
</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="card shadow-sm text-center h-100">
<div class="card-body">
<p class="text-muted small mb-1">Avg Score</p>
{% if avg %}
{% set avg_val = (avg | map('float') | sum) / avg|length %}
<h3 class="fw-bold text-{{ 'success' if avg_val >= 90 else 'warning' if avg_val >= 70 else 'danger' }}">
{{ '%.1f'|format(avg_val) }}%
</h3>
{% else %}<h3 class="text-muted"></h3>{% endif %}
</div>
</div>
</div>
</div>
{# Area scores chart #}
{% if area_scores %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light"><h6 class="mb-0">Avg Score by Area</h6></div>
<div class="card-body"><div class="chart-container"><canvas id="areaChart"></canvas></div></div>
</div>
{% endif %}
{# Inspection history #}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light"><h6 class="mb-0">Inspection History</h6></div>
<div class="table-responsive">
{% if inspections %}
<table class="table table-hover mb-0">
<thead class="table-light">
<tr><th>Date</th><th>Area</th><th>Template</th><th>Inspector</th><th>Score</th><th>Status</th><th></th></tr>
</thead>
<tbody>
{% for ins in inspections %}
<tr>
<td><small>{{ ins.inspection_date.strftime('%Y-%m-%d %H:%M') }}</small></td>
<td>{{ ins.area.name if ins.area else '—' }}</td>
<td>{{ ins.template.name }}</td>
<td>{{ ins.inspector.display_name }}</td>
<td>
{% if ins.overall_score %}
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning text-dark' if ins.overall_score >= 70 else 'danger' }}">
{{ ins.overall_score }}%
</span>
{% else %}—{% endif %}
</td>
<td><span class="badge bg-{{ 'success' if ins.status == 'completed' else 'danger' if ins.status == 'flagged' else 'secondary' }}">
{{ ins.status|replace('_',' ')|title }}</span></td>
<td><a href="{{ url_for('inspections.view', inspection_id=ins.id) }}" class="btn btn-sm btn-outline-secondary">View</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="card-body"><p class="text-muted mb-0">No inspections in this date range.</p></div>
{% endif %}
</div>
</div>
{# Open issues #}
{% if open_issues %}
<div class="card shadow-sm">
<div class="card-header bg-danger text-white"><h6 class="mb-0">Open Issues</h6></div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Severity</th><th>Area</th><th>Description</th><th>Reported</th><th></th></tr></thead>
<tbody>
{% for issue in open_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[:60] }}{% if issue.description|length > 60 %}…{% endif %}</td>
<td><small>{{ issue.reported_at.strftime('%Y-%m-%d') }}</small></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>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
{% if area_scores %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
new Chart(document.getElementById('areaChart'), {
type: 'bar',
data: {
labels: {{ area_scores | map(attribute='name') | list | tojson }},
datasets: [{
label: 'Avg Score (%)',
data: {{ area_scores | map(attribute='avg_score') | list | tojson }},
backgroundColor: {{ area_scores | map(attribute='avg_score') | list | tojson }}
.map(s => s >= 90 ? '#198754' : s >= 70 ? '#ffc107' : '#dc3545'),
borderRadius: 4,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
plugins: { legend: { display: false } }
}
});
</script>
{% endif %}
{% endblock %}
+186
View File
@@ -0,0 +1,186 @@
{% extends "base.html" %}
{% block title %}Follow-up Closure Rate{% endblock %}
{% block content %}
{# ── Sub-nav ── #}
{% include 'reports/_subnav.html' %}
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h2><i class="bi bi-arrow-repeat text-purple me-2" style="color:#7c3aed;"></i>Follow-up Closure Rate</h2>
<p class="text-muted mb-0">
{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}
&nbsp;·&nbsp; Inspections flagged for follow-up and whether a re-inspection was completed.
</p>
</div>
<a href="{{ url_for('reports.export_followup_closure', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-outline-success">
<i class="bi bi-file-earmark-excel me-1"></i>Export Excel
</a>
</div>
{# ── Date filter ── #}
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small mb-1">From</label>
<input type="date" name="start" class="form-control form-control-sm" value="{{ start.strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-3">
<label class="form-label small mb-1">To</label>
<input type="date" name="end" class="form-control form-control-sm" value="{{ end.strftime('%Y-%m-%d') }}">
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary">Apply</button>
<a href="{{ url_for('reports.followup_closure') }}" class="btn btn-sm btn-outline-secondary">Reset</a>
</div>
</form>
</div>
</div>
{# ── KPI row ── #}
<div class="row g-3 mb-4">
<div class="col-6 col-md-4">
<div class="card shadow-sm h-100" style="border-left:5px solid #7c3aed;">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">Flagged for Follow-up</div>
<div class="fs-1 fw-bold" style="color:#7c3aed;">{{ total }}</div>
<div class="text-muted small">inspections in period</div>
</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="card shadow-sm h-100" style="border-left:5px solid #16a34a;">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">Re-inspected</div>
<div class="fs-1 fw-bold text-success">{{ closed }}</div>
<div class="text-muted small">follow-up completed</div>
</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="card shadow-sm h-100"
style="border-left:5px solid {{ '#16a34a' if rate and rate >= 80 else '#d97706' if rate and rate >= 50 else '#dc2626' }};">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">Closure Rate</div>
<div class="fs-1 fw-bold
{{ 'text-success' if rate and rate >= 80 else 'text-warning' if rate and rate >= 50 else 'text-danger' if rate is not none else 'text-muted' }}">
{{ rate|round(1) if rate is not none else '—' }}{% if rate is not none %}%{% endif %}
</div>
<div class="text-muted small">of flagged inspections re-done</div>
</div>
</div>
</div>
</div>
{% if total == 0 %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>No inspections were flagged for follow-up in this period.
</div>
{% else %}
{# ── By facility ── #}
{% if by_facility %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-building me-1"></i>Closure Rate by Facility
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Facility</th>
<th class="text-center">Flagged</th>
<th class="text-center">Re-inspected</th>
<th class="text-center">Closure Rate</th>
<th>Progress</th>
</tr>
</thead>
<tbody>
{% for row in by_facility %}
<tr>
<td class="fw-semibold">{{ row.name }}</td>
<td class="text-center">{{ row.total }}</td>
<td class="text-center">{{ row.closed }}</td>
<td class="text-center">
<span class="badge bg-{{ 'success' if row.rate >= 80 else 'warning text-dark' if row.rate >= 50 else 'danger' }} px-3">
{{ row.rate }}%
</span>
</td>
<td style="min-width:120px;">
<div class="progress" style="height:8px;">
<div class="progress-bar bg-{{ 'success' if row.rate >= 80 else 'warning' if row.rate >= 50 else 'danger' }}"
style="width:{{ row.rate }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Detail table ── #}
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between">
<span class="fw-semibold"><i class="bi bi-list-ul me-1"></i>Inspection Detail</span>
<span class="text-muted small">{{ total }} flagged</span>
</div>
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>ID</th>
<th>Date</th>
<th>Facility</th>
<th>Template</th>
<th>Inspector</th>
<th class="text-center">Score</th>
<th>Note</th>
<th class="text-center">Re-inspected?</th>
<th></th>
</tr>
</thead>
<tbody>
{% for insp in flagged %}
<tr class="{{ '' if insp._has_followup else 'table-warning' }}">
<td class="text-muted small">#{{ insp.id }}</td>
<td class="small">{{ insp.inspection_date.strftime('%Y-%m-%d') if insp.inspection_date else '—' }}</td>
<td class="small">{{ insp.facility.name if insp.facility else '—' }}</td>
<td class="small">{{ insp.template.name if insp.template else '—' }}</td>
<td class="small">{{ insp.inspector.display_name if insp.inspector else '—' }}</td>
<td class="text-center">
{% if insp.overall_score %}
<span class="badge bg-{{ 'success' if insp.overall_score >= 80 else 'warning text-dark' if insp.overall_score >= 60 else 'danger' }}">
{{ insp.overall_score|round(1) }}%
</span>
{% else %}—{% endif %}
</td>
<td class="small text-muted">
{{ insp.follow_up_note[:60] if insp.follow_up_note else '—' }}
{% if insp.follow_up_note and insp.follow_up_note|length > 60 %}…{% endif %}
</td>
<td class="text-center">
{% if insp._has_followup %}
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Yes</span>
{% else %}
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>No</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}{# end total == 0 #}
{% endblock %}
+319
View File
@@ -0,0 +1,319 @@
{% extends "base.html" %}
{% block title %}Reports & Analytics{% endblock %}
{% block extra_css %}
<style>
.stat-card { border-left: 4px solid; }
.stat-card.primary { border-color: #0d6efd; }
.stat-card.success { border-color: #198754; }
.stat-card.danger { border-color: #dc3545; }
.stat-card.info { border-color: #0dcaf0; }
.chart-container { position:relative; height:280px; }
</style>
{% endblock %}
{% block content %}
{# ── Sub-nav ── #}
{% include 'reports/_subnav.html' %}
{# ── Header + date filter ── #}
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h2><i class="bi bi-graph-up"></i> Reports & Analytics</h2>
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
</div>
<div class="d-flex gap-2">
<a href="{{ url_for('reports.export_inspections', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-outline-success"><i class="bi bi-download"></i> Export Inspections CSV</a>
<a href="{{ url_for('reports.export_issues', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-outline-danger"><i class="bi bi-download"></i> Export Issues CSV</a>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small mb-1">From</label>
<input type="date" name="start" class="form-control form-control-sm"
value="{{ start.strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-3">
<label class="form-label small mb-1">To</label>
<input type="date" name="end" class="form-control form-control-sm"
value="{{ end.strftime('%Y-%m-%d') }}">
</div>
{% if inspectors %}
<div class="col-md-3">
<label class="form-label small mb-1">Inspector</label>
<select name="inspector_id" class="form-select form-select-sm">
<option value="">All Inspectors</option>
{% for u in inspectors %}
<option value="{{ u.id }}" {% if inspector_filter == u.id %}selected{% endif %}>
{{ u.display_name }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="col-auto">
<button class="btn btn-sm btn-primary">Apply</button>
<a href="{{ url_for('reports.index') }}" class="btn btn-sm btn-outline-secondary">Reset</a>
</div>
</form>
</div>
</div>
{# ── KPI cards ── #}
<div class="row mb-4">
{% for label, value, color, icon in [
('Total Inspections', total_inspections, 'primary', 'bi-clipboard-data'),
('Completed', completed, 'success', 'bi-check-circle'),
('Open Issues', flagged, 'danger', 'bi-flag'),
('Avg Score', (avg_score|string + '%') if avg_score else '—', 'info', 'bi-graph-up'),
] %}
<div class="col-md-3 mb-3">
<div class="card shadow-sm stat-card {{ color }} h-100">
<div class="card-body d-flex align-items-center gap-3">
<i class="bi {{ icon }} text-{{ color }}" style="font-size:2rem;"></i>
<div>
<p class="text-muted small mb-0">{{ label }}</p>
<h3 class="mb-0 fw-bold">{{ value }}</h3>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{# ── Charts row 1 ── #}
<div class="row mb-4">
<div class="col-lg-8 mb-3">
<div class="card shadow-sm h-100">
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-graph-up-arrow"></i> Score Trend</h6></div>
<div class="card-body"><div class="chart-container"><canvas id="trendChart"></canvas></div></div>
</div>
</div>
<div class="col-lg-4 mb-3">
<div class="card shadow-sm h-100">
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Issues by Severity</h6></div>
<div class="card-body"><div class="chart-container"><canvas id="severityChart"></canvas></div></div>
</div>
</div>
</div>
{# ── Charts row 2 ── #}
<div class="row mb-4">
<div class="col-lg-8 mb-3">
<div class="card shadow-sm h-100">
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-building"></i> Avg Score by Facility</h6></div>
<div class="card-body"><div class="chart-container"><canvas id="facilityChart"></canvas></div></div>
</div>
</div>
<div class="col-lg-4 mb-3">
<div class="card shadow-sm h-100">
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-pie-chart"></i> Issue Status</h6></div>
<div class="card-body"><div class="chart-container"><canvas id="statusChart"></canvas></div></div>
</div>
</div>
</div>
{# ── Facility period-over-period comparison table ── #}
{% if facility_scores %}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex align-items-center gap-2">
<h6 class="mb-0"><i class="bi bi-building"></i> Facility Score Comparison</h6>
<span class="text-muted small">vs. prior equal-length period</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th>Facility</th>
<th class="text-end">Current Period</th>
<th class="text-end">Prior Period</th>
<th class="text-end">Change</th>
<th class="text-end">Inspections</th>
</tr>
</thead>
<tbody>
{% for row in facility_scores %}
<tr>
<td class="fw-semibold">{{ row.name }}</td>
<td class="text-end">
<span class="badge bg-{{ 'success' if row.avg_score >= 90 else 'warning text-dark' if row.avg_score >= 70 else 'danger' }}">
{{ '%.1f'|format(row.avg_score|float) }}%
</span>
</td>
<td class="text-end text-muted">
{% if row.prior_avg is not none %}
{{ '%.1f'|format(row.prior_avg|float) }}%
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-end">
{% if row.delta is not none %}
{% if row.delta > 0 %}
<span class="badge bg-success-subtle text-success border border-success fw-semibold">
<i class="bi bi-arrow-up-short"></i>+{{ '%.1f'|format(row.delta|float) }}
</span>
{% elif row.delta < 0 %}
<span class="badge bg-danger-subtle text-danger border border-danger fw-semibold">
<i class="bi bi-arrow-down-short"></i>{{ '%.1f'|format(row.delta|float) }}
</span>
{% else %}
<span class="badge bg-secondary-subtle text-secondary border fw-semibold">
<i class="bi bi-dash"></i> 0.0
</span>
{% endif %}
{% else %}
<span class="text-muted small">No prior data</span>
{% endif %}
</td>
<td class="text-end text-muted small">{{ row.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{# ── Top inspectors table ── #}
{% if top_inspectors %}
<div class="row mb-4">
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-person-check"></i> Top Inspectors</h6></div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Inspector</th><th>Inspections</th><th>Avg Score</th></tr></thead>
<tbody>
{% for row in top_inspectors %}
<tr>
<td>{{ row.display_name }}</td>
<td>{{ row.count }}</td>
<td>
{% if row.avg_score %}
<span class="badge bg-{{ 'success' if row.avg_score >= 90 else 'warning text-dark' if row.avg_score >= 70 else 'danger' }}">
{{ '%.1f'|format(row.avg_score|float) }}%
</span>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ── Critical / High open issues ── #}
<div class="col-lg-6">
<div class="card shadow-sm">
<div class="card-header bg-danger text-white"><h6 class="mb-0"><i class="bi bi-fire"></i> Open Critical / High Issues</h6></div>
{% if critical_issues %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light"><tr><th>Severity</th><th>Area</th><th>Description</th><th></th></tr></thead>
<tbody>
{% for issue in critical_issues %}
<tr>
<td><span class="badge bg-danger">{{ issue.severity|title }}</span></td>
<td>{{ issue.area.name }}</td>
<td>{{ issue.description[:50] }}{% if issue.description|length > 50 %}…{% endif %}</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>
{% else %}
<div class="card-body"><p class="text-muted mb-0">No open critical or high issues. 🎉</p></div>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
Chart.defaults.font.family = "'Segoe UI', system-ui, sans-serif";
Chart.defaults.color = '#6c757d';
const BLUE = '#0d6efd', GREEN = '#198754', RED = '#dc3545',
AMBER = '#ffc107', TEAL = '#0dcaf0', GRAY = '#adb5bd';
// ── Trend chart ──────────────────────────────────────────────────────────────
new Chart(document.getElementById('trendChart'), {
type: 'line',
data: {
labels: {{ daily_scores | map(attribute='day') | map('string') | list | tojson }},
datasets: [{
label: 'Avg Score (%)',
data: {{ daily_scores | map(attribute='avg') | list | tojson }},
borderColor: BLUE, backgroundColor: 'rgba(13,110,253,.1)',
tension: .3, fill: true, pointRadius: 4,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
plugins: { legend: { display: false } }
}
});
// ── Facility bar chart ────────────────────────────────────────────────────────
new Chart(document.getElementById('facilityChart'), {
type: 'bar',
data: {
labels: {{ facility_scores | map(attribute='name') | list | tojson }},
datasets: [{
label: 'Avg Score (%)',
data: {{ facility_scores | map(attribute='avg_score') | list | tojson }},
backgroundColor: {{ facility_scores | map(attribute='avg_score') | list | tojson }}
.map(s => s >= 90 ? GREEN : s >= 70 ? AMBER : RED),
borderRadius: 4,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
plugins: { legend: { display: false } }
}
});
// ── Severity doughnut ─────────────────────────────────────────────────────────
const sevData = {{ issue_severity | tojson }};
new Chart(document.getElementById('severityChart'), {
type: 'doughnut',
data: {
labels: sevData.map(r => r.severity ? r.severity.charAt(0).toUpperCase() + r.severity.slice(1) : 'Unknown'),
datasets: [{
data: sevData.map(r => r.count),
backgroundColor: sevData.map(r => ({critical:RED,high:'#fd7e14',medium:AMBER,low:GRAY}[r.severity] || GRAY)),
}]
},
options: { responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom' } } }
});
// ── Status pie ────────────────────────────────────────────────────────────────
const stData = {{ issue_status | tojson }};
new Chart(document.getElementById('statusChart'), {
type: 'pie',
data: {
labels: stData.map(r => r.status ? r.status.replace('_',' ').replace(/\b\w/g,c=>c.toUpperCase()) : 'Unknown'),
datasets: [{
data: stData.map(r => r.count),
backgroundColor: stData.map(r => ({open:RED, in_progress:AMBER, resolved:GREEN}[r.status] || GRAY)),
}]
},
options: { responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'bottom' } } }
});
</script>
{% endblock %}
@@ -0,0 +1,395 @@
{% extends "base.html" %}
{% block title %}Inspector Performance{% endblock %}
{% block extra_css %}
<style>
.stat-card { border-left: 4px solid; }
.stat-card.primary { border-color: #0d6efd; }
.stat-card.success { border-color: #198754; }
.stat-card.danger { border-color: #dc3545; }
.stat-card.info { border-color: #0dcaf0; }
.stat-card.warning { border-color: #ffc107; }
.chart-container { position:relative; height:280px; }
.inspector-row { cursor:pointer; }
.inspector-row.table-active td { background-color: #e8f0fe !important; }
</style>
{% endblock %}
{% block content %}
{# ── Sub-nav ── #}
{% include 'reports/_subnav.html' %}
{# ── Header ── #}
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2><i class="bi bi-person-lines-fill"></i> Inspector Performance</h2>
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
</div>
<div>
<a href="{{ url_for('reports.export_inspector_performance',
start=start.strftime('%Y-%m-%d'),
end=end.strftime('%Y-%m-%d'),
**({'inspector_id': selected_id} if selected_id else {})) }}"
class="btn btn-sm btn-success">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Export Excel
</a>
</div>
</div>
{# ── Date filter ── #}
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small mb-1">From</label>
<input type="date" name="start" class="form-control form-control-sm"
value="{{ start.strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-3">
<label class="form-label small mb-1">To</label>
<input type="date" name="end" class="form-control form-control-sm"
value="{{ end.strftime('%Y-%m-%d') }}">
</div>
{% if selected_id %}
<input type="hidden" name="inspector_id" value="{{ selected_id }}">
{% endif %}
<div class="col-auto">
<button class="btn btn-sm btn-primary">Apply</button>
<a href="{{ url_for('reports.inspector_performance') }}" class="btn btn-sm btn-outline-secondary">Reset</a>
</div>
</form>
</div>
</div>
{% if not inspector_stats %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-1"></i>
No inspections found for the selected date range.
</div>
{% else %}
{# ── Comparison charts ── #}
<div class="row mb-4">
<div class="col-lg-7 mb-3">
<div class="card shadow-sm h-100">
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-bar-chart"></i> Avg Score by Inspector</h6></div>
<div class="card-body"><div class="chart-container"><canvas id="scoreChart"></canvas></div></div>
</div>
</div>
<div class="col-lg-5 mb-3">
<div class="card shadow-sm h-100">
<div class="card-header bg-light"><h6 class="mb-0"><i class="bi bi-clipboard-data"></i> Inspections Completed</h6></div>
<div class="card-body"><div class="chart-container"><canvas id="countChart"></canvas></div></div>
</div>
</div>
</div>
{# ── Summary table ── #}
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-table me-1"></i>All Inspectors — Summary</h6>
<span class="badge bg-secondary">{{ inspector_stats|length }} inspector{{ 's' if inspector_stats|length != 1 }}</span>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Inspector</th>
<th class="text-center">Total</th>
<th class="text-center">Completed</th>
<th class="text-center">Completion Rate</th>
<th class="text-center">Avg Score</th>
<th class="text-center">
vs. Avg
{% if team_avg_score %}<span class="text-muted fw-normal small">({{ '%.1f'|format(team_avg_score) }}%)</span>{% endif %}
</th>
<th class="text-center">Avg Time</th>
<th class="text-center">Issues Flagged</th>
<th class="text-center">Follow-ups</th>
<th class="text-center">Facilities</th>
<th></th>
</tr>
</thead>
<tbody>
{% for s in inspector_stats %}
<tr class="inspector-row {% if selected_id == s.id %}table-active{% endif %}"
data-inspector-id="{{ s.id }}">
<td class="fw-semibold">
{{ s.display_name }}
</td>
<td class="text-center">{{ s.total }}</td>
<td class="text-center">{{ s.completed }}</td>
<td class="text-center">
<div class="d-flex align-items-center justify-content-center gap-2">
<div class="progress flex-grow-1" style="height:6px;max-width:60px;">
<div class="progress-bar bg-{{ 'success' if s.completion_rate >= 90 else 'warning' if s.completion_rate >= 70 else 'danger' }}"
style="width:{{ s.completion_rate }}%;"></div>
</div>
<span class="small">{{ s.completion_rate }}%</span>
</div>
</td>
<td class="text-center">
{% if s.avg_score %}
<span class="badge bg-{{ 'success' if s.avg_score >= 90 else 'warning text-dark' if s.avg_score >= 70 else 'danger' }}">
{{ '%.1f'|format(s.avg_score) }}%
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-center">
{% if s.vs_avg is not none %}
{% if s.vs_avg > 0 %}
<span class="badge bg-success">+{{ '%.1f'|format(s.vs_avg) }}%</span>
{% elif s.vs_avg < 0 %}
<span class="badge bg-danger">{{ '%.1f'|format(s.vs_avg) }}%</span>
{% else %}
<span class="badge bg-secondary">0.0%</span>
{% endif %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td class="text-center">
<span class="small text-muted">{{ s.avg_time or '—' }}</span>
</td>
<td class="text-center">
{% if s.issues_flagged > 0 %}
<span class="badge bg-danger">{{ s.issues_flagged }}</span>
{% else %}
<span class="text-muted">0</span>
{% endif %}
</td>
<td class="text-center">
{% if s.follow_ups > 0 %}
<span class="badge bg-warning text-dark">{{ s.follow_ups }}</span>
{% else %}
<span class="text-muted">0</span>
{% endif %}
</td>
<td class="text-center">{{ s.facilities }}</td>
<td>
{% if selected_id == s.id %}
<a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-outline-secondary">Close</a>
{% else %}
<a href="{{ url_for('reports.inspector_performance', inspector_id=s.id, start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-outline-primary">Details</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# ── Individual inspector drill-down ── #}
{% if selected_inspector and selected_kpis %}
<div class="card shadow-sm border-primary mb-4" id="inspectorDetail">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="bi bi-person-circle me-2"></i>{{ selected_inspector.display_name }}
</h6>
<a href="{{ url_for('reports.inspector_performance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d')) }}"
class="btn btn-sm btn-light text-primary">
<i class="bi bi-x-lg"></i>
</a>
</div>
<div class="card-body">
{# KPI stat row #}
<div class="row g-3 mb-4">
{% for label, value, color, icon in [
('Total Inspections', selected_kpis.total, 'primary', 'bi-clipboard-data'),
('Completed', selected_kpis.completed, 'success', 'bi-check-circle'),
('Avg Score', (('%.1f'|format(selected_kpis.avg_score)) + '%') if selected_kpis.avg_score else '—', 'info', 'bi-graph-up'),
('Avg Completion Time',selected_kpis.avg_time or '—', 'info', 'bi-stopwatch'),
('Issues Flagged', selected_kpis.issues_flagged, 'danger', 'bi-flag'),
('Follow-ups Req.', selected_kpis.follow_ups, 'warning', 'bi-arrow-repeat'),
('Facilities Covered', selected_kpis.facilities, 'primary', 'bi-building'),
] %}
<div class="col-6 col-md-4 col-lg-3 col-xl-auto" style="min-width:130px;">
<div class="card shadow-sm stat-card {{ color }} h-100">
<div class="card-body py-2 px-3">
<p class="text-muted small mb-1">{{ label }}</p>
<div class="d-flex align-items-center gap-2">
<i class="bi {{ icon }} text-{{ color }}"></i>
<span class="fw-bold">{{ value }}</span>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{# Score trend chart #}
<div class="row g-3 mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light">
<h6 class="mb-0"><i class="bi bi-graph-up-arrow me-1"></i>Score Trend — {{ selected_inspector.display_name }}</h6>
</div>
<div class="card-body">
{% if trend_data %}
<div class="chart-container"><canvas id="trendChart"></canvas></div>
{% else %}
<p class="text-muted text-center py-3 mb-0">No completed inspections with scores in this period.</p>
{% endif %}
</div>
</div>
</div>
</div>
{# Recent inspections #}
{% if recent_inspections %}
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="mb-0"><i class="bi bi-list-ul me-1"></i>Recent Inspections</h6>
<span class="badge bg-secondary">{{ recent_inspections|length }}</span>
</div>
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Facility</th>
<th>Area</th>
<th>Template</th>
<th class="text-center">Score</th>
<th class="text-center">Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ins in recent_inspections %}
<tr>
<td class="small">{{ ins.inspection_date.strftime('%b %d, %Y') }}</td>
<td class="small">{{ ins.facility.name if ins.facility else '—' }}</td>
<td class="small">{{ ins.area.name if ins.area else '—' }}</td>
<td class="small">{{ ins.template.name if ins.template else '—' }}</td>
<td class="text-center">
{% if ins.overall_score %}
<span class="badge bg-{{ 'success' if ins.overall_score >= 90 else 'warning text-dark' if ins.overall_score >= 70 else 'danger' }}">
{{ '%.1f'|format(ins.overall_score|float) }}%
</span>
{% else %}—{% endif %}
</td>
<td class="text-center">
{% set sc = ins.status %}
<span class="badge bg-{{ 'success' if sc == 'completed' else 'warning text-dark' if sc == 'flagged' else 'secondary' }}">
{{ 'Submitted' if sc == 'completed' else sc.replace('_',' ')|title }}
</span>
</td>
<td>
<a href="{{ url_for('inspections.view', inspection_id=ins.id) }}"
class="btn btn-xs btn-outline-secondary" style="font-size:.75rem;padding:2px 8px;">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% endif %}{# end if inspector_stats #}
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
Chart.defaults.font.family = "'Segoe UI', system-ui, sans-serif";
Chart.defaults.color = '#6c757d';
const BLUE = '#0d6efd', GREEN = '#198754', RED = '#dc3545',
AMBER = '#ffc107', GRAY = '#adb5bd';
function scoreColor(s) { return s >= 90 ? GREEN : s >= 70 ? AMBER : RED; }
{% if inspector_stats %}
const stats = {{ inspector_stats | tojson }};
// ── Avg score bar chart ───────────────────────────────────────────────────────
new Chart(document.getElementById('scoreChart'), {
type: 'bar',
data: {
labels: stats.map(s => s.display_name),
datasets: [{
label: 'Avg Score (%)',
data: stats.map(s => s.avg_score),
backgroundColor: stats.map(s => s.avg_score ? scoreColor(s.avg_score) : GRAY),
borderRadius: 4,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
plugins: { legend: { display: false } }
}
});
// ── Inspection count chart ────────────────────────────────────────────────────
new Chart(document.getElementById('countChart'), {
type: 'bar',
data: {
labels: stats.map(s => s.display_name),
datasets: [
{
label: 'Completed',
data: stats.map(s => s.completed),
backgroundColor: GREEN,
borderRadius: 4,
stack: 'a',
},
{
label: 'Other',
data: stats.map(s => s.total - s.completed),
backgroundColor: GRAY,
borderRadius: 4,
stack: 'a',
},
]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
plugins: { legend: { position: 'bottom' } }
}
});
{% endif %}
{% if trend_data %}
// ── Individual inspector score trend ─────────────────────────────────────────
new Chart(document.getElementById('trendChart'), {
type: 'line',
data: {
labels: {{ trend_data | map(attribute='day') | list | tojson }},
datasets: [{
label: 'Avg Score (%)',
data: {{ trend_data | map(attribute='avg') | list | tojson }},
borderColor: BLUE, backgroundColor: 'rgba(13,110,253,.1)',
tension: .3, fill: true, pointRadius: 4,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
scales: { y: { min: 0, max: 100, ticks: { callback: v => v + '%' } } },
plugins: { legend: { display: false } }
}
});
{% endif %}
{% if selected_id %}
// Scroll to detail panel on load
document.addEventListener('DOMContentLoaded', function () {
var el = document.getElementById('inspectorDetail');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
{% endif %}
</script>
{% endblock %}
+175
View File
@@ -0,0 +1,175 @@
{% extends "base.html" %}
{% block title %}Issues Aging{% endblock %}
{% block content %}
{# ── Sub-nav ── #}
{% include 'reports/_subnav.html' %}
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h2><i class="bi bi-clock-history text-danger me-2"></i>Issues Aging</h2>
<p class="text-muted mb-0">All currently open issues grouped by how long they have been waiting.</p>
</div>
<a href="{{ url_for('reports.export_issues_aging', severity=severity_filter, facility_id=facility_id_filter or '') }}"
class="btn btn-sm btn-outline-success">
<i class="bi bi-file-earmark-excel me-1"></i>Export Excel
</a>
</div>
{# ── Filters ── #}
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-2">
<label class="form-label small mb-1">Severity</label>
<select name="severity" class="form-select form-select-sm">
<option value="">All</option>
{% for s in ['critical','high','medium','low'] %}
<option value="{{ s }}" {{ 'selected' if severity_filter == s }}>{{ s|title }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary">Apply</button>
<a href="{{ url_for('reports.issues_aging') }}" class="btn btn-sm btn-outline-secondary">Clear</a>
</div>
</form>
</div>
</div>
{# ── KPI row ── #}
<div class="row g-3 mb-4">
<div class="col-6 col-md-4">
<div class="card shadow-sm border-0 h-100" style="background:#fff7ed;">
<div class="card-body">
<div class="small fw-semibold text-muted mb-1">Total Open</div>
<div class="fs-1 fw-bold" style="color:#ea580c;">{{ total }}</div>
<div class="small text-muted">unresolved issues</div>
</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="card shadow-sm border-0 h-100" style="background:#fef2f2;">
<div class="card-body">
<div class="small fw-semibold text-muted mb-1">SLA Breached</div>
<div class="fs-1 fw-bold text-danger">{{ sla_breached }}</div>
<div class="small text-muted">past resolution deadline</div>
</div>
</div>
</div>
<div class="col-6 col-md-4">
<div class="card shadow-sm border-0 h-100" style="background:#fefce8;">
<div class="card-body">
<div class="small fw-semibold text-muted mb-1">SLA At Risk</div>
<div class="fs-1 fw-bold text-warning">{{ sla_at_risk }}</div>
<div class="small text-muted">approaching deadline</div>
</div>
</div>
</div>
</div>
{# ── Bucket accordions ── #}
<div class="accordion" id="agingAccordion">
{% for label in bucket_labels %}
{% set bucket = buckets[label] %}
{% set bucket_id = 'bucket-' ~ loop.index %}
{% set is_danger = label in ['>4 weeks', '14 weeks'] %}
{% set is_warning = label == '37 days' %}
<div class="accordion-item mb-2 shadow-sm border-0">
<h2 class="accordion-header">
<button class="accordion-button {{ 'collapsed' if loop.index > 1 else '' }} fw-semibold"
type="button" data-bs-toggle="collapse"
data-bs-target="#{{ bucket_id }}">
<span class="badge rounded-pill me-2
{{ 'bg-danger' if is_danger else 'bg-warning text-dark' if is_warning else 'bg-secondary' }}">
{{ bucket|length }}
</span>
{{ label }}
{% if is_danger and bucket|length > 0 %}
<span class="ms-2 badge bg-danger bg-opacity-25 text-danger" style="font-size:.7rem;">Needs attention</span>
{% endif %}
</button>
</h2>
<div id="{{ bucket_id }}" class="accordion-collapse collapse {{ 'show' if loop.index == 1 else '' }}"
data-bs-parent="#agingAccordion">
<div class="accordion-body p-0">
{% if bucket %}
<div class="table-responsive">
<table class="table table-hover table-sm mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Age</th>
<th>Severity</th>
<th>Facility / Area</th>
<th>Description</th>
<th>Status</th>
<th>SLA</th>
<th>Assigned</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in bucket %}
{% set issue = item.issue %}
{% set sla = item.sla %}
<tr class="{{ 'table-danger' if sla == 'breached' else 'table-warning' if sla == 'at_risk' else '' }}">
<td class="text-muted small">#{{ issue.id }}</td>
<td class="text-nowrap small">{{ item.age_h }}h</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 class="small">
{{ issue.resolved_facility.name if issue.resolved_facility else '—' }}<br>
<span class="text-muted">{{ issue.area.name if issue.area else '—' }}</span>
</td>
<td class="small">{{ issue.description[:70] }}{% if issue.description|length > 70 %}…{% endif %}</td>
<td>
<span class="badge bg-{{ 'info text-dark' if issue.status == 'pending_verification' else 'warning text-dark' if issue.status == 'in_progress' else 'secondary' }}">
{{ 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">At Risk</span>
{% elif sla == 'ok' %}<span class="badge bg-secondary">OK</span>
{% else %}<span class="text-muted small"></span>{% endif %}
</td>
<td class="small">{{ issue.assigned_user.display_name if issue.assigned_user else '—' }}</td>
<td>
<a href="{{ url_for('issues.view', issue_id=issue.id) }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-muted small">No issues in this age range.</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% if total == 0 %}
<div class="alert alert-success mt-3">
<i class="bi bi-check-circle me-2"></i>No open issues match the selected filters.
</div>
{% endif %}
{% endblock %}
+279
View File
@@ -0,0 +1,279 @@
{% extends "base.html" %}
{% block title %}{{ facility.name }} — Scorecard{% endblock %}
{% block extra_css %}
<style>
.kpi-card { border-left: 4px solid; }
.kpi-blue { border-color: #2563eb; }
.kpi-green { border-color: #16a34a; }
.kpi-yellow { border-color: #d97706; }
.kpi-red { border-color: #dc2626; }
.chart-container { position: relative; height: 260px; }
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h2><i class="bi bi-speedometer2 text-primary me-2"></i>{{ facility.name }}</h2>
<p class="text-muted mb-0">Facility Scorecard — last {{ days }} days</p>
</div>
<div class="d-flex gap-2 align-items-center">
{# Period selector #}
<div class="btn-group btn-group-sm" role="group">
{% for d, label in [(30,'30d'),(60,'60d'),(90,'90d'),(180,'180d'),(365,'1yr')] %}
<a href="{{ url_for('reports.facility_scorecard', facility_id=facility.id, days=d) }}"
class="btn btn-outline-secondary {{ 'active' if days == d else '' }}">{{ label }}</a>
{% endfor %}
</div>
<a href="{{ url_for('reports.facility_report', facility_id=facility.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-text"></i> Full Report
</a>
<a href="{{ url_for('reports.facility_summary_pdf', facility_id=facility.id, days=days) }}"
class="btn btn-sm btn-outline-danger" title="Download customer-facing PDF summary">
<i class="bi bi-file-earmark-pdf"></i> PDF Summary
</a>
<a href="{{ url_for('reports.index') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Reports
</a>
</div>
</div>
{# ── KPI row ── #}
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card shadow-sm kpi-card kpi-blue h-100">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">Total Inspections</div>
<div class="fs-2 fw-bold">{{ total_inspections }}</div>
<div class="text-muted small">{{ completed_insp }} completed</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm kpi-card {{ 'kpi-green' if avg_score and avg_score >= 80 else 'kpi-yellow' if avg_score and avg_score >= 60 else 'kpi-red' }} h-100">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">Avg Score</div>
<div class="fs-2 fw-bold">{{ avg_score|round(1) if avg_score else '—' }}{% if avg_score %}%{% endif %}</div>
<div class="text-muted small">{{ days }}-day average</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm kpi-card {{ 'kpi-green' if sla_pct and sla_pct >= 90 else 'kpi-yellow' if sla_pct else 'kpi-red' }} h-100">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">SLA Compliance</div>
<div class="fs-2 fw-bold">{{ sla_pct|round(1) if sla_pct is not none else '—' }}{% if sla_pct is not none %}%{% endif %}</div>
<div class="text-muted small">{{ sla_met }}/{{ sla_total }} closed on time</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm kpi-card {{ 'kpi-red' if open_issues|length > 0 else 'kpi-green' }} h-100">
<div class="card-body">
<div class="text-muted small fw-semibold mb-1">Open Issues</div>
<div class="fs-2 fw-bold">{{ open_issues|length }}</div>
<div class="text-muted small">
{% if pending_verification > 0 %}
<span class="text-warning">{{ pending_verification }} pending verification</span>
{% else %}
across all severities
{% endif %}
</div>
</div>
</div>
</div>
</div>
<div class="row g-4">
{# ── Score trend chart ── #}
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-graph-up me-1"></i> Score Trend
</div>
<div class="card-body">
{% if trend_labels %}
<div class="chart-container">
<canvas id="trendChart"></canvas>
</div>
{% else %}
<p class="text-muted text-center py-4 mb-0">No completed inspections in this period.</p>
{% endif %}
</div>
</div>
</div>
{# ── Issue severity breakdown ── #}
<div class="col-md-4">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-exclamation-triangle me-1"></i> Open Issues by Severity
</div>
<div class="card-body">
{% for sev, color in [('critical','danger'),('high','danger'),('medium','warning'),('low','secondary')] %}
<div class="d-flex justify-content-between align-items-center mb-2">
<span class="badge bg-{{ color }} {{ 'text-dark' if color == 'warning' else '' }}">
{{ sev|title }}
</span>
<span class="fw-bold fs-5">{{ sev_counts.get(sev, 0) }}</span>
</div>
{% endfor %}
{% if pending_verification > 0 %}
<hr class="my-2">
<div class="d-flex justify-content-between align-items-center">
<span class="badge bg-info text-dark">Pending Verification</span>
<span class="fw-bold fs-5">{{ pending_verification }}</span>
</div>
{% endif %}
</div>
</div>
</div>
{# ── Area scores ── #}
{% if area_scores %}
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-building me-1"></i> Score by Area
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>Area</th><th class="text-center">Avg Score</th><th class="text-center">Inspections</th></tr>
</thead>
<tbody>
{% for a in area_scores %}
<tr>
<td>{{ a.name }}</td>
<td class="text-center">
<span class="badge bg-{{ 'success' if a.avg >= 80 else 'warning text-dark' if a.avg >= 60 else 'danger' }}">
{{ a.avg|round(1) }}%
</span>
</td>
<td class="text-center text-muted small">{{ a.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{# ── Follow-up required ── #}
{% if followup_required %}
<div class="col-md-6">
<div class="card shadow-sm border-warning">
<div class="card-header bg-warning text-dark fw-semibold">
<i class="bi bi-arrow-repeat me-1"></i> Follow-up Required
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>Inspection</th><th>Date</th><th>Score</th><th></th></tr>
</thead>
<tbody>
{% for insp in followup_required %}
<tr>
<td>#{{ insp.id }} — {{ insp.template.name }}</td>
<td class="text-muted small">{{ insp.inspection_date.strftime('%Y-%m-%d') }}</td>
<td>
{% if insp.overall_score %}
<span class="badge bg-{{ 'success' if insp.overall_score >= 80 else 'warning text-dark' if insp.overall_score >= 60 else 'danger' }}">
{{ insp.overall_score|round(1) }}%
</span>
{% else %}—{% endif %}
</td>
<td>
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
class="btn btn-xs btn-outline-secondary btn-sm">View</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
{# ── Open issues list ── #}
{% if open_issues %}
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-bug me-1"></i> Open Issues
</div>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr><th>ID</th><th>Severity</th><th>Area</th><th>Description</th><th>Status</th><th>Reported</th><th></th></tr>
</thead>
<tbody>
{% for issue in open_issues %}
<tr>
<td class="text-muted small">#{{ issue.id }}</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 class="small">{{ issue.area.name }}</td>
<td class="small">{{ issue.description[:80] }}{% if issue.description|length > 80 %}…{% endif %}</td>
<td>
<span class="badge bg-{{ 'info text-dark' if issue.status == 'pending_verification' else 'warning text-dark' if issue.status == 'in_progress' else 'secondary' }}">
{{ issue.status|replace('_',' ')|title }}
</span>
</td>
<td class="text-muted small">{{ issue.reported_at.strftime('%Y-%m-%d') }}</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>
</div>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
{% if trend_labels %}
(function () {
const ctx = document.getElementById('trendChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: {{ trend_labels | tojson }},
datasets: [{
label: 'Avg Score (%)',
data: {{ trend_data | tojson }},
borderColor: '#2563eb',
backgroundColor: 'rgba(37,99,235,.1)',
fill: true,
tension: 0.3,
pointRadius: 3,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
y: { min: 0, max: 100, ticks: { callback: v => v + '%' } }
}
}
});
}());
{% endif %}
</script>
{% endblock %}
+149
View File
@@ -0,0 +1,149 @@
{% extends "base.html" %}
{% block title %}SLA Compliance{% endblock %}
{% block extra_css %}
<style>
.compliance-ring { position:relative; display:inline-flex; align-items:center; justify-content:center; width:120px; height:120px; }
</style>
{% endblock %}
{% block content %}
{# ── Sub-nav ── #}
{% include 'reports/_subnav.html' %}
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<h2><i class="bi bi-shield-check text-success me-2"></i>SLA Compliance</h2>
<p class="text-muted mb-0">{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}</p>
</div>
<a href="{{ url_for('reports.export_sla_compliance', start=start.strftime('%Y-%m-%d'), end=end.strftime('%Y-%m-%d'), facility_id=facility_id_filter or '') }}"
class="btn btn-sm btn-outline-success">
<i class="bi bi-file-earmark-excel me-1"></i>Export Excel
</a>
</div>
{# ── Filters ── #}
<div class="card shadow-sm mb-4">
<div class="card-body py-2">
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small mb-1">From</label>
<input type="date" name="start" class="form-control form-control-sm" value="{{ start.strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-3">
<label class="form-label small mb-1">To</label>
<input type="date" name="end" class="form-control form-control-sm" value="{{ end.strftime('%Y-%m-%d') }}">
</div>
<div class="col-md-4">
<label class="form-label small mb-1">Facility</label>
<select name="facility_id" class="form-select form-select-sm">
<option value="">All Facilities</option>
{% for f in facilities %}
<option value="{{ f.id }}" {{ 'selected' if facility_id_filter == f.id }}>{{ f.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary">Apply</button>
<a href="{{ url_for('reports.sla_compliance') }}" class="btn btn-sm btn-outline-secondary">Reset</a>
</div>
</form>
</div>
</div>
{% if total == 0 %}
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>No resolved issues found for this period and filter.
</div>
{% else %}
{# ── Overall KPI ── #}
<div class="row g-3 mb-4 align-items-stretch">
<div class="col-md-3">
<div class="card shadow-sm h-100 text-center"
style="border-left:6px solid {{ '#16a34a' if overall_pct and overall_pct >= 90 else '#d97706' if overall_pct and overall_pct >= 70 else '#dc2626' }};">
<div class="card-body d-flex flex-column align-items-center justify-content-center py-4">
<div class="text-muted small fw-semibold mb-1">Overall Compliance</div>
<div class="display-4 fw-bold
{{ 'text-success' if overall_pct and overall_pct >= 90 else 'text-warning' if overall_pct and overall_pct >= 70 else 'text-danger' }}">
{{ overall_pct|round(1) if overall_pct is not none else '—' }}{% if overall_pct is not none %}%{% endif %}
</div>
<div class="text-muted small mt-1">{{ met }}/{{ total }} resolved on time</div>
</div>
</div>
</div>
{# ── Per-severity compliance cards ── #}
{% for sev, color_cls in [('critical','danger'),('high','danger'),('medium','warning'),('low','secondary')] %}
{% set d = by_severity[sev] %}
<div class="col-6 col-md">
<div class="card shadow-sm h-100">
<div class="card-body text-center">
<div class="mb-1"><span class="badge bg-{{ color_cls }} {{ 'text-dark' if color_cls == 'warning' else '' }} px-2">
{{ sev|title }}
</span></div>
<div class="small text-muted mb-1">SLA: {{ d.sla_hours }}h window</div>
<div class="fs-3 fw-bold
{{ 'text-success' if d.pct and d.pct >= 90 else 'text-warning' if d.pct and d.pct >= 70 else 'text-danger' if d.pct is not none else 'text-muted' }}">
{{ d.pct|round(1) if d.pct is not none else '—' }}{% if d.pct is not none %}%{% endif %}
</div>
<div class="small text-muted">{{ d.met }}/{{ d.total }}</div>
{% if d.total > 0 %}
<div class="progress mt-2" style="height:4px;">
<div class="progress-bar bg-{{ 'success' if d.pct and d.pct >= 90 else 'warning' if d.pct and d.pct >= 70 else 'danger' }}"
style="width:{{ d.pct or 0 }}%;"></div>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{# ── Facility breakdown ── #}
{% if by_facility %}
<div class="card shadow-sm">
<div class="card-header bg-light fw-semibold">
<i class="bi bi-building me-1"></i>Compliance by Facility
<span class="badge bg-secondary ms-1">{{ by_facility|length }}</span>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Facility</th>
<th class="text-center">Resolved Issues</th>
<th class="text-center">Met SLA</th>
<th class="text-center">Compliance %</th>
<th>Progress</th>
</tr>
</thead>
<tbody>
{% for row in by_facility %}
{% set pct_val = row.pct or 0 %}
{% set bar_class = 'bg-success' if pct_val >= 90 else 'bg-warning' if pct_val >= 70 else 'bg-danger' %}
<tr>
<td class="fw-semibold">{{ row.name }}</td>
<td class="text-center">{{ row.total }}</td>
<td class="text-center">{{ row.met }}</td>
<td class="text-center">
{% if row.pct is not none %}
<span class="badge bg-{{ 'success' if row.pct >= 90 else 'warning text-dark' if row.pct >= 70 else 'danger' }} px-3">
{{ row.pct }}%
</span>
{% else %}—{% endif %}
</td>
<td style="min-width:120px;">
<div class="progress" style="height:8px;">
<div class="progress-bar {{ bar_class }}" style="width:{{ row.pct or 0 }}%;"></div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% endif %}{# end total == 0 #}
{% endblock %}
+185
View File
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html>
<body style="font-family:Arial,sans-serif;color:#333;max-width:640px;margin:auto;">
<div style="background:#1a1d23;padding:20px 28px;border-radius:8px 8px 0 0;">
<h2 style="color:#fff;margin:0;font-size:1.1rem;">
<span style="color:#93c5fd;">&#128203;</span>
{{ report.frequency|title }} Report — {{ report.name }}
</h2>
<p style="color:#94a3b8;font-size:.8rem;margin:4px 0 0;">
{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}
{% if facility %} · {{ facility.name }}{% endif %}
</p>
</div>
<div style="background:#fff;border:1px solid #e2e8f0;border-top:none;
border-radius:0 0 8px 8px;padding:24px;">
{# ── Summary / Facility type ── #}
{% if report.report_type in ('summary','facility') %}
{# KPI row #}
<table width="100%" cellspacing="0" cellpadding="0" style="margin-bottom:24px;">
<tr>
<td align="center" style="padding:12px;background:#f1f5f9;border-radius:8px;">
<div style="font-size:1.6rem;font-weight:700;color:#1d4ed8;">{{ total_inspections }}</div>
<div style="font-size:.75rem;color:#64748b;">Inspections</div>
</td>
<td width="12"></td>
<td align="center" style="padding:12px;background:#f0fdf4;border-radius:8px;">
<div style="font-size:1.6rem;font-weight:700;color:#15803d;">{{ completed }}</div>
<div style="font-size:.75rem;color:#64748b;">Completed</div>
</td>
<td width="12"></td>
<td align="center" style="padding:12px;background:#fef3c7;border-radius:8px;">
<div style="font-size:1.6rem;font-weight:700;color:#b45309;">{{ open_issues }}</div>
<div style="font-size:.75rem;color:#64748b;">Open Issues</div>
</td>
<td width="12"></td>
<td align="center" style="padding:12px;background:#f0f9ff;border-radius:8px;">
<div style="font-size:1.6rem;font-weight:700;color:#0369a1;">
{{ '%.1f'|format(avg_score) ~ '%' if avg_score else '—' }}
</div>
<div style="font-size:.75rem;color:#64748b;">Avg Score</div>
</td>
</tr>
</table>
{% if facility_scores %}
<h3 style="font-size:.9rem;border-bottom:1px solid #e2e8f0;padding-bottom:6px;">
Facility Scores
</h3>
<table width="100%" style="border-collapse:collapse;font-size:.85rem;margin-bottom:20px;">
<thead>
<tr style="background:#f8fafc;">
<th style="text-align:left;padding:6px 8px;">Facility</th>
<th style="text-align:center;padding:6px 8px;">Inspections</th>
<th style="text-align:center;padding:6px 8px;">Avg Score</th>
</tr>
</thead>
<tbody>
{% for row in facility_scores %}
<tr style="border-bottom:1px solid #f1f5f9;">
<td style="padding:6px 8px;">{{ row.name }}</td>
<td style="padding:6px 8px;text-align:center;">{{ row.count }}</td>
<td style="padding:6px 8px;text-align:center;">
<span style="font-weight:600;color:{{ '#15803d' if row.avg >= 90 else '#b45309' if row.avg >= 70 else '#dc2626' }}">
{{ '%.1f'|format(row.avg) }}%
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% if critical_issues %}
<h3 style="font-size:.9rem;border-bottom:1px solid #e2e8f0;padding-bottom:6px;color:#dc2626;">
&#9888; Open Critical / High Issues
</h3>
<table width="100%" style="border-collapse:collapse;font-size:.82rem;margin-bottom:20px;">
<thead>
<tr style="background:#fef2f2;">
<th style="text-align:left;padding:6px 8px;">Issue</th>
<th style="text-align:left;padding:6px 8px;">Severity</th>
<th style="text-align:left;padding:6px 8px;">Facility / Area</th>
<th style="text-align:left;padding:6px 8px;">Reported</th>
</tr>
</thead>
<tbody>
{% for i in critical_issues %}
<tr style="border-bottom:1px solid #fee2e2;">
<td style="padding:6px 8px;">
<a href="{{ base_url }}/issues/{{ i.id }}" style="color:#1d4ed8;">#{{ i.id }}</a>
— {{ i.description[:60] }}{% if i.description|length > 60 %}…{% endif %}
</td>
<td style="padding:6px 8px;font-weight:600;color:#dc2626;">{{ i.severity|title }}</td>
<td style="padding:6px 8px;">
{% set rf = i.resolved_facility %}
{{ rf.name if rf else '—' }} / {{ i.area.name if i.area else '—' }}
</td>
<td style="padding:6px 8px;color:#64748b;">{{ i.reported_at.strftime('%b %d') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
{% elif report.report_type == 'issues' %}
{# SLA summary bar #}
{% if issues %}
<table width="100%" cellspacing="0" cellpadding="0" style="margin-bottom:20px;">
<tr>
<td align="center" style="padding:10px;background:#fef2f2;border-radius:8px;">
<div style="font-size:1.4rem;font-weight:700;color:#dc2626;">{{ sla_breached }}</div>
<div style="font-size:.75rem;color:#64748b;">SLA Breached</div>
</td>
<td width="12"></td>
<td align="center" style="padding:10px;background:#fefce8;border-radius:8px;">
<div style="font-size:1.4rem;font-weight:700;color:#b45309;">{{ sla_at_risk }}</div>
<div style="font-size:.75rem;color:#64748b;">At Risk</div>
</td>
<td width="12"></td>
<td align="center" style="padding:10px;background:#f0f9ff;border-radius:8px;">
<div style="font-size:1.4rem;font-weight:700;color:#0369a1;">{{ issues|length }}</div>
<div style="font-size:.75rem;color:#64748b;">Total Open</div>
</td>
</tr>
</table>
{# Per-facility sections #}
{% for facility_name, fac_issues in issues_by_facility %}
<h3 style="font-size:.85rem;border-bottom:1px solid #e2e8f0;padding-bottom:4px;margin-top:20px;">
&#127970; {{ facility_name }}
<span style="font-weight:400;color:#64748b;font-size:.78rem;">({{ fac_issues|length }} issue{{ 's' if fac_issues|length != 1 else '' }})</span>
</h3>
<table width="100%" style="border-collapse:collapse;font-size:.82rem;margin-bottom:8px;">
<thead>
<tr style="background:#f8fafc;">
<th style="padding:5px 8px;text-align:left;">#</th>
<th style="padding:5px 8px;text-align:left;">Severity</th>
<th style="padding:5px 8px;text-align:left;">Area</th>
<th style="padding:5px 8px;text-align:left;">Description</th>
<th style="padding:5px 8px;text-align:left;">Status</th>
<th style="padding:5px 8px;text-align:left;">SLA</th>
<th style="padding:5px 8px;text-align:left;">Reported</th>
</tr>
</thead>
<tbody>
{% for i, sla in fac_issues %}
{% set row_bg = '#fef2f2' if sla == 'breached' else '#fefce8' if sla == 'at_risk' else '#fff' %}
<tr style="border-bottom:1px solid #f1f5f9;background:{{ row_bg }};">
<td style="padding:5px 8px;">
<a href="{{ base_url }}/issues/{{ i.id }}" style="color:#1d4ed8;">#{{ i.id }}</a>
</td>
<td style="padding:5px 8px;font-weight:600;color:{{ '#dc2626' if i.severity in ['critical','high'] else '#b45309' if i.severity == 'medium' else '#64748b' }}">
{{ i.severity|title }}
</td>
<td style="padding:5px 8px;color:#64748b;">{{ i.area.name if i.area else '—' }}</td>
<td style="padding:5px 8px;">{{ i.description[:70] }}{% if i.description|length > 70 %}…{% endif %}</td>
<td style="padding:5px 8px;">{{ i.status|replace('_',' ')|title }}</td>
<td style="padding:5px 8px;font-weight:600;color:{{ '#dc2626' if sla == 'breached' else '#b45309' if sla == 'at_risk' else '#64748b' }}">
{{ 'Breached' if sla == 'breached' else 'At Risk' if sla == 'at_risk' else 'OK' }}
</td>
<td style="padding:5px 8px;color:#64748b;">{{ i.reported_at.strftime('%b %d') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
{% else %}
<p style="color:#64748b;">No open issues at this time.</p>
{% endif %}
{% endif %}
<hr style="border:none;border-top:1px solid #e2e8f0;margin:28px 0 16px;">
<p style="font-size:.75rem;color:#94a3b8;">
Janitorial QC System — scheduled report. Do not reply to this email.<br>
<a href="{{ base_url }}/reports" style="color:#94a3b8;">View full reports dashboard</a>
</p>
</div>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
{{ report.frequency|title }} Report — {{ report.name }}
{{ start.strftime('%b %d, %Y') }} — {{ end.strftime('%b %d, %Y') }}{% if facility %} · {{ facility.name }}{% endif %}
{% if report.report_type in ('summary','facility') %}
SUMMARY
-------
Inspections: {{ total_inspections }}
Completed: {{ completed }}
Open Issues: {{ open_issues }}
Avg Score: {{ '%.1f'|format(avg_score) ~ '%' if avg_score else '—' }}
{% if facility_scores %}
FACILITY SCORES
---------------
{% for row in facility_scores %}
{{ row.name }}: {{ '%.1f'|format(row.avg) }}% ({{ row.count }} inspection{{ 's' if row.count != 1 else '' }})
{% endfor %}
{% endif %}
{% if critical_issues %}
OPEN CRITICAL / HIGH ISSUES
----------------------------
{% for i in critical_issues %}
{% set rf = i.resolved_facility %}#{{ i.id }} [{{ i.severity|title }}] {{ rf.name if rf else '—' }} — {{ i.description[:80] }}
Link: {{ base_url }}/issues/{{ i.id }}
{% endfor %}
{% endif %}
{% elif report.report_type == 'issues' %}
OPEN ISSUES ({{ issues|length }}) — Breached: {{ sla_breached }} At Risk: {{ sla_at_risk }}
{% if issues %}
{% for facility_name, fac_issues in issues_by_facility %}
{{ facility_name }} ({{ fac_issues|length }} issue{{ 's' if fac_issues|length != 1 else '' }})
{% for i, sla in fac_issues %} #{{ i.id }} [{{ i.severity|title }}] [SLA: {{ 'Breached' if sla == 'breached' else 'At Risk' if sla == 'at_risk' else 'OK' }}] {{ i.area.name if i.area else '—' }}
Status: {{ i.status|replace('_',' ')|title }} | {{ i.description[:70] }}
Link: {{ base_url }}/issues/{{ i.id }}
{% endfor %}
{% endfor %}
{% else %}
No open issues.
{% endif %}
{% endif %}
--
Janitorial QC System — automated scheduled report.
View dashboard: {{ base_url }}/reports
+102
View File
@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0"><i class="bi bi-calendar-check me-2"></i>{{ title }}</h4>
</div>
<div class="card-body">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold">Report Name</label>
<input type="text" name="name" class="form-control"
value="{{ report.name if report else '' }}" required
placeholder="e.g. Weekly Facility Summary">
</div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold">Report Type</label>
<select name="report_type" class="form-select">
{% for val, label in [('summary','Summary KPIs'),('facility','Facility Detail'),('issues','Open Issues')] %}
<option value="{{ val }}" {{ 'selected' if report and report.report_type == val else '' }}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold">Frequency</label>
<select name="frequency" class="form-select">
{% for val in ['daily','weekly','monthly'] %}
<option value="{{ val }}" {{ 'selected' if report and report.frequency == val else '' }}>
{{ val|title }}
</option>
{% endfor %}
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold">Facility <span class="text-muted small">(optional)</span></label>
<select name="facility_id" class="form-select">
<option value="">— All Facilities —</option>
{% for f in facilities %}
<option value="{{ f.id }}"
{{ 'selected' if report and report.facility_id == f.id else '' }}>
{{ f.name }}
</option>
{% endfor %}
</select>
<div class="form-text">Leave blank to include all facilities.</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Recipients</label>
<input type="text" name="recipients" class="form-control"
value="{{ report.recipient_list()|join(', ') if report else '' }}"
placeholder="email1@example.com, email2@example.com"
required>
<div class="form-text">Comma-separated list of email addresses.</div>
</div>
<div class="row mb-3">
<div class="col-auto">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="include_csv"
id="include_csv" value="1"
{{ 'checked' if report and report.include_csv else '' }}>
<label class="form-check-label" for="include_csv">Attach CSV export</label>
</div>
</div>
</div>
{% if report %}
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="active"
id="active" value="1"
{{ 'checked' if report.active else '' }}>
<label class="form-check-label" for="active">Active (send on schedule)</label>
</div>
</div>
{% endif %}
<div class="d-flex gap-2 mt-3">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>
{{ 'Save Changes' if report else 'Create Schedule' }}
</button>
<a href="{{ url_for('scheduled_reports.index') }}" class="btn btn-secondary">
<i class="bi bi-x-circle me-1"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,96 @@
{% extends "base.html" %}
{% block title %}Scheduled Reports{% endblock %}
{% block content %}
{% include 'reports/_subnav.html' %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2><i class="bi bi-calendar-check"></i> Scheduled Reports</h2>
<a href="{{ url_for('scheduled_reports.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> New Schedule
</a>
</div>
{% if reports %}
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Name</th><th>Type</th><th>Frequency</th><th>Facility</th>
<th>Recipients</th><th>Next Send</th><th>Last Sent</th>
<th>Status</th><th width="160"></th>
</tr>
</thead>
<tbody>
{% for r in reports %}
<tr class="{{ 'text-muted' if not r.active else '' }}">
<td><strong>{{ r.name }}</strong></td>
<td><span class="badge bg-secondary">{{ r.report_type|title }}</span></td>
<td>{{ r.frequency|title }}</td>
<td>{{ r.facility.name if r.facility else '— All —' }}</td>
<td>
<span title="{{ r.recipient_list()|join(', ') }}">
{{ r.recipient_list()|length }} recipient{{ 's' if r.recipient_list()|length != 1 else '' }}
</span>
</td>
<td class="small text-muted">
{{ r.next_send_at.strftime('%Y-%m-%d %H:%M') if r.next_send_at else '—' }}
</td>
<td class="small text-muted">
{{ r.last_sent_at.strftime('%Y-%m-%d %H:%M') if r.last_sent_at else 'Never' }}
</td>
<td>
{% if r.active %}<span class="badge bg-success">Active</span>
{% else %}<span class="badge bg-secondary">Paused</span>{% endif %}
</td>
<td class="text-end">
<a href="{{ url_for('scheduled_reports.edit', report_id=r.id) }}"
class="btn btn-sm btn-outline-secondary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
<a href="{{ url_for('scheduled_reports.preview', report_id=r.id) }}"
class="btn btn-sm btn-outline-info" title="Preview Email" target="_blank">
<i class="bi bi-eye"></i>
</a>
<a href="{{ url_for('scheduled_reports.preview_pdf', report_id=r.id) }}"
class="btn btn-sm btn-outline-info" title="Preview PDF" target="_blank">
<i class="bi bi-file-earmark-pdf"></i>
</a>
<form method="POST"
action="{{ url_for('scheduled_reports.send_now', report_id=r.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-primary" title="Send Now"
onclick="return confirm('Send this report now?')">
<i class="bi bi-send"></i>
</button>
</form>
<form method="POST"
action="{{ url_for('scheduled_reports.delete', report_id=r.id) }}"
class="d-inline"
onsubmit="return confirm('Delete this scheduled report?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="bi bi-trash3"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="card shadow-sm">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-calendar-x fs-1 d-block mb-3 opacity-25"></i>
<p class="mb-3">No scheduled reports configured yet.</p>
<a href="{{ url_for('scheduled_reports.create') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create First Schedule
</a>
</div>
</div>
{% endif %}
{% endblock %}
+39
View File
@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JQC App Support — LT Services Inc.</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body { background: #f8f9fa; }
.support-card { max-width: 640px; margin: 60px auto; }
.brand { font-weight: 700; color: #0d6efd; }
</style>
</head>
<body>
<div class="support-card card shadow-sm p-4">
<h1 class="h4 mb-1"><span class="brand">JQC</span> — Janitorial Quality Control</h1>
<p class="text-muted mb-4">LT Services Inc. &mdash; Internal Operations App</p>
<h2 class="h6 text-uppercase text-secondary mb-3">Support</h2>
<p>This app is an internal tool for LT Services Inc. employees and staff. It is not available for public download or use.</p>
<p>If you are an LT Services Inc. employee experiencing an issue with the app, contact your IT administrator:</p>
<ul class="list-unstyled mb-4">
<li><i class="bi bi-envelope-fill me-2 text-primary"></i>
<a href="mailto:da.nguyen8744@gmail.com">da.nguyen8744@gmail.com</a>
</li>
</ul>
<h2 class="h6 text-uppercase text-secondary mb-3">About This App</h2>
<p class="mb-1"><strong>App name:</strong> JQC — Janitorial Quality Control</p>
<p class="mb-1"><strong>Developer:</strong> LT Services Inc.</p>
<p class="mb-1"><strong>Platform:</strong> iPadOS</p>
<p class="mb-4"><strong>Purpose:</strong> Facility inspection management, issue tracking, and quality reporting for internal janitorial operations staff.</p>
<p class="text-muted small">&copy; {{ current_year }} LT Services Inc. All rights reserved.</p>
</div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
</body>
</html>
@@ -0,0 +1,143 @@
{% extends "base.html" %}
{% block title %}Ticket #{{ ticket.id }}{% endblock %}
{% block content %}
<div class="mb-3">
<a href="{{ url_for('support.admin_tickets') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>All Tickets
</a>
</div>
<div class="row g-4">
{# ── Left column: original message + replies ── #}
<div class="col-lg-8">
{# Original ticket card #}
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Ticket #{{ ticket.id }}: {{ ticket.subject }}</strong>
{% set status_class = {'open': 'danger', 'answered': 'success', 'closed': 'secondary'} %}
<span class="badge bg-{{ status_class.get(ticket.status, 'secondary') }}">
{{ ticket.status | capitalize }}
</span>
</div>
<div class="card-body">
<div class="text-muted small mb-2">
<i class="bi bi-person me-1"></i>
{{ ticket.customer.display_name if ticket.customer else 'Unknown' }}
{% if ticket.facility %}
&nbsp;·&nbsp;<i class="bi bi-building me-1"></i>{{ ticket.facility.name }}
{% endif %}
&nbsp;·&nbsp;<i class="bi bi-clock me-1"></i>
{{ ticket.created_at.strftime('%b %d, %Y %I:%M %p') }}
</div>
<p class="mb-0" style="white-space:pre-wrap;">{{ ticket.body }}</p>
</div>
</div>
{# Reply thread #}
{% if replies %}
<div class="card shadow-sm mb-3">
<div class="card-header"><i class="bi bi-chat-left-text me-2"></i>Replies</div>
<div class="card-body">
<div class="d-flex flex-column gap-3">
{% for reply in replies %}
<div class="border rounded p-3 bg-light">
<div class="text-muted small mb-2">
<i class="bi bi-person-badge me-1"></i>
<strong>{{ reply.author.display_name if reply.author else 'Support Team' }}</strong>
&nbsp;·&nbsp;{{ reply.created_at.strftime('%b %d, %Y %I:%M %p') }}
</div>
<div style="white-space:pre-wrap;word-break:break-word;">{{ reply.body }}</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
{# Add reply form #}
{% if ticket.status != 'closed' %}
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-reply me-2"></i>Add Reply</div>
<div class="card-body">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="reply">
<div class="mb-3">
<textarea name="body" class="form-control" rows="5" required
placeholder="Type your reply to the customer…"></textarea>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Send Reply
</button>
<span class="text-muted small ms-2">
The customer will be notified by email.
</span>
</form>
</div>
</div>
{% else %}
<div class="alert alert-secondary">
<i class="bi bi-lock me-2"></i>This ticket is closed. Change the status to re-open it.
</div>
{% endif %}
</div>
{# ── Right column: status management ── #}
<div class="col-lg-4">
<div class="card shadow-sm">
<div class="card-header"><i class="bi bi-gear me-2"></i>Ticket Status</div>
<div class="card-body">
<p class="mb-2 text-muted small">Current status:</p>
<p class="fw-semibold mb-3">
<span class="badge bg-{{ status_class.get(ticket.status, 'secondary') }} fs-6">
{{ ticket.status | capitalize }}
</span>
</p>
<form method="post" class="d-grid gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="status">
{% if ticket.status != 'open' %}
<button type="submit" name="status" value="open" class="btn btn-outline-danger btn-sm">
<i class="bi bi-envelope-open me-1"></i>Reopen
</button>
{% endif %}
{% if ticket.status != 'answered' %}
<button type="submit" name="status" value="answered" class="btn btn-outline-success btn-sm">
<i class="bi bi-check-circle me-1"></i>Mark Answered
</button>
{% endif %}
{% if ticket.status != 'closed' %}
<button type="submit" name="status" value="closed" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-x-circle me-1"></i>Close Ticket
</button>
{% endif %}
</form>
</div>
</div>
{# Customer info card #}
{% if ticket.customer %}
<div class="card shadow-sm mt-3">
<div class="card-header"><i class="bi bi-person-circle me-2"></i>Customer</div>
<div class="card-body small">
<p class="mb-1"><strong>{{ ticket.customer.display_name }}</strong></p>
<p class="mb-1 text-muted">{{ ticket.customer.email }}</p>
{% if ticket.facility %}
<hr class="my-2">
<p class="mb-1"><i class="bi bi-building me-1 text-muted"></i>{{ ticket.facility.name }}</p>
{% if ticket.facility.address %}
<p class="mb-0 text-muted">{{ ticket.facility.address }}</p>
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+114
View File
@@ -0,0 +1,114 @@
{% extends "base.html" %}
{% block title %}Support Tickets{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-inbox me-2 text-primary"></i>Customer Support Tickets</h4>
<small class="text-muted">{{ tickets.total }} ticket{{ 's' if tickets.total != 1 }}</small>
</div>
</div>
{# Status filter tabs #}
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link {% if not status_filter %}active{% endif %}"
href="{{ url_for('support.admin_tickets') }}">All</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'open' %}active{% endif %}"
href="{{ url_for('support.admin_tickets', status='open') }}">
<span class="badge bg-danger me-1">!</span>Open
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'answered' %}active{% endif %}"
href="{{ url_for('support.admin_tickets', status='answered') }}">Answered</a>
</li>
<li class="nav-item">
<a class="nav-link {% if status_filter == 'closed' %}active{% endif %}"
href="{{ url_for('support.admin_tickets', status='closed') }}">Closed</a>
</li>
</ul>
{% if tickets.items %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width:60px">#</th>
<th>Customer</th>
<th>Facility</th>
<th>Subject</th>
<th>Status</th>
<th>Submitted</th>
<th></th>
</tr>
</thead>
<tbody>
{% for ticket in tickets.items %}
{% set status_class = {'open': 'danger', 'answered': 'success', 'closed': 'secondary'} %}
<tr>
<td class="text-muted small">{{ ticket.id }}</td>
<td>{{ ticket.customer.display_name if ticket.customer else '—' }}</td>
<td>{{ ticket.facility.name if ticket.facility else '—' }}</td>
<td>{{ ticket.subject }}</td>
<td>
<span class="badge bg-{{ status_class.get(ticket.status, 'secondary') }}">
{{ ticket.status | capitalize }}
</span>
</td>
<td class="text-muted small text-nowrap">
{{ ticket.created_at.strftime('%b %d, %Y %I:%M %p') }}
</td>
<td>
<a href="{{ url_for('support.admin_ticket_detail', ticket_id=ticket.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye me-1"></i>View
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# Pagination #}
{% if tickets.pages > 1 %}
<nav class="mt-3">
<ul class="pagination justify-content-center mb-0">
<li class="page-item {% if not tickets.has_prev %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('support.admin_tickets', page=tickets.prev_num, status=status_filter) }}">
&laquo; Prev
</a>
</li>
{% for p in tickets.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if p %}
<li class="page-item {% if p == tickets.page %}active{% endif %}">
<a class="page-link"
href="{{ url_for('support.admin_tickets', page=p, status=status_filter) }}">{{ p }}</a>
</li>
{% else %}
<li class="page-item disabled"><span class="page-link"></span></li>
{% endif %}
{% endfor %}
<li class="page-item {% if not tickets.has_next %}disabled{% endif %}">
<a class="page-link"
href="{{ url_for('support.admin_tickets', page=tickets.next_num, status=status_filter) }}">
Next &raquo;
</a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
No tickets{% if status_filter %} with status "{{ status_filter }}"{% endif %}.
</div>
{% endif %}
{% endblock %}
+279
View File
@@ -0,0 +1,279 @@
{% extends "base.html" %}
{% block title %}Support Chat{% endblock %}
{% block extra_css %}
<style>
#chat-window {
height: 420px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: .75rem;
padding: 1rem;
background: #f8f9fa;
}
.msg-bubble {
max-width: 80%;
padding: .6rem .9rem;
border-radius: 1rem;
font-size: .9rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.msg-user { background:#0d6efd; color:#fff; border-bottom-right-radius:.25rem; align-self:flex-end; }
.msg-ai { background:#fff; border:1px solid #dee2e6; border-bottom-left-radius:.25rem; align-self:flex-start; }
.msg-system { background:#fff3cd; border:1px solid #ffc107; border-radius:.5rem; align-self:center;
font-size:.8rem; text-align:center; padding:.4rem .8rem; color:#664d03; }
.typing-dot { display:inline-block; width:8px; height:8px; border-radius:50%;
background:#adb5bd; margin:0 2px; animation:blink 1.2s infinite; }
.typing-dot:nth-child(2) { animation-delay:.2s; }
.typing-dot:nth-child(3) { animation-delay:.4s; }
@keyframes blink { 0%,80%,100%{opacity:.2} 40%{opacity:1} }
.faq-btn { font-size:.82rem; }
</style>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
{# ── Header ── #}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-chat-dots me-2 text-primary"></i>JQC Support Chat</h4>
<small class="text-muted">Ask a question or browse common topics below</small>
</div>
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#submitModal">
<i class="bi bi-envelope me-1"></i>Submit to Support
</button>
</div>
{# ── Chat card ── #}
<div class="card shadow-sm">
{# Message window #}
<div id="chat-window"></div>
{# FAQ quick-reply chips #}
<div id="faq-section" class="px-3 pt-2 pb-1 border-top bg-white">
<p class="small text-muted mb-2"><i class="bi bi-lightning-charge me-1"></i>Common questions:</p>
<div class="d-flex flex-wrap gap-2 mb-2">
{% for faq in faqs %}
<button class="btn btn-outline-secondary btn-sm faq-btn"
data-faq="{{ faq.text | e }}"
onclick="sendFaq(this)">
<i class="{{ faq.icon }} me-1"></i>{{ faq.text }}
</button>
{% endfor %}
</div>
</div>
{# Input row #}
<div class="p-3 border-top bg-white">
<div class="input-group">
<input id="chat-input" type="text" class="form-control"
placeholder="Type your question…" maxlength="500"
{% if not groq_ready %}disabled title="AI assistant not configured"{% endif %}>
<button id="send-btn" class="btn btn-primary" onclick="sendUserMessage()"
{% if not groq_ready %}disabled{% endif %}>
<i class="bi bi-send"></i>
</button>
</div>
{% if not groq_ready %}
<div class="text-muted small mt-1">
<i class="bi bi-info-circle me-1"></i>
AI assistant is not configured. Please
<a href="#" data-bs-toggle="modal" data-bs-target="#submitModal">submit a request</a>
to reach our team.
</div>
{% endif %}
</div>
</div>
<p class="text-muted small mt-2 text-center">
Can't find what you need?
<a href="#" data-bs-toggle="modal" data-bs-target="#submitModal">Submit a support request</a>
and our team will respond by email.
</p>
</div>
</div>
{# ── Submit to Support modal ── #}
<div class="modal fade" id="submitModal" tabindex="-1" aria-labelledby="submitModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="submitModalLabel">
<i class="bi bi-envelope me-2"></i>Submit Support Request
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="post" action="{{ url_for('support.submit_ticket') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">Subject <span class="text-danger">*</span></label>
<input type="text" name="subject" class="form-control" required maxlength="200"
placeholder="Briefly describe your issue">
</div>
{% if facilities %}
<div class="mb-3">
<label class="form-label fw-semibold">Related Facility</label>
<select name="facility_id" class="form-select">
<option value="">— Not facility-specific —</option>
{% for f in facilities %}
<option value="{{ f.id }}">{{ f.name }}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="mb-3">
<label class="form-label fw-semibold">Description <span class="text-danger">*</span></label>
<textarea name="body" class="form-control" rows="5" required
placeholder="Please describe your question or concern in detail…"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-send me-1"></i>Submit Request
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
(function () {
'use strict';
const chatWindow = document.getElementById('chat-window');
const chatInput = document.getElementById('chat-input');
const faqSection = document.getElementById('faq-section');
const CSRF_TOKEN = '{{ csrf_token() }}';
// In-memory conversation history sent to the server with each message
let history = [];
// ── Greeting on page load ──────────────────────────────────────────────
var userName = {{ current_user.display_name | tojson }};
appendMessage('ai', "👋 Hi " + userName + "! I'm your JQC support assistant. " +
"I can help you with inspections, issues, reports, and more. " +
"Click a question below or type your own.");
// ── Append a message bubble ────────────────────────────────────────────
function appendMessage(role, text) {
const div = document.createElement('div');
div.className = 'msg-bubble msg-' + role;
div.textContent = text;
chatWindow.appendChild(div);
chatWindow.scrollTop = chatWindow.scrollHeight;
return div;
}
function appendSystem(text) {
const div = document.createElement('div');
div.className = 'msg-bubble msg-system';
div.textContent = text;
chatWindow.appendChild(div);
chatWindow.scrollTop = chatWindow.scrollHeight;
}
// ── Typing indicator ───────────────────────────────────────────────────
function showTyping() {
const div = document.createElement('div');
div.id = 'typing-indicator';
div.className = 'msg-bubble msg-ai';
div.innerHTML = '<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>';
chatWindow.appendChild(div);
chatWindow.scrollTop = chatWindow.scrollHeight;
}
function hideTyping() {
const el = document.getElementById('typing-indicator');
if (el) el.remove();
}
// ── Send message to Groq ───────────────────────────────────────────────
function sendMessage(text) {
if (!text.trim()) return;
// Hide FAQ chips after first interaction
if (faqSection) faqSection.style.display = 'none';
appendMessage('user', text);
history.push({ role: 'user', content: text });
chatInput.value = '';
chatInput.disabled = true;
document.getElementById('send-btn').disabled = true;
showTyping();
fetch('{{ url_for("support.chat_message") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': CSRF_TOKEN,
},
body: JSON.stringify({ message: text, messages: history }),
})
.then(function (r) { return r.json(); })
.then(function (data) {
hideTyping();
const reply = data.reply || 'Sorry, I could not process your request.';
appendMessage('ai', reply);
history.push({ role: 'assistant', content: reply });
// Suggest escalation if the AI hints it can't help
const lower = reply.toLowerCase();
if (lower.includes('submit to support') || lower.includes('admin team') ||
lower.includes("can't resolve") || lower.includes('contact us')) {
appendSystem('💡 Tip: Click "Submit to Support" above to send a message directly to our team.');
}
})
.catch(function () {
hideTyping();
appendMessage('ai', 'Network error — please check your connection and try again.');
})
.finally(function () {
chatInput.disabled = false;
document.getElementById('send-btn').disabled = false;
chatInput.focus();
});
}
// ── Public helpers called from inline onclick ──────────────────────────
window.sendUserMessage = function () {
sendMessage(chatInput.value);
};
window.sendFaq = function (btn) {
btn.disabled = true;
sendMessage(btn.dataset.faq);
};
// ── Enter key support ──────────────────────────────────────────────────
if (chatInput) {
chatInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage(chatInput.value);
}
});
}
// ── Pre-fill modal subject from last user message ──────────────────────
document.getElementById('submitModal').addEventListener('show.bs.modal', function () {
const subjectInput = this.querySelector('[name="subject"]');
if (subjectInput && !subjectInput.value && history.length) {
const lastUser = [...history].reverse().find(function (m) { return m.role === 'user'; });
if (lastUser) subjectInput.value = lastUser.content.slice(0, 200);
}
});
}());
</script>
{% endblock %}
@@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block title %}Support Request #{{ ticket.id }}{% endblock %}
{% block content %}
<div class="mb-3">
<a href="{{ url_for('support.my_tickets') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>All My Requests
</a>
</div>
<div class="row justify-content-center">
<div class="col-lg-8">
{# Original request #}
<div class="card shadow-sm mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Request #{{ ticket.id }}: {{ ticket.subject }}</strong>
{% set status_class = {'open': 'warning', 'answered': 'success', 'closed': 'secondary'} %}
{% set status_label = {'open': 'Awaiting reply', 'answered': 'Answered', 'closed': 'Closed'} %}
<span class="badge bg-{{ status_class.get(ticket.status, 'secondary') }}">
{{ status_label.get(ticket.status, ticket.status | capitalize) }}
</span>
</div>
<div class="card-body">
<div class="text-muted small mb-2">
{% if ticket.facility %}
<i class="bi bi-building me-1"></i>{{ ticket.facility.name }}&nbsp;·&nbsp;
{% endif %}
<i class="bi bi-clock me-1"></i>{{ ticket.created_at.strftime('%b %d, %Y %I:%M %p') }}
</div>
<p class="mb-0" style="white-space:pre-wrap;">{{ ticket.body }}</p>
</div>
</div>
{# Staff replies #}
{% if replies %}
<div class="card shadow-sm mb-3">
<div class="card-header">
<i class="bi bi-chat-left-text me-2"></i>Replies from Support Team
</div>
<div class="card-body">
<div id="reply-thread">
{% for reply in replies %}
<div class="border rounded p-3 bg-light">
<div class="text-muted small mb-2">
<i class="bi bi-person-badge me-1"></i>
<strong>{{ reply.author.display_name if reply.author else 'Support Team' }}</strong>
&nbsp;·&nbsp;{{ reply.created_at.strftime('%b %d, %Y %I:%M %p') }}
</div>
<div style="white-space:pre-wrap;word-break:break-word;">{{ reply.body }}</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% else %}
<div class="alert alert-info">
<i class="bi bi-hourglass-split me-2"></i>
Your request has been received. Our team will reply shortly.
</div>
{% endif %}
{% if ticket.status != 'closed' %}
<div class="card shadow-sm mt-3">
<div class="card-header"><i class="bi bi-reply me-2"></i>Add a Follow-up</div>
<div class="card-body">
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<textarea name="body" class="form-control" rows="4" required
placeholder="Type your follow-up message…"></textarea>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-send me-1"></i>Send Reply
</button>
</form>
</div>
</div>
{% else %}
<div class="alert alert-secondary mt-3">
<i class="bi bi-lock me-2"></i>This request is closed.
<a href="{{ url_for('support.chat') }}">Start a new chat</a> if you need further help.
</div>
{% endif %}
<div class="text-center mt-3">
<a href="{{ url_for('support.chat') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-chat-dots me-1"></i>Ask another question
</a>
</div>
</div>
</div>
{% endblock %}
+64
View File
@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% block title %}My Support Requests{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-inbox me-2 text-primary"></i>My Support Requests</h4>
<small class="text-muted">{{ tickets | length }} request{{ 's' if tickets | length != 1 }}</small>
</div>
<a href="{{ url_for('support.chat') }}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-chat-dots me-1"></i>New Chat / Ask a Question
</a>
</div>
{% if tickets %}
<div class="card shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width:60px">#</th>
<th>Subject</th>
<th>Facility</th>
<th>Status</th>
<th>Submitted</th>
<th></th>
</tr>
</thead>
<tbody>
{% set status_class = {'open': 'warning', 'answered': 'success', 'closed': 'secondary'} %}
{% set status_label = {'open': 'Awaiting reply', 'answered': 'Answered', 'closed': 'Closed'} %}
{% for ticket in tickets %}
<tr>
<td class="text-muted small">{{ ticket.id }}</td>
<td>{{ ticket.subject }}</td>
<td>{{ ticket.facility.name if ticket.facility else '—' }}</td>
<td>
<span class="badge bg-{{ status_class.get(ticket.status, 'secondary') }}">
{{ status_label.get(ticket.status, ticket.status | capitalize) }}
</span>
</td>
<td class="text-muted small text-nowrap">
{{ ticket.created_at.strftime('%b %d, %Y') }}
</td>
<td>
<a href="{{ url_for('support.my_ticket_detail', ticket_id=ticket.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye me-1"></i>View
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-inbox fs-1 d-block mb-2"></i>
You haven't submitted any support requests yet.<br>
<a href="{{ url_for('support.chat') }}" class="mt-2 d-inline-block">Start a support chat</a>
</div>
{% endif %}
{% endblock %}
+246
View File
@@ -0,0 +1,246 @@
{% extends "base.html" %}
{% block title %}Edit Template — {{ template.name }}{% 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; }
.page-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 1.5rem; gap: 1rem;
}
.page-title { font-size: 1.3rem; font-weight: 700; color: #0f172a; margin: 0; }
/* two-column layout */
.edit-layout {
display: grid;
grid-template-columns: 340px 1fr;
gap: 1.5rem;
align-items: start;
}
/* left: settings card */
.settings-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
position: sticky;
top: 1rem;
}
.settings-card-header {
background: #1a1d23;
color: #fff;
padding: .85rem 1.25rem;
font-weight: 600;
font-size: .9rem;
display: flex;
align-items: center;
gap: .5rem;
}
.settings-card-body { padding: 1.25rem; }
/* right: form canvas preview */
.canvas-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
overflow: hidden;
}
.canvas-card-header {
display: flex; align-items: center; justify-content: space-between;
padding: .85rem 1.25rem;
border-bottom: 1px solid #e2e8f0;
background: #fafbfc;
}
.canvas-card-header h6 { margin: 0; font-weight: 700; font-size: .88rem; color: #0f172a; }
.canvas-card-body { padding: 1.25rem; overflow-x: auto; }
/* grid minimap — same constants as editor */
.form-grid {
display: grid;
grid-template-columns: repeat(12, 72px);
grid-auto-rows: 52px;
gap: 8px;
width: calc(12 * 72px + 11 * 8px);
}
.fg-cell {
overflow: hidden; display: flex; flex-direction: column;
background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 8px; padding: .4rem .6rem;
cursor: default;
transition: border-color .15s, box-shadow .15s;
}
.fg-cell:hover { border-color: #93c5fd; box-shadow: 0 2px 8px rgba(37,99,235,.1); }
.fg-cell .fl {
font-size: .72rem; font-weight: 600; color: #374151;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: .15rem;
}
.fg-cell .ft {
font-size: .6rem; font-family: monospace;
color: #2563eb; background: #eff6ff;
padding: .05rem .28rem; border-radius: 10px;
display: inline-block; width: fit-content;
text-transform: uppercase; letter-spacing: .04em;
}
.req-dot { color: #dc2626; }
.fg-section {
border-top: 2px solid #e2e8f0; padding-top: .35rem;
display: flex; align-items: center; height: 100%;
}
.fg-section span { font-weight: 700; font-size: .82rem; color: #374151; }
/* empty / open-editor CTA */
.editor-cta {
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 3rem 2rem; text-align: center; color: #94a3b8;
gap: .75rem;
}
.editor-cta i { font-size: 2.2rem; opacity: .3; }
.editor-cta p { margin: 0; font-size: .85rem; }
/* field count badge */
.field-count {
font-size: .72rem; font-weight: 600;
background: #eff6ff; color: #2563eb;
padding: .2rem .6rem; border-radius: 20px;
}
/* open editor banner */
.editor-banner {
margin: 1rem 1.25rem 0;
padding: .75rem 1rem;
background: #eff6ff; border: 1px solid #bfdbfe;
border-radius: 8px;
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
font-size: .83rem; color: #1e40af;
}
.editor-banner .left { display: flex; align-items: center; gap: .5rem; }
</style>
{% endblock %}
{% block content %}
<div class="page-header">
<h2 class="page-title"><i class="bi bi-file-earmark-text text-primary"></i> Edit Template</h2>
<a href="{{ url_for('templates.view_template', template_id=template.id) }}"
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back
</a>
</div>
<div class="edit-layout">
<!-- LEFT: template metadata settings -->
<div class="settings-card">
<div class="settings-card-header">
<i class="bi bi-sliders"></i> Template Settings
</div>
<div class="settings-card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label fw-semibold small") }}
{{ form.name(class="form-control form-control-sm") }}
{% if form.name.errors %}
<div class="text-danger small mt-1">{{ form.name.errors[0] }}</div>
{% endif %}
</div>
<div class="mb-3">
{{ form.description.label(class="form-label fw-semibold small") }}
{{ form.description(class="form-control form-control-sm", rows=3) }}
</div>
<div class="mb-4">
{{ form.frequency.label(class="form-label fw-semibold small") }}
{{ form.frequency(class="form-select form-select-sm") }}
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-save"></i> Save Settings
</button>
</div>
</form>
</div>
<!-- open form editor banner -->
<div class="editor-banner">
<div class="left">
<i class="bi bi-grid-3x3-gap"></i>
<span>Edit fields &amp; layout in the Form Editor</span>
</div>
<a href="{{ url_for('templates.form_editor', template_id=template.id) }}"
class="btn btn-primary btn-sm flex-shrink-0">
<i class="bi bi-pencil-square"></i> Open Editor
</a>
</div>
<div style="height:.85rem;"></div>
</div>
<!-- RIGHT: form canvas preview -->
<div class="canvas-card">
<div class="canvas-card-header">
<h6><i class="bi bi-grid-3x3-gap me-1 text-primary"></i> Form Layout</h6>
<div class="d-flex align-items: center; gap: .75rem;">
<span class="field-count">{{ form_fields|length }} field{{ 's' if form_fields|length != 1 else '' }}</span>
<a href="{{ url_for('templates.form_preview', template_id=template.id) }}"
target="_blank" class="btn btn-outline-secondary btn-sm ms-2">
<i class="bi bi-eye"></i> Preview
</a>
<a href="{{ url_for('templates.form_editor', template_id=template.id) }}"
class="btn btn-primary btn-sm">
<i class="bi bi-pencil-square"></i> Edit Form
</a>
</div>
</div>
<div class="canvas-card-body">
{% if form_fields %}
<div class="form-grid">
{% for f in form_fields %}
<div class="fg-cell"
style="grid-column: {{ f.col }} / span {{ f.colSpan }};
grid-row: {{ f.row }} / span {{ f.rowSpan }};"
title="{{ f.label }} ({{ f.type.replace('_', ' ') }})">
{% if f.type == 'section' %}
<div class="fg-section"><span>{{ f.label }}</span></div>
{% elif f.type == 'table' %}
<div class="fl"><i class="bi bi-table" style="color:#2563eb;margin-right:.2rem;"></i>{{ f.label }}</div>
<span class="ft">table · {{ f.col_headers|length if f.col_headers else 3 }} cols × {{ f.table_rows or 3 }} rows</span>
{% elif f.type == 'label' %}
<div class="fl" style="font-size:.7rem;color:#374151;overflow:hidden;line-height:1.3;white-space:nowrap;text-overflow:ellipsis;">{{ f.text_content or 'Label text' }}</div>
<span class="ft">label</span>
{% elif f.type.startswith('button_') %}
<div class="fl"><i class="bi bi-{% if f.type == 'button_submit' %}send-fill{% elif f.type == 'button_print' %}printer-fill{% else %}envelope-fill{% endif %}" style="color:#2563eb;margin-right:.2rem;"></i>{{ f.btn_label or f.type.replace('button_','') | title }}</div>
<span class="ft">{{ f.type.replace('_',' ') }}</span>
{% else %}
<div class="fl">
{{ f.label }}{% if f.required %}<span class="req-dot"> *</span>{% endif %}
</div>
<span class="ft">{{ f.type.replace('_', ' ') }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="editor-cta">
<i class="bi bi-layout-text-sidebar-reverse"></i>
<p>No fields yet. Open the Form Editor to start building your inspection form.</p>
<a href="{{ url_for('templates.form_editor', template_id=template.id) }}"
class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Open Form Editor
</a>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+44
View File
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6 offset-md-3">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">{{ title }}</h4>
</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control") }}
</div>
<div class="mb-3">
{{ form.description.label(class="form-label") }}
{{ form.description(class="form-control", rows=3) }}
</div>
<div class="mb-4">
{{ form.frequency.label(class="form-label") }}
{{ form.frequency(class="form-select") }}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Save Template
</button>
<a href="{{ url_for('templates.index') }}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
+406
View File
@@ -0,0 +1,406 @@
{% extends "base.html" %}
{% block title %}Preview — {{ template.name }}{% 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; }
.preview-wrap { max-width: 960px; margin: 2rem auto; padding: 0 1rem 3rem; }
.preview-header {
background: #1a1d23; color: #fff;
padding: 1.25rem 1.75rem;
border-radius: 12px 12px 0 0;
display: flex; align-items: center; justify-content: space-between;
}
.preview-header h4 { margin: 0; font-weight: 600; font-size: 1.05rem; }
.preview-header .sub { font-size: .78rem; color: #94a3b8; margin-top: .2rem; }
.freq-badge {
font-size: .72rem;
background: rgba(255,255,255,.15);
padding: .28rem .65rem; border-radius: 20px; font-weight: 500;
}
.preview-notice {
background: #eff6ff; border: 1px solid #bfdbfe;
border-radius: 8px; padding: .65rem 1rem;
font-size: .82rem; color: #1d4ed8;
margin-bottom: 1.5rem;
display: flex; align-items: center; gap: .45rem;
}
.preview-body {
background: #fff;
border: 1px solid #e2e8f0; border-top: none;
border-radius: 0 0 12px 12px;
padding: 1.75rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* 12-col grid — same columns as editor, rows shrink to content in preview */
.form-grid {
display: grid;
grid-template-columns: repeat(12, 72px);
grid-auto-rows: auto; /* no fixed row height — rows are only as tall as their content */
gap: 4px 8px; /* 4px row-gap (tight), 8px col-gap (matches editor) */
width: max-content;
}
/* —— iPad / touch-device 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));
width: 100%;
}
.preview-body { padding: 1rem; }
}
/* ── Cell ── */
.fg-cell {
overflow: hidden;
display: flex; flex-direction: column;
padding: .22rem .55rem; /* top/bottom halved from .45rem */
}
/* Label shown above the input — same style as editor card header label */
.fg-cell .field-lbl {
font-size: .74rem; font-weight: 500;
color: #64748b; margin-bottom: .2rem; display: block;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
flex-shrink: 0;
}
.required-mark { color: #dc2626; }
.help-text { font-size: .72rem; color: #64748b; margin-top: .18rem; flex-shrink: 0; }
/* Inputs/selects: compact to match editor body sizing */
.fg-cell .form-control,
.fg-cell .form-select {
font-size: .76rem;
padding: .2rem .4rem;
border-color: #e2e8f0;
background: #f8fafc;
/* NO flex/height stretch — inputs must be their natural intrinsic height,
exactly as in the editor where no flex is applied to .fcard-body inputs */
}
.fg-cell textarea.form-control { resize: vertical; flex: 1; min-height: 4; }
.fg-cell .upload-zone,
.fg-cell .signature-box { flex: 1; min-height: 0; }
.fg-cell .form-check-label { font-size: .76rem; color: #64748b; }
.fg-cell .form-check-input { margin-top: .18rem; }
.fg-cell .form-check { margin-bottom: .1rem; }
.upload-zone {
border: 2px dashed #cbd5e1; border-radius: 6px;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
color: #64748b; background: #f8fafc;
cursor: pointer; font-size: .74rem; gap: .2rem;
transition: border-color .18s, background .18s;
}
.upload-zone:hover { border-color: #2563eb; background: #eff6ff; color: #2563eb; }
.upload-zone i { font-size: 1.1rem; }
.signature-box {
border-bottom: 2px solid #e2e8f0;
flex: 1; min-height: 0;
display: flex; align-items: flex-end;
padding: .75rem .4rem .15rem; /* same as editor .mock-sig */
color: #94a3b8; font-size: .72rem; font-style: italic;
}
.rating-stars { display: flex; gap: .25rem; align-items: center; padding-top: .08rem; }
.rating-stars button {
border: none; background: none;
font-size: 1.45rem; color: #cbd5e1;
cursor: pointer; padding: 0; line-height: 1;
transition: color .12s;
}
.rating-stars button:hover,
.rating-stars button.on { color: #f59e0b; }
.section-divider {
border-top: 2px solid #e2e8f0; padding-top: .4rem;
height: 100%; display: flex; align-items: center;
padding: 0; /* cell already has .45rem .55rem padding */
}
.section-divider h5 { font-weight: 700; color: #374151; margin: 0; font-size: .95rem; }
.preview-footer {
display: flex; align-items: center; justify-content: space-between;
margin-top: 1.5rem; padding-top: 1.25rem;
border-top: 1px solid #e2e8f0;
}
.btn-back {
display: inline-flex; align-items: center; gap: .4rem;
padding: .42rem .95rem; border-radius: 7px;
background: #1a1d23; color: #fff;
font-size: .83rem; font-weight: 600;
text-decoration: none; transition: background .18s;
}
.btn-back:hover { background: #374151; color: #fff; }
.empty-form {
text-align: center; padding: 3rem 1rem; color: #94a3b8;
}
.empty-form i { font-size: 2.25rem; display: block; margin-bottom: .65rem; opacity: .38; }
/* table field */
.tbl-field {
width: 100%; border-collapse: collapse; font-size: .8rem;
}
.tbl-field th {
background: #f1f5f9; font-weight: 600; color: #374151;
padding: .3rem .5rem; border: 1px solid #e2e8f0;
white-space: nowrap; font-size: .78rem;
}
.tbl-field td {
border: 1px solid #e2e8f0; padding: .15rem .25rem;
}
.tbl-input {
width: 100%; border: none; outline: none;
font-size: .8rem; padding: .1rem .2rem;
background: transparent; color: #0f172a;
}
.tbl-input:focus { background: #eff6ff; }
</style>
{% endblock %}
{% block content %}
<div class="preview-wrap">
<div class="preview-header">
<div>
<h4><i class="bi bi-file-earmark-check"></i> {{ template.name }}</h4>
<div class="sub">{{ template.description or 'Inspection form preview' }}</div>
</div>
<span class="freq-badge">{{ template.frequency|title }}</span>
</div>
<div class="preview-body">
<div class="preview-notice">
<i class="bi bi-eye"></i>
<span>This is a <strong>read-only preview</strong>. Layout matches the grid editor exactly.</span>
</div>
{% if form_fields %}
<div class="form-grid">
{% for field in form_fields %}
<div class="fg-cell"
style="grid-column: {{ field.col }} / span {{ field.colSpan }};
grid-row: {{ field.row }} / span {{ field.rowSpan }};">
{% if field.type == 'section' %}
<div class="section-divider"><strong>{{ field.label }}</strong></div>
{% elif field.type == 'text' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="text" class="form-control" placeholder="{{ field.placeholder or '' }}">
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'textarea' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<textarea class="form-control" style="flex:1;resize:vertical;" placeholder="{{ field.placeholder or '' }}"></textarea>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'number' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="number" class="form-control" placeholder="{{ field.placeholder or '' }}">
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'date' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="date" class="form-control">
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'email' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<input type="email" class="form-control" placeholder="{{ field.placeholder or 'name@example.com' }}">
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'checkbox' %}
<div class="form-check">
<input class="form-check-input" type="checkbox" id="f_{{ loop.index }}">
<label class="form-check-label" for="f_{{ loop.index }}">
{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}
</label>
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'checkbox_group' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{% for opt in field.options %}
<div class="form-check">
<input class="form-check-input" type="checkbox" id="cg_{{ loop.index }}_{{ loop.index0 }}">
<label class="form-check-label" for="cg_{{ loop.index }}_{{ loop.index0 }}">{{ opt }}</label>
</div>
{% endfor %}
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'radio' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
{% for opt in field.options %}
<div class="form-check">
<input class="form-check-input" type="radio" name="rg_{{ loop.index }}" id="rg_{{ loop.index }}_{{ loop.index0 }}">
<label class="form-check-label" for="rg_{{ loop.index }}_{{ loop.index0 }}">{{ opt }}</label>
</div>
{% endfor %}
{% 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' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<select class="form-select">
<option value="">-- Select --</option>
{% for opt in field.options %}<option>{{ opt }}</option>{% endfor %}
</select>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'image' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div class="upload-zone">
<i class="bi bi-cloud-upload"></i>
<span>Upload photo</span>
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'signature' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div class="signature-box">Sign here…</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'rating' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div class="rating-stars" data-rating="0">
{% for i in range(1, 6) %}
<button type="button" data-val="{{ i }}" onclick="setRating(this)"></button>
{% endfor %}
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% elif field.type == 'label' %}
{% set fs_map = {'small':'0.78rem','normal':'0.9rem','large':'1.05rem','x-large':'1.25rem'} %}
<div style="font-size:{{ fs_map.get(field.font_size or 'normal', '0.9rem') }};
font-weight:{{ field.font_weight or 'normal' }};
color:#374151;line-height:1.5;white-space:pre-wrap;overflow:hidden;height:100%;">{{ field.text_content or 'Label text' }}</div>
{% elif field.type == 'button_submit' %}
<button type="button" class="btn btn-primary w-100" onclick="handleSubmit(this)">
<i class="bi bi-send-fill"></i> {{ field.btn_label or 'Submit Form' }}
</button>
{% elif field.type == 'button_print' %}
<button type="button" class="btn btn-outline-secondary w-100" onclick="window.print()">
<i class="bi bi-printer-fill"></i> {{ field.btn_label or 'Print Form' }}
</button>
{% elif field.type == 'button_email' %}
<button type="button" class="btn btn-outline-primary w-100" onclick="handleEmail(this)">
<i class="bi bi-envelope-fill"></i> {{ field.btn_label or 'Email Form' }}
</button>
{% elif field.type == 'table' %}
<label class="field-lbl">{{ field.label }}{% if field.required %}<span class="required-mark"> *</span>{% endif %}</label>
<div style="overflow:auto;flex:1;min-height:0;">
<table class="tbl-field">
<thead>
<tr>
{% for hdr in (field.col_headers or ['Column 1','Column 2','Column 3']) %}
<th>{{ hdr }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for r in range(field.table_rows or 3) %}
<tr>
{% for hdr in (field.col_headers or ['Column 1','Column 2','Column 3']) %}
<td><input type="text" class="tbl-input"></td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if field.help_text %}<div class="help-text">{{ field.help_text }}</div>{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
<div class="preview-footer">
<button onclick="window.close()" class="btn-back">
<i class="bi bi-x-lg"></i> Close Preview
</button>
<button class="btn btn-primary" disabled>
<i class="bi bi-send"></i> Submit Inspection
</button>
</div>
{% else %}
<div class="empty-form">
<i class="bi bi-layout-text-sidebar-reverse"></i>
<strong>No fields yet</strong>
<p class="mb-3" style="font-size:.84rem;">
This template has no form fields. Open the editor to add some.
</p>
<button onclick="window.close()" class="btn-back">
<i class="bi bi-x-lg"></i> Close Preview
</button>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function setRating(btn) {
const group = btn.closest('.rating-stars');
const val = parseInt(btn.dataset.val);
group.dataset.rating = val;
group.querySelectorAll('button').forEach(b => {
b.classList.toggle('on', parseInt(b.dataset.val) <= val);
});
}
function handleSubmit(btn) {
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-check-circle-fill"></i> Submitted!';
btn.classList.replace('btn-primary', 'btn-success');
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = btn.dataset.orig || btn.innerHTML;
btn.classList.replace('btn-success', 'btn-primary');
}, 2500);
}
function handleEmail(btn) {
// In a real inspection this would POST and email; here it shows a toast.
const toast = document.getElementById('emailToast');
toast.style.display = 'flex';
setTimeout(() => { toast.style.display = 'none'; }, 3000);
}
</script>
<!-- email toast -->
<div id="emailToast" style="display:none;position:fixed;bottom:1.5rem;right:1.5rem;
background:#16a34a;color:#fff;padding:.65rem 1.1rem;border-radius:8px;
align-items:center;gap:.5rem;font-size:.85rem;font-weight:600;
box-shadow:0 4px 16px rgba(0,0,0,.15);z-index:9999;">
<i class="bi bi-check-circle-fill"></i> Form emailed successfully
</div>
{% endblock %}
+61
View File
@@ -0,0 +1,61 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">{{ title }}</h4>
<small>Template: {{ template.name }}</small>
</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-6 mb-3">
{{ form.category.label(class="form-label") }}
{{ form.category(class="form-control", placeholder="e.g., Restrooms, Floors, etc.") }}
</div>
<div class="col-md-6 mb-3">
{{ form.scoring_type.label(class="form-label") }}
{{ form.scoring_type(class="form-select") }}
</div>
</div>
<div class="mb-3">
{{ form.item_description.label(class="form-label") }}
{{ form.item_description(class="form-control", rows=3) }}
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.weight.label(class="form-label") }}
{{ form.weight(class="form-control") }}
<small class="text-muted">1.0 = standard weight</small>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">&nbsp;</label>
<div class="form-check">
{{ form.requires_photo(class="form-check-input") }}
{{ form.requires_photo.label(class="form-check-label") }}
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Save Item
</button>
<a href="{{ url_for('templates.edit_template', template_id=template.id) }}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+269
View File
@@ -0,0 +1,269 @@
{% extends "base.html" %}
{% block title %}Inspection Templates{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-md-6">
<h2><i class="bi bi-file-earmark-text"></i> Inspection Templates</h2>
</div>
<div class="col-md-6 text-end">
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('templates.create_template') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Create Template
</a>
{% endif %}
</div>
</div>
<div class="row">
{% for template in templates %}
<div class="col-md-6 col-lg-4 mb-4">
<div class="card shadow-sm h-100 {% if not template.active %}opacity-75 border-secondary{% endif %}">
<div class="card-body">
<h5 class="card-title d-flex align-items-start gap-2">
<a href="{{ url_for('templates.view_template', template_id=template.id) }}" class="text-decoration-none flex-grow-1">
{{ template.name }}
</a>
{% if template.active %}
<span class="badge bg-success flex-shrink-0">Active</span>
{% else %}
<span class="badge bg-secondary flex-shrink-0">Inactive</span>
{% endif %}
</h5>
<p class="card-text text-muted small">{{ template.description or 'No description' }}</p>
<div class="mt-3">
<span class="badge bg-info">{{ template.frequency|title }}</span>
<small class="text-muted ms-2">
<i class="bi bi-check2-square"></i> {{ template.checklist_items.count() }} items
</small>
</div>
</div>
<div class="card-footer bg-transparent d-flex gap-2 align-items-center flex-wrap">
<a href="{{ url_for('templates.view_template', template_id=template.id) }}"
class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View
</a>
{% if current_user.role in ['admin', 'director'] %}
<!-- Rename -->
<button type="button"
class="btn btn-sm btn-outline-secondary"
data-bs-toggle="modal"
data-bs-target="#renameModal"
data-template-id="{{ template.id }}"
data-template-name="{{ template.name }}"
data-template-description="{{ template.description or '' }}"
data-template-frequency="{{ template.frequency or 'daily' }}"
title="Edit template details">
<i class="bi bi-pencil"></i> Edit
</button>
<!-- Duplicate -->
<form method="POST"
action="{{ url_for('templates.duplicate_template', template_id=template.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-secondary"
title="Duplicate template">
<i class="bi bi-copy"></i> Duplicate
</button>
</form>
<!-- Toggle active/inactive -->
<form method="POST"
action="{{ url_for('templates.toggle_active', template_id=template.id) }}"
class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% if template.active %}
<button type="submit" class="btn btn-sm btn-outline-warning"
title="Deactivate template">
<i class="bi bi-pause-circle"></i> Deactivate
</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-success"
title="Activate template">
<i class="bi bi-play-circle"></i> Activate
</button>
{% endif %}
</form>
<!-- Delete -->
<button type="button"
class="btn btn-sm btn-outline-danger ms-auto"
data-bs-toggle="modal"
data-bs-target="#deleteModal"
data-template-id="{{ template.id }}"
data-template-name="{{ template.name }}"
data-inspection-count="{{ template.inspections.count() }}"
title="Delete template">
<i class="bi bi-trash"></i> Delete
</button>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="col-12">
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> No templates created yet.
</div>
</div>
{% endfor %}
</div>
{% if current_user.role in ['admin', 'director'] %}
<!-- Edit Template Modal -->
<div class="modal fade" id="renameModal" tabindex="-1" aria-labelledby="renameModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="renameModalLabel">
<i class="bi bi-pencil"></i> Edit Template
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form id="renameTemplateForm" method="POST" action="">
<div class="modal-body">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="renameInput" class="form-label fw-semibold">Name <span class="text-danger">*</span></label>
<input type="text" id="renameInput" name="name"
class="form-control" maxlength="255"
placeholder="Enter template name" required>
</div>
<div class="mb-3">
<label for="editDescription" class="form-label fw-semibold">Description</label>
<textarea id="editDescription" name="description"
class="form-control" rows="3"
placeholder="Optional description"></textarea>
</div>
<div class="mb-1">
<label for="editFrequency" class="form-label fw-semibold">Frequency</label>
<select id="editFrequency" name="frequency" class="form-select">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-lg"></i> Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteModalLabel">
<i class="bi bi-exclamation-triangle-fill"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="mb-1">You are about to permanently delete:</p>
<p class="fw-bold fs-5 mb-3" id="modalTemplateName"></p>
<!-- Shown when template has inspections -->
<div id="modalWarningBlock" class="alert alert-danger d-none mb-0">
<i class="bi bi-x-circle-fill"></i>
<strong>Cannot delete this template.</strong>
It has existing inspection records linked to it.
Remove all associated inspections first.
</div>
<!-- Shown when safe to delete -->
<div id="modalConfirmBlock">
<p class="text-muted mb-0">
This action is <strong>irreversible</strong>.
All checklist items and form schema for this template will be permanently removed.
</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle"></i> Cancel
</button>
<form id="deleteTemplateForm" method="POST" action="" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" id="confirmDeleteBtn" class="btn btn-danger">
<i class="bi bi-trash-fill"></i> Delete Permanently
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block extra_js %}
{% if current_user.role in ['admin', 'director'] %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Edit template modal
const renameModal = document.getElementById('renameModal');
renameModal.addEventListener('show.bs.modal', function (event) {
const btn = event.relatedTarget;
const templateId = btn.getAttribute('data-template-id');
const templateName = btn.getAttribute('data-template-name');
const templateDesc = btn.getAttribute('data-template-description');
const templateFreq = btn.getAttribute('data-template-frequency');
document.getElementById('renameInput').value = templateName;
document.getElementById('editDescription').value = templateDesc;
document.getElementById('editFrequency').value = templateFreq;
document.getElementById('renameTemplateForm').action =
'/templates/' + templateId + '/rename';
renameModal.addEventListener('shown.bs.modal', function focusInput() {
const input = document.getElementById('renameInput');
input.select();
renameModal.removeEventListener('shown.bs.modal', focusInput);
});
});
const deleteModal = document.getElementById('deleteModal');
deleteModal.addEventListener('show.bs.modal', function (event) {
const btn = event.relatedTarget;
const templateId = btn.getAttribute('data-template-id');
const templateName = btn.getAttribute('data-template-name');
const inspectionCount = parseInt(btn.getAttribute('data-inspection-count'), 10);
document.getElementById('modalTemplateName').textContent = templateName;
document.getElementById('deleteTemplateForm').action =
'/templates/' + templateId + '/delete';
const warningBlock = document.getElementById('modalWarningBlock');
const confirmBlock = document.getElementById('modalConfirmBlock');
const confirmBtn = document.getElementById('confirmDeleteBtn');
if (inspectionCount > 0) {
warningBlock.classList.remove('d-none');
confirmBlock.classList.add('d-none');
confirmBtn.disabled = true;
} else {
warningBlock.classList.add('d-none');
confirmBlock.classList.remove('d-none');
confirmBtn.disabled = false;
}
});
});
</script>
{% endif %}
{% endblock %}
+179
View File
@@ -0,0 +1,179 @@
{% extends "base.html" %}
{% block title %}{{ template.name }}{% 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; }
.tpl-header {
display: flex; align-items: flex-start;
justify-content: space-between; gap: 1rem;
margin-bottom: 1.5rem;
}
.tpl-title { font-size: 1.45rem; font-weight: 700; color: #0f172a; margin: 0; }
.tpl-desc { color: #64748b; font-size: .9rem; margin-top: .25rem; }
/* meta card */
.meta-card {
background: #fff; border: 1px solid #e2e8f0;
border-radius: 10px; padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
display: flex; gap: 2rem; flex-wrap: wrap;
align-items: center;
}
.meta-item { display: flex; align-items: center; gap: .45rem; font-size: .85rem; color: #374151; }
.meta-item i { color: #2563eb; font-size: 1rem; }
.meta-item strong { color: #0f172a; }
/* form grid preview */
.grid-preview-wrap {
background: #fff; border: 1px solid #e2e8f0;
border-radius: 10px; padding: 1.5rem;
overflow-x: auto;
}
.grid-preview-title {
font-size: .75rem; font-weight: 700; letter-spacing: .08em;
text-transform: uppercase; color: #64748b;
margin-bottom: 1rem;
}
/* mirrors editor constants */
.form-grid {
display: grid;
grid-template-columns: repeat(12, 72px);
grid-auto-rows: 52px;
gap: 8px;
width: calc(12 * 72px + 11 * 8px);
}
.fg-cell {
overflow: hidden; display: flex; flex-direction: column;
background: #f8fafc; border: 1px solid #e2e8f0;
border-radius: 8px; padding: .4rem .6rem;
}
.fg-cell .fl {
font-size: .72rem; font-weight: 600; color: #374151;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
margin-bottom: .15rem;
}
.fg-cell .ft {
font-size: .62rem; font-family: monospace;
color: #2563eb; background: #eff6ff;
padding: .05rem .3rem; border-radius: 10px;
display: inline-block; width: fit-content;
text-transform: uppercase; letter-spacing: .04em;
}
.req-dot { color: #dc2626; }
.fg-section {
border-top: 2px solid #e2e8f0; padding-top: .35rem;
display: flex; align-items: center; height: 100%;
}
.fg-section span { font-weight: 700; font-size: .82rem; color: #374151; }
/* empty state */
.empty-state {
text-align: center; padding: 2.5rem 1rem; color: #94a3b8;
}
.empty-state i { font-size: 2rem; display: block; margin-bottom: .6rem; opacity: .35; }
.empty-state p { font-size: .85rem; margin: 0; }
</style>
{% endblock %}
{% block content %}
<div class="tpl-header">
<div>
<h2 class="tpl-title"><i class="bi bi-file-earmark-text text-primary"></i> {{ template.name }}</h2>
{% if template.description %}
<p class="tpl-desc">{{ template.description }}</p>
{% endif %}
</div>
<div class="d-flex gap-2 flex-shrink-0 mt-1">
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('templates.form_editor', template_id=template.id) }}" class="btn btn-primary btn-sm">
<i class="bi bi-pencil-square"></i> Edit Form
</a>
<form method="POST" action="{{ url_for('templates.delete_template', template_id=template.id) }}"
class="d-inline" onsubmit="return confirm('Delete this template? This cannot be undone.');">
<button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash"></i> Delete
</button>
</form>
{% endif %}
<a href="{{ url_for('templates.form_preview', template_id=template.id) }}"
target="_blank" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-eye"></i> Preview
</a>
<a href="{{ url_for('templates.index') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back
</a>
</div>
</div>
<!-- Meta strip -->
<div class="meta-card">
<div class="meta-item">
<i class="bi bi-arrow-repeat"></i>
<span>Frequency: <strong>{{ template.frequency|title }}</strong></span>
</div>
<div class="meta-item">
<i class="bi bi-layout-text-sidebar-reverse"></i>
<span>Fields: <strong>{{ form_fields|length }}</strong></span>
</div>
<div class="meta-item">
<i class="bi bi-clipboard-check"></i>
<span>Inspections: <strong>{{ template.inspections.count() }}</strong></span>
</div>
{% if template.created_at %}
<div class="meta-item">
<i class="bi bi-calendar3"></i>
<span>Created: <strong>{{ template.created_at.strftime('%Y-%m-%d') }}</strong></span>
</div>
{% endif %}
</div>
<!-- Form layout preview -->
<div class="grid-preview-wrap">
<div class="grid-preview-title"><i class="bi bi-grid-3x3-gap"></i> Form Layout</div>
{% if form_fields %}
<div class="form-grid">
{% for f in form_fields %}
<div class="fg-cell"
style="grid-column: {{ f.col }} / span {{ f.colSpan }};
grid-row: {{ f.row }} / span {{ f.rowSpan }};">
{% if f.type == 'section' %}
<div class="fg-section"><span>{{ f.label }}</span></div>
{% elif f.type == 'table' %}
<div class="fl"><i class="bi bi-table" style="color:#2563eb;margin-right:.2rem;"></i>{{ f.label }}</div>
<span class="ft">table · {{ f.col_headers|length if f.col_headers else 3 }} cols × {{ f.table_rows or 3 }} rows</span>
{% elif f.type == 'label' %}
<div class="fl" style="font-size:.7rem;color:#374151;overflow:hidden;line-height:1.3;white-space:nowrap;text-overflow:ellipsis;">{{ f.text_content or 'Label text' }}</div>
<span class="ft">label</span>
{% elif f.type.startswith('button_') %}
<div class="fl"><i class="bi bi-{% if f.type == 'button_submit' %}send-fill{% elif f.type == 'button_print' %}printer-fill{% else %}envelope-fill{% endif %}" style="color:#2563eb;margin-right:.2rem;"></i>{{ f.btn_label or f.type.replace('button_','') | title }}</div>
<span class="ft">{{ f.type.replace('_',' ') }}</span>
{% else %}
<div class="fl">
{{ f.label }}{% if f.required %}<span class="req-dot"> *</span>{% endif %}
</div>
<span class="ft">{{ f.type.replace('_', ' ') }}</span>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<i class="bi bi-layout-text-sidebar-reverse"></i>
<p>No fields defined yet.</p>
{% if current_user.role in ['admin', 'director'] %}
<a href="{{ url_for('templates.form_editor', template_id=template.id) }}"
class="btn btn-primary btn-sm mt-3">
<i class="bi bi-plus-circle"></i> Open Form Editor
</a>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}