05/06 Phase 2: fixed issues
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}Platform Analytics{% endblock %}
|
||||
{% block content %}
|
||||
<h4 class="fw-bold mb-4"><i class="bi bi-bar-chart me-2"></i>Platform Analytics</h4>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
{% set kpis = [
|
||||
('Total Tenants', stats.total_tenants, 'building', 'primary'),
|
||||
('Active', stats.active_count, 'check-circle', 'success'),
|
||||
('Trial', stats.trial_count, 'hourglass-split', 'info'),
|
||||
('Suspended/Cancelled', stats.suspended_count + stats.cancelled_count, 'x-circle', 'danger'),
|
||||
('MRR', '$' ~ "%.2f"|format(stats.mrr), 'currency-dollar', 'success'),
|
||||
('Revenue (30d)', '$' ~ "%.2f"|format(stats.revenue_30d), 'graph-up', 'primary'),
|
||||
('New Tenants (30d)', stats.new_tenants_30d, 'person-plus', 'secondary'),
|
||||
('Conversions (30d)', stats.converted_30d, 'arrow-up-circle', 'success'),
|
||||
('Churn (30d)', stats.churned_30d, 'arrow-down-circle', 'danger'),
|
||||
] %}
|
||||
{% for label, value, icon, color in kpis %}
|
||||
<div class="col-md-4 col-lg-3">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body d-flex align-items-center gap-3">
|
||||
<div class="rounded-circle bg-{{ color }} bg-opacity-10 p-3">
|
||||
<i class="bi bi-{{ icon }} fs-4 text-{{ color }}"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="fs-5 fw-bold">{{ value }}</div>
|
||||
<div class="text-muted small">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Tenants by plan -->
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header fw-semibold">Active Tenants by Plan</div>
|
||||
<div class="card-body">
|
||||
{% for plan_name, count in stats.plan_counts %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span>{{ plan_name }}</span>
|
||||
<span class="badge bg-primary rounded-pill">{{ count }}</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted small">No data yet.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trials expiring soon -->
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header fw-semibold text-warning-emphasis">
|
||||
<i class="bi bi-hourglass me-1"></i>Trials Expiring in 3 Days
|
||||
</div>
|
||||
{% if expiring_soon %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Tenant</th><th>Plan</th><th>Trial Ends</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in expiring_soon %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('tenants.detail', tenant_id=t.id) }}">{{ t.name }}</a></td>
|
||||
<td>{{ t.plan.name if t.plan else '—' }}</td>
|
||||
<td class="text-warning small fw-semibold">{{ t.trial_ends_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('tenants.set_status', tenant_id=t.id) }}"
|
||||
class="btn btn-success btn-sm" style="display:inline-block;">Convert</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body text-muted small">No trials expiring in the next 3 days.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}Audit Log{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="fw-bold mb-0"><i class="bi bi-journal-text me-2"></i>Audit Log</h4>
|
||||
<a href="{{ url_for('audit_log.export_csv', **filters) }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-download me-1"></i>Export CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<form method="GET" class="row g-2 align-items-end">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Actor</label>
|
||||
<select class="form-select form-select-sm" name="actor_id">
|
||||
<option value="">All</option>
|
||||
{% for u in system_users %}
|
||||
<option value="{{ u.id }}" {{ 'selected' if filters.actor_id == u.id }}>{{ u.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Action prefix</label>
|
||||
<input type="text" class="form-control form-control-sm font-monospace" name="action"
|
||||
value="{{ filters.action }}" placeholder="e.g. tenant.">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">Target type</label>
|
||||
<input type="text" class="form-control form-control-sm font-monospace" name="target_type"
|
||||
value="{{ filters.target_type }}" placeholder="tenant, plan…">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">From</label>
|
||||
<input type="date" class="form-control form-control-sm" name="date_from" value="{{ filters.date_from }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small mb-1">To</label>
|
||||
<input type="date" class="form-control form-control-sm" name="date_to" value="{{ filters.date_to }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-dark btn-sm w-100">Filter</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0 font-monospace" style="font-size:0.82rem;">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Time</th><th>Actor</th><th>Action</th><th>Target</th><th>IP</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr>
|
||||
<td class="text-nowrap">{{ e.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
<td>{{ e.actor_type }}:{{ e.actor_id }}</td>
|
||||
<td>{{ e.action }}</td>
|
||||
<td>{{ (e.target_type ~ ':' ~ e.target_id) if e.target_type else '—' }}</td>
|
||||
<td class="text-muted">{{ e.ip_address or '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">No entries match your filter.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if pagination.pages > 1 %}
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination pagination-sm">
|
||||
{% if pagination.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('audit_log.index', page=pagination.prev_num, **filters) }}">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="page-item disabled"><span class="page-link">Page {{ pagination.page }} of {{ pagination.pages }}</span></li>
|
||||
{% if pagination.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('audit_log.index', page=pagination.next_num, **filters) }}">Next</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,26 +1,44 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}Admin Login{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm mt-5">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="card-title mb-4 text-center">Admin Portal</h4>
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('admin_auth.login') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" name="email" class="form-control" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-dark w-100">Sign In</button>
|
||||
</form>
|
||||
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||
<div class="card shadow" style="width: 400px;">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-shield-lock-fill fs-1 text-dark"></i>
|
||||
<h4 class="mt-2 fw-bold">Admin Portal</h4>
|
||||
<p class="text-muted small">Restricted access</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('admin_auth.login') }}" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email address</label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
autocomplete="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-4">
|
||||
<button type="submit" class="btn btn-dark">Sign In</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('admin_auth.password_reset_request') }}" class="text-muted small">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}Set New Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||
<div class="card shadow" style="width: 420px;">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">Set New Password</h5>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">New password</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
autocomplete="new-password" required autofocus>
|
||||
<div class="form-text">Min 10 characters. Must include uppercase, lowercase, and a digit.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm_password" class="form-label">Confirm password</label>
|
||||
<input type="password" class="form-control" id="confirm_password"
|
||||
name="confirm_password" autocomplete="new-password" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-dark">Update Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}Reset Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||
<div class="card shadow" style="width: 400px;">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">Reset Admin Password</h5>
|
||||
<p class="text-muted small">Enter your registered email address and we'll send a reset link.</p>
|
||||
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email address</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required autofocus>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-dark">Send Reset Link</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('admin_auth.login') }}" class="text-muted small">Back to login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}Add Billing Entry{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><h5 class="mb-0">Add Billing Entry — {{ tenant.name }}</h5></div>
|
||||
<div class="card-body">
|
||||
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Amount ($)</label>
|
||||
<input type="number" step="0.01" class="form-control" name="amount" min="0" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" class="form-control" name="description" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Invoice Reference <small class="text-muted">(optional)</small></label>
|
||||
<input type="text" class="form-control" name="invoice_ref">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Paid At <small class="text-muted">(optional)</small></label>
|
||||
<input type="datetime-local" class="form-control" name="paid_at">
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-dark">Save</button>
|
||||
<a href="{{ url_for('billing.tenant_billing', tenant_id=tenant.id) }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}Billing History{% endblock %}
|
||||
{% block content %}
|
||||
<h4 class="fw-bold mb-4"><i class="bi bi-receipt me-2"></i>Billing History</h4>
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Date</th><th>Tenant</th><th>Amount</th><th>Description</th><th>Invoice Ref</th><th>Paid</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in entries %}
|
||||
<tr>
|
||||
<td class="small">{{ b.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('tenants.detail', tenant_id=b.tenant_id) }}" class="text-decoration-none">
|
||||
{{ b.tenant.name if b.tenant else b.tenant_id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="fw-semibold">${{ "%.2f"|format(b.amount) }}</td>
|
||||
<td>{{ b.description }}</td>
|
||||
<td class="text-muted small">{{ b.invoice_ref or '—' }}</td>
|
||||
<td class="text-muted small">{{ b.paid_at.strftime('%Y-%m-%d') if b.paid_at else '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="text-center text-muted py-4">No billing entries yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}Billing — {{ tenant.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="fw-bold mb-0">Billing — {{ tenant.name }}</h4>
|
||||
<a href="{{ url_for('billing.add_entry', tenant_id=tenant.id) }}" class="btn btn-dark btn-sm">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add Entry
|
||||
</a>
|
||||
</div>
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr><th>Date</th><th>Amount</th><th>Description</th><th>Invoice Ref</th><th>Paid</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in entries %}
|
||||
<tr>
|
||||
<td class="small">{{ b.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td class="fw-semibold">${{ "%.2f"|format(b.amount) }}</td>
|
||||
<td>{{ b.description }}</td>
|
||||
<td class="text-muted small">{{ b.invoice_ref or '—' }}</td>
|
||||
<td class="text-muted small">{{ b.paid_at.strftime('%Y-%m-%d') if b.paid_at else '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="5" class="text-center text-muted py-4">No entries yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<a href="{{ url_for('tenants.detail', tenant_id=tenant.id) }}" class="btn btn-outline-secondary btn-sm mt-3">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Tenant
|
||||
</a>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Admin Portal{% endblock %} — Salon POS</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/admin.css') }}">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand fw-bold" href="#">
|
||||
<i class="bi bi-shield-lock-fill me-2"></i>Admin Portal
|
||||
</a>
|
||||
<div class="navbar-nav ms-auto">
|
||||
<span class="navbar-text text-light me-3">{{ current_user.name }}</span>
|
||||
<a class="nav-link text-warning" href="{{ url_for('admin_auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- Sidebar -->
|
||||
<nav class="col-md-2 d-none d-md-block bg-white border-end vh-100 pt-3 position-sticky top-0">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('tenants') %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('tenants.index') }}">
|
||||
<i class="bi bi-building me-2"></i>Tenants
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('system_users') %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('system_users.index') }}">
|
||||
<i class="bi bi-people me-2"></i>System Users
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('plans') %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('plans.index') }}">
|
||||
<i class="bi bi-card-list me-2"></i>Plans
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('billing') %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('billing.index') }}">
|
||||
<i class="bi bi-receipt me-2"></i>Billing
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('audit_log') %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('audit_log.index') }}">
|
||||
<i class="bi bi-journal-text me-2"></i>Audit Log
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and request.endpoint.startswith('analytics') %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('analytics.index') }}">
|
||||
<i class="bi bi-bar-chart me-2"></i>Analytics
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="col-md-10 ms-sm-auto px-4 py-3">
|
||||
{% endif %}
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show mt-2" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} Plan{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><h5 class="mb-0">{{ 'Edit' if mode == 'edit' else 'New' }} Plan</h5></div>
|
||||
<div class="card-body">
|
||||
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Plan Name</label>
|
||||
<input type="text" class="form-control" name="name"
|
||||
value="{{ plan.name if plan else '' }}" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Monthly Price ($)</label>
|
||||
<input type="number" step="0.01" class="form-control" name="price_monthly"
|
||||
value="{{ plan.price_monthly if plan else '' }}" required>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Max Staff <small class="text-muted">(blank = unlimited)</small></label>
|
||||
<input type="number" class="form-control" name="max_staff"
|
||||
value="{{ plan.max_staff if plan and plan.max_staff else '' }}">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Max Locations <small class="text-muted">(blank = unlimited)</small></label>
|
||||
<input type="number" class="form-control" name="max_locations"
|
||||
value="{{ plan.max_locations if plan and plan.max_locations else '' }}">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label fw-semibold">Feature Flags</label>
|
||||
<div class="row g-2">
|
||||
{% for flag in all_features %}
|
||||
<div class="col-md-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" name="feature_{{ flag }}" value="1"
|
||||
id="f_{{ flag }}"
|
||||
{{ 'checked' if plan and (plan.features_json or {}).get(flag) }}>
|
||||
<label class="form-check-label small" for="f_{{ flag }}">{{ flag }}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" value="1"
|
||||
id="is_active" {{ 'checked' if not plan or plan.is_active }}>
|
||||
<label class="form-check-label" for="is_active">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button type="submit" class="btn btn-dark">Save</button>
|
||||
<a href="{{ url_for('plans.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}Plans{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="fw-bold mb-0"><i class="bi bi-card-list me-2"></i>Subscription Plans</h4>
|
||||
<a href="{{ url_for('plans.create') }}" class="btn btn-dark btn-sm">
|
||||
<i class="bi bi-plus-lg me-1"></i>New Plan
|
||||
</a>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
{% for plan in plans %}
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm h-100 {{ '' if plan.is_active else 'opacity-50' }}">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>{{ plan.name }}</strong>
|
||||
<span class="badge {{ 'bg-success' if plan.is_active else 'bg-secondary' }}">
|
||||
{{ 'Active' if plan.is_active else 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="fw-bold">${{ "%.2f"|format(plan.price_monthly) }}<small class="fs-6 text-muted">/mo</small></h3>
|
||||
<p class="text-muted small mb-2">
|
||||
Staff: {{ plan.max_staff or 'Unlimited' }} •
|
||||
Locations: {{ plan.max_locations or 'Unlimited' }}
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
{% for flag, enabled in (plan.features_json or {}).items() %}
|
||||
<span class="badge {{ 'bg-primary' if enabled else 'bg-light text-muted' }} me-1 mb-1" style="font-size:0.7rem;">
|
||||
{{ flag }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex gap-2">
|
||||
<a href="{{ url_for('plans.edit', plan_id=plan.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||
<form method="POST" action="{{ url_for('plans.toggle_active', plan_id=plan.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Toggle plan status?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-outline-{{ 'danger' if plan.is_active else 'success' }} btn-sm">
|
||||
{{ 'Deactivate' if plan.is_active else 'Activate' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}Set Override — {{ tenant.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><h5 class="mb-0">Set Setting Override — {{ tenant.name }}</h5></div>
|
||||
<div class="card-body">
|
||||
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Setting Key</label>
|
||||
<input type="text" class="form-control font-monospace" name="setting_key"
|
||||
placeholder="e.g. feature_marketing or business_hours" required>
|
||||
<div class="form-text">Alphanumeric, underscores, dots. Use <code>feature_FLAG</code> to force-enable/disable a plan feature.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Value</label>
|
||||
<input type="text" class="form-control" name="setting_value" required>
|
||||
<div class="form-text">For feature flags: <code>true</code> or <code>false</code></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Note <small class="text-muted">(optional)</small></label>
|
||||
<input type="text" class="form-control" name="note" placeholder="Reason for override">
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-warning">Set Override</button>
|
||||
<a href="{{ url_for('settings_override.tenant_overrides', tenant_id=tenant.id) }}"
|
||||
class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,66 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}Settings Overrides — {{ tenant.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="fw-bold mb-0">Overrides — {{ tenant.name }}</h4>
|
||||
<a href="{{ url_for('settings_override.set_override', tenant_id=tenant.id) }}" class="btn btn-warning btn-sm">
|
||||
<i class="bi bi-plus-lg me-1"></i>Set Override
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if active %}
|
||||
<div class="card shadow-sm mb-4 border-warning">
|
||||
<div class="card-header bg-warning-subtle fw-semibold">Active Overrides</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Key</th><th>Value</th><th>Set by</th><th>Set at</th><th>Note</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for ov in active %}
|
||||
<tr>
|
||||
<td><code>{{ ov.setting_key }}</code></td>
|
||||
<td>{{ ov.setting_value }}</td>
|
||||
<td class="small">{{ ov.admin.name if ov.admin else ov.overridden_by }}</td>
|
||||
<td class="small text-muted">{{ ov.overridden_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td class="small text-muted">{{ ov.note or '—' }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('settings_override.lift_override', override_id=ov.id) }}"
|
||||
onsubmit="return confirm('Lift this override?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-outline-warning btn-sm">Lift</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-light">No active overrides for this tenant.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if history %}
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header fw-semibold text-muted">Override History</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Key</th><th>Value</th><th>Set at</th><th>Lifted at</th></tr></thead>
|
||||
<tbody>
|
||||
{% for ov in history %}
|
||||
<tr class="text-muted">
|
||||
<td><code>{{ ov.setting_key }}</code></td>
|
||||
<td>{{ ov.setting_value }}</td>
|
||||
<td class="small">{{ ov.overridden_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
||||
<td class="small">{{ ov.lifted_at.strftime('%Y-%m-%d %H:%M') if ov.lifted_at else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('tenants.detail', tenant_id=tenant.id) }}" class="btn btn-outline-secondary btn-sm mt-3">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Tenant
|
||||
</a>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}{{ 'Edit' if mode == 'edit' else 'New' }} System User{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><h5 class="mb-0">{{ 'Edit' if mode == 'edit' else 'New' }} System User</h5></div>
|
||||
<div class="card-body">
|
||||
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" class="form-control" name="email"
|
||||
value="{{ user.email if user else '' }}"
|
||||
{{ 'readonly' if mode == 'edit' else '' }} required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" class="form-control" name="name"
|
||||
value="{{ user.name if user else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Role</label>
|
||||
<select class="form-select" name="role">
|
||||
<option value="superadmin" {{ 'selected' if user and user.role == 'superadmin' }}>superadmin</option>
|
||||
</select>
|
||||
</div>
|
||||
{% if mode == 'create' %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" name="password" autocomplete="new-password" required>
|
||||
<div class="form-text">Min 10 chars, uppercase, lowercase, digit.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" name="confirm_password" autocomplete="new-password" required>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" name="is_active" value="1"
|
||||
id="is_active" {{ 'checked' if user and user.is_active }}>
|
||||
<label class="form-check-label" for="is_active">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-dark">Save</button>
|
||||
<a href="{{ url_for('system_users.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,61 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}System Users{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="fw-bold mb-0"><i class="bi bi-people me-2"></i>System Users</h4>
|
||||
<a href="{{ url_for('system_users.create') }}" class="btn btn-dark btn-sm">
|
||||
<i class="bi bi-plus-lg me-1"></i>New User
|
||||
</a>
|
||||
</div>
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Email</th><th>Name</th><th>Role</th><th>Status</th>
|
||||
<th>Last Login</th><th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.email }}</td>
|
||||
<td>{{ u.name }}</td>
|
||||
<td><span class="badge bg-secondary">{{ u.role }}</span></td>
|
||||
<td>
|
||||
{% if u.is_active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-danger">Inactive</span>
|
||||
{% endif %}
|
||||
{% if u.is_locked() %}
|
||||
<span class="badge bg-warning text-dark">Locked</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
{{ u.last_login_at.strftime('%Y-%m-%d %H:%M') if u.last_login_at else 'Never' }}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('system_users.edit', user_id=u.id) }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||||
<form method="POST" action="{{ url_for('system_users.toggle_active', user_id=u.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Toggle active status?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-outline-{{ 'danger' if u.is_active else 'success' }} btn-sm">
|
||||
{{ 'Deactivate' if u.is_active else 'Activate' }}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('system_users.force_password_reset', user_id=u.id) }}" class="d-inline"
|
||||
onsubmit="return confirm('Send password reset email?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-outline-warning btn-sm">Reset PW</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="6" class="text-center text-muted py-4">No system users found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,128 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}{{ tenant.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h4 class="fw-bold mb-0">{{ tenant.name }}
|
||||
<code class="fs-6 ms-2">{{ tenant.slug }}</code>
|
||||
{% if tenant.is_demo %}<span class="badge bg-info ms-2">Demo</span>{% endif %}
|
||||
</h4>
|
||||
<span class="badge bg-{{ {'active':'success','trial':'primary','suspended':'warning','cancelled':'danger'}.get(tenant.status,'secondary') }} mt-1">
|
||||
{{ tenant.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{{ url_for('tenants.edit', tenant_id=tenant.id) }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
<a href="{{ url_for('billing.add_entry', tenant_id=tenant.id) }}" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-receipt me-1"></i>Add Billing Entry
|
||||
</a>
|
||||
<a href="{{ url_for('settings_override.set_override', tenant_id=tenant.id) }}" class="btn btn-outline-warning btn-sm">
|
||||
<i class="bi bi-sliders me-1"></i>Set Override
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card shadow-sm text-center py-3">
|
||||
<div class="fs-4 fw-bold">{{ tenant.plan.name if tenant.plan else '—' }}</div>
|
||||
<div class="text-muted small">Plan</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card shadow-sm text-center py-3">
|
||||
<div class="fs-4 fw-bold">{{ locations|length }}</div>
|
||||
<div class="text-muted small">Locations</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card shadow-sm text-center py-3">
|
||||
<div class="fs-4 fw-bold">{{ users|length }}</div>
|
||||
<div class="text-muted small">Portal Users</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card shadow-sm text-center py-3">
|
||||
<div class="fs-4 fw-bold">{{ overrides|length }}</div>
|
||||
<div class="text-muted small">Active Overrides</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status change -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header fw-semibold">Change Status</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('tenants.set_status', tenant_id=tenant.id) }}"
|
||||
class="d-flex gap-2" onsubmit="return confirm('Change tenant status?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<select class="form-select form-select-sm w-auto" name="status">
|
||||
{% for s in ['active', 'trial', 'suspended', 'cancelled'] %}
|
||||
<option value="{{ s }}" {{ 'selected' if tenant.status == s }}>{{ s.capitalize() }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-dark btn-sm">Apply</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active overrides -->
|
||||
{% if overrides %}
|
||||
<div class="card shadow-sm mb-3 border-warning">
|
||||
<div class="card-header fw-semibold text-warning-emphasis bg-warning-subtle">
|
||||
<i class="bi bi-sliders me-1"></i>Active Setting Overrides
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Key</th><th>Value</th><th>Set by</th><th>Note</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for ov in overrides %}
|
||||
<tr>
|
||||
<td><code>{{ ov.setting_key }}</code></td>
|
||||
<td>{{ ov.setting_value }}</td>
|
||||
<td class="text-muted small">{{ ov.admin.name if ov.admin else ov.overridden_by }}</td>
|
||||
<td class="text-muted small">{{ ov.note or '—' }}</td>
|
||||
<td>
|
||||
<form method="POST" action="{{ url_for('settings_override.lift_override', override_id=ov.id) }}"
|
||||
onsubmit="return confirm('Lift this override?')">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-outline-warning btn-sm">Lift</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Billing history -->
|
||||
{% if billing %}
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header fw-semibold">Recent Billing</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Date</th><th>Amount</th><th>Description</th><th>Invoice Ref</th><th>Paid</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in billing %}
|
||||
<tr>
|
||||
<td class="small">{{ b.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>${{ "%.2f"|format(b.amount) }}</td>
|
||||
<td>{{ b.description }}</td>
|
||||
<td class="text-muted small">{{ b.invoice_ref or '—' }}</td>
|
||||
<td>{{ b.paid_at.strftime('%Y-%m-%d') if b.paid_at else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ url_for('tenants.index') }}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Tenants
|
||||
</a>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}{{ 'Edit' if mode == 'edit' else 'New Tenant' }}{% endblock %}
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><h5 class="mb-0">{{ 'Edit Tenant' if mode == 'edit' else 'New Tenant' }}</h5></div>
|
||||
<div class="card-body">
|
||||
{% if error %}<div class="alert alert-danger py-2">{{ error }}</div>{% endif %}
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Business Name</label>
|
||||
<input type="text" class="form-control" name="name"
|
||||
value="{{ tenant.name if tenant else '' }}" required>
|
||||
</div>
|
||||
{% if mode == 'create' %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Slug <small class="text-muted">(URL-friendly, e.g. my-salon)</small></label>
|
||||
<input type="text" class="form-control" name="slug" pattern="[a-z0-9\-]+"
|
||||
placeholder="lowercase-letters-and-hyphens" required>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Owner Email</label>
|
||||
<input type="email" class="form-control" name="owner_email"
|
||||
value="{{ tenant.owner_email if tenant else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Plan</label>
|
||||
<select class="form-select" name="plan_id" required>
|
||||
<option value="">— Select a plan —</option>
|
||||
{% for p in plans %}
|
||||
<option value="{{ p.id }}" {{ 'selected' if tenant and tenant.plan_id == p.id }}>
|
||||
{{ p.name }} (${{ "%.2f"|format(p.price_monthly) }}/mo)
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% if mode == 'create' %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Trial Duration (days)</label>
|
||||
<input type="number" class="form-control" name="trial_days" value="14" min="0" max="90">
|
||||
</div>
|
||||
<hr>
|
||||
<h6 class="fw-semibold mb-3">Owner Account Password</h6>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" class="form-control" name="owner_password" autocomplete="new-password" required>
|
||||
<div class="form-text">Min 10 chars, uppercase, lowercase, digit.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" name="confirm_password" autocomplete="new-password" required>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Subscription Expires At</label>
|
||||
<input type="datetime-local" class="form-control" name="subscription_expires_at"
|
||||
value="{{ tenant.subscription_expires_at.strftime('%Y-%m-%dT%H:%M') if tenant and tenant.subscription_expires_at else '' }}">
|
||||
</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" name="is_demo" value="1" id="is_demo"
|
||||
{{ 'checked' if tenant and tenant.is_demo }}>
|
||||
<label class="form-check-label" for="is_demo">Demo tenant (read-only)</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-dark">Save</button>
|
||||
<a href="{{ url_for('tenants.index') }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "admin/layouts/base.html" %}
|
||||
{% block title %}Tenants{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="fw-bold mb-0"><i class="bi bi-building me-2"></i>Tenants</h4>
|
||||
<a href="{{ url_for('tenants.create') }}" class="btn btn-dark btn-sm">
|
||||
<i class="bi bi-plus-lg me-1"></i>New Tenant
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<form method="GET" class="d-flex gap-2 align-items-center">
|
||||
<label class="text-muted small me-1">Filter by status:</label>
|
||||
{% for s in ['', 'active', 'trial', 'suspended', 'cancelled'] %}
|
||||
<a href="{{ url_for('tenants.index', status=s) }}"
|
||||
class="btn btn-sm {{ 'btn-dark' if status_filter == s else 'btn-outline-secondary' }}">
|
||||
{{ s.capitalize() if s else 'All' }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Slug</th><th>Name</th><th>Plan</th><th>Status</th>
|
||||
<th>Owner</th><th>Created</th><th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in tenants %}
|
||||
<tr>
|
||||
<td><code>{{ t.slug }}</code>{% if t.is_demo %}<span class="badge bg-info ms-1">Demo</span>{% endif %}</td>
|
||||
<td>{{ t.name }}</td>
|
||||
<td>{{ t.plan.name if t.plan else '—' }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ {'active':'success','trial':'primary','suspended':'warning','cancelled':'danger'}.get(t.status,'secondary') }}">
|
||||
{{ t.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted small">{{ t.owner_email }}</td>
|
||||
<td class="text-muted small">{{ t.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('tenants.detail', tenant_id=t.id) }}" class="btn btn-outline-secondary btn-sm">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td colspan="7" class="text-center text-muted py-4">No tenants found.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,17 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Account Suspended{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-center align-items-center" style="min-height: 80vh;">
|
||||
<div class="text-center">
|
||||
<i class="bi bi-lock-fill display-1 text-danger"></i>
|
||||
<h3 class="mt-3">Account Suspended</h3>
|
||||
<p class="text-muted">
|
||||
Your account has been suspended. Please contact support to resolve any outstanding issues.
|
||||
</p>
|
||||
<a href="{{ url_for('tenant_auth.logout') }}" class="btn btn-outline-secondary mt-2">
|
||||
Sign Out
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,32 +1,52 @@
|
||||
{% extends "tenant/base.html" %}
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Sign In{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm mt-5">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="card-title mb-4 text-center">Salon Login</h4>
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('tenant_auth.login') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" name="email" class="form-control" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" name="password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Sign In</button>
|
||||
</form>
|
||||
<hr>
|
||||
<div class="text-center small">
|
||||
<a href="{{ url_for('tenant_auth.password_reset_request') }}">Forgot password?</a>
|
||||
|
|
||||
<a href="{{ url_for('staff_auth.staff_login') }}">Staff login</a>
|
||||
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||
<div class="card shadow" style="width: 420px;">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-scissors fs-1 text-primary"></i>
|
||||
<h4 class="mt-2 fw-bold">Salon POS</h4>
|
||||
<p class="text-muted small">Sign in to your account</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('tenant_auth.login') }}" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email address</label>
|
||||
<input type="email" class="form-control" id="email" name="email"
|
||||
autocomplete="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
autocomplete="current-password" required>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-4">
|
||||
<button type="submit" class="btn btn-primary">Sign In</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr class="my-3">
|
||||
|
||||
<div class="text-center">
|
||||
<a href="{{ url_for('staff_auth.staff_login') }}" class="btn btn-outline-secondary btn-sm w-100 mb-2">
|
||||
<i class="bi bi-person-badge me-1"></i>Staff Login (PIN)
|
||||
</a>
|
||||
<a href="{{ url_for('tenant_auth.password_reset_request') }}" class="text-muted small d-block mt-2">
|
||||
Forgot password?
|
||||
</a>
|
||||
<a href="{{ url_for('tenant_auth.demo_login') }}" class="text-muted small d-block mt-1">
|
||||
View demo account
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
{% extends "tenant/base.html" %}
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Set New Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm mt-5">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="card-title mb-3">Set New Password</h5>
|
||||
{% if error %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endif %}
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Password</label>
|
||||
<input type="password" name="password" class="form-control" required minlength="10">
|
||||
<div class="form-text">Min 10 chars, must include uppercase, lowercase, and a digit.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Confirm Password</label>
|
||||
<input type="password" name="confirm_password" class="form-control" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Update Password</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||
<div class="card shadow" style="width: 420px;">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">Set New Password</h5>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">New password</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
autocomplete="new-password" required autofocus>
|
||||
<div class="form-text">Min 10 characters. Uppercase, lowercase, and a digit required.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm_password" class="form-label">Confirm password</label>
|
||||
<input type="password" class="form-control" id="confirm_password"
|
||||
name="confirm_password" autocomplete="new-password" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Update Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{% extends "tenant/base.html" %}
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Reset Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm mt-5">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="card-title mb-3">Reset Password</h5>
|
||||
{% if message %}
|
||||
<div class="alert alert-info">{{ message }}</div>
|
||||
{% else %}
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Email address</label>
|
||||
<input type="email" name="email" class="form-control" required autofocus>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Send Reset Link</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<div class="text-center mt-3 small">
|
||||
<a href="{{ url_for('tenant_auth.login') }}">Back to login</a>
|
||||
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||
<div class="card shadow" style="width: 420px;">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="fw-bold mb-3">Reset Password</h5>
|
||||
<p class="text-muted small">Enter your email address and we'll send a reset link.</p>
|
||||
|
||||
<form method="POST" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email address</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required autofocus>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Send Reset Link</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('tenant_auth.login') }}" class="text-muted small">Back to login</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Feature Unavailable{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-center align-items-center" style="min-height: 70vh;">
|
||||
<div class="text-center">
|
||||
<i class="bi bi-lock-fill display-1 text-warning"></i>
|
||||
<h3 class="mt-3">Feature Not Available on Your Plan</h3>
|
||||
<p class="text-muted">
|
||||
The <strong>{{ feature }}</strong> feature is not included in your current subscription plan.
|
||||
Please contact your account administrator to upgrade.
|
||||
</p>
|
||||
<a href="{{ url_for('dashboard.index') }}" class="btn btn-primary mt-2">
|
||||
Back to Dashboard
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,189 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Salon POS{% endblock %}</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/tenant.css') }}">
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<!-- Top navbar -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand fw-bold" href="{{ url_for('dashboard.index') }}">
|
||||
<i class="bi bi-scissors me-2"></i>
|
||||
{% if g.tenant is defined and g.tenant %}{{ g.tenant.name }}{% else %}Salon POS{% endif %}
|
||||
</a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarMain">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarMain">
|
||||
<!-- Location switcher (tenant_admin and tenant_manager only) -->
|
||||
{% if current_user.role in ('tenant_admin', 'tenant_manager') %}
|
||||
<div class="navbar-nav me-3">
|
||||
<div class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle text-white" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-geo-alt me-1"></i>
|
||||
{% if g.location is defined and g.location %}{{ g.location.name }}{% else %}Select Location{% endif %}
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
{% if locations is defined %}
|
||||
{% for loc in locations %}
|
||||
<li>
|
||||
<a class="dropdown-item {% if g.location and g.location.id == loc.id %}active{% endif %}"
|
||||
href="{{ url_for('locations.switch', location_id=loc.id) }}">
|
||||
{{ loc.name }}
|
||||
{% if loc.is_primary %}<span class="badge bg-secondary ms-1">Primary</span>{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Demo badge -->
|
||||
{% if g.tenant is defined and g.tenant and g.tenant.is_demo %}
|
||||
<span class="badge bg-warning text-dark me-3">Demo Mode — Read Only</span>
|
||||
{% endif %}
|
||||
|
||||
<div class="navbar-nav ms-auto">
|
||||
<span class="navbar-text text-white me-3">
|
||||
<i class="bi bi-person-circle me-1"></i>{{ current_user.name if current_user.name is defined else current_user.email }}
|
||||
</span>
|
||||
{% if current_user.role == 'tenant_staff' %}
|
||||
<a class="nav-link text-warning" href="{{ url_for('staff_auth.staff_logout') }}">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</a>
|
||||
{% else %}
|
||||
<a class="nav-link text-warning" href="{{ url_for('tenant_auth.logout') }}">
|
||||
<i class="bi bi-box-arrow-right"></i> Logout
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- Sidebar (hidden for tenant_staff) -->
|
||||
{% if current_user.role != 'tenant_staff' %}
|
||||
<nav class="col-md-2 d-none d-md-block bg-white border-end sidebar pt-3">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'dashboard' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('dashboard.index') }}">
|
||||
<i class="bi bi-speedometer2 me-2"></i>Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'appointments' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('appointments.index') }}">
|
||||
<i class="bi bi-calendar3 me-2"></i>Appointments
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'pos' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('pos.checkout') }}">
|
||||
<i class="bi bi-cash-register me-2"></i>POS / Checkout
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'customers' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('customers.index') }}">
|
||||
<i class="bi bi-people me-2"></i>Customers
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'staff' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('staff.index') }}">
|
||||
<i class="bi bi-person-badge me-2"></i>Staff
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'services' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('services.index') }}">
|
||||
<i class="bi bi-card-list me-2"></i>Services & Products
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'inventory' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('inventory.index') }}">
|
||||
<i class="bi bi-box-seam me-2"></i>Inventory
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'gift_cards' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('gift_cards.index') }}">
|
||||
<i class="bi bi-gift me-2"></i>Gift Cards
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'reconciliation' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('reconciliation.index') }}">
|
||||
<i class="bi bi-calculator me-2"></i>Reconciliation
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'reports' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('reports.index') }}">
|
||||
<i class="bi bi-bar-chart-line me-2"></i>Reports
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.role == 'tenant_admin' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'marketing' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('marketing.index') }}">
|
||||
<i class="bi bi-megaphone me-2"></i>Marketing
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'locations' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('locations.index') }}">
|
||||
<i class="bi bi-geo-alt me-2"></i>Locations
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.endpoint and 'settings' in request.endpoint %}active fw-bold{% endif %}"
|
||||
href="{{ url_for('settings.index') }}">
|
||||
<i class="bi bi-gear me-2"></i>Settings
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="{% if current_user.role != 'tenant_staff' %}col-md-10{% else %}col-12{% endif %} ms-sm-auto px-4 py-3">
|
||||
{% endif %}
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
{% block title %}Staff Login{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-center align-items-center" style="min-height: 100vh;">
|
||||
<div class="card shadow" style="width: 380px;">
|
||||
<div class="card-body p-4">
|
||||
<div class="text-center mb-4">
|
||||
<i class="bi bi-person-badge fs-1 text-primary"></i>
|
||||
<h4 class="mt-2 fw-bold">Staff Login</h4>
|
||||
<p class="text-muted small">Enter your phone number and PIN</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert-danger py-2">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('staff_auth.staff_login') }}" novalidate>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="phone" class="form-label">Phone number</label>
|
||||
<input type="tel" class="form-control form-control-lg" id="phone" name="phone"
|
||||
autocomplete="tel" placeholder="e.g. 555-123-4567" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="passcode" class="form-label">PIN</label>
|
||||
<input type="password" class="form-control form-control-lg text-center"
|
||||
id="passcode" name="passcode"
|
||||
inputmode="numeric" pattern="[0-9]*"
|
||||
minlength="4" maxlength="6"
|
||||
autocomplete="one-time-code"
|
||||
placeholder="••••" required>
|
||||
<div class="form-text text-center">4–6 digit PIN</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid mt-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg">Sign In</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<a href="{{ url_for('tenant_auth.login') }}" class="text-muted small">
|
||||
Manager / Owner login
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user