First commit
This commit is contained in:
@@ -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 %}
|
||||
Reference in New Issue
Block a user