First commit
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-7 offset-md-2">
|
||||
|
||||
{% if customer %}
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<span class="fw-semibold me-2">Account Status:</span>
|
||||
<span class="badge fs-6 bg-{{ 'success' if customer.active else 'secondary' }}">
|
||||
{{ 'Active' if customer.active else 'Disabled' }}
|
||||
</span>
|
||||
<div class="form-text mt-1">
|
||||
{% if customer.active %}
|
||||
Disabling prevents the customer from logging in immediately.
|
||||
{% else %}
|
||||
This account is currently disabled — the customer cannot log in.
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
|
||||
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
|
||||
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }} me-1"></i>
|
||||
{{ 'Disable Account' if customer.active else 'Enable Account' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0"><i class="bi bi-person-badge me-2"></i>{{ title }}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.username.label(class="form-label") }}
|
||||
{{ form.username(class="form-control") }}
|
||||
{% if form.username.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for e in form.username.errors %}{{ e }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.full_name.label(class="form-label") }}
|
||||
{{ form.full_name(class="form-control", placeholder="e.g. Jane Smith") }}
|
||||
{% if form.full_name.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for e in form.full_name.errors %}{{ e }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.email.label(class="form-label") }}
|
||||
{{ form.email(class="form-control") }}
|
||||
{% if form.email.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for e in form.email.errors %}{{ e }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.password.label(class="form-label") }}
|
||||
{{ form.password(class="form-control",
|
||||
placeholder="Leave blank to keep current" if customer else "Min. 8 characters") }}
|
||||
{% if form.password.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for e in form.password.errors %}{{ e }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
{{ form.confirm_password.label(class="form-label") }}
|
||||
{{ form.confirm_password(class="form-control") }}
|
||||
{% if form.confirm_password.errors %}
|
||||
<div class="text-danger small mt-1">
|
||||
{% for e in form.confirm_password.errors %}{{ e }}{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i>
|
||||
{{ 'Save Changes' if customer else 'Create Customer' }}
|
||||
</button>
|
||||
{% if customer %}
|
||||
<a href="{{ url_for('customers.manage', customer_id=customer.id) }}"
|
||||
class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i> Cancel
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('customers.index') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i> Cancel
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,160 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Bulk Customer Import{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2><i class="bi bi-upload text-primary me-2"></i>Bulk Customer Import</h2>
|
||||
<p class="text-muted mb-0">Create multiple customer accounts and assignments from a CSV file.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('customers.import_template') }}" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-download me-1"></i>Download Template
|
||||
</a>
|
||||
<a href="{{ url_for('customers.index') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Customers
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Format guide ── #}
|
||||
<div class="card border-0 bg-light mb-4">
|
||||
<div class="card-body py-3 px-4">
|
||||
<h6 class="fw-semibold mb-2"><i class="bi bi-info-circle me-1 text-primary"></i>CSV Format</h6>
|
||||
<div class="row g-3 small">
|
||||
<div class="col-md-4">
|
||||
<strong>Required columns</strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
<li><code>username</code> — unique login name</li>
|
||||
<li><code>email</code> — unique email address</li>
|
||||
<li><code>password</code> — min 8 characters</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>Optional columns</strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
<li><code>project_name</code> — exact contract name</li>
|
||||
<li><code>facility_name</code> — exact facility name within contract (leave blank for all)</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<strong>Tips</strong>
|
||||
<ul class="mb-0 mt-1 ps-3">
|
||||
<li>Repeat a username on multiple rows to assign them to multiple contracts</li>
|
||||
<li>Leave <code>facility_name</code> blank to grant access to all facilities in the contract</li>
|
||||
<li>Existing usernames/emails will be flagged as errors before anything is saved</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Upload form ── #}
|
||||
{% if not preview_rows %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white fw-semibold">
|
||||
<i class="bi bi-file-earmark-arrow-up me-1"></i> Upload CSV File
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Select CSV File</label>
|
||||
<input type="file" name="csv_file" accept=".csv" class="form-control" required>
|
||||
<div class="form-text">Maximum recommended file size: 500 KB · UTF-8 encoding.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-search me-1"></i> Parse & Preview
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# ── Preview results ── #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center
|
||||
bg-{{ 'danger' if has_errors else 'success' }} text-white">
|
||||
<span class="fw-semibold">
|
||||
<i class="bi bi-{{ 'x-circle' if has_errors else 'check-circle' }} me-1"></i>
|
||||
Preview — {{ preview_rows|length }} row(s) parsed
|
||||
</span>
|
||||
<span>
|
||||
<span class="badge bg-white text-success">{{ valid_count }} valid</span>
|
||||
{% set err_count = preview_rows|length - valid_count %}
|
||||
{% if err_count > 0 %}
|
||||
<span class="badge bg-white text-danger ms-1">{{ err_count }} error(s)</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0" style="font-size:.85rem;">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th width="50">Row</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Contract</th>
|
||||
<th>Facility Scope</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in preview_rows %}
|
||||
<tr class="{{ 'table-danger' if row.status == 'error' else '' }}">
|
||||
<td class="text-muted">{{ row.row }}</td>
|
||||
<td>{{ row.username or '—' }}</td>
|
||||
<td>{{ row.email or '—' }}</td>
|
||||
<td>{{ row.project }}</td>
|
||||
<td>{{ row.facility }}</td>
|
||||
<td>
|
||||
{% if row.status == 'ok' %}
|
||||
<span class="badge bg-success">Ready</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Error</span>
|
||||
<ul class="mb-0 ps-3 text-danger" style="font-size:.78rem;">
|
||||
{% for e in row.errors %}<li>{{ e }}</li>{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Action buttons ── #}
|
||||
{% if has_errors %}
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
<strong>Errors found.</strong> Fix the issues above and re-upload.
|
||||
Error rows will be skipped — only valid rows can be imported.
|
||||
{% if valid_count > 0 %}
|
||||
You may still import the {{ valid_count }} valid row(s) by clicking below.
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex gap-3 align-items-center">
|
||||
{% if valid_count > 0 %}
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="confirmed" value="1">
|
||||
<input type="hidden" name="rows_json" value="{{ rows_json }}">
|
||||
<button type="submit" class="btn btn-success"
|
||||
onclick="return confirm('Create {{ valid_count }} customer account(s) and their assignments?')">
|
||||
<i class="bi bi-person-check me-1"></i>
|
||||
Import {{ valid_count }} Valid Row{{ 's' if valid_count != 1 else '' }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i> Upload Different File
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,152 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Customer Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col">
|
||||
<h2><i class="bi bi-person-badge"></i> Customer Management</h2>
|
||||
<p class="text-muted mb-0">Manage portal access for all customer accounts.</p>
|
||||
</div>
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<a href="{{ url_for('customers.bulk_import') }}" class="btn btn-outline-success">
|
||||
<i class="bi bi-upload"></i> Import CSV
|
||||
</a>
|
||||
<a href="{{ url_for('customers.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus"></i> New Customer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if customers %}
|
||||
|
||||
{% if expired_invitations %}
|
||||
<div class="alert alert-warning d-flex align-items-start gap-3 mb-3" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-5 mt-1 flex-shrink-0"></i>
|
||||
<div>
|
||||
<strong>{{ expired_invitations|length }} invitation{{ 's' if expired_invitations|length != 1 else '' }} expired</strong>
|
||||
— the following customer{{ 's' if expired_invitations|length != 1 else '' }} never completed account setup
|
||||
and {{ 'their' if expired_invitations|length != 1 else 'their' }} link has expired:
|
||||
<ul class="mb-2 mt-1">
|
||||
{% for c in expired_invitations %}
|
||||
<li>
|
||||
<strong>{{ c.display_name }}</strong> ({{ c.email }}) —
|
||||
expired {{ c.set_password_token_expires.strftime('%Y-%m-%d %H:%M') }}
|
||||
|
||||
<form method="POST" action="{{ url_for('customers.resend_invite', customer_id=c.id) }}"
|
||||
class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-warning py-0 px-2"
|
||||
style="font-size:.75rem;">
|
||||
<i class="bi bi-send me-1"></i>Resend
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<span class="text-muted small">Resend a fresh 72-hour invitation link or delete the account if it is no longer needed.</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>Full Name</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Assigned Contracts</th>
|
||||
<th>Accessible Facilities</th>
|
||||
<th>Created</th>
|
||||
<th width="160"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for customer in customers %}
|
||||
{% set assignments = assignment_map[customer.id] %}
|
||||
{% set facility_ids = scope_map[customer.id] %}
|
||||
<tr class="{{ 'table-secondary text-muted' if not customer.active else '' }}">
|
||||
<td>
|
||||
<strong>
|
||||
<a href="{{ url_for('customers.manage', customer_id=customer.id) }}"
|
||||
class="text-decoration-none">
|
||||
{{ customer.username }}
|
||||
</a>
|
||||
</strong>
|
||||
</td>
|
||||
<td>{{ customer.full_name or '—' }}</td>
|
||||
<td class="small text-muted">{{ customer.email }}</td>
|
||||
<td>
|
||||
{% if customer.active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Disabled</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if assignments %}
|
||||
{% set project_names = assignments | map(attribute='project') | map(attribute='name') | unique | list %}
|
||||
{% for pname in project_names %}
|
||||
<span class="badge bg-primary me-1">{{ pname }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="text-muted small">— None —</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if facility_ids %}
|
||||
<span class="badge bg-info text-dark">{{ facility_ids|length }} facilit{{ 'y' if facility_ids|length == 1 else 'ies' }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted small">— None —</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="small text-muted">{{ customer.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="text-end">
|
||||
<a href="{{ url_for('customers.manage', customer_id=customer.id) }}"
|
||||
class="btn btn-sm btn-outline-primary" title="Manage">
|
||||
<i class="bi bi-gear"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}"
|
||||
class="btn btn-sm btn-outline-secondary" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}"
|
||||
class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
|
||||
title="{{ 'Disable' if customer.active else 'Enable' }}"
|
||||
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
|
||||
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }}"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Summary footer ── #}
|
||||
<div class="mt-3 text-muted small">
|
||||
{{ customers|length }} customer account{{ 's' if customers|length != 1 else '' }} total
|
||||
· {{ customers|selectattr('active')|list|length }} active
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body text-center py-5 text-muted">
|
||||
<i class="bi bi-person-badge fs-1 d-block mb-3 opacity-25"></i>
|
||||
<p class="mb-3">No customer accounts have been created yet.</p>
|
||||
<a href="{{ url_for('customers.create') }}" class="btn btn-primary">
|
||||
<i class="bi bi-person-plus"></i> Create First Customer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Create Customer Account{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-person-plus-fill me-2"></i>Create Customer Account
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<p class="text-muted mb-4" style="font-size:.9rem;">
|
||||
Enter the customer's name and email address. An invitation email will be
|
||||
sent automatically with a secure link where they can choose their own
|
||||
username and password. The account will be activated once they complete that step.
|
||||
</p>
|
||||
|
||||
<form method="POST">
|
||||
{{ form.hidden_tag() }}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ form.full_name.label(class="form-label fw-semibold") }}
|
||||
{{ form.full_name(class="form-control" + (" is-invalid" if form.full_name.errors else ""),
|
||||
placeholder="e.g. Jane Smith", autofocus=true) }}
|
||||
{% for error in form.full_name.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
{{ form.email.label(class="form-label fw-semibold") }}
|
||||
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
|
||||
placeholder="jane@example.com") }}
|
||||
{% for error in form.email.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
<div class="form-text">
|
||||
<i class="bi bi-envelope me-1"></i>
|
||||
An invitation email with an account setup link will be sent to this address.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-send me-1"></i>Create & Send Invitation
|
||||
</button>
|
||||
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-1"></i>Cancel
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3 border-0 bg-light">
|
||||
<div class="card-body py-2 px-3">
|
||||
<p class="mb-1" style="font-size:.8rem;">
|
||||
<i class="bi bi-info-circle me-1 text-primary"></i>
|
||||
<strong>What happens next:</strong>
|
||||
</p>
|
||||
<ol class="mb-0 ps-3" style="font-size:.8rem; color:#555;">
|
||||
<li>An invitation email is sent to the customer with a secure 72-hour link.</li>
|
||||
<li>The customer clicks the link and chooses their own username and password.</li>
|
||||
<li>The account becomes fully active and they can log in immediately.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,249 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ customer.username }} — Customer Portal{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="col">
|
||||
<h2>
|
||||
<i class="bi bi-person-badge"></i> {{ customer.display_name }}
|
||||
{% if not customer.active %}
|
||||
<span class="badge bg-secondary ms-2 fs-6">Disabled</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success ms-2 fs-6">Active</span>
|
||||
{% endif %}
|
||||
</h2>
|
||||
<p class="text-muted mb-0 small">{{ customer.email }}{% if customer.full_name %} · @{{ customer.username }}{% endif %}</p>
|
||||
</div>
|
||||
<div class="col-auto d-flex gap-2">
|
||||
<a href="{{ url_for('customers.edit', customer_id=customer.id) }}"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-pencil"></i> Edit Account
|
||||
</a>
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.toggle_active', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit"
|
||||
class="btn btn-sm {{ 'btn-outline-warning' if customer.active else 'btn-outline-success' }}"
|
||||
onclick="return confirm('{{ 'Disable' if customer.active else 'Enable' }} {{ customer.username }}?')">
|
||||
<i class="bi bi-{{ 'person-slash' if customer.active else 'person-check' }} me-1"></i>
|
||||
{{ 'Disable' if customer.active else 'Enable' }}
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('customers.index') }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-arrow-left"></i> All Customers
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
{# ── Left column: account info + scoped facilities ── #}
|
||||
<div class="col-md-4">
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-person-circle me-1"></i> Account Details
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-5 text-muted">Full Name</dt>
|
||||
<dd class="col-7">{{ customer.full_name or '—' }}</dd>
|
||||
<dt class="col-5 text-muted">Username</dt>
|
||||
<dd class="col-7">{{ customer.username }}</dd>
|
||||
<dt class="col-5 text-muted">Email</dt>
|
||||
<dd class="col-7">{{ customer.email }}</dd>
|
||||
<dt class="col-5 text-muted">Status</dt>
|
||||
<dd class="col-7">
|
||||
<span class="badge bg-{{ 'success' if customer.active else 'secondary' }}">
|
||||
{{ 'Active' if customer.active else 'Disabled' }}
|
||||
</span>
|
||||
</dd>
|
||||
<dt class="col-5 text-muted">Password</dt>
|
||||
<dd class="col-7">
|
||||
{% if customer.password_set %}
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Set</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass-split me-1"></i>Pending setup</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt class="col-5 text-muted">Created</dt>
|
||||
<dd class="col-7">{{ customer.created_at.strftime('%Y-%m-%d') }}</dd>
|
||||
<dt class="col-5 text-muted">Assignments</dt>
|
||||
<dd class="col-7">{{ assignments|length }}</dd>
|
||||
<dt class="col-5 text-muted">Facilities</dt>
|
||||
<dd class="col-7">{{ facilities|length }}</dd>
|
||||
</dl>
|
||||
{% if not customer.password_set %}
|
||||
<hr class="my-3">
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.resend_invite', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm w-100"
|
||||
onclick="return confirm('Resend invitation email to {{ customer.email }}?')">
|
||||
<i class="bi bi-send me-1"></i>Resend Invitation Email
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Scoped facilities ── #}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-building me-1"></i> Accessible Facilities
|
||||
</div>
|
||||
{% if facilities %}
|
||||
<div class="card-body p-0">
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for f in facilities %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center
|
||||
py-2 px-3 small">
|
||||
<span>
|
||||
<i class="bi bi-building text-muted me-1"></i>{{ f.name }}
|
||||
</span>
|
||||
{% if f.project %}
|
||||
<span class="badge bg-primary" style="font-size:.65rem;">{{ f.project.name }}</span>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body text-muted small">
|
||||
No facilities accessible yet — add an assignment below.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{# ── Right column: assignments ── #}
|
||||
<div class="col-md-8">
|
||||
|
||||
{# ── Current assignments table ── #}
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-light fw-semibold d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-diagram-3 me-1"></i> Contract Assignments</span>
|
||||
</div>
|
||||
{% if assignments %}
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Contract</th>
|
||||
<th>Facility Scope</th>
|
||||
<th>Assigned</th>
|
||||
<th width="60"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for a in assignments %}
|
||||
<tr>
|
||||
<td class="small">
|
||||
<a href="{{ url_for('projects.view', project_id=a.project_id) }}"
|
||||
class="text-decoration-none">
|
||||
{{ a.project.name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if a.facility %}
|
||||
<span class="badge bg-info text-dark small">{{ a.facility.name }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary small">All facilities</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">{{ a.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.remove_assignment', assignment_id=a.id) }}"
|
||||
onsubmit="return confirm('Remove this assignment?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger"
|
||||
title="Remove">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body text-muted small">No assignments yet.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ── Add assignment form ── #}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light fw-semibold">
|
||||
<i class="bi bi-plus-circle me-1"></i> Add Assignment
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST"
|
||||
action="{{ url_for('customers.add_assignment', customer_id=customer.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">Contract</label>
|
||||
<select name="project_id" id="proj-select" class="form-select form-select-sm"
|
||||
required>
|
||||
<option value="">— Select contract —</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-semibold">Facility Scope</label>
|
||||
<select name="facility_id" id="fac-select" class="form-select form-select-sm">
|
||||
<option value="">— All facilities in contract —</option>
|
||||
</select>
|
||||
<div class="form-text" style="font-size:.72rem;">
|
||||
Leave blank to grant access to all facilities in the contract.
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary btn-sm w-100">
|
||||
<i class="bi bi-plus-circle me-1"></i> Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const projSelect = document.getElementById('proj-select');
|
||||
const facSelect = document.getElementById('fac-select');
|
||||
|
||||
projSelect.addEventListener('change', function () {
|
||||
const projectId = this.value;
|
||||
facSelect.innerHTML = '<option value="">— All facilities in contract —</option>';
|
||||
|
||||
if (!projectId) return;
|
||||
|
||||
fetch('/customers/facilities-for-project/' + projectId, { credentials: 'same-origin' })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
data.forEach(function (f) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f.id;
|
||||
opt.textContent = f.name;
|
||||
facSelect.appendChild(opt);
|
||||
});
|
||||
})
|
||||
.catch(function () {});
|
||||
});
|
||||
}());
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,179 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Set Your Password — Janitorial QC</title>
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body { background: #eef0f4; font-family: 'Segoe UI', Arial, sans-serif; }
|
||||
.setup-card {
|
||||
max-width: 460px; margin: 80px auto;
|
||||
border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,.1);
|
||||
}
|
||||
.setup-header {
|
||||
background: #1a1d23; color: #fff;
|
||||
border-radius: 12px 12px 0 0;
|
||||
padding: 1.5rem 1.75rem 1.25rem;
|
||||
}
|
||||
.setup-header h4 { margin: 0; font-weight: 600; }
|
||||
.setup-header p { color: #94a3b8; font-size: .85rem; margin: .35rem 0 0; }
|
||||
.setup-body { background: #fff; border-radius: 0 0 12px 12px; padding: 1.75rem; }
|
||||
.req-item { font-size: .8rem; color: #64748b; }
|
||||
.req-item.met { color: #16a34a; }
|
||||
.strength-bar { height: 4px; border-radius: 2px; transition: all .3s; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="setup-card">
|
||||
<div class="setup-header">
|
||||
<h4><i class="bi bi-shield-lock me-2"></i>Set Your Password</h4>
|
||||
<p>Welcome, {{ user.display_name }}. Choose your username and a secure password to activate your account.</p>
|
||||
</div>
|
||||
<div class="setup-body">
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for cat, msg in messages %}
|
||||
<div class="alert alert-{{ 'danger' if cat == 'danger' else 'warning' if cat == 'warning' else 'success' }}
|
||||
alert-dismissible fade show py-2 mb-3" role="alert">
|
||||
{{ msg }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="POST" id="setPasswordForm" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label fw-semibold">Choose a Username</label>
|
||||
<input type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
class="form-control {{ 'is-invalid' if form.username.errors else '' }}"
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
maxlength="100">
|
||||
{% for error in form.username.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
<div class="form-text" style="font-size:.78rem;">
|
||||
3–100 characters. You will use this to log in.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label fw-semibold">New Password</label>
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
class="form-control {{ 'is-invalid' if form.password.errors else '' }}"
|
||||
autocomplete="new-password">
|
||||
{% for error in form.password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
|
||||
{# Strength bar #}
|
||||
<div class="mt-2 mb-1">
|
||||
<div class="bg-light rounded" style="height:4px;">
|
||||
<div id="strengthBar" class="strength-bar bg-secondary" style="width:0%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Requirements checklist #}
|
||||
<div class="d-flex flex-wrap gap-3 mt-2">
|
||||
<span class="req-item" id="req-len">
|
||||
<i class="bi bi-circle me-1"></i>8+ characters
|
||||
</span>
|
||||
<span class="req-item" id="req-upper">
|
||||
<i class="bi bi-circle me-1"></i>Uppercase letter
|
||||
</span>
|
||||
<span class="req-item" id="req-num">
|
||||
<i class="bi bi-circle me-1"></i>Number
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="confirm_password" class="form-label fw-semibold">Confirm Password</label>
|
||||
<input type="password"
|
||||
id="confirm_password"
|
||||
name="confirm_password"
|
||||
class="form-control {{ 'is-invalid' if form.confirm_password.errors else '' }}"
|
||||
autocomplete="new-password">
|
||||
{% for error in form.confirm_password.errors %}
|
||||
<div class="invalid-feedback">{{ error }}</div>
|
||||
{% endfor %}
|
||||
<div id="matchFeedback" class="form-text" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary w-100" id="submitBtn">
|
||||
<i class="bi bi-check2-circle me-1"></i>Activate Account & Log In
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
'use strict';
|
||||
var pwEl = document.getElementById('password');
|
||||
var cfEl = document.getElementById('confirm_password');
|
||||
var bar = document.getElementById('strengthBar');
|
||||
var reqLen = document.getElementById('req-len');
|
||||
var reqUpper = document.getElementById('req-upper');
|
||||
var reqNum = document.getElementById('req-num');
|
||||
var matchFb = document.getElementById('matchFeedback');
|
||||
var submitBtn = document.getElementById('submitBtn');
|
||||
|
||||
function markReq(el, met) {
|
||||
el.className = 'req-item' + (met ? ' met' : '');
|
||||
el.querySelector('i').className = (met ? 'bi bi-check-circle-fill' : 'bi bi-circle') + ' me-1';
|
||||
}
|
||||
|
||||
function updateStrength(pw) {
|
||||
var score = 0;
|
||||
var hasLen = pw.length >= 8;
|
||||
var hasUpper = /[A-Z]/.test(pw);
|
||||
var hasNum = /[0-9]/.test(pw);
|
||||
if (hasLen) score++;
|
||||
if (hasUpper) score++;
|
||||
if (hasNum) score++;
|
||||
if (pw.length >= 12) score++;
|
||||
|
||||
markReq(reqLen, hasLen);
|
||||
markReq(reqUpper, hasUpper);
|
||||
markReq(reqNum, hasNum);
|
||||
|
||||
var pct = score * 25;
|
||||
var color = score <= 1 ? 'danger' : score === 2 ? 'warning' : score === 3 ? 'info' : 'success';
|
||||
bar.style.width = pct + '%';
|
||||
bar.className = 'strength-bar bg-' + color;
|
||||
}
|
||||
|
||||
function checkMatch() {
|
||||
if (!cfEl.value) { matchFb.style.display = 'none'; return; }
|
||||
matchFb.style.display = '';
|
||||
if (pwEl.value === cfEl.value) {
|
||||
matchFb.textContent = '✓ Passwords match';
|
||||
matchFb.style.color = '#16a34a';
|
||||
} else {
|
||||
matchFb.textContent = '✗ Passwords do not match';
|
||||
matchFb.style.color = '#dc2626';
|
||||
}
|
||||
}
|
||||
|
||||
pwEl.addEventListener('input', function () {
|
||||
updateStrength(this.value);
|
||||
checkMatch();
|
||||
});
|
||||
cfEl.addEventListener('input', checkMatch);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user