First commit
This commit is contained in:
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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) }}">
|
||||
« 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 »
|
||||
</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 %}
|
||||
@@ -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 & 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 — 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">
|
||||
© <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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 & 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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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,'&').replace(/</g,'<')
|
||||
.replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
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>
|
||||
@@ -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 %}
|
||||
@@ -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 & 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 %}
|
||||
@@ -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') }}
|
||||
|
||||
<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 %}
|
||||
@@ -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 & 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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;">
|
||||
3–100 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 & 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>
|
||||
@@ -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 & 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 >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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 '—' }}%
|
||||
→
|
||||
{{ 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 %}
|
||||
· 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()">×</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 & 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>>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 %}
|
||||
@@ -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 & 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 %}
|
||||
@@ -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">
|
||||
© 2025 Janitorial QC System
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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) }}">
|
||||
« 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 »
|
||||
</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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 & 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 & 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 & 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>
|
||||
@@ -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 %}
|
||||
@@ -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') }}
|
||||
· 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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', '1–4 weeks'] %}
|
||||
{% set is_warning = label == '3–7 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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;">📋</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;">
|
||||
⚠ 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;">
|
||||
🏢 {{ 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>
|
||||
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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. — 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">© {{ 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 %}
|
||||
· <i class="bi bi-building me-1"></i>{{ ticket.facility.name }}
|
||||
{% 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>
|
||||
|
||||
{# 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>
|
||||
· {{ 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 %}
|
||||
@@ -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) }}">
|
||||
« 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 »
|
||||
</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 %}
|
||||
@@ -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 }} ·
|
||||
{% 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>
|
||||
· {{ 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 & 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 %}
|
||||
@@ -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
@@ -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 %}
|
||||
@@ -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"> </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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user