First commit

This commit is contained in:
2026-06-26 09:04:34 -04:00
commit 77678ed724
166 changed files with 34842 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Forgot Password — Janitorial QC</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background: #eef0f4; font-family: 'Segoe UI', Arial, sans-serif; }
.setup-card {
max-width: 460px; margin: 80px auto;
border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
}
.setup-header {
background: #1a1d23; color: #fff;
border-radius: 12px 12px 0 0;
padding: 1.5rem 1.75rem 1.25rem;
}
.setup-header h4 { margin: 0; font-weight: 600; }
.setup-header p { color: #94a3b8; font-size: .85rem; margin: .35rem 0 0; }
.setup-body { background: #fff; border-radius: 0 0 12px 12px; padding: 1.75rem; }
</style>
</head>
<body>
<div class="setup-card">
<div class="setup-header">
<h4><i class="bi bi-envelope-open me-2"></i>Forgot Your Password?</h4>
<p>Enter the email address on your account and we'll send you a reset link.</p>
</div>
<div class="setup-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ 'danger' if cat == 'danger' else 'warning' if cat == 'warning' else 'info' if cat == 'info' else 'success' }}
alert-dismissible fade show py-2 mb-3" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="email" class="form-label fw-semibold">Email Address</label>
<input type="email"
id="email"
name="email"
class="form-control {{ 'is-invalid' if form.email.errors else '' }}"
autocomplete="email"
autofocus
placeholder="you@example.com">
{% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-send me-1"></i>Send Reset Link
</button>
</form>
<div class="text-center mt-3">
<a href="{{ url_for('auth.login') }}" class="text-muted small">
<i class="bi bi-arrow-left me-1"></i>Back to Login
</a>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% block title %}Contract Assignments — {{ user.display_name }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2><i class="bi bi-briefcase text-primary me-2"></i>Contract Assignments</h2>
<p class="text-muted mb-0">
Inspector <strong>{{ user.display_name }}</strong> can only access facilities
belonging to the contracts ticked below.
</p>
</div>
<a href="{{ url_for('auth.list_users') }}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Users
</a>
</div>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-semibold">Active Contracts</span>
<div class="d-flex align-items-center gap-2">
<span class="badge bg-primary" id="assignedCount">{{ assigned_pids|length }} assigned</span>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setAll(true)">Select All</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="setAll(false)">Deselect All</button>
</div>
</div>
{% if projects %}
<div class="list-group list-group-flush">
{% for project in projects %}
<label class="list-group-item list-group-item-action d-flex align-items-center gap-3 py-3">
<input class="form-check-input flex-shrink-0" type="checkbox"
name="project_ids" value="{{ project.id }}"
{{ 'checked' if project.id in assigned_pids }}>
<div>
<div class="fw-semibold">{{ project.name }}</div>
{% if project.description %}
<div class="text-muted small">{{ project.description }}</div>
{% endif %}
<div class="text-muted small">
{{ project.facilities.count() }} facilit{{ 'ies' if project.facilities.count() != 1 else 'y' }}
</div>
</div>
</label>
{% endfor %}
</div>
{% else %}
<div class="card-body text-muted">
No active contracts exist. Create a contract first.
</div>
{% endif %}
</div>
{% if projects %}
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-floppy me-1"></i>Save Assignments
</button>
<a href="{{ url_for('auth.list_users') }}" class="btn btn-outline-secondary">Cancel</a>
</div>
{% endif %}
</form>
{% endblock %}
{% block extra_js %}
<script>
function setAll(checked) {
document.querySelectorAll('input[name="project_ids"]').forEach(cb => cb.checked = checked);
updateCount();
}
function updateCount() {
const n = document.querySelectorAll('input[name="project_ids"]:checked').length;
document.getElementById('assignedCount').textContent = n + ' assigned';
}
document.querySelectorAll('input[name="project_ids"]').forEach(cb => {
cb.addEventListener('change', updateCount);
});
</script>
{% endblock %}
+334
View File
@@ -0,0 +1,334 @@
{% extends "base.html" %}
{% block title %}Login - Janitorial QC{% endblock %}
{% block extra_css %}
<style>
/* ════════════════════════════════════════════════════════════════════
Login page — split-screen layout
Theme colors preserved: brand #1a56db / #1a4ab5 / #1551c7,
quality-green #16a34a, amber #d97706 / #f59e0b, light-blue surfaces.
════════════════════════════════════════════════════════════════════ */
body.login-page { min-height: 100vh; background: #fff; }
/* Let the split fill the viewport: neutralize base.html content wrapper */
body.login-page .container-fluid.mt-4 {
margin-top: 0 !important;
padding: 0;
max-width: none;
}
/* Flash messages float as top-center toasts instead of pushing layout */
body.login-page .container-fluid.mt-4 > .alert {
position: fixed;
top: 1rem; left: 50%;
transform: translateX(-50%);
z-index: 1080;
min-width: 320px; max-width: 92vw;
box-shadow: 0 8px 28px rgba(0,0,0,.18);
}
.login-split { display: flex; min-height: 100vh; }
/* ── LEFT: brand / QC showcase ──────────────────────────────────────── */
.login-brand {
flex: 1 1 55%;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, #1a56db 0%, #1a4ab5 55%, #1551c7 100%);
color: #fff;
padding: 3rem 3.5rem;
display: flex;
flex-direction: column;
justify-content: center;
gap: 1.75rem;
}
.login-brand::before { /* dotted texture */
content: "";
position: absolute; inset: 0;
background-image: radial-gradient(rgba(255,255,255,.12) 1.5px, transparent 1.5px);
background-size: 26px 26px;
opacity: .55; pointer-events: none;
}
.login-brand::after { /* soft glow */
content: "";
position: absolute; width: 460px; height: 460px;
right: -140px; top: -120px;
background: radial-gradient(circle, rgba(255,255,255,.14), transparent 70%);
border-radius: 50%; pointer-events: none;
}
.login-brand > * { position: relative; z-index: 2; max-width: 500px; }
.brand-logo { display: flex; align-items: center; gap: .8rem; }
.brand-logo .logo-badge {
width: 50px; height: 50px; border-radius: 13px;
background: rgba(255,255,255,.16);
border: 1px solid rgba(255,255,255,.22);
display: flex; align-items: center; justify-content: center;
font-size: 1.6rem;
}
.brand-logo .name { font-weight: 700; font-size: 1.3rem; line-height: 1.1; }
.brand-logo .sub { font-size: .8rem; opacity: .82; }
.brand-headline { font-size: 2.05rem; font-weight: 700; line-height: 1.22; }
.brand-sub { opacity: .86; font-size: 1.02rem; line-height: 1.55; }
.feature-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 1.05rem; }
.feature-list li { display: flex; align-items: flex-start; gap: .85rem; }
.feature-list .fi {
flex: 0 0 auto; width: 32px; height: 32px; border-radius: 50%;
background: #16a34a; color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: .95rem; box-shadow: 0 0 0 4px rgba(22,163,74,.22);
}
.feature-list .ft { font-weight: 600; line-height: 1.2; }
.feature-list .fd { font-size: .85rem; opacity: .82; }
.stat-row { display: flex; gap: .75rem; flex-wrap: wrap; }
.stat-chip {
background: rgba(255,255,255,.12);
border: 1px solid rgba(255,255,255,.20);
border-radius: 12px; padding: .6rem 1.05rem; min-width: 102px;
}
.stat-chip .v { font-size: 1.4rem; font-weight: 700; line-height: 1; }
.stat-chip .l { font-size: .7rem; opacity: .82; text-transform: uppercase; letter-spacing: .6px; margin-top: .25rem; }
.stat-chip.g .v { color: #86efac; }
.stat-chip.a .v { color: #fcd34d; }
/* Decorative janitorial / QC illustration anchored bottom-right */
.brand-illustration {
position: absolute; right: -8px; bottom: -10px;
width: 340px; max-width: 42%; height: auto;
z-index: 1; opacity: .95; pointer-events: none;
}
/* ── RIGHT: sign-in form ────────────────────────────────────────────── */
.login-form-side {
flex: 1 1 45%;
display: flex; align-items: center; justify-content: center;
padding: 2.5rem 1.5rem; background: #fff;
}
.login-form-inner { width: 100%; max-width: 400px; }
.form-mobile-brand { display: none; } /* shown only on small screens */
.form-mobile-brand .logo-badge {
width: 56px; height: 56px; border-radius: 15px;
background: #1a56db; color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 1.8rem;
}
.form-mobile-brand .name { font-weight: 700; color: #1a56db; font-size: 1.2rem; margin-top: .55rem; }
.form-mobile-brand .sub { color: #6b7280; font-size: .8rem; }
.login-form-inner h2 { font-weight: 700; color: #16264a; margin-bottom: .25rem; }
.login-form-inner .lead-sub { color: #6b7280; margin-bottom: 1.75rem; }
.login-form-side .input-group-text {
background: #f0f7ff; border-color: #d4e1f4; color: #1a56db;
}
.login-form-side .form-control:focus {
border-color: #1a56db; box-shadow: 0 0 0 .2rem rgba(26,86,219,.18);
}
.login-form-side .pw-toggle {
border-color: #d4e1f4; color: #6b8fc7; background: #fff;
}
.login-form-side .pw-toggle:hover { color: #1a56db; }
.btn-login {
background: #1a56db; border-color: #1a56db; color: #fff; font-weight: 600;
}
.btn-login:hover, .btn-login:focus { background: #1648b8; border-color: #1648b8; color: #fff; }
.login-foot { margin-top: 2rem; text-align: center; color: #9aa3af; font-size: .8rem; }
/* ── Responsive: collapse to single column ──────────────────────────── */
@media (max-width: 991.98px) {
.login-brand { display: none; }
.form-mobile-brand {
display: flex; flex-direction: column; align-items: center;
text-align: center; margin-bottom: 1.75rem;
}
.login-form-inner h2, .login-form-inner .lead-sub { text-align: center; }
}
</style>
{% endblock %}
{% block content %}
<script>document.body.classList.add('login-page');</script>
<div class="login-split">
{# ══ LEFT: brand / quality-control showcase ══ #}
<section class="login-brand">
<div class="brand-logo">
<span class="logo-badge"><i class="bi bi-clipboard-check-fill"></i></span>
<span>
<span class="name d-block">Janitorial Quality Control System</span>
<span class="sub">By L.T Services, Inc</span>
</span>
</div>
<div>
<h1 class="brand-headline mb-2">Quality control for spotless facilities.</h1>
<p class="brand-sub mb-0">Standardized inspections, real-time issue tracking, and
clear performance reporting &mdash; keeping every facility audit-ready.</p>
</div>
<ul class="feature-list">
<li>
<span class="fi"><i class="bi bi-clipboard-check"></i></span>
<span><span class="ft d-block">Standardized facility inspections</span>
<span class="fd">Consistent checklists across every site.</span></span>
</li>
<li>
<span class="fi"><i class="bi bi-search"></i></span>
<span><span class="ft d-block">Real-time issue tracking</span>
<span class="fd">Log, assign, and resolve quality issues fast.</span></span>
</li>
<li>
<span class="fi"><i class="bi bi-bar-chart-line"></i></span>
<span><span class="ft d-block">Automated quality reporting</span>
<span class="fd">Scores and trends, ready to share.</span></span>
</li>
</ul>
<div class="stat-row">
<div class="stat-chip g"><div class="v">98%</div><div class="l">Pass rate</div></div>
<div class="stat-chip"> <div class="v">247</div><div class="l">Inspections</div></div>
<div class="stat-chip a"><div class="v">4.8</div><div class="l">Avg score</div></div>
</div>
{# ── Decorative inspection / janitorial illustration ── #}
<svg class="brand-illustration" viewBox="0 0 360 340" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<!-- clipboard card -->
<rect x="48" y="36" width="210" height="252" rx="16" fill="#ffffff" opacity="0.97"/>
<rect x="48" y="36" width="210" height="52" rx="16" fill="#1a56db"/>
<rect x="48" y="64" width="210" height="24" fill="#1a56db"/>
<rect x="118" y="26" width="70" height="24" rx="10" fill="#dceeff" stroke="#93c5fd" stroke-width="2"/>
<text x="153" y="70" font-family="Arial,sans-serif" font-size="17" font-weight="700"
fill="#ffffff" text-anchor="middle">Inspection</text>
<!-- row 1 (pass) -->
<circle cx="78" cy="124" r="11" fill="#dcfce7" stroke="#86efac" stroke-width="2"/>
<polyline points="73,124 78,130 85,118" fill="none" stroke="#16a34a" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round"/>
<line x1="98" y1="124" x2="236" y2="124" stroke="#cfe0f5" stroke-width="5" stroke-linecap="round"/>
<!-- row 2 (pass) -->
<circle cx="78" cy="160" r="11" fill="#dcfce7" stroke="#86efac" stroke-width="2"/>
<polyline points="73,160 78,166 85,154" fill="none" stroke="#16a34a" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round"/>
<line x1="98" y1="160" x2="236" y2="160" stroke="#cfe0f5" stroke-width="5" stroke-linecap="round"/>
<!-- row 3 (flag / amber) -->
<circle cx="78" cy="196" r="11" fill="#fef9c3" stroke="#fde047" stroke-width="2"/>
<line x1="78" y1="190" x2="78" y2="197" stroke="#d97706" stroke-width="3" stroke-linecap="round"/>
<circle cx="78" cy="201" r="1.6" fill="#d97706"/>
<line x1="98" y1="196" x2="206" y2="196" stroke="#cfe0f5" stroke-width="5" stroke-linecap="round"/>
<!-- row 4 -->
<circle cx="78" cy="232" r="11" fill="#eef4fc" stroke="#c2d9f8" stroke-width="2"/>
<line x1="98" y1="232" x2="236" y2="232" stroke="#e4edf9" stroke-width="5" stroke-linecap="round"/>
<!-- shield-check badge -->
<g transform="translate(214,210)">
<path d="M30 0 L58 10 L58 38 Q58 62 30 74 Q2 62 2 38 L2 10 Z"
fill="#16a34a"/>
<polyline points="16,38 26,49 46,25" fill="none" stroke="#ffffff" stroke-width="5"
stroke-linecap="round" stroke-linejoin="round"/>
</g>
<!-- magnifying glass -->
<circle cx="118" cy="206" r="56" fill="none" stroke="#1a56db" stroke-width="0" />
<circle cx="118" cy="206" r="46" fill="#dbeafe" fill-opacity="0.55" stroke="#1a56db" stroke-width="7"/>
<line x1="151" y1="239" x2="182" y2="270" stroke="#1a56db" stroke-width="11" stroke-linecap="round"/>
<text x="118" y="214" font-family="Arial,sans-serif" font-size="26" font-weight="800"
fill="#1a56db" text-anchor="middle">QC</text>
</svg>
</section>
{# ══ RIGHT: sign-in form ══ #}
<section class="login-form-side">
<div class="login-form-inner">
<div class="form-mobile-brand">
<span class="logo-badge"><i class="bi bi-clipboard-check-fill"></i></span>
<span class="name">Janitorial Quality Control System</span>
<span class="sub">By L.T Services, Inc</span>
</div>
<h2>Welcome</h2>
<p class="lead-sub">Sign in to access your inspections and reports.</p>
<form method="POST" action="{{ url_for('auth.login') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label fw-semibold") }}
<div class="input-group input-group-lg">
<span class="input-group-text"><i class="bi bi-person"></i></span>
{{ form.username(class="form-control", placeholder="Enter username", autofocus=true) }}
</div>
{% if form.username.errors %}
<div class="text-danger small mt-1">
{% for error in form.username.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-4">
<div class="d-flex justify-content-between align-items-baseline">
{{ form.password.label(class="form-label fw-semibold mb-0") }}
<a href="{{ url_for('auth.forgot_password') }}"
class="small text-muted text-decoration-none">Forgot password?</a>
</div>
<div class="input-group input-group-lg mt-1">
<span class="input-group-text"><i class="bi bi-lock"></i></span>
{{ form.password(class="form-control", placeholder="Enter password") }}
<button class="btn pw-toggle" type="button" id="pw-toggle" tabindex="-1"
aria-label="Show password">
<i class="bi bi-eye" id="pw-toggle-icon"></i>
</button>
</div>
{% if form.password.errors %}
<div class="text-danger small mt-1">
{% for error in form.password.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-4 form-check">
{{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label text-muted") }}
</div>
<button type="submit" class="btn btn-login btn-lg w-100">
<i class="bi bi-box-arrow-in-right me-1"></i> Log In
</button>
</form>
<div class="login-foot">
&copy; <span id="copy-year"></span> Janitorial QC System
</div>
</div>
</section>
</div>
<script>
document.getElementById('copy-year').textContent = new Date().getFullYear();
// Password visibility toggle
(function () {
var btn = document.getElementById('pw-toggle');
var icon = document.getElementById('pw-toggle-icon');
var input = document.getElementById('password');
if (btn && input) {
btn.addEventListener('click', function () {
var show = input.type === 'password';
input.type = show ? 'text' : 'password';
icon.classList.toggle('bi-eye', !show);
icon.classList.toggle('bi-eye-slash', show);
btn.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
});
}
})();
// Remove .login-page when navigating away (SPA-style guard)
window.addEventListener('pagehide', function () {
document.body.classList.remove('login-page');
});
</script>
{% endblock %}
+218
View File
@@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}Notification Matrix{% endblock %}
{% block extra_css %}
<style>
.matrix-wrap { max-width: 1100px; margin: 0 auto; }
/* ── Matrix table ── */
.matrix-tbl { border-collapse: collapse; width: 100%; font-size: .82rem; }
.matrix-tbl th, .matrix-tbl td {
border: 1px solid #e2e8f0;
padding: .35rem .55rem;
vertical-align: middle;
}
.matrix-tbl thead th {
background: #1a1d23; color: #fff; font-weight: 600;
text-align: center; white-space: nowrap;
}
.matrix-tbl thead th.event-col { text-align: left; }
.matrix-tbl thead th.group-hdr {
background: #2563eb; font-size: .75rem; letter-spacing: .04em;
text-transform: uppercase;
}
.matrix-tbl tbody tr:nth-child(even) td { background: #f8fafc; }
.matrix-tbl tbody tr:hover td { background: #eff6ff; }
.matrix-tbl td.event-name { font-weight: 500; color: #1a1d23; white-space: nowrap; }
.matrix-tbl td.check-cell { text-align: center; }
/* Checkbox styling */
.matrix-check {
width: 1.1rem; height: 1.1rem; cursor: pointer;
accent-color: #2563eb;
}
/* Disabled row (event has no meaningful broadcast) */
.matrix-tbl tr.implicit td { color: #94a3b8; }
.matrix-tbl tr.implicit td.event-name { color: #64748b; font-style: italic; }
/* Custom emails cell */
.custom-cell { min-width: 180px; }
.custom-input {
width: 100%; font-size: .75rem;
border: 1px solid #e2e8f0; border-radius: 4px;
padding: .2rem .4rem; color: #374151;
background: #f8fafc;
}
.custom-input:focus {
outline: none; border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37,99,235,.15);
}
/* Legend */
.legend { font-size: .78rem; color: #64748b; }
.legend span { display: inline-flex; align-items: center; gap: .3rem; margin-right: 1rem; }
/* Implicit badge */
.badge-implicit {
font-size: .62rem; background: #f1f5f9; color: #64748b;
border: 1px solid #e2e8f0; border-radius: 4px;
padding: 1px 5px; vertical-align: middle;
}
</style>
{% endblock %}
{% block content %}
<div class="matrix-wrap mt-3">
{# ── Page header ── #}
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-grid-3x3-gap-fill text-primary me-2"></i>Notification Matrix</h4>
<p class="text-muted mb-0 mt-1" style="font-size:.85rem;">
Control which roles receive email notifications for each event.
<strong>Assignee</strong>, <strong>followers</strong>, and <strong>customer portal</strong>
recipients are handled automatically where marked.
</p>
</div>
<a href="{{ url_for('auth.list_users') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Users
</a>
</div>
{# ── Legend ── #}
<div class="legend mb-3 d-flex flex-wrap align-items-center">
<span><input type="checkbox" checked disabled class="matrix-check"> Enabled by default</span>
<span><input type="checkbox" disabled class="matrix-check"> Disabled by default</span>
<span><span class="badge-implicit">implicit</span> Always notified — not controlled here</span>
</div>
<form method="POST" action="{{ url_for('auth.notification_matrix') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="table-responsive shadow-sm rounded">
<table class="matrix-tbl">
<thead>
{# Row 1: spanning group headers #}
<tr>
<th class="event-col" rowspan="2" style="min-width:200px;">Event</th>
<th class="group-hdr" colspan="4">Internal Recipients</th>
<th class="group-hdr" colspan="1">Customer</th>
<th class="group-hdr" colspan="1">Custom Recipients</th>
</tr>
{# Row 2: individual role headers #}
<tr>
{% for role_key, role_label in matrix_roles if role_key != 'custom' and role_key != 'customer' %}
<th style="min-width:80px;">{{ role_label }}</th>
{% endfor %}
<th style="min-width:80px;">Customer</th>
<th style="min-width:200px;">Email addresses<br><span style="font-weight:400;font-size:.7rem;color:#94a3b8;">(comma-separated)</span></th>
</tr>
</thead>
<tbody>
{% for event_key, event_label in matrix_events.items() %}
{% set event_state = state.get(event_key, {}) %}
<tr>
<td class="event-name">
{{ event_label }}
{# Show implicit labels where assignee/followers are always notified #}
{% if event_key in ('issue_assigned', 'issue_reassigned', 'issue_unassigned',
'issue_status', 'issue_comment') %}
<span class="badge-implicit ms-1">assignee</span>
{% endif %}
{% if event_key == 'issue_follow_update' %}
<span class="badge-implicit ms-1">followers</span>
{% endif %}
{% if event_key == 'sla_alert' %}
<span class="badge-implicit ms-1">assignee</span>
<span class="badge-implicit ms-1">followers</span>
{% endif %}
</td>
{# Internal role checkboxes (admin, supervisor, inspector, project_manager) #}
{% for role_key, _ in matrix_roles if role_key not in ('custom', 'customer') %}
{% set row = event_state.get(role_key) %}
{% if row is not none %}
{% set checked = row.enabled %}
{% else %}
{% set checked = defaults.get((event_key, role_key), false) %}
{% endif %}
<td class="check-cell">
<input type="checkbox"
class="matrix-check"
name="matrix_{{ event_key }}_{{ role_key }}"
value="1"
{% if checked %}checked{% endif %}>
</td>
{% endfor %}
{# Customer checkbox #}
{% set cust_row = event_state.get('customer') %}
{% if cust_row is not none %}
{% set cust_checked = cust_row.enabled %}
{% else %}
{% set cust_checked = defaults.get((event_key, 'customer'), false) %}
{% endif %}
<td class="check-cell">
<input type="checkbox"
class="matrix-check"
name="matrix_{{ event_key }}_customer"
value="1"
{% if cust_checked %}checked{% endif %}>
</td>
{# Custom emails text input #}
{% set custom_row = event_state.get('custom') %}
{% if custom_row is not none %}
{% set custom_val = custom_row.get_custom_emails() | join(', ') %}
{% else %}
{% set custom_val = '' %}
{% endif %}
<td class="custom-cell">
<input type="text"
class="custom-input"
name="custom_{{ event_key }}"
value="{{ custom_val }}"
placeholder="e.g. ops@company.com, mgr@co.com">
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-3 d-flex gap-2 align-items-center">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2-circle me-1"></i>Save Matrix
</button>
<a href="{{ url_for('auth.notification_matrix') }}" class="btn btn-outline-secondary">
Reset
</a>
<span class="text-muted ms-2" style="font-size:.8rem;">
<i class="bi bi-info-circle me-1"></i>
Changes take effect immediately for all subsequent notifications.
</span>
</div>
</form>
{# ── Notes card ── #}
<div class="card mt-4 border-0 bg-light">
<div class="card-body py-2 px-3">
<p class="mb-1" style="font-size:.8rem;">
<strong>Internal:</strong> Admin, Supervisor, Inspector, and Project Manager users
receive in-app notifications and emails based on their individual
<a href="{{ url_for('notifications.preferences') }}">preference settings</a>.
</p>
<p class="mb-1" style="font-size:.8rem;">
<strong>Customer:</strong> Customer-portal users are notified only for facilities
they are assigned to via their contract/facility assignments.
</p>
<p class="mb-0" style="font-size:.8rem;">
<strong>Custom Recipients:</strong> Additional email addresses (e.g. external managers)
receive a plain email. They do not get in-app notifications and are not affected
by individual user preference settings.
</p>
</div>
</div>
</div>
{% endblock %}
+224
View File
@@ -0,0 +1,224 @@
{% extends "base.html" %}
{% block title %}My Profile{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2><i class="bi bi-person-circle"></i> My Profile</h2>
<p class="text-muted mb-0">Manage your account information and review your activity.</p>
</div>
</div>
<div class="row g-4">
<!-- ── Left column: account card + stats ─────────────────────────── -->
<div class="col-lg-4">
<!-- Account Info Card -->
<div class="card shadow-sm mb-4">
<div class="card-body text-center py-4">
<div class="mb-3">
<span class="display-1 text-primary">
<i class="bi bi-person-circle"></i>
</span>
</div>
<h4 class="mb-1 fw-bold">{{ current_user.display_name }}</h4>
{% if current_user.full_name %}
<p class="text-muted mb-1" style="font-size:.85rem;">@{{ current_user.username }}</p>
{% endif %}
<p class="text-muted mb-2">{{ current_user.email }}</p>
<span class="badge fs-6 bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'director' %}warning{% else %}info{% endif %}">
<i class="bi bi-{% if current_user.role == 'admin' %}shield-fill{% elif current_user.role == 'director' %}star-fill{% else %}person-badge-fill{% endif %} me-1"></i>
{{ current_user.role | title }}
</span>
<hr>
<small class="text-muted">
<i class="bi bi-calendar3 me-1"></i>
Member since {{ current_user.created_at.strftime('%B %d, %Y') }}
</small>
</div>
</div>
<!-- Activity Stats Card -->
<div class="card shadow-sm">
<div class="card-header bg-light">
<h6 class="mb-0 fw-semibold"><i class="bi bi-bar-chart-fill me-1"></i>Activity Summary</h6>
</div>
<div class="card-body">
<div class="row text-center g-3">
<div class="col-6">
<div class="p-2 rounded bg-primary bg-opacity-10">
<div class="fs-3 fw-bold text-primary">{{ total_inspections }}</div>
<small class="text-muted">Total Inspections</small>
</div>
</div>
<div class="col-6">
<div class="p-2 rounded bg-success bg-opacity-10">
<div class="fs-3 fw-bold text-success">{{ completed_inspections }}</div>
<small class="text-muted">Completed</small>
</div>
</div>
<div class="col-6">
<div class="p-2 rounded bg-warning bg-opacity-10">
<div class="fs-3 fw-bold text-warning">{{ open_issues }}</div>
<small class="text-muted">Open Issues</small>
</div>
</div>
<div class="col-6">
<div class="p-2 rounded bg-info bg-opacity-10">
{% if total_inspections > 0 %}
<div class="fs-3 fw-bold text-info">
{{ ((completed_inspections / total_inspections) * 100) | int }}%
</div>
{% else %}
<div class="fs-3 fw-bold text-info"></div>
{% endif %}
<small class="text-muted">Completion Rate</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Right column: edit form + recent inspections ──────────────── -->
<div class="col-lg-8">
<!-- Edit Profile Form -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h6 class="mb-0 fw-semibold"><i class="bi bi-pencil-square me-1"></i>Edit Profile</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('auth.profile') }}">
{{ form.hidden_tag() }}
<!-- Full Name -->
<div class="mb-3">
{{ form.full_name.label(class="form-label fw-semibold") }}
{{ form.full_name(class="form-control", placeholder="e.g. Jane Smith") }}
{% for error in form.full_name.errors %}
<div class="invalid-feedback d-block">{{ error }}</div>
{% endfor %}
</div>
<!-- Email -->
<div class="mb-3">
{{ form.email.label(class="form-label fw-semibold") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
placeholder="your@email.com") }}
{% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<hr class="my-3">
<p class="text-muted small mb-3">
<i class="bi bi-lock me-1"></i>Leave the password fields blank to keep your current password.
</p>
<!-- Current Password -->
<div class="mb-3">
{{ form.current_password.label(class="form-label fw-semibold") }}
{{ form.current_password(class="form-control" + (" is-invalid" if form.current_password.errors else ""),
autocomplete="current-password") }}
{% for error in form.current_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<!-- New Password -->
<div class="mb-3">
{{ form.new_password.label(class="form-label fw-semibold") }}
{{ form.new_password(class="form-control" + (" is-invalid" if form.new_password.errors else ""),
autocomplete="new-password") }}
{% for error in form.new_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="form-text">Minimum 6 characters.</div>
</div>
<!-- Confirm New Password -->
<div class="mb-4">
{{ form.confirm_password.label(class="form-label fw-semibold") }}
{{ form.confirm_password(class="form-control" + (" is-invalid" if form.confirm_password.errors else ""),
autocomplete="new-password") }}
{% for error in form.confirm_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Save Changes
</button>
</form>
</div>
</div>
<!-- Recent Inspections -->
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold"><i class="bi bi-clipboard-check me-1"></i>Recent Inspections</h6>
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-primary">
View All
</a>
</div>
<div class="card-body p-0">
{% if recent_inspections %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Facility</th>
<th>Template</th>
<th>Score</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for insp in recent_inspections %}
<tr>
<td>{{ insp.inspection_date.strftime('%Y-%m-%d') }}</td>
<td>{{ insp.facility.name if insp.facility else '—' }}</td>
<td>{{ insp.template.name if insp.template else '—' }}</td>
<td>
{% if insp.overall_score is not none %}
<span class="badge bg-{% if insp.overall_score >= 80 %}success{% elif insp.overall_score >= 60 %}warning{% else %}danger{% endif %}">
{{ "%.1f"|format(insp.overall_score) }}%
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'in_progress' %}primary{% else %}warning{% endif %}">
{{ insp.status | replace('_', ' ') | title }}
</span>
</td>
<td>
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4 text-muted">
<i class="bi bi-clipboard-x fs-3 d-block mb-2"></i>
No inspections recorded yet.
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
+151
View File
@@ -0,0 +1,151 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Reset Password — Janitorial QC</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<style>
body { background: #eef0f4; font-family: 'Segoe UI', Arial, sans-serif; }
.setup-card {
max-width: 460px; margin: 80px auto;
border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
}
.setup-header {
background: #1a1d23; color: #fff;
border-radius: 12px 12px 0 0;
padding: 1.5rem 1.75rem 1.25rem;
}
.setup-header h4 { margin: 0; font-weight: 600; }
.setup-header p { color: #94a3b8; font-size: .85rem; margin: .35rem 0 0; }
.setup-body { background: #fff; border-radius: 0 0 12px 12px; padding: 1.75rem; }
.req-item { font-size: .8rem; color: #64748b; }
.req-item.met { color: #16a34a; }
.strength-bar { height: 4px; border-radius: 2px; transition: all .3s; }
</style>
</head>
<body>
<div class="setup-card">
<div class="setup-header">
<h4><i class="bi bi-shield-lock me-2"></i>Set a New Password</h4>
<p>Hi {{ user.display_name }}. Choose a new secure password for your account.</p>
</div>
<div class="setup-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="alert alert-{{ 'danger' if cat == 'danger' else 'warning' if cat == 'warning' else 'success' }}
alert-dismissible fade show py-2 mb-3" role="alert">
{{ msg }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endwith %}
<form method="POST" novalidate>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="password" class="form-label fw-semibold">New Password</label>
<input type="password"
id="password"
name="password"
class="form-control {{ 'is-invalid' if form.password.errors else '' }}"
autocomplete="new-password"
autofocus>
{% for error in form.password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="mt-2 mb-1">
<div class="bg-light rounded" style="height:4px;">
<div id="strengthBar" class="strength-bar bg-secondary" style="width:0%;"></div>
</div>
</div>
<div class="d-flex flex-wrap gap-3 mt-2">
<span class="req-item" id="req-len">
<i class="bi bi-circle me-1"></i>8+ characters
</span>
<span class="req-item" id="req-upper">
<i class="bi bi-circle me-1"></i>Uppercase letter
</span>
<span class="req-item" id="req-num">
<i class="bi bi-circle me-1"></i>Number
</span>
</div>
</div>
<div class="mb-4">
<label for="confirm_password" class="form-label fw-semibold">Confirm Password</label>
<input type="password"
id="confirm_password"
name="confirm_password"
class="form-control {{ 'is-invalid' if form.confirm_password.errors else '' }}"
autocomplete="new-password">
{% for error in form.confirm_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div id="matchFeedback" class="form-text" style="display:none;"></div>
</div>
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
<i class="bi bi-check2-circle me-1"></i>Reset Password &amp; Log In
</button>
</form>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
(function () {
'use strict';
var pwEl = document.getElementById('password');
var cfEl = document.getElementById('confirm_password');
var bar = document.getElementById('strengthBar');
var reqLen = document.getElementById('req-len');
var reqUpper = document.getElementById('req-upper');
var reqNum = document.getElementById('req-num');
var matchFb = document.getElementById('matchFeedback');
function markReq(el, met) {
el.className = 'req-item' + (met ? ' met' : '');
el.querySelector('i').className = (met ? 'bi bi-check-circle-fill' : 'bi bi-circle') + ' me-1';
}
function updateStrength(pw) {
var hasLen = pw.length >= 8;
var hasUpper = /[A-Z]/.test(pw);
var hasNum = /[0-9]/.test(pw);
var score = [hasLen, hasUpper, hasNum, pw.length >= 12].filter(Boolean).length;
markReq(reqLen, hasLen);
markReq(reqUpper, hasUpper);
markReq(reqNum, hasNum);
var color = score <= 1 ? 'danger' : score === 2 ? 'warning' : score === 3 ? 'info' : 'success';
bar.style.width = (score * 25) + '%';
bar.className = 'strength-bar bg-' + color;
}
function checkMatch() {
if (!cfEl.value) { matchFb.style.display = 'none'; return; }
matchFb.style.display = '';
if (pwEl.value === cfEl.value) {
matchFb.textContent = '✓ Passwords match';
matchFb.style.color = '#16a34a';
} else {
matchFb.textContent = '✗ Passwords do not match';
matchFb.style.color = '#dc2626';
}
}
pwEl.addEventListener('input', function () { updateStrength(this.value); checkMatch(); });
cfEl.addEventListener('input', checkMatch);
})();
</script>
</body>
</html>
+130
View File
@@ -0,0 +1,130 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-8 offset-md-2">
{# ── Account Status card (edit mode only, not own account) ── #}
{% if user and user.id != current_user.id %}
<div class="card shadow-sm mb-3">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<span class="fw-semibold me-2">Account Status:</span>
<span class="badge fs-6 bg-{{ 'success' if user.active else 'secondary' }}">
{{ 'Active' if user.active else 'Disabled' }}
</span>
<div class="form-text mt-1">
{% if user.active %}
Disabling this account will immediately prevent the user from logging in.
{% else %}
This account is currently disabled — the user cannot log in.
{% endif %}
</div>
</div>
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-sm {{ 'btn-outline-secondary' if user.active else 'btn-outline-success' }}"
onclick="return confirm('{{ 'Disable' if user.active else 'Enable' }} user {{ user.username }}?')">
<i class="bi bi-{{ 'person-slash' if user.active else 'person-check' }} me-1"></i>
{{ 'Disable Account' if user.active else 'Enable Account' }}
</button>
</form>
</div>
</div>
{% endif %}
{# ── Edit form ── #}
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">{{ title }}</h4>
</div>
<div class="card-body">
<form method="POST">
{{ form.hidden_tag() }}
<div class="row">
<div class="col-md-6 mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control") }}
{% if form.username.errors %}
<div class="text-danger small mt-1">
{% for error in form.username.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
{{ form.full_name.label(class="form-label") }}
{{ form.full_name(class="form-control", placeholder="e.g. Jane Smith") }}
{% if form.full_name.errors %}
<div class="text-danger small mt-1">
{% for error in form.full_name.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% if form.email.errors %}
<div class="text-danger small mt-1">
{% for error in form.email.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control", placeholder="Leave blank to keep current" if user else "") }}
{% if form.password.errors %}
<div class="text-danger small mt-1">
{% for error in form.password.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
{{ form.confirm_password.label(class="form-label") }}
{{ form.confirm_password(class="form-control") }}
{% if form.confirm_password.errors %}
<div class="text-danger small mt-1">
{% for error in form.confirm_password.errors %}{{ error }}{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="mb-4">
{{ form.role.label(class="form-label") }}
{% if director_editing %}
{# Directors can see the current role but cannot change it #}
<div class="form-control bg-light text-muted" style="cursor: not-allowed;">
{{ (user.role if user else 'Inspector').replace('_', ' ')|title }}
</div>
<div class="form-text">Role assignment requires Administrator access.</div>
{% else %}
{{ form.role(class="form-select") }}
{% endif %}
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Save User
</button>
<a href="{{ url_for('auth.list_users') }}" class="btn btn-secondary">
<i class="bi bi-x-circle"></i> Cancel
</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+99
View File
@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block title %}User Management{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col-md-6">
<h2><i class="bi bi-people-fill"></i> Internal User Management</h2>
</div>
<div class="col-md-6 text-end">
<a href="{{ url_for('auth.create_user') }}" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Add User
</a>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead class="table-light">
<tr>
<th>Username</th>
<th>Full Name</th>
<th>Email</th>
<th>Role</th>
<th>Contracts</th>
<th>Created</th>
<th>Status</th>
<th width="220">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr class="{{ 'table-secondary text-muted' if not user.active else '' }}">
<td><strong>{{ user.username }}</strong></td>
<td>{{ user.full_name or '—' }}</td>
<td>{{ user.email }}</td>
<td>
<span class="badge bg-{% if user.role == 'admin' %}danger{% elif user.role == 'director' %}warning{% elif user.role == 'project_manager' %}primary{% elif user.role == 'customer' %}success{% else %}info{% endif %}">
{{ user.role.replace('_',' ')|title }}
</span>
</td>
<td>
{% if user.role == 'inspector' %}
{% set cnt = inspector_contract_counts.get(user.id, 0) %}
{% if cnt > 0 %}
<span class="badge bg-success">{{ cnt }} contract{{ 's' if cnt != 1 else '' }}</span>
{% else %}
<span class="badge bg-warning text-dark" title="No contracts assigned — inspector sees nothing">None</span>
{% endif %}
{% else %}
<span class="text-muted small"></span>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
<td>
{% if user.active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Disabled</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('auth.edit_user', user_id=user.id) }}" class="btn btn-sm btn-outline-primary" title="Edit">
<i class="bi bi-pencil"></i>
</a>
{% if user.role == 'inspector' %}
<a href="{{ url_for('auth.assign_inspector_contracts', user_id=user.id) }}"
class="btn btn-sm btn-outline-secondary" title="Assign contracts">
<i class="bi bi-briefcase"></i>
</a>
{% endif %}
{% if user.id != current_user.id %}
<form method="POST" action="{{ url_for('auth.toggle_active', user_id=user.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="btn btn-sm {{ 'btn-outline-secondary' if user.active else 'btn-outline-success' }}"
title="{{ 'Disable' if user.active else 'Enable' }}"
onclick="return confirm('{{ 'Disable' if user.active else 'Enable' }} user {{ user.username }}?')">
<i class="bi bi-{{ 'person-slash' if user.active else 'person-check' }}"></i>
</button>
</form>
<form method="POST" action="{{ url_for('auth.delete_user', user_id=user.id) }}" class="d-inline" onsubmit="return confirm('Delete this user?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}